diff --git a/docs/dto-mapping-requirements.md b/docs/dto-mapping-requirements.md index 5c65c805b..eb46702d4 100644 --- a/docs/dto-mapping-requirements.md +++ b/docs/dto-mapping-requirements.md @@ -139,6 +139,35 @@ while keeping DTOs as plain, framework-unattached classes. querybean-generator codegen support (static/instance dispatch, constructor wiring deduplicated by converter type). Test coverage: `tests/test-dto-mapping` `TestDtoConvert`.* +- **Type-pair (package-level) custom scalar conversion** + Motivated by real hand-written mapper code (`EboxMapper`, central-access): the same conversion repeats + across many unrelated properties on one target - `DateUtils.toCalendar(...)` on ~9 fields, + `parseEnum(EnumType.class, value)` on ~3 - under today's `@DtoConvert` every one of those properties must + carry its own repeated annotation. MapStruct solves this by letting a conversion method be defined once + (in the mapper or a `uses = {...}` helper) and auto-applying it to *every* property whose source/target + types match that method's signature - no per-field wiring. Proposed: a package-level, repeatable + `@DtoConverters({ConverterType.class, ...})` (sibling to `@DtoMapping` in `package-info.java`) - the + generator indexes every public static/instance method on the referenced type(s) by `(paramType -> + returnType)`, then for any property whose source getter type doesn't already match the target field type + and which carries no explicit per-property `@DtoConvert`, looks up that type pair and wires it in + automatically (same static-vs-instance/`DtoConverterManager` dispatch rules as `@DtoConvert` today). An + explicit per-property `@DtoConvert` always overrides the type-level default. Deliberately no built-in + conversions shipped by Ebean itself (no implicit `Enum.valueOf`/`.name()`) - the app still owns + exception/null-handling semantics (e.g. `parseEnum`'s catch-and-null-on-bad-value), just declares it once + instead of per-field. + **Status: implemented.** `@DtoConverters(ConverterType.class, ...)` (a single non-repeatable annotation + taking a `Class[]`, `@Target({PACKAGE, MODULE})`) is registered once per package/module alongside + `@DtoMapping`. The generator indexes every public, single-arg, non-void method on each referenced type by + exact `(paramType -> returnType)`; any SCALAR property (plain or `@DtoPath`-renamed) with no explicit + `@DtoConvert` and a source/target type mismatch is auto-wired to the matching method (a duplicate/ambiguous + type pair across the registered types is a compile-time processor error). List-element-wise conversion and + `@DtoRef` (FK-id) properties are out of scope. Test coverage: + `tests/test-dto-mapping/.../TestDtoConverters.java` (`UuidConverters`/`UuidShortCodeConverter`, + `ContactTypeConverterDto`) - covers same-name auto-dispatch, `@DtoPath`-renamed auto-dispatch, and explicit + `@DtoConvert` overriding the registered default. + *Inspiration: `EboxMapper` (central-access) hand-written pattern; MapStruct type-signature-matched + conversion methods.* + - **`@DtoMixin` for DTOs that cannot be annotated directly** Some DTOs are generated (e.g. from an OpenAPI spec) and not editable/annotatable, so `@DtoPath`/ `@DtoConvert`/`@DtoRef` cannot always be placed directly on the DTO. Introduce a `@DtoMixin(Target.class)` @@ -229,6 +258,44 @@ instead (see "Recipe: adding extra caller-supplied fields after mapping" in *Inspiration: `UserService`/`User` (central-access).* *Status: implemented.* +- **Setter-based (mutable JavaBean) target construction** + Motivated by `EboxMapper` (central-access): its target types (`Ebox`, `MachineSummaryInfo`, from + `nz.co.eroad.schema.eroadtypes`, JAXB/XSD-generated legacy SOAP shapes) are plain mutable JavaBeans - a + public no-arg constructor plus a `void setXxx(...)` setter per property - neither a positional constructor + match nor a RecordBuilder-style fluent builder (see section G above). The generator currently only + recognizes those two construction strategies, so this common third shape (typical of JAXB/XSD-generated + and many hand-written mutable POJOs) can't be targeted by `@DtoMapping` at all today. Proposed: detect a + no-arg constructor plus a `void setXxx(propertyType)` setter per mapped property as a third construction + strategy, generating `Target target = new Target(); target.setX(...); ...; return target;` (mirroring the + existing `build = AUTO | ALWAYS | NEVER` override precedent from section G for explicit control over which + strategy applies). Would also unblock the `mapToBuilder()`-style "populate ignored/derived properties after + the generated mapping, before finishing construction" pattern for these targets (currently only available + for builder-shaped targets) - relevant to `EboxMapper`'s `machineSummaryInfo` (a genuinely composite, + multi-association derived value, out of reach of `@DtoConvert`/`@DtoPath` regardless of this gap, but a + natural fit for the same "map base fields via codegen, then set the derived one by hand" pattern already + used for `Fleet.assignedMachines`/`assignedDrivers`). + *Inspiration: `EboxMapper` (central-access); JAXB/XSD-generated SOAP DTO shapes generally.* + **Status: implemented.** `@DtoMapping(setter = AUTO | ALWAYS | NEVER)` mirrors `builder()`'s override + precedent. Detection requires a public no-arg constructor plus a public `setXxx(...)` setter for every + mapped property - either `void` or fluent-style (returning the target type itself, e.g. `public Target + setXxx(...) { ...; return this; }`); the generated code always calls the setter as a bare statement and + discards any return value, so either shape works identically. A builder, when selected, always takes + priority over setter-based construction. Under the default `AUTO`, setter-based construction is only + attempted when the target has no positional constructor matching the mapped properties (arity-based) and + no builder was selected - existing positional-constructor and builder-shaped targets are entirely + unaffected. `ALWAYS` requires the shape (codegen-time error otherwise); `NEVER` always uses a positional + constructor. Generated shape: `Target target = new Target(); target.setX(...); ...; return target;` (a + `computeIfAbsent(...)`-wrapped block-lambda variant when the target is nested elsewhere in the graph). + Deliberately **no** `mapToBuilder(...)`-style post-construction accessor is generated for this strategy - + the returned target is already the final, fully mutable instance (setters are required to be `public`), so + a caller can already call e.g. `dto.setExternalRef(...)` directly on the mapped result, exactly the pattern + `EboxMapper` already uses by hand; this is unlike the builder strategy, where the intermediate builder is + otherwise unreachable after its one-shot `build()` call. Test coverage: + `tests/test-dto-mapping/.../TestDtoSetterConstruction.java` (`ContactSetterDto`) - covers auto-detected + setter-chain construction plus post-construction population of two `@DtoIgnore` properties (a plain scalar + and a `List`) via their public setters; plus `ContactSetterFluentDto` - covers the fluent-setter-return-shape + variant. + ### H. Record entity sources - **Record-style (bare/fluent) accessors on the source (entity) side** diff --git a/pom.xml b/pom.xml index 61d868ecf..e89c5433a 100644 --- a/pom.xml +++ b/pom.xml @@ -45,7 +45,7 @@ 3.2 3.0 - 8.9 + 8.11 2.3 1.2 14.3.0 diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/Constants.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/Constants.java index ee0318ea0..8cd02fe74 100644 --- a/querybean-generator/src/main/java/io/ebean/querybean/generator/Constants.java +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/Constants.java @@ -34,6 +34,7 @@ interface Constants { String DTO_PATH = "io.ebean.annotation.DtoPath"; String DTO_REF = "io.ebean.annotation.DtoRef"; String DTO_CONVERT = "io.ebean.annotation.DtoConvert"; + String DTO_CONVERTERS = "io.ebean.annotation.DtoConverters"; String DTO_MIXIN = "io.ebean.annotation.DtoMixin"; String DTO_CONVERTER_MANAGER = "io.ebean.DtoConverterManager"; String METAINF_SERVICES_DTOMAPPERREGISTER = "META-INF/services/io.ebean.config.DtoMapperRegister"; diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoBeanMeta.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoBeanMeta.java index 049e526bc..ea02867c7 100644 --- a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoBeanMeta.java +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoBeanMeta.java @@ -18,6 +18,7 @@ class DtoBeanMeta { private final List properties = new ArrayList<>(); private final List variants = new ArrayList<>(); private DtoBuilderMeta builderMeta; + private DtoSetterMeta setterMeta; /** Cycle detection state - see {@link DtoMappingReader#checkForCycles()}. */ enum Visit { WHITE, GRAY, BLACK } @@ -32,11 +33,13 @@ class DtoBeanMeta { */ private boolean nestedElsewhere; - DtoBeanMeta(TypeElement source, TypeElement target, String mapperPackage) { + DtoBeanMeta(TypeElement source, TypeElement target, String mapperPackage, String mapperName) { this.source = source; this.target = target; this.mapperPackage = mapperPackage; - this.mapperShortName = target.getSimpleName().toString() + "Mapper"; + this.mapperShortName = mapperName != null && !mapperName.isEmpty() + ? mapperName + : target.getSimpleName() + "Mapper"; this.mapperFullName = mapperPackage + "." + mapperShortName; } @@ -97,6 +100,20 @@ class DtoBeanMeta { this.builderMeta = builderMeta; } + /** + * The detected+selected setter-based construction path for this target, or {@code null} if not + * applicable - see {@link DtoMappingReader#resolveSetter(DtoBeanMeta, String)}. Mutually + * exclusive with {@link #builderMeta()} - a builder, when selected, always takes priority (see + * {@link DtoMappingReader#resolveAndValidate()}). + */ + DtoSetterMeta setterMeta() { + return setterMeta; + } + + void setterMeta(DtoSetterMeta setterMeta) { + this.setterMeta = setterMeta; + } + /** * Return the distinct instance-dispatch {@code @DtoConvert} converters used by this mapper's * properties (static-dispatch converters need no field/constructor wiring, so are excluded), diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperVariant.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperVariant.java index 07ba1d7e5..0e9618221 100644 --- a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperVariant.java +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperVariant.java @@ -36,7 +36,7 @@ final class DtoMapperVariant { return suffix; } - /** Dto field names (of {@code NESTED_ONE}/{@code NESTED_MANY} properties) this variant excludes. */ + /** Dto field names ({@code NESTED_ONE}/{@code NESTED_MANY}, or non-primitive {@code SCALAR}) this variant excludes. */ Set excludedProperties() { return excludedProperties; } diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperWriter.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperWriter.java index eb5700ba7..b3e73ea2d 100644 --- a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperWriter.java +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperWriter.java @@ -41,6 +41,7 @@ class DtoMapperWriter { writeConstructors(); writeFetchGroupMethod(); writeMapMethod(); + writeMapToBuilderMethod(); if (!meta.variants().isEmpty()) { writeBuildMethod(); writeVariantAccessors(); @@ -60,6 +61,9 @@ class DtoMapperWriter { Set allReferenced = new LinkedHashSet<>(); allReferenced.add(meta.sourceFullName()); allReferenced.add(meta.targetFullName()); + if (meta.builderMeta() != null) { + allReferenced.add(meta.builderMeta().builderTypeFullName()); + } for (DtoPropertyMeta property : meta.properties()) { if (property.nested() != null) { allReferenced.add(property.nested().sourceFullName()); @@ -107,9 +111,13 @@ class DtoMapperWriter { if (meta.properties().stream().anyMatch(DtoPropertyMeta::usesMapperSupport)) { imports.add("io.ebean.DtoMapperSupport"); } - if (variableProperties().stream().anyMatch(p -> p.kind() == DtoPropertyMeta.Kind.NESTED_MANY)) { + if (variableProperties().stream().anyMatch(DtoPropertyMeta::isListTarget) + || meta.properties().stream().anyMatch(p -> p.isIgnored() && p.isListTarget())) { imports.add("java.util.List"); } + if (meta.builderMeta() != null) { + addImportIfNeeded(imports, meta.builderMeta().builderTypeFullName()); + } for (String imp : imports) { writer.append("import %s;", imp).eol(); } @@ -219,7 +227,7 @@ class DtoMapperWriter { private List fetchGroupChainCalls(Set excludedFieldNames) { List activeProperties = new ArrayList<>(); for (DtoPropertyMeta property : meta.properties()) { - if (!excludedFieldNames.contains(property.dtoFieldName())) { + if (!excludedFieldNames.contains(property.dtoFieldName()) && !property.isIgnored()) { activeProperties.add(property); } } @@ -228,6 +236,13 @@ class DtoMapperWriter { if ((property.kind() == DtoPropertyMeta.Kind.NESTED_ONE || property.kind() == DtoPropertyMeta.Kind.NESTED_MANY) && !property.hasComputedSegment()) { nestedAssocPaths.add(property.sourcePropertyPath().get(0)); + } else if (property.kind() == DtoPropertyMeta.Kind.SCALAR && property.isListTarget() + && !property.hasComputedSegment() && property.sourcePropertyPath().size() == 1) { + // a single-segment SCALAR property whose DTO field is a List with no registered nested + // DTO mapping of its own (e.g. @DtoConvert reducing a ToMany association) - still fully + // fetches its association path via a bare fetch(path) call below, so any other property + // targeting a sub-path of the same association must not also try a narrower select. + nestedAssocPaths.add(property.sourcePropertyPath().get(0)); } } Set rootSelect = new LinkedHashSet<>(); @@ -262,7 +277,16 @@ class DtoMapperWriter { } List path = property.sourcePropertyPath(); if (path.size() == 1) { - rootSelect.add(path.get(0)); + if (property.isListTarget()) { + // a single-segment path whose DTO field type is a List, but with no registered + // nested DTO mapping of its own (e.g. a @DtoConvert-backed property reducing a + // ToMany association to a simpler element type) - the source side is still a real + // ToMany association, which needs an actual fetch/join (like NESTED_MANY below), + // not a plain root select() (that only works for scalar columns on the base table). + fetchCalls.add(String.format("fetch(\"%s\")", path.get(0))); + } else { + rootSelect.add(path.get(0)); + } } else { String fetchPath = String.join(".", path.subList(0, path.size() - 1)); if (nestedAssocPaths.contains(fetchPath)) { @@ -391,7 +415,18 @@ class DtoMapperWriter { writer.append(" if (source == null) {").eol(); writer.append(" return null;").eol(); writer.append(" }").eol(); - if (!variableProps.isEmpty()) { + if (meta.builderMeta() != null) { + // the base (full) mapping is always exactly mapToBuilder(...).build() - shares that one + // chain rather than re-emitting a second copy of it here, whether or not named variants + // exist (build(...), below, is only ever needed for a named variant's own partial view) + if (meta.nestedElsewhere()) { + writer.append(" // dedup using DtoMapContext, same %s instance can be reached via more than one path in the graph", sourceShort).eol(); + writer.append(" return context.computeIfAbsent(%s.class, source, s -> mapToBuilder(s, context).build());", targetShort).eol(); + } else { + writer.append(" // DtoMapContext for nested mappers only").eol(); + writer.append(" return mapToBuilder(source, context).build();").eol(); + } + } else if (!variableProps.isEmpty()) { // named variants exist - route construction through the shared build(...) method, this // (base) mapping includes every variable property (each still evaluated inline, at its own // declared position, inside build() - see buildCallArgs()) @@ -405,43 +440,137 @@ class DtoMapperWriter { } } else if (meta.nestedElsewhere()) { writer.append(" // dedup using DtoMapContext, same %s instance can be reached via more than one path in the graph", sourceShort).eol(); - writeConstructionExpression( - String.format(" return context.computeIfAbsent(%s.class, source, s -> ", targetShort), - "s", variableProps, ")"); + writeConstructionExpression(true, variableProps); } else if (nestedProperties().isEmpty()) { writer.append(" // skip DtoMapContext, only ever a top-level mapping").eol(); - writeConstructionExpression(" return ", "source", variableProps, ""); + writeConstructionExpression(false, variableProps); } else { writer.append(" // DtoMapContext for nested mappers only").eol(); - writeConstructionExpression(" return ", "source", variableProps, ""); + writeConstructionExpression(false, variableProps); } writer.append(" }").eol(); } /** - * Emit the target construction expression - either a positional constructor call or, when - * {@link DtoBeanMeta#builderMeta()} is present (see {@code @DtoMapping(builder = ...)}), a - * {@code Target.builder()....build()} fluent chain. {@code linePrefix} is written before the - * expression starts (e.g. {@code " return "} or a {@code computeIfAbsent(...)} lambda - * opener); {@code wrapperClosing} is any additional closing needed for an enclosing call this - * expression is nested inside (e.g. {@code ")"} to close an enclosing - * {@code computeIfAbsent(...)} call, or {@code ""} for a plain {@code return}) - the trailing - * {@code ";"} is always added here. Every property - including ones in {@code variableProps} - - * is evaluated inline, in true {@link DtoBeanMeta#properties()} declared order; a variable - * property's expression is simply guarded by its {@code includeXxx} boolean parameter (see - * {@link #writeBuildMethod()}), so no property's evaluation is ever hoisted out of its declared - * position (only non-empty when named variants exist - see {@link #variableProperties()}). + * Additionally expose a {@code mapToBuilder(source)} accessor - returning the target's detected + * builder (see {@link DtoBeanMeta#builderMeta()}) one step before its final {@code build()} + * call - only when the target is constructed via a builder in the first place. Lets a caller set + * an {@code @DtoIgnore} property's real value (typically sourced from another query/service call + * entirely outside this mapper's own fetch graph) before finishing construction, e.g. + * {@code mapper.mapToBuilder(source).assignedMachines(loadMachines(source)).build()}. + *

+ * When named variants exist, the actual guarded builder chain (each variable property behind + * its own {@code includeXxx} ternary) is written exactly once, in a private flagged overload - + * both this public (always-full) accessor and the shared {@link #writeBuildMethod()} (used by + * each variant's own partial view) delegate to it, rather than each keeping their own copy. */ - private void writeConstructionExpression(String linePrefix, String rootVariable, Set variableProps, String wrapperClosing) { - String targetShort = Split.shortName(meta.targetFullName()); + private void writeMapToBuilderMethod() { if (meta.builderMeta() == null) { - writer.append("%snew %s(", linePrefix, targetShort).eol(); - writeConstructionArgs(rootVariable, variableProps, ")" + wrapperClosing + ";"); - } else { - writer.append("%s%s.builder()", linePrefix, targetShort).eol(); - writeBuilderChain(rootVariable, variableProps); - writer.append(" .build()%s;", wrapperClosing).eol(); + return; } + String sourceShort = Split.shortName(meta.sourceFullName()); + String targetShort = Split.shortName(meta.targetFullName()); + String builderShort = meta.builderMeta().builderTypeShortName(); + Set variableProps = variableProperties(); + writer.append(" /**").eol(); + writer.append(" * Map to the target's builder, one step before its final build() call - lets a caller").eol(); + writer.append(" * set an @DtoIgnore property's real value before finishing construction.").eol(); + writer.append(" */").eol(); + writer.append(" public %s mapToBuilder(%s source) {", builderShort, sourceShort).eol(); + writer.append(" return mapToBuilder(source, new DtoMapContext());").eol(); + writer.append(" }").eol().eol(); + + writer.append(" public %s mapToBuilder(%s source, DtoMapContext context) {", builderShort, sourceShort).eol(); + writer.append(" if (source == null) {").eol(); + writer.append(" return null;").eol(); + writer.append(" }").eol(); + if (variableProps.isEmpty()) { + writer.append(" return %s.builder()", targetShort).eol(); + writeBuilderChain("source", variableProps); + writer.append(" ;").eol(); + } else { + // this (base) mapping always includes every variable property - delegate to the shared + // flagged overload below rather than re-emitting the chain here + writer.append(" return mapToBuilder(source, context%s);", buildCallArgs(variableProps)).eol(); + } + writer.append(" }").eol().eol(); + + if (!variableProps.isEmpty()) { + writer.append(" private %s mapToBuilder(%s source, DtoMapContext context%s) {", + builderShort, sourceShort, buildMethodParams(variableProps)).eol(); + writer.append(" return %s.builder()", targetShort).eol(); + writeBuilderChain("source", variableProps); + writer.append(" ;").eol(); + writer.append(" }").eol().eol(); + } + } + + /** + * Emit the target construction expression - a positional constructor call, a + * {@code Target.builder()....build()} fluent chain (when {@link DtoBeanMeta#builderMeta()} is + * present, see {@code @DtoMapping(builder = ...)}), or a setter-based + * {@code Target target = new Target(); target.setX(...); ...; return target;} statement block + * (when {@link DtoBeanMeta#setterMeta()} is present, see {@code @DtoMapping(setter = ...)}). + *

+ * {@code computeIfAbsent} selects between a plain {@code return EXPR;} (the common case) and a + * {@code return context.computeIfAbsent(Target.class, source, s -> EXPR)} wrapper (only used + * when {@link DtoBeanMeta#nestedElsewhere()}, so the same source instance reached via more than + * one path in the graph maps to the same target instance) - the setter-based case needs its own + * block-lambda form (see {@link #writeSetterConstruction}) since, unlike the other two + * strategies, it's a multi-statement construction rather than a single expression. Every + * property - including ones in {@code variableProps} - is evaluated inline, in true + * {@link DtoBeanMeta#properties()} declared order; a variable property's expression is simply + * guarded by its {@code includeXxx} ternary (see {@link #writeBuildMethod()}), so no property's + * evaluation is ever hoisted out of its declared position (only non-empty when named variants + * exist - see {@link #variableProperties()}). + */ + private void writeConstructionExpression(boolean computeIfAbsent, Set variableProps) { + String targetShort = Split.shortName(meta.targetFullName()); + String rootVariable = computeIfAbsent ? "s" : "source"; + if (meta.setterMeta() != null) { + writeSetterConstruction(computeIfAbsent, targetShort, rootVariable, variableProps); + return; + } + String returnPrefix = computeIfAbsent + ? String.format(" return context.computeIfAbsent(%s.class, source, s -> ", targetShort) + : " return "; + String closing = computeIfAbsent ? ")" : ""; + if (meta.builderMeta() == null) { + writer.append("%snew %s(", returnPrefix, targetShort).eol(); + writeConstructionArgs(rootVariable, variableProps, ")" + closing + ";"); + } else { + writer.append("%s%s.builder()", returnPrefix, targetShort).eol(); + writeBuilderChain(rootVariable, variableProps); + writer.append(" .build()%s;", closing).eol(); + } + } + + /** + * Emit setter-based (mutable JavaBean) construction - see {@code @DtoMapping(setter = ...)}, + * {@link DtoSetterMeta}. Unlike a positional constructor call or a builder chain (both single + * expressions), this is a multi-statement local-variable-then-setter-calls block, so the + * {@code computeIfAbsent(...)}-wrapped (nested-elsewhere) case needs an explicit block lambda + * (curly braces + its own {@code return}) rather than a single continued expression. + */ + private void writeSetterConstruction(boolean computeIfAbsent, String targetShort, String rootVariable, Set variableProps) { + String indent = computeIfAbsent ? " " : " "; + if (computeIfAbsent) { + writer.append(" return context.computeIfAbsent(%s.class, source, s -> {", targetShort).eol(); + } + writer.append("%s%s target = new %s();", indent, targetShort, targetShort).eol(); + for (DtoPropertyMeta property : meta.properties()) { + writer.append("%starget.%s(%s);", indent, setterName(property.dtoFieldName()), + constructionValueExpression(property, rootVariable, variableProps)).eol(); + } + writer.append("%sreturn target;", indent).eol(); + if (computeIfAbsent) { + writer.append(" });").eol(); + } + } + + /** {@code referenceCode} -> {@code setReferenceCode} (standard JavaBean setter naming convention) - mirrors {@code DtoMappingReader.setterName}. */ + private String setterName(String propertyName) { + return "set" + Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1); } private void writeConstructionArgs(String rootVariable, Set variableProps, String closing) { @@ -471,6 +600,10 @@ class DtoMapperWriter { * to be excluded by a variant. */ private String constructionValueExpression(DtoPropertyMeta property, String rootVariable, Set variableProps) { + if (property.isIgnored()) { + // permanently excluded (see DtoIgnore) - never resolved from source, regardless of variant + return defaultValueFor(property); + } String expression = propertyValueExpression(property, rootVariable); return variableProps.contains(property) ? includeFlagName(property) + " ? " + expression + " : " + defaultValueFor(property) @@ -521,10 +654,25 @@ class DtoMapperWriter { String targetShort = Split.shortName(meta.targetFullName()); writer.append(" private %s build(%s source, DtoMapContext context%s) {", targetShort, sourceShort, buildMethodParams(variableProps)).eol(); - writeConstructionExpression(" return ", "source", variableProps, ""); + if (meta.builderMeta() != null) { + // reuse the very same flagged builder chain as the private mapToBuilder(...) overload + // (see writeMapToBuilderMethod()) - no second copy of it here + writer.append(" return mapToBuilder(source, context%s).build();", buildMethodArgs(variableProps)).eol(); + } else { + writeConstructionExpression(false, variableProps); + } writer.append(" }").eol().eol(); } + /** Forward each variable property's own {@code includeXxx} parameter unchanged - used when one method delegates its flags to another (e.g. {@code build()} delegating to the flagged {@code mapToBuilder(...)} overload). */ + private String buildMethodArgs(Set variableProps) { + StringBuilder sb = new StringBuilder(); + for (DtoPropertyMeta property : variableProps) { + sb.append(", ").append(includeFlagName(property)); + } + return sb.toString(); + } + private String buildMethodParams(Set variableProps) { StringBuilder sb = new StringBuilder(); for (DtoPropertyMeta property : variableProps) { @@ -540,12 +688,13 @@ class DtoMapperWriter { } /** - * The empty/default value a variant supplies for a property it excludes - {@code null} for a - * {@code NESTED_ONE}, {@code List.of()} for a {@code NESTED_MANY} (always valid since - * {@code NESTED_MANY} only ever derives from a {@code java.util.List} target property). + * The empty/default value a variant supplies for a property it excludes - {@code List.of()} + * when the property's DTO field type is a {@code List} ({@link DtoPropertyMeta.Kind#NESTED_MANY}, + * always list-shaped, or a {@link DtoPropertyMeta.Kind#SCALAR}/{@code @DtoConvert}-backed + * property whose field happens to be a {@code List}), {@code null} otherwise. */ private String defaultValueFor(DtoPropertyMeta property) { - return property.kind() == DtoPropertyMeta.Kind.NESTED_MANY ? "List.of()" : "null"; + return property.isListTarget() ? "List.of()" : "null"; } /** @@ -555,6 +704,13 @@ class DtoMapperWriter { * {@code DtoMapContext} identity-dedup wrapper used by the base mapping when * {@link DtoBeanMeta#nestedElsewhere()} - named variants are only ever used as an independent, * top-level {@code query.mapTo(...)} result, never nested inside another DTO graph. + *

+ * When the target uses a builder, each variant additionally gets its own named + * {@code mapToBuilder(source, context)} accessor (e.g. {@code mapToBuilderNoFleets(...)}) + * - not a raw flags parameter, and not a method on the variant's own (deliberately {@code + * private}, inaccessible-by-type-from-outside) inner class - {@code mapToBuilder(...)} isn't + * part of the {@link DtoMapper} interface (builder types vary per target), so there'd be no way + * for external code to call it on a {@code DtoMapper}-typed variant view at all. */ private void writeVariantAccessors() { Set variableProps = variableProperties(); @@ -567,11 +723,35 @@ class DtoMapperWriter { writer.append(" return %s;", variant.fieldName()).eol(); writer.append(" }").eol().eol(); } + if (meta.builderMeta() != null) { + for (DtoMapperVariant variant : meta.variants()) { + writeVariantMapToBuilderMethods(variant, variableProps, sourceShort); + } + } for (DtoMapperVariant variant : meta.variants()) { writeVariantInnerClass(variant, variableProps, sourceShort, targetShort); } } + /** + * Write one named variant's {@code mapToBuilder(source, context)} (+ single-arg + * convenience overload) - delegates to the same shared, once-written flagged + * {@code mapToBuilder(source, context, includeXxx...)} overload (see + * {@link #writeMapToBuilderMethod()}) with this variant's own excluded properties fixed to + * {@code false}, so no property's chain expression is ever duplicated per variant. + */ + private void writeVariantMapToBuilderMethods(DtoMapperVariant variant, Set variableProps, String sourceShort) { + String builderShort = meta.builderMeta().builderTypeShortName(); + writer.append(" /** {@code mapToBuilder(...)} for the {%s} variant - see {@link #%s()}. */", + variant.name(), variant.name()).eol(); + writer.append(" public %s mapToBuilder%s(%s source, DtoMapContext context) {", builderShort, variant.suffix(), sourceShort).eol(); + writer.append(" return mapToBuilder(source, context%s);", variantBuildArgs(variant, variableProps)).eol(); + writer.append(" }").eol().eol(); + writer.append(" public %s mapToBuilder%s(%s source) {", builderShort, variant.suffix(), sourceShort).eol(); + writer.append(" return mapToBuilder%s(source, new DtoMapContext());", variant.suffix()).eol(); + writer.append(" }").eol().eol(); + } + private void writeVariantInnerClass(DtoMapperVariant variant, Set variableProps, String sourceShort, String targetShort) { writer.append(" private final class %s implements DtoMapper<%s, %s> {", variant.innerClassName(), sourceShort, targetShort).eol().eol(); writer.append(" @Override").eol(); diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMappingReader.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMappingReader.java index 32d121931..f6b0a5d20 100644 --- a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMappingReader.java +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMappingReader.java @@ -52,6 +52,16 @@ class DtoMappingReader { /** {@code builder()} attribute value of each target's base (unnamed) registration. */ private final Map builderModeByTarget = new LinkedHashMap<>(); + /** {@code setter()} attribute value of each target's base (unnamed) registration. */ + private final Map setterModeByTarget = new LinkedHashMap<>(); + + /** + * {@code @DtoConverters}-registered type-pair conversion methods, keyed by + * {@link #converterKey(TypeMirror, TypeMirror)} (exact parameter type -> exact return type) - + * see {@link #collectTypeConverters(RoundEnvironment)}/{@link #autoTypeConverter}. + */ + private final Map typeConverters = new LinkedHashMap<>(); + private static final class RawVariant { private final Element declaringElement; private final TypeElement source; @@ -108,6 +118,7 @@ class DtoMappingReader { } } collectMixins(roundEnv); + collectTypeConverters(roundEnv); } /** @@ -146,6 +157,89 @@ class DtoMappingReader { } } + /** + * Discover package/module-level {@code @DtoConverters({ConverterType.class, ...})} + * declarations (proactively, via {@code roundEnv.getElementsAnnotatedWith(...)} - same + * rationale as {@link #collectMixins}: a package-level annotation isn't tied to any specific + * field being resolved, so it can't be discovered lazily the way a per-property + * {@code @DtoConvert} is). Indexes every public, single-parameter, non-{@code void}-returning + * method declared directly on each referenced type by its exact (parameter type, return type) + * pair - see {@link #autoTypeConverter}. + */ + private void collectTypeConverters(RoundEnvironment roundEnv) { + TypeElement annotationType = ctx.elementUtils().getTypeElement(Constants.DTO_CONVERTERS); + if (annotationType == null) { + return; + } + for (Element element : roundEnv.getElementsAnnotatedWith(annotationType)) { + DtoConvertersPrism prism = DtoConvertersPrism.getInstanceOn(element); + if (prism == null) { + continue; + } + for (TypeMirror converterTypeMirror : prism.value()) { + TypeElement converterType = asTypeElement(converterTypeMirror); + if (converterType == null) { + ctx.logError(element, "@DtoConverters value() must be class types"); + continue; + } + registerTypeConverterMethods(converterType); + } + } + } + + /** + * Index every public, single-parameter, non-{@code void}-returning method declared directly on + * {@code converterType} by its exact (parameter type, return type) pair, logging an error for + * any pair that would otherwise ambiguously match more than one registered method (across this + * or any other {@code @DtoConverters}-registered type). + */ + private void registerTypeConverterMethods(TypeElement converterType) { + for (ExecutableElement method : ElementFilter.methodsIn(converterType.getEnclosedElements())) { + if (!method.getModifiers().contains(Modifier.PUBLIC) || method.getParameters().size() != 1 + || method.getReturnType().getKind() == TypeKind.VOID) { + continue; + } + TypeMirror paramType = method.getParameters().get(0).asType(); + TypeMirror returnType = method.getReturnType(); + String key = converterKey(paramType, returnType); + ExecutableElement existing = typeConverters.get(key); + if (existing != null) { + ctx.logError(method, "@DtoConverters: ambiguous type-pair conversion (%s -> %s) - %s.%s and %s.%s" + + " both match this exact type pair; only one registered method per type pair is allowed", + paramType, returnType, ((TypeElement) existing.getEnclosingElement()).getQualifiedName(), + existing.getSimpleName(), converterType.getQualifiedName(), method.getSimpleName()); + continue; + } + typeConverters.put(key, method); + } + } + + /** + * Auto-apply a {@code @DtoConverters}-registered type-pair conversion when {@code explicit} is + * {@code null} (no per-property {@code @DtoConvert} present) and {@code sourceType} doesn't + * already exactly match {@code targetType}. Matched by exact type equality on both sides - no + * widening/supertype matching - so which method (if any) applies stays fully predictable. Never + * overrides an explicit per-property {@code @DtoConvert} - only ever consulted when + * {@code explicit} is already {@code null}. + */ + private DtoConverterMeta autoTypeConverter(DtoConverterMeta explicit, TypeMirror sourceType, TypeMirror targetType) { + if (explicit != null || sourceType == null || typeConverters.isEmpty() + || ctx.typeUtils().isSameType(sourceType, targetType)) { + return explicit; + } + ExecutableElement method = typeConverters.get(converterKey(sourceType, targetType)); + if (method == null) { + return null; + } + TypeElement converterType = (TypeElement) method.getEnclosingElement(); + boolean isStatic = method.getModifiers().contains(Modifier.STATIC); + return new DtoConverterMeta(converterType.getQualifiedName().toString(), method.getSimpleName().toString(), isStatic); + } + + private String converterKey(TypeMirror paramType, TypeMirror returnType) { + return paramType.toString() + "->" + returnType.toString(); + } + private void addAnnotatedElements(RoundEnvironment roundEnv, String annotationTypeName, Set elements) { TypeElement annotationType = ctx.elementUtils().getTypeElement(annotationTypeName); if (annotationType != null) { @@ -194,6 +288,7 @@ class DtoMappingReader { resolveVariants(); for (DtoBeanMeta meta : byTargetName.values()) { resolveBuilder(meta, builderModeByTarget.get(meta.targetFullName())); + resolveSetter(meta, setterModeByTarget.get(meta.targetFullName())); } List result = excludeCycles(); markNestedElsewhere(result); @@ -204,7 +299,8 @@ class DtoMappingReader { * Attach each raw {@code @DtoMapping(name = ..., exclude = ...)} variant registration (see * {@link #register}) to its base mapping's {@link DtoBeanMeta}, validating that a base exists, * variant names are unique per target, the variant's source matches the base's source, and - * each excluded name resolves to a {@code NESTED_ONE}/{@code NESTED_MANY} property. + * each excluded name resolves to a {@code NESTED_ONE}/{@code NESTED_MANY} property, or a + * non-primitive {@code SCALAR} property. */ private void resolveVariants() { for (var entry : variantsByTarget.entrySet()) { @@ -239,9 +335,12 @@ class DtoMappingReader { /** * Validate and resolve one variant's {@code exclude()} property names against {@code meta}'s - * already-resolved properties - only {@code NESTED_ONE}/{@code NESTED_MANY} properties can be - * excluded (there's no type-safe "absent" value for an arbitrary scalar type). Returns - * {@code null} (with error(s) already logged) if anything doesn't resolve. + * already-resolved properties - a {@code NESTED_ONE}/{@code NESTED_MANY} property can always be + * excluded (falls back to {@code null}/{@code List.of()}); a plain {@link DtoPropertyMeta.Kind#SCALAR} + * property (e.g. a {@code @DtoConvert}-backed property with no registered nested DTO mapping of + * its own) can also be excluded as long as its DTO field type isn't a Java primitive - there's + * no type-safe "absent" value for those. Returns {@code null} (with error(s) already logged) if + * anything doesn't resolve. */ private Set resolveExcludedProperties(DtoBeanMeta meta, RawVariant raw) { if (raw.exclude().isEmpty()) { @@ -257,18 +356,36 @@ class DtoMappingReader { ctx.logError(raw.declaringElement(), "@DtoMapping(name = \"%s\") exclude(\"%s\") does not match" + " any property on target %s", raw.name(), propName, meta.targetFullName()); ok = false; - } else if (property.kind() != DtoPropertyMeta.Kind.NESTED_ONE && property.kind() != DtoPropertyMeta.Kind.NESTED_MANY) { + } else if (property.isIgnored()) { ctx.logError(raw.declaringElement(), "@DtoMapping(name = \"%s\") exclude(\"%s\") on target %s is" - + " not a nested ToOne/ToMany DTO property - only nested properties can be excluded (there's" - + " no type-safe \"absent\" value for an arbitrary scalar type)", raw.name(), propName, meta.targetFullName()); + + " already @DtoIgnore - it's permanently excluded from every mapping already, remove it from" + + " this variant's exclude() list", raw.name(), propName, meta.targetFullName()); + ok = false; + } else if (isExcludable(property)) { + result.add(propName); + } else if (property.kind() == DtoPropertyMeta.Kind.SCALAR) { + ctx.logError(raw.declaringElement(), "@DtoMapping(name = \"%s\") exclude(\"%s\") on target %s" + + " is a primitive property - there's no type-safe \"absent\" value for it, so it can't be" + + " excluded from a named variant", raw.name(), propName, meta.targetFullName()); ok = false; } else { - result.add(propName); + ctx.logError(raw.declaringElement(), "@DtoMapping(name = \"%s\") exclude(\"%s\") on target %s is" + + " not a nested ToOne/ToMany DTO property or a non-primitive scalar - only those can be" + + " excluded (there's no type-safe \"absent\" value for an arbitrary scalar type)", raw.name(), propName, meta.targetFullName()); + ok = false; } } return ok ? result : null; } + /** {@code true} if {@code property} can be excluded from a named variant - see {@link #resolveExcludedProperties}. */ + private boolean isExcludable(DtoPropertyMeta property) { + if (property.kind() == DtoPropertyMeta.Kind.NESTED_ONE || property.kind() == DtoPropertyMeta.Kind.NESTED_MANY) { + return true; + } + return property.kind() == DtoPropertyMeta.Kind.SCALAR && !property.isPrimitiveTarget(); + } + private DtoPropertyMeta findProperty(DtoBeanMeta meta, String name) { for (DtoPropertyMeta property : meta.properties()) { if (property.dtoFieldName().equals(name)) { @@ -344,6 +461,111 @@ class DtoMappingReader { return new DtoBuilderMeta(builderType.getQualifiedName().toString()); } + /** + * Decide (and, if applicable, detect) the setter-based construction path for {@code meta} - see + * {@code @DtoMapping(setter = ...)} and {@link DtoSetterMeta}. A no-op if a builder was already + * selected for {@code meta} (see {@link #resolveBuilder}) - a builder always takes priority. + * {@code NEVER} always uses a positional constructor. {@code ALWAYS} requires a matching + * no-arg-constructor-plus-setters shape to be found (a codegen error otherwise). {@code AUTO} + * (the default) only attempts detection when the target has no positional constructor matching + * the mapped properties (arity-based - see {@link #hasPositionalConstructor}), silently falling + * back to a positional constructor call if no matching setter shape is found either. + */ + private void resolveSetter(DtoBeanMeta meta, String setterMode) { + if (meta.builderMeta() != null) { + return; + } + String mode = (setterMode == null || setterMode.isEmpty()) ? "AUTO" : setterMode; + if ("NEVER".equals(mode)) { + return; + } + boolean required = "ALWAYS".equals(mode); + if (!required && hasPositionalConstructor(meta.target(), meta.properties().size())) { + return; + } + DtoSetterMeta setter = detectSetter(meta, required); + if (setter != null) { + meta.setterMeta(setter); + } + } + + /** + * Detect a plain mutable-JavaBean shape on {@code meta.target()}: a public no-arg constructor + * plus a void {@code setXxx(...)} setter for every one of {@code meta}'s properties. Returns + * {@code null} if any part of that shape is missing - logging a codegen error only when + * {@code required} (i.e. {@code setter = ALWAYS}); silent otherwise (i.e. {@code setter = AUTO}, + * where a missing shape just means "use a positional constructor instead"). + */ + private DtoSetterMeta detectSetter(DtoBeanMeta meta, boolean required) { + TypeElement target = meta.target(); + if (!hasPublicNoArgConstructor(target)) { + if (required) { + ctx.logError(target, "@DtoMapping(setter = ALWAYS) on target %s but no public no-arg" + + " constructor was found", meta.targetFullName()); + } + return null; + } + for (DtoPropertyMeta property : meta.properties()) { + if (findSetter(target, property.dtoFieldName()) == null) { + if (required) { + ctx.logError(target, "@DtoMapping(setter = ALWAYS) on target %s but no public" + + " \"%s(...)\" setter method was found", meta.targetFullName(), setterName(property.dtoFieldName())); + } + return null; + } + } + return new DtoSetterMeta(); + } + + private boolean hasPublicNoArgConstructor(TypeElement type) { + for (ExecutableElement ctor : ElementFilter.constructorsIn(type.getEnclosedElements())) { + if (ctor.getParameters().isEmpty() && ctor.getModifiers().contains(Modifier.PUBLIC)) { + return true; + } + } + return false; + } + + /** {@code true} if {@code type} has any declared constructor with exactly {@code arity} parameters - the shape a positional constructor call assumes. */ + private boolean hasPositionalConstructor(TypeElement type, int arity) { + for (ExecutableElement ctor : ElementFilter.constructorsIn(type.getEnclosedElements())) { + if (ctor.getParameters().size() == arity) { + return true; + } + } + return false; + } + + /** + * Find a public single-arg {@code setXxx(...)} method on {@code type} - either {@code void} + * or fluent-style (returning {@code type} itself, e.g. {@code public Target setXxx(...) { ...; + * return this; }}). Either shape is accepted since the generated code always calls the setter + * as a bare statement and discards any return value. + */ + private ExecutableElement findSetter(TypeElement type, String propertyName) { + String setterName = setterName(propertyName); + for (ExecutableElement method : ElementFilter.methodsIn(type.getEnclosedElements())) { + if (method.getSimpleName().contentEquals(setterName) && method.getParameters().size() == 1 + && method.getModifiers().contains(Modifier.PUBLIC) && isVoidOrFluentReturn(method, type)) { + return method; + } + } + return null; + } + + /** {@code true} if {@code method} returns {@code void}, or returns {@code declaringType} itself (fluent-style setter). */ + private boolean isVoidOrFluentReturn(ExecutableElement method, TypeElement declaringType) { + TypeMirror returnType = method.getReturnType(); + return returnType.getKind() == TypeKind.VOID + || (returnType.getKind() == TypeKind.DECLARED + && ctx.typeUtils().isSameType(ctx.typeUtils().erasure(returnType), ctx.typeUtils().erasure(declaringType.asType()))); + } + + /** {@code referenceCode} -> {@code setReferenceCode} (standard JavaBean setter naming convention). */ + private String setterName(String propertyName) { + return "set" + Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1); + } + private ExecutableElement findStaticNoArgMethod(TypeElement type, String name) { for (ExecutableElement method : ElementFilter.methodsIn(type.getEnclosedElements())) { if (method.getSimpleName().contentEquals(name) && method.getModifiers().contains(Modifier.STATIC) @@ -400,8 +622,10 @@ class DtoMappingReader { return; } String mapperPackage = resolveMapperPackage(prism, targetType, declaringElement); - byTargetName.put(targetFqn, new DtoBeanMeta(sourceType, targetType, mapperPackage)); + String mapperName = prism.mapperName() == null ? "" : prism.mapperName().trim(); + byTargetName.put(targetFqn, new DtoBeanMeta(sourceType, targetType, mapperPackage, mapperName)); builderModeByTarget.put(targetFqn, prism.builder()); + setterModeByTarget.put(targetFqn, prism.setter()); } else { variantsByTarget.computeIfAbsent(targetFqn, t -> new ArrayList<>()) .add(new RawVariant(declaringElement, sourceType, name, prism.exclude())); @@ -449,9 +673,13 @@ class DtoMappingReader { private DtoPropertyMeta resolveProperty(VariableElement field, DtoBeanMeta meta) { String name = field.getSimpleName().toString(); + DtoIgnorePrism ignorePrism = prismOn(field, meta, DtoIgnorePrism::getInstanceOn); DtoConverterMeta converter = resolveConverter(field, meta); DtoRefPrism refPrism = prismOn(field, meta, DtoRefPrism::getInstanceOn); DtoPathPrism pathPrism = prismOn(field, meta, DtoPathPrism::getInstanceOn); + if (ignorePrism != null) { + return resolveIgnoredProperty(field, meta, name, converter, refPrism, pathPrism); + } if (refPrism != null && pathPrism != null) { ctx.logError(field, "%s.%s carries both @DtoRef and @DtoPath - these are mutually exclusive" + " (@DtoRef always wins silently otherwise), remove whichever doesn't apply", @@ -490,13 +718,15 @@ class DtoMappingReader { // @DtoRef has no failOnNull escape hatch - always default to the primitive's zero-equivalent // rather than let a null-guarded getter chain auto-unbox to a NullPointerException. return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.REF, List.of(assocGetter, idGetter), List.of(assocName, "id"), - null, converter, field.asType().getKind().isPrimitive(), false, computedSegment, requiredFetchPaths); + null, converter, field.asType().getKind().isPrimitive(), false, computedSegment, requiredFetchPaths, false, false); } if (pathPrism != null) { List getters = new ArrayList<>(); List properties = new ArrayList<>(); TypeElement currentType = meta.source(); TypeElement computedDeclaringType = null; + TypeElement lastOwnerType = null; + String lastGetter = null; int computedFrom = -1; for (String segment : pathPrism.value().split("\\.")) { String getter = getterName(currentType, segment); @@ -506,6 +736,8 @@ class DtoMappingReader { computedFrom = properties.size() - 1; computedDeclaringType = currentType; } + lastOwnerType = currentType; + lastGetter = getter; currentType = currentType != null ? getterReturnType(currentType, getter) : null; } List requiredFetchPaths = List.of(); @@ -586,8 +818,12 @@ class DtoMappingReader { // primitive, the generated null-guarded getter chain would otherwise auto-unbox a null // straight into a NullPointerException. Default to the primitive's zero-equivalent value, // or fail fast with a clear message instead when @DtoPath(failOnNull = true). - return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.SCALAR, getters, properties, null, converter, - field.asType().getKind().isPrimitive(), pathPrism.failOnNull(), computedFrom >= 0, requiredFetchPaths); + boolean isListTarget = listElementType(field.asType()) != null; + DtoConverterMeta pathConverter = isListTarget ? converter + : autoTypeConverter(converter, lastOwnerType != null ? getterReturnTypeMirror(lastOwnerType, lastGetter) : null, field.asType()); + return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.SCALAR, getters, properties, null, pathConverter, + field.asType().getKind().isPrimitive(), pathPrism.failOnNull(), computedFrom >= 0, requiredFetchPaths, + isListTarget, false); } TypeMirror fieldType = field.asType(); TypeMirror listElementType = listElementType(fieldType); @@ -604,7 +840,34 @@ class DtoMappingReader { return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.NESTED_ONE, List.of(getterName(meta.source(), name)), List.of(name), nested); } } - return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.SCALAR, List.of(getterName(meta.source(), name)), List.of(name), null, converter); + String getter = getterName(meta.source(), name); + DtoConverterMeta scalarConverter = listElementType != null ? converter + : autoTypeConverter(converter, getterReturnTypeMirror(meta.source(), getter), fieldType); + return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.SCALAR, List.of(getter), List.of(name), null, scalarConverter, + fieldType.getKind().isPrimitive(), listElementType != null); + } + + /** + * Resolve a {@code @DtoIgnore} property - permanently excluded from every mapping (base and + * every named variant alike), always given its empty default rather than resolved from any + * source getter/path at all (see {@code DtoIgnore}'s javadoc). Validates it isn't combined with + * {@code @DtoRef}/{@code @DtoPath}/{@code @DtoConvert} (all pointless - there's no source + * expression to convert/redirect when nothing is ever read from the source) and isn't a + * primitive-typed field (no type-safe "absent" value for it). + */ + private DtoPropertyMeta resolveIgnoredProperty(VariableElement field, DtoBeanMeta meta, String name, + DtoConverterMeta converter, DtoRefPrism refPrism, DtoPathPrism pathPrism) { + if (converter != null || refPrism != null || pathPrism != null) { + ctx.logError(field, "@DtoIgnore on %s.%s cannot be combined with @DtoConvert/@DtoRef/@DtoPath -" + + " an @DtoIgnore property is never resolved from the source at all, so there's no source" + + " expression for those to act on. Remove @DtoIgnore or the other annotation(s).", + meta.targetFullName(), name); + } + if (field.asType().getKind().isPrimitive()) { + ctx.logError(field, "@DtoIgnore cannot be applied to primitive-typed property %s.%s - there's" + + " no type-safe \"absent\" value for it", meta.targetFullName(), name); + } + return DtoPropertyMeta.ignored(name, listElementType(field.asType()) != null); } /** diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoPropertyMeta.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoPropertyMeta.java index 195e2f922..d3ca8d21c 100644 --- a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoPropertyMeta.java +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoPropertyMeta.java @@ -29,11 +29,24 @@ class DtoPropertyMeta { private final boolean failOnNull; private final boolean computedSegment; private final List requiredFetchPaths; + private final boolean listTarget; + private final boolean ignored; DtoPropertyMeta(String dtoFieldName, Kind kind, List sourceGetterPath, List sourcePropertyPath, DtoBeanMeta nested) { this(dtoFieldName, kind, sourceGetterPath, sourcePropertyPath, nested, null); } + /** + * {@code @DtoIgnore} factory - a property permanently excluded from every mapping (base and + * every named variant alike), always given its empty default rather than resolved from any + * source getter/path at all - see {@code DtoIgnore}'s javadoc. Kept as {@link Kind#SCALAR} with + * empty getter/property paths since {@link DtoMapperWriter} short-circuits on {@link #isIgnored()} + * before ever consulting them. + */ + static DtoPropertyMeta ignored(String dtoFieldName, boolean listTarget) { + return new DtoPropertyMeta(dtoFieldName, Kind.SCALAR, List.of(), List.of(), null, null, false, false, false, List.of(), listTarget, true); + } + /** * {@code NESTED_ONE}/{@code NESTED_MANY} constructor variant for a single-hop {@code @DtoPath} * rename that traverses a computed/derived getter segment (no backing field) - see @@ -43,16 +56,21 @@ class DtoPropertyMeta { */ DtoPropertyMeta(String dtoFieldName, Kind kind, List sourceGetterPath, List sourcePropertyPath, DtoBeanMeta nested, boolean computedSegment, List requiredFetchPaths) { - this(dtoFieldName, kind, sourceGetterPath, sourcePropertyPath, nested, null, false, false, computedSegment, requiredFetchPaths); + this(dtoFieldName, kind, sourceGetterPath, sourcePropertyPath, nested, null, false, false, computedSegment, requiredFetchPaths, false, false); } DtoPropertyMeta(String dtoFieldName, Kind kind, List sourceGetterPath, List sourcePropertyPath, DtoBeanMeta nested, DtoConverterMeta converter) { this(dtoFieldName, kind, sourceGetterPath, sourcePropertyPath, nested, converter, false, false); } + /** + * Plain (no {@code @DtoPath}) {@link Kind#SCALAR} constructor variant that also records whether + * the DTO field type is a {@code java.util.List} - see {@code listTarget} on the full + * constructor. + */ DtoPropertyMeta(String dtoFieldName, Kind kind, List sourceGetterPath, List sourcePropertyPath, - DtoBeanMeta nested, DtoConverterMeta converter, boolean primitiveTarget, boolean failOnNull) { - this(dtoFieldName, kind, sourceGetterPath, sourcePropertyPath, nested, converter, primitiveTarget, failOnNull, false, List.of()); + DtoBeanMeta nested, DtoConverterMeta converter, boolean primitiveTarget, boolean listTarget) { + this(dtoFieldName, kind, sourceGetterPath, sourcePropertyPath, nested, converter, primitiveTarget, false, false, List.of(), listTarget, false); } /** @@ -63,11 +81,15 @@ class DtoPropertyMeta { * {@code @DtoPath} that traverses a computed/derived getter segment (no backing field) - see * {@link #hasComputedSegment()}; kept separate from whether {@code requiredFetchPaths} happens * to be empty, since {@code @DtoPath(requires = {})} legitimately declares "nothing extra - * needed" for a computed segment. + * needed" for a computed segment. {@code listTarget} is {@code true} when this property's DTO + * field type is a {@code java.util.List} - relevant only for {@link Kind#SCALAR} (e.g. a + * {@code @DtoConvert}-backed {@code List} property with no registered nested DTO mapping of its + * own, like a fleet list populated from ad-hoc SQL) - see + * {@code DtoMapperWriter#defaultValueFor}. */ DtoPropertyMeta(String dtoFieldName, Kind kind, List sourceGetterPath, List sourcePropertyPath, DtoBeanMeta nested, DtoConverterMeta converter, boolean primitiveTarget, boolean failOnNull, - boolean computedSegment, List requiredFetchPaths) { + boolean computedSegment, List requiredFetchPaths, boolean listTarget, boolean ignored) { this.dtoFieldName = dtoFieldName; this.kind = kind; this.sourceGetterPath = sourceGetterPath; @@ -78,6 +100,8 @@ class DtoPropertyMeta { this.failOnNull = failOnNull; this.computedSegment = computedSegment; this.requiredFetchPaths = requiredFetchPaths; + this.listTarget = listTarget; + this.ignored = ignored; } String dtoFieldName() { @@ -132,6 +156,36 @@ class DtoPropertyMeta { return computedSegment; } + /** + * {@code true} when this property's DTO field type is a Java primitive - a primitive property + * has no type-safe "absent" value, so can never be excluded by a named + * {@code @DtoMapping(name = ..., exclude = ...)} variant. + */ + boolean isPrimitiveTarget() { + return primitiveTarget; + } + + /** + * {@code true} when this property's DTO field type is a {@code java.util.List} - set for + * {@link Kind#NESTED_MANY} (always list-shaped) and also for a {@link Kind#SCALAR}/ + * {@code @DtoConvert}-backed property whose DTO field happens to be a {@code List} with no + * registered nested DTO mapping of its own (e.g. a fleet list populated from ad-hoc SQL) - + * used to pick {@code List.of()} rather than {@code null} as the excluded/empty default for a + * named variant, see {@code DtoMapperWriter#defaultValueFor}. + */ + boolean isListTarget() { + return listTarget || kind == Kind.NESTED_MANY; + } + + /** + * {@code true} when this property is marked {@code @DtoIgnore} - permanently excluded from + * every mapping (base and every named variant alike), always given its empty default rather + * than resolved from any source getter/path. + */ + boolean isIgnored() { + return ignored; + } + /** * Real entity paths that must be added to the {@code FetchGroup} to support this property's * computed/derived getter segment - the real prefix path (if any) followed by the declared diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoSetterMeta.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoSetterMeta.java new file mode 100644 index 000000000..8f9999790 --- /dev/null +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoSetterMeta.java @@ -0,0 +1,35 @@ +package io.ebean.querybean.generator; + +/** + * Marks a detected setter-based (mutable JavaBean) construction path for a {@link DtoBeanMeta}'s + * target - see {@code @DtoMapping(setter = ...)}. + *

+ * Detected via the plain JavaBean convention (the shape JAXB/XSD-generated legacy SOAP types + * commonly follow): a public no-arg constructor plus a public {@code setXxx(propertyType)} setter + * for every one of the target's mapped properties - either {@code void} or fluent-style + * (returning the target type itself, e.g. {@code public Target setXxx(...) { ...; return this; }}) + * are both accepted, since the generated code always calls the setter as a bare statement and + * discards any return value. When present (and either explicitly + * requested via {@code setter = ALWAYS}, or no builder was selected and the target has no + * positional constructor matching the mapped properties under {@code setter = AUTO}), the + * generated mapper constructs the target via {@code Target target = new Target(); + * target.setX(...); ...; return target;} instead of a positional constructor call or a builder + * chain. + *

+ * A pure marker (mirrors {@link DtoBuilderMeta}) - unlike a builder, a setter-constructed target + * needs no separate intermediate type name, since the setters are called directly on the target + * type itself. + *

+ * Deliberately no {@code mapToBuilder(...)}-style post-construction accessor is generated for this + * strategy: the returned target is already the final, fully mutable instance (its setters are + * required to be {@code public} - see above), so a caller can already override an + * {@code @DtoIgnore}/derived property directly, e.g. + * {@code Ebox ebox = mapper.map(source); ebox.setMachineSummaryInfo(loadSummary(source));} - + * exactly the pattern already used by hand-written mappers like central-access's + * {@code EboxMapper}. This is unlike the builder strategy, where the intermediate builder object + * is distinct from (and otherwise inaccessible after) the final, one-shot-{@code build()}ed, + * often-immutable target - {@code mapToBuilder(...)} exists there specifically to expose that + * otherwise-unreachable intermediate step. + */ +final class DtoSetterMeta { +} diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/Processor.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/Processor.java index 435babe14..213e06ff5 100644 --- a/querybean-generator/src/main/java/io/ebean/querybean/generator/Processor.java +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/Processor.java @@ -42,6 +42,7 @@ public class Processor extends AbstractProcessor implements Constants { annotations.add(DTO_MAPPING); annotations.add(DTO_MAPPING_LIST); annotations.add(DTO_MIXIN); + annotations.add(DTO_CONVERTERS); return annotations; } diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/package-info.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/package-info.java index d1f60038a..0b10b6dda 100644 --- a/querybean-generator/src/main/java/io/ebean/querybean/generator/package-info.java +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/package-info.java @@ -5,7 +5,9 @@ @GeneratePrism(io.ebean.annotation.DtoRef.class) @GeneratePrism(io.ebean.annotation.DtoMapping.class) @GeneratePrism(io.ebean.annotation.DtoConvert.class) +@GeneratePrism(io.ebean.annotation.DtoConverters.class) @GeneratePrism(io.ebean.annotation.DtoMixin.class) +@GeneratePrism(io.ebean.annotation.DtoIgnore.class) package io.ebean.querybean.generator; import io.avaje.prism.GeneratePrism; diff --git a/querybean-generator/src/test/java/io/ebean/querybean/generator/DtoMapperComputedPathTest.java b/querybean-generator/src/test/java/io/ebean/querybean/generator/DtoMapperComputedPathTest.java index 00e4074fd..9fd7081a6 100644 --- a/querybean-generator/src/test/java/io/ebean/querybean/generator/DtoMapperComputedPathTest.java +++ b/querybean-generator/src/test/java/io/ebean/querybean/generator/DtoMapperComputedPathTest.java @@ -811,4 +811,77 @@ class DtoMapperComputedPathTest { throw new UncheckedIOException(e); } } + + /** + * A named variant may exclude a plain non-primitive {@code SCALAR} property (not just + * {@code NESTED_ONE}/{@code NESTED_MANY}) - see docs/dto-mapping-requirements.md "Section G" + * follow-up - but a primitive-typed scalar property still can't be excluded (no type-safe + * "absent" value). + */ + @Test + void excludeVariant_onPrimitiveScalarProperty_expectCompileError() throws IOException { + Path sourceDir = Files.createTempDirectory("dto-variant-primitive-src"); + Path outDir = Files.createTempDirectory("dto-variant-primitive-out"); + writeSource(sourceDir, "org.tests.variantprimitive.Foo", + "package org.tests.variantprimitive;\n" + + "\n" + + "public class Foo {\n" + + " private String name;\n" + + " private int score;\n" + + "\n" + + " public String getName() { return name; }\n" + + " public int getScore() { return score; }\n" + + "}\n"); + writeSource(sourceDir, "org.tests.variantprimitive.FooDto", + "package org.tests.variantprimitive;\n" + + "\n" + + "public class FooDto {\n" + + " private final String name;\n" + + " private final int score;\n" + + "\n" + + " public FooDto(String name, int score) {\n" + + " this.name = name;\n" + + " this.score = score;\n" + + " }\n" + + "\n" + + " public String getName() { return name; }\n" + + " public int getScore() { return score; }\n" + + "}\n"); + writeSource(sourceDir, "org.tests.variantprimitive.package-info", + "@DtoMapping(source = Foo.class, target = FooDto.class)\n" + + "@DtoMapping(source = Foo.class, target = FooDto.class, name = \"noScore\", exclude = \"score\")\n" + + "package org.tests.variantprimitive;\n" + + "\n" + + "import io.ebean.annotation.DtoMapping;\n"); + + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, Locale.getDefault(), null)) { + List sourceFiles; + try (var walk = Files.walk(sourceDir)) { + sourceFiles = walk.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList()); + } + Iterable compilationUnits = + fileManager.getJavaFileObjectsFromPaths(sourceFiles); + + List options = List.of( + "-d", outDir.toString(), + "-classpath", System.getProperty("java.class.path"), + "-processor", Processor.class.getName()); + + JavaCompiler.CompilationTask task = compiler.getTask( + null, fileManager, diagnostics, options, null, compilationUnits); + boolean success = task.call(); + + assertFalse(success, "compilation should fail excluding a primitive scalar property from a variant"); + List> errors = diagnostics.getDiagnostics().stream() + .filter(d -> d.getKind() == Diagnostic.Kind.ERROR) + .collect(Collectors.toList()); + assertFalse(errors.isEmpty(), "expected at least one compile ERROR diagnostic"); + boolean matched = errors.stream() + .map(d -> d.getMessage(Locale.getDefault())) + .anyMatch(msg -> msg.contains("primitive property") && msg.contains("can't be")); + assertTrue(matched, "expected the primitive-scalar-exclusion error message, got: " + errors); + } + } } diff --git a/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/Contact.java b/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/Contact.java index 56685e520..40a917ba6 100644 --- a/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/Contact.java +++ b/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/Contact.java @@ -5,6 +5,8 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; +import java.util.UUID; + @Entity public class Contact extends Model { @@ -39,6 +41,13 @@ public class Contact extends Model { */ private String secretCode; + /** + * Stand-in for a common recurring type pair (e.g. {@code UUID <-> String}) - exercises + * {@code @DtoConverters} package-level type-pair auto-dispatch (requirement r21), where no + * per-property {@code @DtoConvert} is needed at all unless overriding the registered default. + */ + private UUID referenceCode; + public Contact(String firstName, String lastName, Customer customer) { this.firstName = firstName; this.lastName = lastName; @@ -104,4 +113,12 @@ public class Contact extends Model { public void setSecretCode(String secretCode) { this.secretCode = secretCode; } + + public UUID getReferenceCode() { + return referenceCode; + } + + public void setReferenceCode(UUID referenceCode) { + this.referenceCode = referenceCode; + } } diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/AddressSummaryDto.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/AddressSummaryDto.java new file mode 100644 index 000000000..91db2be3f --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/AddressSummaryDto.java @@ -0,0 +1,34 @@ +package org.tests.dtomapping; + +/** + * Plain DTO mapped from {@link org.tests.dtomapping.model.Address} - registered with an explicit + * {@code mapperName()} override (see {@code package-info.java}) so the generated mapper doesn't + * collide with the pre-existing hand-written {@link AddressSummaryDtoMapper} class of the default + * expected name - mirroring the real-world scenario that motivated {@code mapperName()} + * (central-access's legacy hand-written {@code FleetMapper} occupying the name a generated + * {@code CFleet -> Fleet} mapper would otherwise need). + */ +public class AddressSummaryDto { + + private final Long id; + private final String line1; + private final String city; + + public AddressSummaryDto(Long id, String line1, String city) { + this.id = id; + this.line1 = line1; + this.city = city; + } + + public Long getId() { + return id; + } + + public String getLine1() { + return line1; + } + + public String getCity() { + return city; + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/AddressSummaryDtoMapper.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/AddressSummaryDtoMapper.java new file mode 100644 index 000000000..bd7b50bf7 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/AddressSummaryDtoMapper.java @@ -0,0 +1,17 @@ +package org.tests.dtomapping; + +/** + * Stand-in for a pre-existing hand-written mapper class that already occupies the default + * generated-mapper name ({@code AddressSummaryDtoMapper}, derived from the {@code + * AddressSummaryDto} target's simple name). Deliberately unrelated to {@code io.ebean.DtoMapper} - + * its only purpose is to occupy the name so the {@code @DtoMapping(..., mapperName = "...")} + * override registered in {@code package-info.java} is proven necessary (without the override, + * codegen would emit a second, conflicting {@code AddressSummaryDtoMapper} class and fail to + * compile). + */ +public class AddressSummaryDtoMapper { + + public static String legacyDescribe() { + return "legacy hand-written mapper - not the generated one"; + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactIgnoreDto.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactIgnoreDto.java new file mode 100644 index 000000000..8274b354e --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactIgnoreDto.java @@ -0,0 +1,96 @@ +package org.tests.dtomapping; + +import io.ebean.annotation.DtoIgnore; + +import java.util.List; + +/** + * Builder-constructed DTO exercising {@code @DtoIgnore} - {@code externalRef} and + * {@code auditNotes} have no corresponding getter on {@link org.tests.dtomapping.model.Contact} + * at all (unlike a plain excluded/{@code @DtoConvert}-backed property, which still has some + * source expression - these have none), modelling a property only ever populated later by + * application code from an entirely different source (e.g. a separate ad-hoc query or service + * call), as {@code Fleet.assignedMachines}/{@code assignedDrivers} do in central-access. + */ +public class ContactIgnoreDto { + + private final Long id; + private final String firstName; + private final String lastName; + + @DtoIgnore + private final String externalRef; + + @DtoIgnore + private final List auditNotes; + + private ContactIgnoreDto(Builder builder) { + this.id = builder.id; + this.firstName = builder.firstName; + this.lastName = builder.lastName; + this.externalRef = builder.externalRef; + this.auditNotes = builder.auditNotes; + } + + public static Builder builder() { + return new Builder(); + } + + public Long getId() { + return id; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } + + public String getExternalRef() { + return externalRef; + } + + public List getAuditNotes() { + return auditNotes; + } + + public static final class Builder { + + private Long id; + private String firstName; + private String lastName; + private String externalRef; + private List auditNotes; + + public Builder id(Long id) { + this.id = id; + return this; + } + + public Builder firstName(String firstName) { + this.firstName = firstName; + return this; + } + + public Builder lastName(String lastName) { + this.lastName = lastName; + return this; + } + + public Builder externalRef(String externalRef) { + this.externalRef = externalRef; + return this; + } + + public Builder auditNotes(List auditNotes) { + this.auditNotes = auditNotes; + return this; + } + + public ContactIgnoreDto build() { + return new ContactIgnoreDto(this); + } + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactNameConverter.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactNameConverter.java new file mode 100644 index 000000000..ff9b9ba22 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactNameConverter.java @@ -0,0 +1,25 @@ +package org.tests.dtomapping; + +import org.tests.dtomapping.model.Contact; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Static converter for {@link CustomerTagsDto#getTags()} - collapses each {@link Contact} into a + * plain {@code "First Last"} string. Mirrors a real-world case where a DTO's {@code List} + * property has no registered nested DTO mapping of its own (e.g. it's populated from ad-hoc SQL, + * or - as here - reduced to a simpler element type), so it's handled as a plain {@code SCALAR} + * {@code @DtoConvert} rather than a {@code NESTED_MANY} delegate. + */ +public final class ContactNameConverter { + + private ContactNameConverter() { + } + + public static List toNames(List contacts) { + return contacts.stream() + .map(c -> c.getFirstName() + " " + c.getLastName()) + .collect(Collectors.toList()); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactSetterDto.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactSetterDto.java new file mode 100644 index 000000000..4f4ed6f56 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactSetterDto.java @@ -0,0 +1,74 @@ +package org.tests.dtomapping; + +import io.ebean.annotation.DtoIgnore; + +import java.util.List; + +/** + * Plain mutable JavaBean-shaped DTO (public no-arg constructor + public setters, mirroring + * JAXB/XSD-generated legacy SOAP types) exercising setter-based construction (requirement r22, + * "Section G: Setter-based (mutable JavaBean) target construction" in + * docs/dto-mapping-requirements.md) - detected automatically ({@code setter = AUTO}, the default) + * since this type has no positional constructor matching its properties, only the no-arg one. + *

+ * {@code externalRef}/{@code auditNotes} are {@code @DtoIgnore} - unlike the builder-based + * {@link ContactIgnoreDto}, no {@code mapToBuilder(...)}-style accessor is needed to populate them + * afterwards: the mapped target is already fully mutable, so a caller just calls its public + * setters directly, e.g. {@code dto.setExternalRef(loadExternalRef(...))} - see + * {@code TestDtoSetterConstruction}. + */ +public class ContactSetterDto { + + private long id; + private String firstName; + private String lastName; + + @DtoIgnore + private String externalRef; + + @DtoIgnore + private List auditNotes; + + public ContactSetterDto() { + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getExternalRef() { + return externalRef; + } + + public void setExternalRef(String externalRef) { + this.externalRef = externalRef; + } + + public List getAuditNotes() { + return auditNotes; + } + + public void setAuditNotes(List auditNotes) { + this.auditNotes = auditNotes; + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactSetterFluentDto.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactSetterFluentDto.java new file mode 100644 index 000000000..21cc94b65 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactSetterFluentDto.java @@ -0,0 +1,46 @@ +package org.tests.dtomapping; + +/** + * Mutable JavaBean-shaped DTO exercising setter-based construction (requirement r22) with + * fluent-style setters - each returns {@code this} rather than {@code void}, e.g. + * {@code public ContactSetterFluentDto setFirstName(String firstName) { this.firstName = + * firstName; return this; }}. Setter detection accepts either shape (see + * {@code DtoMappingReader.findSetter}) since the generated code always calls the setter as a bare + * statement and discards any return value - see {@code TestDtoSetterConstruction}. + */ +public class ContactSetterFluentDto { + + private long id; + private String firstName; + private String lastName; + + public ContactSetterFluentDto() { + } + + public long getId() { + return id; + } + + public ContactSetterFluentDto setId(long id) { + this.id = id; + return this; + } + + public String getFirstName() { + return firstName; + } + + public ContactSetterFluentDto setFirstName(String firstName) { + this.firstName = firstName; + return this; + } + + public String getLastName() { + return lastName; + } + + public ContactSetterFluentDto setLastName(String lastName) { + this.lastName = lastName; + return this; + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactTypeConverterDto.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactTypeConverterDto.java new file mode 100644 index 000000000..540f39fed --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactTypeConverterDto.java @@ -0,0 +1,51 @@ +package org.tests.dtomapping; + +import io.ebean.annotation.DtoConvert; +import io.ebean.annotation.DtoPath; + +/** + * Exercises {@code @DtoConverters} package-level type-pair auto-dispatch (requirement r21, + * "Section E: Type-pair (package-level) custom scalar conversion" in + * docs/dto-mapping-requirements.md) - {@code UUID -> String} is registered once (see + * {@code package-info.java}, {@link UuidConverters}) and auto-applied to any matching property + * with no per-property {@code @DtoConvert} needed. + */ +public class ContactTypeConverterDto { + + private final long id; + + /** Plain same-name property - {@code Contact.referenceCode} (a {@code UUID}) auto-converted via {@link UuidConverters#toHex}. */ + private final String referenceCode; + + /** Renamed via {@code @DtoPath} from the same field - proves auto-dispatch also applies on the renamed/multi-hop resolution path. */ + @DtoPath("referenceCode") + private final String referenceCodeRenamed; + + /** Explicit {@code @DtoConvert} on the same {@code UUID -> String} field - proves it overrides the registered package default. */ + @DtoPath("referenceCode") + @DtoConvert(value = UuidShortCodeConverter.class, method = "toShortCode") + private final String referenceCodeShort; + + public ContactTypeConverterDto(long id, String referenceCode, String referenceCodeRenamed, String referenceCodeShort) { + this.id = id; + this.referenceCode = referenceCode; + this.referenceCodeRenamed = referenceCodeRenamed; + this.referenceCodeShort = referenceCodeShort; + } + + public long getId() { + return id; + } + + public String getReferenceCode() { + return referenceCode; + } + + public String getReferenceCodeRenamed() { + return referenceCodeRenamed; + } + + public String getReferenceCodeShort() { + return referenceCodeShort; + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/CustomerTagsDto.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/CustomerTagsDto.java new file mode 100644 index 000000000..4977d3e27 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/CustomerTagsDto.java @@ -0,0 +1,40 @@ +package org.tests.dtomapping; + +import io.ebean.annotation.DtoConvert; +import io.ebean.annotation.DtoPath; + +import java.util.List; + +/** + * Plain DTO exercising a named variant excluding a non-nested ({@code SCALAR}) {@code List} + * property - see docs/dto-mapping-requirements.md "Section G" follow-up: a {@code @DtoConvert}- + * backed {@code List} property (element type has no registered {@code @DtoMapping} of its own, + * e.g. populated from ad-hoc SQL in a real app) can still be excluded from a named variant, just + * like a {@code NESTED_MANY} property - falling back to an empty list rather than {@code null}. + */ +public class CustomerTagsDto { + + private final Long id; + private final String name; + @DtoPath("contacts") + @DtoConvert(value = ContactNameConverter.class, method = "toNames") + private final List tags; + + public CustomerTagsDto(Long id, String name, List tags) { + this.id = id; + this.name = name; + this.tags = tags; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public List getTags() { + return tags; + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoConverters.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoConverters.java new file mode 100644 index 000000000..c3c04450c --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoConverters.java @@ -0,0 +1,44 @@ +package org.tests.dtomapping; + +import io.ebean.DB; +import org.junit.jupiter.api.Test; +import org.tests.dtomapping.model.Contact; +import org.tests.dtomapping.model.Customer; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Validates {@code @DtoConverters} package-level type-pair auto-dispatch (requirement r21, + * "Section E: Type-pair (package-level) custom scalar conversion" in + * docs/dto-mapping-requirements.md) - {@link UuidConverters#toHex} is registered once for the + * whole {@code org.tests.dtomapping} package (see {@code package-info.java}) and auto-applied to + * any {@code UUID -> String} property with no per-property {@code @DtoConvert}, while an explicit + * {@code @DtoConvert} on the same field still takes precedence. + */ +class TestDtoConverters { + + @Test + void mapTo_expectAutoDispatchedTypeConverterApplied() { + Customer customer = new Customer("Acme"); + customer.save(); + Contact contact = new Contact("Jane", "Doe", customer); + UUID referenceCode = UUID.randomUUID(); + contact.setReferenceCode(referenceCode); + contact.save(); + + ContactTypeConverterDto dto = DB.find(Contact.class) + .where().idEq(contact.getId()) + .mapTo(ContactTypeConverterDto.class) + .findOne(); + + assertThat(dto).isNotNull(); + // auto-dispatched, no @DtoConvert on the property itself + assertThat(dto.getReferenceCode()).isEqualTo(UuidConverters.toHex(referenceCode)); + // auto-dispatched via the @DtoPath-renamed resolution path + assertThat(dto.getReferenceCodeRenamed()).isEqualTo(UuidConverters.toHex(referenceCode)); + // explicit @DtoConvert overrides the registered package default + assertThat(dto.getReferenceCodeShort()).isEqualTo(UuidShortCodeConverter.toShortCode(referenceCode)); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoIgnore.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoIgnore.java new file mode 100644 index 000000000..a9d7db769 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoIgnore.java @@ -0,0 +1,87 @@ +package org.tests.dtomapping; + +import io.ebean.DB; +import io.ebean.DtoMapContext; +import org.junit.jupiter.api.Test; +import org.tests.dtomapping.model.Contact; +import org.tests.dtomapping.model.Customer; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Validates {@code @DtoIgnore} - a property permanently excluded from every mapping (base and + * every named variant alike), always given its empty default rather than resolved from any + * source getter/path, plus the generated {@code mapToBuilder(source)} accessor (emitted whenever + * the target uses builder construction) that lets a caller set its real value - sourced from + * elsewhere entirely, outside this mapper's own fetch graph - before finishing construction. + */ +class TestDtoIgnore { + + private final ContactIgnoreDtoMapper mapper = new ContactIgnoreDtoMapper(); + + @Test + void mapTo_expectIgnoredPropertiesDefaulted() { + Customer customer = new Customer("Acme"); + customer.save(); + Contact contact = new Contact("Jane", "Doe", customer); + contact.save(); + + ContactIgnoreDto dto = DB.find(Contact.class) + .where().idEq(contact.getId()) + .mapTo(ContactIgnoreDto.class) + .findOne(); + + assertThat(dto).isNotNull(); + assertThat(dto.getId()).isEqualTo(contact.getId()); + assertThat(dto.getFirstName()).isEqualTo("Jane"); + assertThat(dto.getLastName()).isEqualTo("Doe"); + // @DtoIgnore properties always default, never resolved from Contact + assertThat(dto.getExternalRef()).isNull(); + assertThat(dto.getAuditNotes()).isEmpty(); + } + + @Test + void mapToBuilder_expectIgnoredPropertiesSettableBeforeBuild() { + Customer customer = new Customer("Acme"); + customer.save(); + Contact contact = new Contact("Jane", "Doe", customer); + contact.save(); + + ContactIgnoreDto dto = mapper.mapToBuilder(contact) + .externalRef("EXT-123") + .auditNotes(List.of("created", "reviewed")) + .build(); + + assertThat(dto.getId()).isEqualTo(contact.getId()); + assertThat(dto.getFirstName()).isEqualTo("Jane"); + assertThat(dto.getExternalRef()).isEqualTo("EXT-123"); + assertThat(dto.getAuditNotes()).containsExactly("created", "reviewed"); + } + + @Test + void mapToBuilder_withContext_expectSameBehaviour() { + Customer customer = new Customer("Acme"); + customer.save(); + Contact contact = new Contact("Jane", "Doe", customer); + contact.save(); + + ContactIgnoreDto dto = mapper.mapToBuilder(contact, new DtoMapContext()) + .externalRef("EXT-456") + .build(); + + assertThat(dto.getExternalRef()).isEqualTo("EXT-456"); + assertThat(dto.getAuditNotes()).isEmpty(); + } + + @Test + void mapToBuilder_whenSourceNull_expectNullBuilder() { + assertThat(mapper.mapToBuilder(null)).isNull(); + } + + @Test + void map_whenSourceNull_expectNullDto() { + assertThat(mapper.map(null)).isNull(); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperName.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperName.java new file mode 100644 index 000000000..4ce2c8b87 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperName.java @@ -0,0 +1,30 @@ +package org.tests.dtomapping; + +import org.junit.jupiter.api.Test; +import org.tests.dtomapping.model.Address; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Exercises {@code @DtoMapping(mapperName = ...)} - see {@code package-info.java} and + * {@link AddressSummaryDtoMapper} (the stand-in pre-existing hand-written class whose name would + * otherwise collide with the generated mapper's default name). The generated mapper for + * {@code Address -> AddressSummaryDto} is named {@code AddressSummaryMapper} instead, proven here + * simply by the fact that this compiles and the two classes coexist in the same package. + */ +class TestDtoMapperName { + + @Test + void mapperName_rendersUnderOverriddenName_avoidingClashWithHandWrittenClass() { + Address address = new Address("1 Queen Street", "Auckland"); + address.save(); + + // AddressSummaryMapper is the *generated* mapper - AddressSummaryDtoMapper (the default name) + // is the unrelated hand-written stand-in class, both coexist in this same package + AddressSummaryDto dto = new AddressSummaryMapper().map(address); + + assertThat(dto.getLine1()).isEqualTo("1 Queen Street"); + assertThat(dto.getCity()).isEqualTo("Auckland"); + assertThat(AddressSummaryDtoMapper.legacyDescribe()).contains("legacy"); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperVariants.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperVariants.java index 0422c778a..e38ef7f66 100644 --- a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperVariants.java +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperVariants.java @@ -69,4 +69,45 @@ class TestDtoMapperVariants { void noContacts_whenSourceNull_expectNullDto() { assertThat(mapper.noContacts().map(null)).isNull(); } + + /** + * "Section G" follow-up - a named variant can also exclude a plain non-nested {@code SCALAR} + * property (here, a {@code @DtoConvert}-backed {@code List} with no registered nested + * DTO mapping for its element type - the same shape as a real-world "fleet list populated from + * ad-hoc SQL" property), not just {@code NESTED_ONE}/{@code NESTED_MANY}. + */ + @Test + void mapTo_withNoTagsVariant_expectTagsExcludedAndNoContactsFetch() { + CustomerTagsDtoMapper tagsMapper = new CustomerTagsDtoMapper(); + Customer customer = new Customer("Acme"); + customer.save(); + new Contact("Jane", "Doe", customer).save(); + + CustomerTagsDto dto = DB.find(Customer.class) + .where().idEq(customer.getId()) + .mapTo(CustomerTagsDto.class, tagsMapper.noTags()) + .findOne(); + + assertThat(dto).isNotNull(); + assertThat(dto.getName()).isEqualTo("Acme"); + // tags IS excluded by this variant - empty, not null + assertThat(dto.getTags()).isEmpty(); + // and its own fetchGroup is distinct from the base mapping's + assertThat(tagsMapper.noTags().fetchGroup()).isNotSameAs(tagsMapper.fetchGroup()); + } + + @Test + void mapTo_baseCustomerTagsMapping_expectTagsPopulated() { + CustomerTagsDtoMapper tagsMapper = new CustomerTagsDtoMapper(); + Customer customer = new Customer("Acme"); + customer.save(); + new Contact("Jane", "Doe", customer).save(); + + CustomerTagsDto dto = DB.find(Customer.class) + .where().idEq(customer.getId()) + .mapTo(CustomerTagsDto.class) + .findOne(); + + assertThat(dto.getTags()).containsExactly("Jane Doe"); + } } diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoSetterConstruction.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoSetterConstruction.java new file mode 100644 index 000000000..b22e70fb1 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoSetterConstruction.java @@ -0,0 +1,70 @@ +package org.tests.dtomapping; + +import io.ebean.DB; +import org.junit.jupiter.api.Test; +import org.tests.dtomapping.model.Contact; +import org.tests.dtomapping.model.Customer; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Validates setter-based (mutable JavaBean) target construction (requirement r22, "Section G: + * Setter-based (mutable JavaBean) target construction" in docs/dto-mapping-requirements.md) - + * {@link ContactSetterDto} has only a no-arg constructor plus public setters (mirroring + * JAXB/XSD-generated legacy SOAP types), so it's auto-detected ({@code setter = AUTO}) and mapped + * via {@code new ContactSetterDto(); dto.setX(...); ...; return dto;} rather than a positional + * constructor call or a builder chain. + */ +class TestDtoSetterConstruction { + + @Test + void mapTo_expectSetterBasedConstruction() { + Customer customer = new Customer("Acme"); + customer.save(); + Contact contact = new Contact("Jane", "Doe", customer); + contact.save(); + + ContactSetterDto dto = DB.find(Contact.class) + .where().idEq(contact.getId()) + .mapTo(ContactSetterDto.class) + .findOne(); + + assertThat(dto).isNotNull(); + assertThat(dto.getId()).isEqualTo(contact.getId()); + assertThat(dto.getFirstName()).isEqualTo("Jane"); + assertThat(dto.getLastName()).isEqualTo("Doe"); + // @DtoIgnore - not populated by the mapper itself (List defaults to List.of(), not null) + assertThat(dto.getExternalRef()).isNull(); + assertThat(dto.getAuditNotes()).isEmpty(); + + // the mapped target is already fully mutable - no special generated accessor is needed to + // populate an @DtoIgnore property afterwards, unlike the builder strategy's mapToBuilder(...) + dto.setExternalRef("EXT-123"); + dto.setAuditNotes(List.of("created via import")); + + assertThat(dto.getExternalRef()).isEqualTo("EXT-123"); + assertThat(dto.getAuditNotes()).containsExactly("created via import"); + } + + @Test + void mapTo_expectFluentSetterBasedConstruction() { + Customer customer = new Customer("Acme"); + customer.save(); + Contact contact = new Contact("Jane", "Doe", customer); + contact.save(); + + // ContactSetterFluentDto's setters return `this` (fluent-style) rather than void - detection + // accepts either shape, and the generated code calls the setter as a bare statement either way + ContactSetterFluentDto dto = DB.find(Contact.class) + .where().idEq(contact.getId()) + .mapTo(ContactSetterFluentDto.class) + .findOne(); + + assertThat(dto).isNotNull(); + assertThat(dto.getId()).isEqualTo(contact.getId()); + assertThat(dto.getFirstName()).isEqualTo("Jane"); + assertThat(dto.getLastName()).isEqualTo("Doe"); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/UuidConverters.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/UuidConverters.java new file mode 100644 index 000000000..b958a0788 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/UuidConverters.java @@ -0,0 +1,20 @@ +package org.tests.dtomapping; + +import java.util.UUID; + +/** + * Registered package-wide via {@code @DtoConverters} (requirement r21, "Section E: Type-pair + * (package-level) custom scalar conversion" in docs/dto-mapping-requirements.md) - {@link + * #toHex(UUID)} is auto-dispatched to any {@code UUID -> String} property with no per-property + * {@code @DtoConvert} of its own, mirroring MapStruct's type-signature auto-matching. + */ +public final class UuidConverters { + + private UuidConverters() { + } + + /** Auto-dispatched default for any un-annotated {@code UUID -> String} property. */ + public static String toHex(UUID uuid) { + return uuid == null ? null : uuid.toString().replace("-", ""); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/UuidShortCodeConverter.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/UuidShortCodeConverter.java new file mode 100644 index 000000000..5b27dc235 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/UuidShortCodeConverter.java @@ -0,0 +1,19 @@ +package org.tests.dtomapping; + +import java.util.UUID; + +/** + * Deliberately not registered via {@code @DtoConverters} - only ever referenced by an + * explicit per-property {@code @DtoConvert} (requirement r21's precedence rule: an explicit + * {@code @DtoConvert} always wins over a registered package-level type-pair default, and can + * point at any converter method, not just a registered one). + */ +public final class UuidShortCodeConverter { + + private UuidShortCodeConverter() { + } + + public static String toShortCode(UUID uuid) { + return uuid == null ? null : uuid.toString().substring(0, 8); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/package-info.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/package-info.java index 4c9668dc6..55df514ed 100644 --- a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/package-info.java +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/package-info.java @@ -39,10 +39,53 @@ * {@link org.tests.dtomapping.ContactBuilderDto} follows the {@code avaje-recordbuilder} * convention (a static {@code builder()} factory + fluent setters + {@code build()}), forced via * {@code builder = ALWAYS}. + *

+ * {@code Contact -> ContactIgnoreDto} exercises {@code @DtoIgnore} - {@code externalRef}/ + * {@code auditNotes} have no corresponding getter on {@code Contact} at all (unlike a plain + * excluded/{@code @DtoConvert}-backed property), modelling a property permanently populated by + * application code from elsewhere (e.g. {@code Fleet.assignedMachines} in central-access). The + * generated mapper additionally exposes {@code mapToBuilder(source)} (since the target uses + * builder construction) so a caller can set the real value before calling {@code build()}. + *

+ * {@code Customer -> CustomerTagsDto, name = "noTags", exclude = "tags"} exercises the extended + * named-variant exclusion (docs/dto-mapping-requirements.md, "Section G" follow-up) - a plain + * {@code SCALAR}/{@code @DtoConvert}-backed {@code List} property (no registered nested DTO + * mapping for its element type) can also be excluded from a variant, not just + * {@code NESTED_ONE}/{@code NESTED_MANY} properties. + *

+ * {@code Address -> AddressSummaryDto, mapperName = "AddressSummaryMapper"} exercises {@code + * mapperName()} - {@link AddressSummaryDtoMapper} is a stand-in pre-existing hand-written class + * occupying the default expected name ({@code AddressSummaryDtoMapper}), so the override renames + * the generated mapper to {@code AddressSummaryMapper} instead, letting both coexist (mirrors the + * real central-access {@code CFleet -> Fleet}/legacy {@code FleetMapper} collision). + *

+ * {@code Contact -> ContactTypeConverterDto} exercises {@code @DtoConverters} (requirement r21, + * "Section E: Type-pair (package-level) custom scalar conversion") - {@link UuidConverters} is + * registered once below for the whole package, and {@code UUID -> String} properties with no + * per-property {@code @DtoConvert} are auto-dispatched to it (both a plain same-name property and + * a {@code @DtoPath}-renamed one), while an explicit {@code @DtoConvert} on the same field still + * takes precedence over the registered default. + *

+ * {@code Contact -> ContactSetterDto} exercises setter-based (mutable JavaBean) construction + * (requirement r22, "Section G: Setter-based (mutable JavaBean) target construction") - {@link + * ContactSetterDto} is a plain no-arg-constructor-plus-setters type (mirroring JAXB/XSD-generated + * legacy SOAP types), auto-detected ({@code setter = AUTO}, the default) since it has no + * positional constructor matching its properties. Its {@code @DtoIgnore} properties are populated + * directly via their public setters after mapping, with no special generated accessor needed + * (unlike the builder strategy's {@code mapToBuilder(...)}). + *

+ * {@code Contact -> ContactSetterFluentDto} exercises the same setter-based construction path but + * with fluent-style setters (each returns {@code this} rather than {@code void}) - {@link + * ContactSetterFluentDto} - confirming detection accepts either shape and the generated code + * simply calls the setter as a bare statement, ignoring any return value. */ +@DtoConverters(UuidConverters.class) @DtoMapping(source = Customer.class, target = CustomerDto.class) @DtoMapping(source = Customer.class, target = CustomerDto.class, name = "noContacts", exclude = "contacts") +@DtoMapping(source = Customer.class, target = CustomerTagsDto.class) +@DtoMapping(source = Customer.class, target = CustomerTagsDto.class, name = "noTags", exclude = "tags") @DtoMapping(source = Address.class, target = AddressDto.class) +@DtoMapping(source = Address.class, target = AddressSummaryDto.class, mapperName = "AddressSummaryMapper") @DtoMapping(source = Contact.class, target = ContactDto.class) @DtoMapping(source = Customer.class, target = CustomerRefDto.class) @DtoMapping(source = ContactSummary.class, target = ContactSummaryDto.class) @@ -50,6 +93,7 @@ @DtoMapping(source = Contact.class, target = ContactConversionDto.class) @DtoMapping(source = Contact.class, target = ContactMixinDto.class) @DtoMapping(source = Contact.class, target = ContactBuilderDto.class, builder = DtoMapping.Builder.ALWAYS) +@DtoMapping(source = Contact.class, target = ContactIgnoreDto.class, builder = DtoMapping.Builder.ALWAYS) @DtoMapping(source = Contact.class, target = PrimitiveNullPathDto.class) @DtoMapping(source = Contact.class, target = PrimitiveNullPathFailOnNullDto.class) @DtoMapping(source = Customer.class, target = ComputedPathDto.class) @@ -59,8 +103,12 @@ @DtoMapping(source = Customer.class, target = ComputedNestedListDto.class) @DtoMapping(source = Customer.class, target = ComputedRefDto.class) @DtoMapping(source = Customer.class, target = FetchCollisionDto.class) +@DtoMapping(source = Contact.class, target = ContactTypeConverterDto.class) +@DtoMapping(source = Contact.class, target = ContactSetterDto.class) +@DtoMapping(source = Contact.class, target = ContactSetterFluentDto.class) package org.tests.dtomapping; +import io.ebean.annotation.DtoConverters; import io.ebean.annotation.DtoMapping; import org.tests.dtomapping.model.Address; import org.tests.dtomapping.model.Contact;