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.23.0
- 8.9
+ 8.112.31.214.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 extends JavaFileObject> 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(...)}).
+ *