mirror of
https://github.com/ebean-orm/ebean.git
synced 2026-10-19 16:16:08 +00:00
Merge pull request #3861 from ebean-orm/feture/dto-more
dto mapping: add DtoConverters
This commit is contained in:
@@ -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**
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<ebean-persistence-api.version>3.2</ebean-persistence-api.version>
|
||||
<ebean-types.version>3.0</ebean-types.version>
|
||||
|
||||
<ebean-annotation.version>8.9</ebean-annotation.version>
|
||||
<ebean-annotation.version>8.11</ebean-annotation.version>
|
||||
<ebean-ddl-runner.version>2.3</ebean-ddl-runner.version>
|
||||
<ebean-migration-auto.version>1.2</ebean-migration-auto.version>
|
||||
<ebean-migration.version>14.3.0</ebean-migration.version>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -18,6 +18,7 @@ class DtoBeanMeta {
|
||||
private final List<DtoPropertyMeta> properties = new ArrayList<>();
|
||||
private final List<DtoMapperVariant> 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),
|
||||
|
||||
+1
-1
@@ -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<String> excludedProperties() {
|
||||
return excludedProperties;
|
||||
}
|
||||
|
||||
+214
-34
@@ -41,6 +41,7 @@ class DtoMapperWriter {
|
||||
writeConstructors();
|
||||
writeFetchGroupMethod();
|
||||
writeMapMethod();
|
||||
writeMapToBuilderMethod();
|
||||
if (!meta.variants().isEmpty()) {
|
||||
writeBuildMethod();
|
||||
writeVariantAccessors();
|
||||
@@ -60,6 +61,9 @@ class DtoMapperWriter {
|
||||
Set<String> 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<String> fetchGroupChainCalls(Set<String> excludedFieldNames) {
|
||||
List<DtoPropertyMeta> 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<String> rootSelect = new LinkedHashSet<>();
|
||||
@@ -262,7 +277,16 @@ class DtoMapperWriter {
|
||||
}
|
||||
List<String> 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()}.
|
||||
* <p>
|
||||
* 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<DtoPropertyMeta> 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<DtoPropertyMeta> 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 = ...)}).
|
||||
* <p>
|
||||
* {@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<DtoPropertyMeta> 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<DtoPropertyMeta> 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<DtoPropertyMeta> variableProps, String closing) {
|
||||
@@ -471,6 +600,10 @@ class DtoMapperWriter {
|
||||
* to be excluded by a variant.
|
||||
*/
|
||||
private String constructionValueExpression(DtoPropertyMeta property, String rootVariable, Set<DtoPropertyMeta> 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<DtoPropertyMeta> variableProps) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (DtoPropertyMeta property : variableProps) {
|
||||
sb.append(", ").append(includeFlagName(property));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String buildMethodParams(Set<DtoPropertyMeta> 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<X>} 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.
|
||||
* <p>
|
||||
* When the target uses a builder, each variant additionally gets its own named
|
||||
* {@code mapToBuilder<Suffix>(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<SOURCE, TARGET>}-typed variant view at all.
|
||||
*/
|
||||
private void writeVariantAccessors() {
|
||||
Set<DtoPropertyMeta> 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<Suffix>(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<DtoPropertyMeta> 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<DtoPropertyMeta> 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();
|
||||
|
||||
+276
-13
@@ -52,6 +52,16 @@ class DtoMappingReader {
|
||||
/** {@code builder()} attribute value of each target's base (unnamed) registration. */
|
||||
private final Map<String, String> builderModeByTarget = new LinkedHashMap<>();
|
||||
|
||||
/** {@code setter()} attribute value of each target's base (unnamed) registration. */
|
||||
private final Map<String, String> 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<String, ExecutableElement> 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<Element> 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<DtoBeanMeta> 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<String> 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<String> getters = new ArrayList<>();
|
||||
List<String> 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<String> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+59
-5
@@ -29,11 +29,24 @@ class DtoPropertyMeta {
|
||||
private final boolean failOnNull;
|
||||
private final boolean computedSegment;
|
||||
private final List<String> requiredFetchPaths;
|
||||
private final boolean listTarget;
|
||||
private final boolean ignored;
|
||||
|
||||
DtoPropertyMeta(String dtoFieldName, Kind kind, List<String> sourceGetterPath, List<String> 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<String> sourceGetterPath, List<String> sourcePropertyPath,
|
||||
DtoBeanMeta nested, boolean computedSegment, List<String> 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<String> sourceGetterPath, List<String> 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<String> sourceGetterPath, List<String> 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<String> sourceGetterPath, List<String> sourcePropertyPath,
|
||||
DtoBeanMeta nested, DtoConverterMeta converter, boolean primitiveTarget, boolean failOnNull,
|
||||
boolean computedSegment, List<String> requiredFetchPaths) {
|
||||
boolean computedSegment, List<String> 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
|
||||
|
||||
@@ -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 = ...)}.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+73
@@ -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<JavaFileObject> diagnostics = new DiagnosticCollector<>();
|
||||
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
|
||||
try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, Locale.getDefault(), null)) {
|
||||
List<Path> 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<String> 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<Diagnostic<? extends JavaFileObject>> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+17
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> getAuditNotes() {
|
||||
return auditNotes;
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
|
||||
private Long id;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String externalRef;
|
||||
private List<String> 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<String> auditNotes) {
|
||||
this.auditNotes = auditNotes;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ContactIgnoreDto build() {
|
||||
return new ContactIgnoreDto(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> toNames(List<Contact> contacts) {
|
||||
return contacts.stream()
|
||||
.map(c -> c.getFirstName() + " " + c.getLastName())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* {@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<String> 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<String> getAuditNotes() {
|
||||
return auditNotes;
|
||||
}
|
||||
|
||||
public void setAuditNotes(List<String> auditNotes) {
|
||||
this.auditNotes = auditNotes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.tests.dtomapping;
|
||||
|
||||
/**
|
||||
* Mutable JavaBean-shaped DTO exercising setter-based construction (requirement r22) with
|
||||
* <b>fluent-style</b> 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;
|
||||
}
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String> tags;
|
||||
|
||||
public CustomerTagsDto(Long id, String name, List<String> tags) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public List<String> getTags() {
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<String>} 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");
|
||||
}
|
||||
}
|
||||
|
||||
+70
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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("-", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.tests.dtomapping;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Deliberately <b>not</b> 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);
|
||||
}
|
||||
}
|
||||
@@ -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}.
|
||||
* <p>
|
||||
* {@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()}.
|
||||
* <p>
|
||||
* {@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.
|
||||
* <p>
|
||||
* {@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).
|
||||
* <p>
|
||||
* {@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.
|
||||
* <p>
|
||||
* {@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(...)}).
|
||||
* <p>
|
||||
* {@code Contact -> ContactSetterFluentDto} exercises the same setter-based construction path but
|
||||
* with <b>fluent-style</b> setters (each returns {@code this} rather than {@code void}) - {@link
|
||||
* ContactSetterFluentDto} - confirming detection accepts either shape and the generated code
|
||||
* simply calls the setter as a bare statement, ignoring any return value.
|
||||
*/
|
||||
@DtoConverters(UuidConverters.class)
|
||||
@DtoMapping(source = Customer.class, target = CustomerDto.class)
|
||||
@DtoMapping(source = Customer.class, target = CustomerDto.class, name = "noContacts", exclude = "contacts")
|
||||
@DtoMapping(source = Customer.class, target = CustomerTagsDto.class)
|
||||
@DtoMapping(source = Customer.class, target = CustomerTagsDto.class, name = "noTags", exclude = "tags")
|
||||
@DtoMapping(source = Address.class, target = AddressDto.class)
|
||||
@DtoMapping(source = Address.class, target = AddressSummaryDto.class, mapperName = "AddressSummaryMapper")
|
||||
@DtoMapping(source = Contact.class, target = ContactDto.class)
|
||||
@DtoMapping(source = Customer.class, target = CustomerRefDto.class)
|
||||
@DtoMapping(source = ContactSummary.class, target = ContactSummaryDto.class)
|
||||
@@ -50,6 +93,7 @@
|
||||
@DtoMapping(source = Contact.class, target = ContactConversionDto.class)
|
||||
@DtoMapping(source = Contact.class, target = ContactMixinDto.class)
|
||||
@DtoMapping(source = Contact.class, target = ContactBuilderDto.class, builder = DtoMapping.Builder.ALWAYS)
|
||||
@DtoMapping(source = Contact.class, target = ContactIgnoreDto.class, builder = DtoMapping.Builder.ALWAYS)
|
||||
@DtoMapping(source = Contact.class, target = PrimitiveNullPathDto.class)
|
||||
@DtoMapping(source = Contact.class, target = PrimitiveNullPathFailOnNullDto.class)
|
||||
@DtoMapping(source = Customer.class, target = ComputedPathDto.class)
|
||||
@@ -59,8 +103,12 @@
|
||||
@DtoMapping(source = Customer.class, target = ComputedNestedListDto.class)
|
||||
@DtoMapping(source = Customer.class, target = ComputedRefDto.class)
|
||||
@DtoMapping(source = Customer.class, target = FetchCollisionDto.class)
|
||||
@DtoMapping(source = Contact.class, target = ContactTypeConverterDto.class)
|
||||
@DtoMapping(source = Contact.class, target = ContactSetterDto.class)
|
||||
@DtoMapping(source = Contact.class, target = ContactSetterFluentDto.class)
|
||||
package org.tests.dtomapping;
|
||||
|
||||
import io.ebean.annotation.DtoConverters;
|
||||
import io.ebean.annotation.DtoMapping;
|
||||
import org.tests.dtomapping.model.Address;
|
||||
import org.tests.dtomapping.model.Contact;
|
||||
|
||||
Reference in New Issue
Block a user