This is an automated email from the ASF dual-hosted git repository. danhaywood pushed a commit to branch CAUSEWAY-2873 in repository https://gitbox.apache.org/repos/asf/causeway.git
commit a0721e3eaa859ad5d126562fefad1c60a639b530 Author: Dan Haywood <[email protected]> AuthorDate: Sun May 26 09:41:50 2024 +0100 CAUSEWAY-2873: 03-13 --- .../petclinic/pages/030-petowner-entity.adoc | 466 +++++++++++++-------- .../modules/petclinic/pages/100-todo.adoc | 6 +- 2 files changed, 296 insertions(+), 176 deletions(-) diff --git a/antora/components/tutorials/modules/petclinic/pages/030-petowner-entity.adoc b/antora/components/tutorials/modules/petclinic/pages/030-petowner-entity.adoc index f0b01273b5..33c41617cd 100644 --- a/antora/components/tutorials/modules/petclinic/pages/030-petowner-entity.adoc +++ b/antora/components/tutorials/modules/petclinic/pages/030-petowner-entity.adoc @@ -611,7 +611,9 @@ Note that Apache Causeway allows services to be injected into entities (actually @Property @PropertyLayout(fieldSetId = LayoutConstants.FieldSetId.DETAILS, sequence = "3.1") // <.> public long getDaysSinceLastVisit() { - return Math.abs(ChronoUnit.DAYS.between(getLastVisit(), clockService.getClock().nowAsLocalDate())); + return getLastVisit() != null + ? ChronoUnit.DAYS.between(getLastVisit(), clockService.getClock().nowAsLocalDate()) + : null; } ---- <.> positioned just after the `lastVisit` property @@ -650,34 +652,22 @@ daysSinceLastVisit -[#exercise-3-8-add-other-properties-for-petowner] -== Ex 3.8: Add other properties for PetOwner +[#exercise-3-10-use-meta-annotations-to-reduce-duplication] +== Ex 3.10: Use meta-annotations to reduce duplication -Let's add the two remaining properties for `PetOwner`: +There is some duplication between menu:PetOwners[create] action and the `PetOwner` class; both define a "name" parameter along with other optional parameters, "telephoneNumber, "emailAddress" and "knownAs". -[plantuml] ----- -hide empty members -hide methods +With "name" you might have noticed that the `@Name` meta-annotation that came with the starter, and which centralizes the domain knowledge about what a "name" is. -class Owner { - +id - .. - #lastName - #firstName - .. - -phoneNumber - -emailAddress -} ----- +In this exercise we'll use the same approach with the "telephoneNumber". +(We won't do "emailAddress" or "knownAs" though - we'll explore an even more powerful way to reduce duplication in the following exercise). -They are `phoneNumber` and `emailAddress`. === Solution [source,bash] ---- -git checkout tags/03-08-add-remaining-PetOwner-properties +git checkout tags/03-10-use-meta-annotations-to-reduce-duplication mvn clean install mvn -pl spring-boot:run ---- @@ -690,73 +680,73 @@ mvn -pl spring-boot:run .PhoneNumber.java ---- @Property( - editing = Editing.ENABLED, // <.> + editing = Editing.ENABLED, + maxLength = PhoneNumber.MAX_LEN, + optionality = Optionality.OPTIONAL +) +@Parameter( maxLength = PhoneNumber.MAX_LEN, optionality = Optionality.OPTIONAL ) -@Parameter(maxLength = PhoneNumber.MAX_LEN, optionality = Optionality.OPTIONAL) @Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) public @interface PhoneNumber { - int MAX_LEN = 30; + int MAX_LEN = 40; } ---- -<.> any properties annotated with this meta-annotation will be editable by default -* Similarly, create an `@EmailAddress` meta-annotation, defined to be an editable property: +* update the `telephoneNumber` of `PetOwner` to use the meta-annotation: + [source,java] -.EmailAddress.java +.PetOwner.java ---- -@Property( - editing = Editing.ENABLED, - maxLength = EmailAddress.MAX_LEN, - optionality = Optionality.OPTIONAL -) -@PropertyLayout(named = "E-mail") // <.> -@Parameter(maxLength = EmailAddress.MAX_LEN, optionality = Optionality.OPTIONAL) -@ParameterLayout(named = "E-mail") // <.> -@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) -@Retention(RetentionPolicy.RUNTIME) -public @interface EmailAddress { - - int MAX_LEN = 100; -} +@PhoneNumber // <.> +@Column(length = PhoneNumber.MAX_LEN, nullable = true, name = "telephoneNumber") // <.> +@Getter @Setter +@PropertyLayout(fieldSetId = "contact", sequence = "1.1") // <.> +private String telephoneNumber; ---- -<.> xref:refguide:applib:index/annotation/PropertyLayout.adoc#named[@PropertyLayout#named] allows characters to be used that are not valid Java identifiers. -<.> xref:refguide:applib:index/annotation/ParameterLayout.adoc#named[@ParameterLayout#named] - ditto. +<.> updated to use meta-annotation +<.> The JPA implementation used by Apache Causeway (EclipseLink) does not support meta-annotations, so the field must still be annotated with `@Column`. +We can at least use the `PhoneNumber.MAX_LEN` for the length. +<.> Any annotations defined at the field level supplement or override those inherited from the meta-annotation. -* add properties to `PetOwner`: +* and update the `PetOwners#create()` action method also: + [source,java] -.PetOwner.java +.PetOwners.java ---- -@PhoneNumber -@Column(length = PhoneNumber.MAX_LEN, nullable = true) -@PropertyLayout(fieldSetId = "name", sequence = "1.5") -@Getter @Setter -private String phoneNumber; - -@EmailAddress -@Column(length = EmailAddress.MAX_LEN, nullable = true) -@PropertyLayout(fieldSetId = "name", sequence = "1.6") -@Getter @Setter -private String emailAddress; +@Action(semantics = SemanticsOf.NON_IDEMPOTENT) +// @ActionLayout(promptStyle = PromptStyle.DIALOG_SIDEBAR) +public PetOwner create( + @Name final String name, + @Parameter(maxLength = 40, optionality = Optionality.OPTIONAL) + final String knownAs, + @PhoneNumber // <.> + final String telephoneNumber, + @Parameter(maxLength = 40, optionality = Optionality.OPTIONAL) + final String emailAddress) { + // ... +} ---- +<.> updated to use meta-annotation -[#exercise-3-9-validation] -== Ex 3.9: Validation +[#exercise-3-11-validation] +== Ex 3.11: Validation -At the moment there are no constraints for the format of `phoneNumber` or `emailAddress` properties. +At the moment there are no constraints for the format of `telePhoneNumber` properties. We can fix this by adding rules to their respective meta-annotations. + +=== Solution + [source,bash] ---- -git checkout tags/03-09-validation-rules-using-metaannotations +git checkout tags/03-11-validation-rules-using-metaannotations mvn clean install mvn -pl spring-boot:run ---- @@ -773,49 +763,35 @@ mvn -pl spring-boot:run editing = Editing.ENABLED, maxLength = PhoneNumber.MAX_LEN, optionality = Optionality.OPTIONAL, - regexPattern = "[+]?[0-9 ]+", // <.> - regexPatternReplacement = // <.> - "Specify only numbers and spaces, optionally prefixed with '+'. " + - "For example, '+353 1 555 1234', or '07123 456789'" + regexPattern = PhoneNumber.REGEX_PATTERN, // <.> + regexPatternReplacement = PhoneNumber.REGEX_PATTERN_REPLACEMENT // <.> +) +@Parameter( + maxLength = PhoneNumber.MAX_LEN, + optionality = Optionality.OPTIONAL, + regexPattern = PhoneNumber.REGEX_PATTERN, // <1> + regexPatternReplacement = PhoneNumber.REGEX_PATTERN_REPLACEMENT // <2> ) -@Parameter(maxLength = PhoneNumber.MAX_LEN, optionality = Optionality.OPTIONAL) @Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) public @interface PhoneNumber { - int MAX_LEN = 30; + int MAX_LEN = 40; + String REGEX_PATTERN = "[+]?[0-9 ]+"; + String REGEX_PATTERN_REPLACEMENT = + "Specify only numbers and spaces, optionally prefixed with '+'. " + + "For example, '+353 1 555 1234', or '07123 456789'"; + } ---- <.> regex constraint <.> validation message if the constraint is not met -* Similarly, update `@EmailAddress`: -+ -[source,java] -.EmailAddress.java ----- -@Property( - editing = Editing.ENABLED, - maxLength = EmailAddress.MAX_LEN, - optionality = Optionality.OPTIONAL, - regexPattern = "[^@]+@[^@]+[.][^@]+", // <.> - regexPatternReplacement = "Invalid email address" // <.> -) -@PropertyLayout(named = "E-mail") -@Parameter(maxLength = EmailAddress.MAX_LEN, optionality = Optionality.OPTIONAL) -@ParameterLayout(named = "E-mail") -@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) -@Retention(RetentionPolicy.RUNTIME) -public @interface EmailAddress { +Try out the application and check that these rules are applied. - int MAX_LEN = 100; -} ----- -<.> regex constraint. -(Should really use a more comprehensive regex, eg see https://emailregex.com). -<.> validation message if the constraint is not met -Try out the application and check that these rules are applied. +[#exercise-3-12-more-validation] +== Ex 3.12: More validation The `updateName` action also has a validation rule, applied directly to the method: @@ -834,25 +810,38 @@ public String validate0UpdateName(String newName) { // <.> <.> validates the "0^th^" parameter of `updateName`. More details on the validate supporting method can be found xref:refguide:applib-methods:prefixes.adoc#validate[here]. -We can Move this constraint onto the `@LastName` meta-annotation instead: +In this exercise we'll move this constraint onto the `@Name` meta-annotation instead, using a xref:refguide:applib:index/spec/Specification.adoc[]. + -* Update the `@LastName` meta-annotation using a xref:refguide:applib-classes:spec.adoc#specification[Specification]: +=== Solution + +[source,bash] +---- +git checkout tags/03-12-moves-validation-onto-metaannotation +mvn clean install +mvn -pl spring-boot:run +---- + +=== Task + +* Update the `@Name` meta-annotation using a xref:refguide:applib-classes:spec.adoc#specification[Specification]: + [source,java] -.LastName.java +.Name.java ---- -@Property(maxLength = LastName.MAX_LEN, mustSatisfy = LastName.Spec.class) // <.> -@Parameter(maxLength = LastName.MAX_LEN, mustSatisfy = LastName.Spec.class) // <1> -@ParameterLayout(named = "Last Name") +@Property(maxLength = Name.MAX_LEN, mustSatisfy = Name.Spec.class) // <.> +@Parameter(maxLength = Name.MAX_LEN, mustSatisfy = Name.Spec.class) // <1> +@ParameterLayout(named = "Name") @Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) -public @interface LastName { +public @interface Name { int MAX_LEN = 40; + String PROHIBITED_CHARACTERS = "&%$!"; - class Spec extends AbstractSpecification<String> { // <.> + class Spec extends AbstractSpecification<String> { // <.> @Override public String satisfiesSafely(String candidate) { - for (char prohibitedCharacter : "&%$!".toCharArray()) { + for (char prohibitedCharacter : PROHIBITED_CHARACTERS.toCharArray()) { if( candidate.contains(""+prohibitedCharacter)) { return "Character '" + prohibitedCharacter + "' is not allowed."; } @@ -865,19 +854,221 @@ public @interface LastName { <.> indicates that the property or parameter value must satisfy the specification below <.> defines the specification definition, where a non-null value is the reason why the specification is not satisfied. -* Remove the `validate0UpdateName` from `PetOwner`. +* Remove the `validate0UpdateName` method and `PROHIBITED_CHARACTERS` constant from `PetOwner`. + +* update the `@ActionLayout#describedAs` annotation for "updateName" to use `Name.PROHIBITED_CHARACTERS` Test the app once more. -=== Optional exercise +NOTE: in making this refactoring we actually fixed a bug: there was no validation of the parameter when a new `PetOwner` was created; but now there is. -NOTE: 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. -As well as validating the `lastName`, it would be nice to also validate `firstName` with the same rule. -As the logic is shared, create a new meta-(meta-)annotation called `@Name`, move the specification (and anything else that is common between lastName and firstName) to that new meta annotation, and then meta-annotate `@LastName` and `@FirstName` with `@Name`. +[#exercise-3-13-scalar-custom-value-types] +== Ex 3.13: Scalar custom value types + +We could use meta-annotations for the "emailAddress" property and parameter, but instead we'll reduce duplication using an even more powerful technique, namely custom value types. +We'll define a custom class `EmailAddress` with value semantics, allowing validation and any other behaviour to move onto the custom class itself. + +Apache Causeway supports both scalar and composite value types. +For email address, we'll use a single string, so it's a scalar value type. + +=== Solution + +[source,bash] +---- +git checkout tags/03-13-scalar-custom-value-type-for-email-address +mvn clean install +mvn -pl spring-boot:run +---- + +=== Task + +* Define the `EmailAddress` value type: ++ +[source,java] +.EmailAddress.java +---- [email protected] // <.> [email protected] // <.> [email protected] // <.> +public class EmailAddress implements Serializable { // <.> + + static final int MAX_LEN = 100; + static final int TYPICAL_LEN = 30; + static final Pattern REGEX = + Pattern.compile("^[\\w-\\+]+(\\.[\\w]+)*@[\\w-]+(\\.[\\w]+)*(\\.[a-zA-Z]{2,})$"); + + public static EmailAddress of(String value) { // <.> + if (_Strings.isNullOrEmpty(value)) { + return null; + } + if(!EmailAddress.REGEX.matcher(value).matches()) { + throw new RuntimeException("Invalid email format"); + } + + final var ea = new EmailAddress(); + ea.value = value; + return ea; + } + + protected EmailAddress() {} // <.> + + @Getter + @Column( length = MAX_LEN, nullable = true, name = "emailAddress") // <1> + String value; // <.> +} +---- +<.> Required JPA annotations +<.> Indicates to Causeway that this class is a value type (as opposed to an entity, view model or domain service) +<.> Value types generally implement equals and hashCode +<.> Value types generally are serializable +<.> Validation moves to the factory method +<.> no-arg constructor is required by JPA +<.> The single data attribute to be persisted + +* Implement a "value semantics provider". +This tells Causeway how to interact with the value type ++ +[source,java] +.EmailAddressValueSemantics.java +---- +@Named(PetOwnerModule.NAMESPACE + ".EmailAddressValueSemantics") +@Component // <.> +public class EmailAddressValueSemantics + extends ValueSemanticsAbstract<EmailAddress> { // <.> + + @Override + public Class<EmailAddress> getCorrespondingClass() { + return EmailAddress.class; + } + + @Override + public ValueType getSchemaValueType() { // <.> + return ValueType.STRING; + } + + @Override + public ValueDecomposition decompose(final EmailAddress value) { // <.> + return decomposeAsNullable(value, EmailAddress::getValue, ()->null); + } + + @Override + public EmailAddress compose(final ValueDecomposition decomposition) { // <4> + return composeFromNullable( + decomposition, ValueWithTypeDto::getString, EmailAddress::of, ()->null); + } + + @Override + public DefaultsProvider<EmailAddress> getDefaultsProvider() { // <.> + return () -> null; + } + + @Override + public Renderer<EmailAddress> getRenderer() { // <.> + return (context, emailAddress) -> emailAddress == null ? null : emailAddress.getValue(); + } + + @Override + public Parser<EmailAddress> getParser() { // <.> + return new Parser<>() { + + @Override + public String parseableTextRepresentation(Context context, EmailAddress emailAddress) { + return renderTitle(emailAddress, EmailAddress::getValue); + } + + @Override + public EmailAddress parseTextRepresentation(Context context, String text) { + return EmailAddress.of(text); + } + + @Override + public int typicalLength() { + return EmailAddress.TYPICAL_LEN; + } + + @Override + public int maxLength() { + return EmailAddress.MAX_LEN; + } + }; + } + + @Override + public IdStringifier<EmailAddress> getIdStringifier() { // <.> + return new IdStringifier.EntityAgnostic<>() { + @Override + public Class<EmailAddress> getCorrespondingClass() { + return EmailAddressValueSemantics.this.getCorrespondingClass(); + } + + @Override + public String enstring(@NonNull EmailAddress value) { + return _Strings.base64UrlEncode(value.getValue()); + } + + @Override + public EmailAddress destring(@NonNull String stringified) { + return EmailAddress.of(_Strings.base64UrlDecode(stringified)); + } + }; + } +} +---- +<.> Defined as a Spring `@Component` so that the framework can discover and use this value semantics provider +<.> Typically inherit from `ValueSemanticsAbstract`, a convenience superclass +<.> The `schemaValueType` in essence defines the widget that will be used to interact with render the value +<.> The `ValueDecomposition` is primarily used by the xref:vro::about.adoc[] to convert to/from JSON. +<.> The `DefaultsProvider` provides an initial value, if any. +For some values there is often a reasonable default, eg `0` for a number, or `[0,0]` for a coordinate, or today's date. +<.> The `Renderer` provides string and if required HTML representations of the value +<.> The `Parser` converts string representations into the value. +Note how this code delegates back to the `EmailAddress` value type itself +<.> The `IdStringifier` returns a string representation of the value, in case it is used as an identifier of the object. +The returned string would appear in URLs or bookmarks, for example. + +* update `PetOwner#emailAddress` to use the `EmailAddress` value type: ++ +[source,java] +.PetOwner.java +---- [email protected] // <.> +@Getter @Setter +@Property(editing = Editing.ENABLED, optionality = Optionality.OPTIONAL) // <.> +@PropertyLayout(fieldSetId = "contact", sequence = "1.2") +private EmailAddress emailAddress; // <.> +---- +<.> required JPA annotation +<.> need to explicitly indicate that this property is optional (previously it was inferred from `@Column(nullable=)`) +<.> change the type from `String` to `EmailAddress` + +* update `PetOwners#create` to use the `EmailAddress` value type: ++ +[source,java] +.PetOwner.java +---- +@Action(semantics = SemanticsOf.NON_IDEMPOTENT) +// @ActionLayout(promptStyle = PromptStyle.DIALOG_SIDEBAR) +public PetOwner create( + @Name final String name, + @Parameter(maxLength = 40, optionality = Optionality.OPTIONAL) + final String knownAs, + @PhoneNumber + final String telephoneNumber, + @Parameter(optionality = Optionality.OPTIONAL) + final EmailAddress emailAddress) { // <.> + final var petOwner = PetOwner.withName(name); + petOwner.setKnownAs(knownAs); + petOwner.setTelephoneNumber(telephoneNumber); + petOwner.setEmailAddress(emailAddress); + return repositoryService.persist(petOwner); +} +---- +<.> Change the parameter' type from `String` to `EmailAddress` +Run the application and try to enter an invalid [#exercise-3-10-field-layout] @@ -983,70 +1174,3 @@ You might find though that the main benefit of the layout file is to declare how It really is a matter of personal preference which approach you use. - -[#exercise-3-11-column-orders] -== Ex 3.11: Column Orders - -The home page of the webapp shows a list of all `PetOwner`s (inherited from the original simple app). -We also see a list of `PetOwner`s if we invoke menu:Pet Owners[List All]. - -The first is a "parented" collection (it is parented by the home page view model), the second is a standalone collection (it is returned from an action). - -The properties that are shown as columns that are shown is based on two different mechanisms. -The first is whether the property is visible at all in any tables, which can be specified using `@PropertyLayout(hidden=...)` (see xref:refguide:applib:index/annotation/PropertyLayout.adoc#hidden[@PropertyLayout#hidden]). -The second is to use a "columnOrder" file. - -In this exercise, we'll use the latter approach. - -=== Solution - -[source,bash] ----- -git checkout tags/03-11-PetOwner-columnOrder -mvn clean install -mvn -pl spring-boot:run ----- - -=== Task - -* Declare the `id` field of `PetOwner` as a property by adding a getter and other annotations: -+ -[source,java] -.PetOwner.java ----- -@Id -@GeneratedValue(strategy = GenerationType.AUTO) -@Column(name = "id", nullable = false) -@Getter @Setter // <.> -@PropertyLayout(fieldSetId = "metadata", sequence = "1") // <.> -private Long id; ----- -<.> makes field available as a property -<.> positions property in the metadata fieldset (before `version`). - -* update the columnOrder for standalone collections of `PetOrder`: -+ -[source,java] -.PetOwner.columnOrder.txt ----- -name -id -#version ----- -+ -This will show only `name` and `id`; none of the other properties will be visible as columns. - -* create a new file `HomePageViewModel#objects.columnOrder.txt` (in the same package as `HomePageViewModel`) to define the columns visible in the `objects` collection of that view model: -+ -[source,java] -.HomePageViewModel#objects.columnOrder.txt ----- -name -id -#version ----- - -* delete the (unused) `PetOwner#others.columnOrder.txt` file. - -Run the application and confirm the columns are as expected. -You should also be able to update the files and reload changes (on IntelliJ, menu:Run[Debugging Actions > Reload Changed Classes]) and inspect the updates without having to restart the app. diff --git a/antora/components/tutorials/modules/petclinic/pages/100-todo.adoc b/antora/components/tutorials/modules/petclinic/pages/100-todo.adoc index 6ebd2b2b7f..760a44e164 100644 --- a/antora/components/tutorials/modules/petclinic/pages/100-todo.adoc +++ b/antora/components/tutorials/modules/petclinic/pages/100-todo.adoc @@ -1,15 +1,11 @@ TODO: ideas for future steps: -- introduce a meta-annotation -- introduce a custom type for these +* more user-friendly error message if hit duplicate (enter two pet owners with same name) -add validation for telephone number and email address. - PetOwner#daysSinceLastVisit could be made into a mixin - eg if these marketing analytics were the responsibility of some other module. - hidden properties
