Pet entity
Right now our domain model still only consists of the single domain class, PetOwner
.
We still have the Pet
and Visit
entities to add, along with the PetSpecies
enum.
In this set of exercises we’ll focus on the Pet
entity and its relationship with PetOwner
.
Each PetOwner
will hold a collection of their Pet
s, with actions to add or remove Pet
instances for that collection.
Ex 4.1: Pet entity’s key properties
In this exercise we’ll just create the outline of the Pet
entity, and ensure it is mapped to the database correctly.
Solution
git checkout tags/04-01-pet-entity-key-properties
mvn clean install
mvn -pl spring-boot:run
Tasks
-
create a meta-annotation
@PetName
for the Pet’s name:PetName.java@Property(maxLength = PetName.MAX_LEN, optionality = Optionality.MANDATORY) @Parameter(maxLength = PetName.MAX_LEN, optionality = Optionality.MANDATORY) @Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) public @interface PetName { int MAX_LEN = 60; }
-
create the
Pet
entity, using the@PetName
meta-annotation for thename
property:Pet.java@Entity @Table( schema="pets", uniqueConstraints = { @UniqueConstraint(name = "Pet__owner_name__UNQ", columnNames = {"owner_id, name"}) } ) @EntityListeners(IsisEntityListener.class) @Named("pets.Pet") @DomainObject(entityChangePublishing = Publishing.ENABLED) @DomainObjectLayout() @NoArgsConstructor(access = AccessLevel.PUBLIC) @XmlJavaTypeAdapter(PersistentEntityAdapter.class) @ToString(onlyExplicitlyIncluded = true) public class Pet implements Comparable<Pet> { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = "id", nullable = false) @Getter @Setter @PropertyLayout(fieldSetId = "metadata", sequence = "1") private Long id; @Version @Column(name = "version", nullable = false) @PropertyLayout(fieldSetId = "metadata", sequence = "999") @Getter @Setter private long version; Pet(PetOwner petOwner, String name) { this.petOwner = petOwner; this.name = name; } @ManyToOne(optional = false) @JoinColumn(name = "owner_id") @PropertyLayout(fieldSetId = "name", sequence = "1") @Getter @Setter private PetOwner petOwner; @PetName @Column(name = "name", length = FirstName.MAX_LEN, nullable = false) @Getter @Setter @PropertyLayout(fieldSetId = "name", sequence = "2") private String name; private final static Comparator<Pet> comparator = Comparator.comparing(Pet::getPetOwner).thenComparing(Pet::getName); @Override public int compareTo(final Pet other) { return comparator.compare(this, other); } }
Run the application, and confirm that the table is created correctly using
.Ex 4.2: Add PetRepository
We will need to find the Pet
s belonging to a PetOwner
.
We do this by introducing a PetRepository
, implemented as a Spring Data repository.
Tasks
-
create the
PetRepository
, extending Spring Data’sorg.springframework.data.repository.Repository
interface:PetRepository.javaimport org.springframework.data.repository.Repository; public interface PetRepository extends Repository<Pet, Long> { List<Pet> findByPetOwner(PetOwner petOwner); }
Confirm the application still runs
Ex 4.3: Add PetOwner’s collection of Pets
In this next exercise we’ll add the PetOwner
's collection of Pet
s, using a mixin.
Solution
git checkout tags/04-03-PetOwner-pets-mixin-collection
mvn clean install
mvn -pl spring-boot:run
Tasks
-
create the
PetOwner_pets
mixin class:import org.apache.isis.applib.annotation.Collection; import org.apache.isis.applib.annotation.CollectionLayout; import lombok.RequiredArgsConstructor; @Collection (1) @CollectionLayout(defaultView = "table") @RequiredArgsConstructor (2) public class PetOwner_pets { (3) private final PetOwner petOwner; (4) public List<Pet> coll() { return petRepository.findByPetOwner(petOwner); (5) } @Inject PetRepository petRepository; (5) }
1 indicates that this is a collection mixin 2 lombok annotation to avoid some boilerplate 3 collection name is derived from the mixin class name, being the name after the '_'. 4 the "mixee" that is being contributed to, in other words PetOwner
.5 inject the PetRepository
as defined in previous exercise, in order to find thePet
s owned by thePetOwner
. -
Run the application to confirm that the
pets
collection is visible (it won’t have anyPet
instances in it just yet). -
update the
PetOwner.layout.xml
file to specify the position of thepets
collection. For example:PetOwner.layout.xml<bs3:grid> <bs3:row> <!--...--> </bs3:row> <bs3:row> <bs3:col span="6"> <!--...--> </bs3:col> <bs3:col span="6"> <bs3:tabGroup unreferencedCollections="true" collapseIfOne="false"> <bs3:tab name="Pets"> (1) <bs3:row> <bs3:col span="12"> <c:collection id="pets"/> </bs3:col> </bs3:row> </bs3:tab> </bs3:tabGroup> </bs3:col> </bs3:row> </bs3:grid>
1 define a tab on the right hand side to hold the pets
collection.Run the application (or just reload the changed classes) and confirm the positioning the
pets
collection. -
Create a column order file to define the order of columns in the
PetOwner
'spets
collection:PetOwner#pets.columnOrder.txtname id
Run the application (or just reload the changed classes) and confirm the columns of the
pets
collection are correct.
Ex 4.4: Add Pet’s remaining properties
In this exercise we’ll add the remaining properties for Pet
.
Tasks
-
declare the
PetSpecies
enum:PetSpecies.javapublic enum PetSpecies { Dog, Cat, Hamster, Budgerigar, }
-
add in a reference to
PetSpecies
:Pet.java@Enumerated(EnumType.STRING) (1) @Column(nullable = false) @Getter @Setter @PropertyLayout(fieldSetId = "details", sequence = "1") (2) private PetSpecies petSpecies;
1 mapped to a string rather than an integer value in the database 2 anticipates adding a 'details' fieldSet in the layout xml (see ex 4.7) -
As the
petSpecies
property is mandatory, also update the constructor:Pet.javaPet(PetOwner petOwner, String name, PetSpecies petSpecies) { this.petOwner = petOwner; this.name = name; this.petSpecies = petSpecies; }
-
add in an optional
notes
property:@Notes @Column(length = Notes.MAX_LEN, nullable = true) @Getter @Setter @Property(commandPublishing = Publishing.ENABLED, executionPublishing = Publishing.ENABLED) @PropertyLayout(fieldSetId = "notes", sequence = "1") private String notes;
Run the application and use Pet
is as expected.
Ex 4.5: Digression: clean-up casing of database schema
Ex 4.6: Add PetOwner action to add Pets
In this exercise we’ll bring in the capability to add Pet
s, as a responsibility of PetOwner
.
We’ll use an mixin action to implement this.
Tasks
-
create the
PetOwner_addPet
action mixin:PetOwner_addPet.java@Action( (1) semantics = SemanticsOf.IDEMPOTENT, commandPublishing = Publishing.ENABLED, executionPublishing = Publishing.ENABLED ) @ActionLayout(associateWith = "pets") (2) @RequiredArgsConstructor public class PetOwner_addPet { (3) private final PetOwner petOwner; (4) public PetOwner act( @PetName final String name, final PetSpecies petSpecies ) { repositoryService.persist(new Pet(petOwner, name, petSpecies)); return petOwner; } @Inject RepositoryService repositoryService; }
1 indicates that this class is a mixin action. 2 the action is associated with the "pets" collection (defined earlier). This means that in the UI, the button representing the action will be rendered close to the table representing the "pets" collection. 3 the action name "addPet" is derived from the mixin class name. Run the application and verify that
Pet
s can now be added toPetOwner
s.
Let’s now add some validation to ensure that two pets with the same name cannot be added.
-
first, we need a new method in
PetRepository
:PetRepository.javaOptional<Pet> findByPetOwnerAndName(PetOwner petOwner, String name);
-
Now use a supporting validate method to prevent two pets with the same name from being added:
PetOwner_addPet.javapublic String validate0Act(final String name) { return petRepository.findByPetOwnerAndName(petOwner, name).isPresent() ? String.format("Pet with name '%s' already defined for this owner", name) : null; } @Inject PetRepository petRepository;
we could also just rely on the database, but adding a check here will make for better UX. Run the application and check the validation message is fired when you attempt to add two
Pet
s with the same name for the samePetOwner
(but two differentPetOwner
s should be able to have aPet
with the same name). -
Let’s suppose that owners own dogs for this particular clinic. Use a default supporting method to default the petSpecies parameter:
PetOwner_addPet.javapublic PetSpecies default1Act() { return PetSpecies.Dog; }
Run the application once more to test.
Ex 4.7: Add Pet’s UI customisation
If we run the application and create a Pet
, then the framework will render a page but the layout could be improved.
So in this exercise we’ll add a layout file for Pet
and other UI files.
Tasks
-
Create a
Pet.layout.xml
file as follows:Pet.layout.xml<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <bs3:grid xsi:schemaLocation="http://isis.apache.org/applib/layout/component http://isis.apache.org/applib/layout/component/component.xsd http://isis.apache.org/applib/layout/links http://isis.apache.org/applib/layout/links/links.xsd http://isis.apache.org/applib/layout/grid/bootstrap3 http://isis.apache.org/applib/layout/grid/bootstrap3/bootstrap3.xsd" xmlns:bs3="http://isis.apache.org/applib/layout/grid/bootstrap3" xmlns:cpt="http://isis.apache.org/applib/layout/component" xmlns:lnk="http://isis.apache.org/applib/layout/links" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <bs3:row> <bs3:col span="12" unreferencedActions="true"> <cpt:domainObject bookmarking="AS_ROOT"/> </bs3:col> </bs3:row> <bs3:row> <bs3:col span="6"> <bs3:row> <bs3:col span="12"> <bs3:tabGroup> <bs3:tab name="General"> <bs3:row> <bs3:col span="12"> <cpt:fieldSet id="name"/> </bs3:col> </bs3:row> </bs3:tab> <bs3:tab name="Metadata"> <bs3:row> <bs3:col span="12"> <cpt:fieldSet name="Metadata" id="metadata"/> </bs3:col> </bs3:row> </bs3:tab> <bs3:tab name="Other"> <bs3:row> <bs3:col span="12"> <cpt:fieldSet name="Other" id="other" unreferencedProperties="true"/> </bs3:col> </bs3:row> </bs3:tab> </bs3:tabGroup> <cpt:fieldSet id="details" name="Details"/> <cpt:fieldSet id="notes" name="Notes"/> </bs3:col> </bs3:row> <bs3:row> <bs3:col span="12"> </bs3:col> </bs3:row> </bs3:col> <bs3:col span="6"> <bs3:tabGroup unreferencedCollections="true"/> </bs3:col> </bs3:row> </bs3:grid>
-
reload changed classes (or run the application), and check the layout.
if the layout isn’t quite as you expect, try using to force the domain object metamodel to be recreated. -
add a
Pet.png
file to act as the icon, in the same package.This might be a good point to find a better icon for
PetOwner
, too. -
we also need a title for each
Pet
, which we can provide using a title() method:Pet.javapublic String title() { return getName() + " " + getPetOwner().getLastName(); }
In the same way that titles are specific an object instance, we can also customise the icon:
-
download additional icons for each of the
PetSpecies
(dog, cat, hamster, budgie) -
save these icons as
Pet-dog.png
,Pet-cat.png
and so on, ie the pet species as suffix. -
implement the iconName() method as follows:
Pet.javapublic String iconName() { return getPetSpecies().name().toLowerCase(); }
-
Run the application. You should find that the appropriate icon is selected based upon the species of the
Pet
. -
One further tweak is to show both the title and icon for objects in tables. This can be done by changing some configuration properties:
application-custom.ymlisis: viewer: wicket: max-title-length-in-standalone-tables: 10 max-title-length-in-parented-tables: 10
also update the
application.css
file, otherwise the icon and title will be centred:application.csstd.title-column > div > div > div { text-align: left; } .collectionContentsAsAjaxTablePanel table.contents thead th.title-column, .collectionContentsAsAjaxTablePanel table.contents tbody td.title-column { width: 10%; }
Ex 4.8: Update fixture script using Pet personas
By now you are probably tiring of continually creating a Pet in order to perform your tests.
So let’s take some time out to extend our fixture so that each PetOwner
also has some Pet
s.
Tasks
-
First we need to modify the
PetOwnerBuilder
to make it idempotent:PetOwnerBuilder.java@Accessors(chain = true) public class PetOwnerBuilder extends BuilderScriptWithResult<PetOwner> { @Getter @Setter private String name; @Override protected PetOwner buildResult(final ExecutionContext ec) { checkParam("name", ec, String.class); PetOwner petOwner = petOwners.findByLastNameExact(name); if(petOwner == null) { petOwner = wrap(petOwners).create(name, null); } return this.object = petOwner; } @Inject PetOwners petOwners; }
-
Now we create a similar
PetBuilder
fixture script to addPet
s through aPetOwner
:PetBuilder.java@Accessors(chain = true) public class PetBuilder extends BuilderScriptWithResult<Pet> { @Getter @Setter String name; @Getter @Setter PetSpecies petSpecies; @Getter @Setter PetOwner_persona petOwner_persona; @Override protected Pet buildResult(final ExecutionContext ec) { checkParam("name", ec, String.class); checkParam("petSpecies", ec, PetSpecies.class); checkParam("petOwner_persona", ec, PetOwner_persona.class); PetOwner petOwner = ec.executeChildT(this, petOwner_persona.builder()).getObject(); (1) Pet pet = petRepository.findByPetOwnerAndName(petOwner, name).orElse(null); if(pet == null) { wrapMixin(PetOwner_addPet.class, petOwner).act(name, petSpecies); (2) pet = petRepository.findByPetOwnerAndName(petOwner, name).orElseThrow(); } return this.object = pet; } @Inject PetRepository petRepository; }
1 Transitively sets up its prereqs ( PetOwner
). This relies on thefact thatPetOwnerBuilder
is idempotent.2 calls domain logic to add a Pet
if required -
Now we create a "persona" enum for
Pet
s:Pet_persona.java@AllArgsConstructor public enum Pet_persona implements PersonaWithBuilderScript<PetBuilder>, PersonaWithFinder<Pet> { TIDDLES_JONES("Tiddles", PetSpecies.Cat, PetOwner_persona.JONES), ROVER_JONES("Rover", PetSpecies.Dog, PetOwner_persona.JONES), HARRY_JONES("Harry", PetSpecies.Hamster, PetOwner_persona.JONES), BURT_JONES("Burt", PetSpecies.Budgerigar, PetOwner_persona.JONES), TIDDLES_FARRELL("Tiddles", PetSpecies.Cat, PetOwner_persona.FARRELL), SPIKE_FORD("Spike", PetSpecies.Dog, PetOwner_persona.FORD), BARRY_ITOJE("Barry", PetSpecies.Budgerigar, PetOwner_persona.ITOJE); @Getter private final String name; @Getter private final PetSpecies petSpecies; @Getter private final PetOwner_persona petOwner_persona; @Override public PetBuilder builder() { return new PetBuilder() (1) .setName(name) (2) .setPetSpecies(petSpecies) .setPetOwner_persona(petOwner_persona); } @Override public Pet findUsing(final ServiceRegistry serviceRegistry) { (3) PetOwner petOwner = petOwner_persona.findUsing(serviceRegistry); PetRepository petRepository = serviceRegistry.lookupService(PetRepository.class).orElseThrow(); return petRepository.findByPetOwnerAndName(petOwner, name).orElse(null); } public static class PersistAll extends PersonaEnumPersistAll<Pet_persona, Pet> { public PersistAll() { super(Pet_persona.class); } } }
1 Returns the PetBuilder
added earlier2 Copies over the state of the enum to the builder 3 Personas can also be used to lookup domain entities. The ServiceRegistry can be used as a service locator of any domain service (usually a repository). -
Finally, update the top-level
PetClinicDemo
to create bothPet
s and alsoPetOwner
s.PetClinicDemo.javapublic class PetClinicDemo extends FixtureScript { @Override protected void execute(final ExecutionContext ec) { ec.executeChildren(this, moduleWithFixturesService.getTeardownFixture()); ec.executeChild(this, new Pet_persona.PersistAll()); ec.executeChild(this, new PetOwner_persona.PersistAll()); } @Inject ModuleWithFixturesService moduleWithFixturesService; }
Ex 4.9: Add PetOwner action to delete a Pet
We will probably also need to delete an action to delete a Pet
(though once there are associated Visit
s for a Pet
, we’ll need to disable this action).
Solution
git checkout tags/04-09-PetOwner-deletePet-action
mvn clean install
mvn -pl spring-boot:run
Tasks
+ create a new action mixins, PetOwner_removePet
:
+
@Action(
semantics = SemanticsOf.IDEMPOTENT,
commandPublishing = Publishing.ENABLED,
executionPublishing = Publishing.ENABLED
)
@ActionLayout(associateWith = "pets", sequence = "2")
@RequiredArgsConstructor
public class PetOwner_removePet {
private final PetOwner petOwner;
public PetOwner act(@PetName final String name) {
petRepository.findByPetOwnerAndName(petOwner, name)
.ifPresent(pet -> repositoryService.remove(pet));
return petOwner;
}
@Inject PetRepository petRepository;
@Inject RepositoryService repositoryService;
}
-
To be explicit, add in an @ActionLayout#sequence for "addPet" also:
PetOwner_addPet.java// ... @ActionLayout(associateWith = "pets", sequence = "1") // ... public class PetOwner_addPet { // ... }
-
Run the application and test the action; it should work, but requires the
Pet
'sname
to be spelt exactly correctly. -
Use a choices supporting method to restrict the list of
Pet
name
s:PetOwner_removePet.javapublic List<String> choices0Act() { return petRepository.findByPetOwner(petOwner) .stream() .map(Pet::getName) .collect(Collectors.toList()); }
-
We also should disable (grey out) the
removePet
action if thePetOwner
has noPet
s:PetOwner_removePet.javapublic String disableAct() { return petRepository.findByPetOwner(petOwner).isEmpty() ? "No pets" : null; }
-
As a final refinement, if there is exactly one
Pet
then that could be the default:PetOwner_removePet.javapublic String default0Act() { List<String> names = choices0Act(); return names.size() == 1 ? names.get(0) : null; }
Optional exercise
If you decide to do this optional exercise, make the changes on a git branch so that you can resume with the main flow of exercises later. |
If we wanted to work with multiple instances of the pets
collection, we can use the choices method using the @Action#choicesFrom attribute.
Add this mixin to allow multiple Pet
s to be removed at the same time:
@Action(
semantics = SemanticsOf.IDEMPOTENT,
commandPublishing = Publishing.ENABLED,
executionPublishing = Publishing.ENABLED,
choicesFrom = "pets" (1)
)
@ActionLayout(associateWith = "pets", sequence = "2")
@RequiredArgsConstructor
public class PetOwner_removePets { (2)
private final PetOwner petOwner;
public PetOwner act(final List<Pet> pets) { (3)
pets.forEach(repositoryService::remove);
return petOwner;
}
public String disableAct() {
return petRepository.findByPetOwner(petOwner).isEmpty() ? "No pets" : null;
}
(4)
@Inject PetRepository petRepository;
@Inject RepositoryService repositoryService;
}
1 | Results in checkboxes in the table, allowing the user to optionally check one or more instances before invoking the action. |
2 | Renamed as the action now works with a list of Pet s |
3 | Signature changed. |
4 | The choices method is removed. |
Ex 4.10: Cleanup
Reviewing the contents of the pets
module, we can see (in the solutions provided at least) that there are a few thing that still need some attention:
-
the classes and files for
Pet
are in the same package as forPetOwner
; they probably should live in their own package -
the "delete" action for
PetOwner
is not present in the UI, because its "associateWith" relates to a non-visible property -
the "delete" action for
PetOwner
fails if there arePet
s, due to a referential integrity issue.
In this exercise we clean up these oversights.
Tasks
Just check out the tag above and inspect the fixes:
-
the
Pet
entity,PetRepository
and related UI files have been moved topetclinic.modules.pets.dom.pet
package -
the
PetOwner_pet
,PetOwner_addPet
andPetOwner_removePet
mixins have also been moved.This means that
PetOwner
is actually unaware of the fact that there are associatedPet
s. This abliity to control the direction of dependencies is very useful for ensuring modularity. -
the
PetOwner
'sdelete
action has been refactored into a mixin, and also moved to thepets
package so that it will delete the childPet
s first.Also fixes tests.
-
the fixtures for
PetOwner
andPet
have also been moved into their own packages. -
the tear down fixture for
PetsModule
has been updated to also delete from thePet
entity.