Compare commits

...
Author SHA1 Message Date
robin.bygrave 19b9e82afd dto mapping: add some negative tests 2026-07-16 22:02:44 +12:00
robin.bygrave 01ab7edc8c Bump ebean-annotation to 8.9 with DtoRef.requires property 2026-07-16 21:41:08 +12:00
robin.bygrave bd0b274973 dto mapping: various fixes
Fix #2 — @DtoRef never checked hasField(): Added requires() to @DtoRef
 (same explicit-empty semantics as @DtoPath). DtoMappingReader now
 detects a computed association getter and requires an explicit
 requires(); DtoMapperWriter's REF case routes computed segments
 through extraFetchPaths instead of a broken select(assoc).

Fix #3 — requires() values never validated: Added DtoMappingReader.validateRequiresPath(...), walking every requires() segment against the real property graph (with List unwrapping via a new getterReturnTypeMirror helper). A typo now fails at compile time instead of resurfacing as a runtime PersistenceException. New negative

Fix #4 — bare requires() fetch vs narrowed sibling @DtoPath fetch collision: Traced into Ebean's OrmQueryDetail.fetch(...) and confirmed same-path fetch calls replace, not merge (Map.put). The old dedup logic had priority backwards, silently letting a narrow selection win and drop a computed getter's real dependency. Fixed by prioritizing the full fetch. Proved the regression test was meaningful by reverting the fix and confirming it fails with LazyInitialisationException: Property not loaded: line1, then restored the fix and confirmed it passes.
2026-07-16 21:36:17 +12:00
robin.bygrave 7b7a86201d dto mapping: support calculated getter methods with and without requires fetch properties 2026-07-16 20:38:03 +12:00
Rob Bygrave 5317fb0d5c Merge pull request #3856 from ebean-orm/feature/dto-failOnNull
dto mapping: Add @DtoPath( failOnNull=true) option for null -> primit…
2026-07-16 19:27:15 +12:00
robin.bygrave 022958f417 dto mapping: need ebean-annotation 8.7 2026-07-16 18:56:54 +12:00
robin.bygrave 3431eee81a dto mapping: Add @DtoPath( failOnNull=true) option for null -> primitive handling 2026-07-16 18:26:40 +12:00
robin.bygrave 470326830a Bump test versions to 18.3.0 2026-07-16 18:25:17 +12:00
Rob Bygraveandrobin.bygrave b6225b6ae4 InsertOnConflict - change to exclude non-updatable and generated on insert only (e.g. @WhenCreated) (#3855)
* Version 18.3.0

* InsertOnConflict - change to exclude non-updatable and generated on insert only (e.g. @WhenCreated)

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-16 17:14:40 +12:00
robin.bygrave f38f0ffa25 InsertOnConflict - change to exclude non-updatable and generated on insert only (e.g. @WhenCreated) 2026-07-16 17:12:46 +12:00
Rob Bygraveandrobin.bygrave 6c939d72f8 dto extensions - usingMaster, findStream etc (#3854)
* DtoQuery - add usingMaster, usingTransaction, usingConnection options.

* DtoQuery - Support DtoPath renaming a ToOne or ToMany

* MappedQuery - Add findStream() support

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-16 17:04:22 +12:00
robin.bygrave d083b27e91 Version 18.3.0 2026-07-16 16:57:52 +12:00
92 changed files with 2746 additions and 252 deletions
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -17,13 +17,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-clickhouse</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -17,13 +17,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -17,13 +17,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-db2</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -17,13 +17,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-h2</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -17,13 +17,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-hana</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -17,13 +17,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-mariadb</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -17,13 +17,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-mysql</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+6 -6
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -22,13 +22,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -47,19 +47,19 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-net-postgis-types</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -17,13 +17,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-nuodb</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -17,13 +17,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-oracle</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+6 -6
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -22,13 +22,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -47,19 +47,19 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-pgvector-types</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
+6 -6
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -22,13 +22,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -47,19 +47,19 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-postgis-types</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -17,13 +17,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -17,13 +17,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-sqlite</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -17,13 +17,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-sqlserver</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -17,13 +17,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+6 -6
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -17,13 +17,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -41,7 +41,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-jackson-mapper</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -60,13 +60,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-all</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+1 -1
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<artifactId>composites</artifactId>
+292 -2
View File
@@ -136,8 +136,31 @@ already recognise, which is worth spelling out explicitly so it's easy to "grok
or the query can't group correctly at all. Fixed so `REF` always contributes its association name to the
root `select(...)` (deduped against any existing `NESTED_ONE`/`NESTED_MANY` fetch of the same path).
### Read-only entity memory overhead: `InterceptReadOnly`
**Bug found and fixed (validation phase, testing against `central-access`): primitive-typed field +
nullable intermediate hop = unboxing `NullPointerException`.** A multi-hop `@DtoPath` (or `@DtoRef`,
which is always 2-hop) null-guards each intermediate getter with a ternary, e.g.
`(source.getOrganisation() == null ? null : source.getOrganisation().getId())`. That ternary's static
type is always the boxed wrapper (`Long`), since one branch is the `null` literal - fine when the DTO
field is itself a reference type (`Long organisationId`), but when the DTO field is a **primitive**
(`long organisationId`), passing that boxed expression to the constructor auto-unboxes it, throwing an
unhelpful `NullPointerException` at runtime whenever the relation really is `null`. This compiled clean
and only failed at runtime with real (nullable) production data - exactly the kind of gap a hand-written
mapper would defensively guard against (e.g. `cEbox.getOrganisation() == null ? 0 : ...getId()`) but
generated code didn't.
Fixed in the generator: when a multi-hop `SCALAR`/`REF` property's DTO field type is primitive, the
whole null-guarded chain is now wrapped in a small runtime helper (`io.ebean.DtoMapperSupport`) that
resolves it safely:
- **Default** (`@DtoPath` with no `failOnNull`, or any `@DtoRef`): silently defaults to the primitive's
zero-equivalent value (`0`/`false`/etc.) - matches the old hand-written-mapper convention.
- **`@DtoPath(failOnNull = true)`**: throws a clear `IllegalStateException` naming the offending property
path instead, for callers who'd rather fail fast than silently mask a null they don't expect.
`@DtoRef` has no `failOnNull` attribute (it has no other attributes at all) - it always uses the
default (silent zero) behaviour. See `PrimitiveNullPathDto`/`PrimitiveNullPathFailOnNullDto` /
`TestPrimitiveNullPath` for regression coverage.
### Read-only entity memory overhead: `InterceptReadOnly`
`setUnmodifiable(true)` isn't just a behavioural fail-fast flag - it also swaps the per-bean intercept
implementation to `InterceptReadOnly`, which is deliberately minimal: just a `boolean[] loaded` (one flag
per property) and a `boolean frozen`, plus the inherited owner reference and `fullyLoadedBean` flag. Compare
@@ -578,7 +601,7 @@ the chain.
### mapTo(Dto.class) runtime wiring (implemented)
`query.mapTo(dtoType)` returns a `MappedQuery<D>` (`findList()`/`findOne()`/`findOneOrEmpty()`/
`findPagedList()`/`usingMaster(boolean)`/`usingTransaction(Transaction)`/`usingConnection(Connection)`).
`findStream()`/`findPagedList()`/`usingMaster(boolean)`/`usingTransaction(Transaction)`/`usingConnection(Connection)`).
On first use it resolves the generated `DtoMapper<S, D>` for the query's `(getBeanType(), dtoType)`
pair via a `DtoMapperManager` (a `ServiceLoader`-backed aggregator over all generated
`DtoMapperRegister`s, analogous to `DtoBeanManager`), then:
@@ -599,6 +622,15 @@ caller retry against the master data source after a read-replica failure by call
`usingMaster(true)` on the *same* `MappedQuery` instance and re-invoking a find method - there's no
need to rebuild the query and call `.mapTo(...)` again.
`MappedQuery<D>.findStream()` mirrors `QueryBuilder#findStream()` - the underlying entity query is
streamed (supporting very large result sets, potentially using multiple persistence contexts
internally) and each entity is mapped to its target DTO lazily as the stream is consumed. One
`DtoMapContext` is shared across the whole stream (not per-element), so identity de-duplication of
nested DTOs (e.g. several `Contact`s sharing the same `Customer`) still holds even when the source
entities are never materialized into one `List` at all. As with the entity-level `findStream()`,
callers must consume it via try-with-resources to ensure the underlying resources are closed.
## Still open / to revisit during implementation
- Whether `.fetch(...)` calls can still be layered on top of a `mapTo(Dto.class)` query for explicit
@@ -692,6 +724,264 @@ need to rebuild the query and call `.mapTo(...)` again.
`DtoConverterManager`) are all constructed eagerly during `Database` startup, which can be
triggered by whichever test class in the module happens to run first.
- **Fixed (validation phase, found via `central-access`): `@DtoPath` through a computed/derived
getter now fails at compile time, with an explicit `requires()` escape hatch.** `@DtoPath`
assumes every dotted segment names a real, fetchable Ebean bean property - so a path like
`@DtoPath("currentMachine.organisationMachine.registrationPlate")`, where `getOrganisationMachine()`
is a hand-written derived getter (not a real relation/column), used to **compile cleanly** (the
codegen had no way to tell it apart from a real property from source alone) but **fail at
runtime** with a `PersistenceException: No property found for [organisationMachine] in
expression ...`, because the generated `FetchGroup` builder tried to `fetch`/`select` it as if it
were a real Ebean property.
- Two genuinely separate sub-problems: (1) *detecting* that a path segment isn't a real,
fetchable property - solvable at compile time, since a real persistent property always has a
backing field (Ebean requires one to enhance), checked via `javax.lang.model`
(`ElementFilter.fieldsIn(...)` over the type + superclass chain, see `DtoMappingReader.hasField(...)`);
versus (2) *knowing what the computed getter needs fetched* to execute safely - not solvable at
compile time without full static/bytecode analysis of the getter's method body, out of scope.
- Resolution: don't attempt to infer (2) automatically. When `DtoMappingReader` detects a `@DtoPath`
segment with no backing field, it now fails fast at compile time (`ctx.logError(...)`) unless the
developer explicitly declares the real entity paths that must be fetched via
`@DtoPath(requires = {...})` (dot-notation, same convention as `@DtoPath`'s own `value()`) - e.g.
`@DtoPath(value = "primaryContact.lastName", requires = "contacts")` where `getPrimaryContact()`
picks the first entry out of the `contacts` collection. The real prefix before the computed
segment (if any) is automatically combined with the declared `requires()` paths, so the developer
doesn't need to redundantly repeat it. Declared paths are emitted as bare `.fetch(path)` calls in
the generated `FetchGroup` (distinct from the `.fetch(path, "props")` shape used for ordinary
scalar `@DtoPath` properties, since there's no specific target property list to narrow to here).
- **The zero-extra-fetch case is also supported, via an explicit `requires = {}`** - e.g.
`@DtoPath(value = "idBadge", requires = {})` where `getIdBadge()` derives purely from `id`
(always fetched regardless). An explicit empty array confirms "nothing extra needed", distinct
from omitting `requires()` entirely ("not yet considered", still a compile error) - `requires()`
itself can't tell the two cases apart (both read back as an empty `List`), so `DtoMappingReader`
checks the avaje-prism-generated `DtoPathPrism.values.requires()` instead, which returns `null`
only when the member was left at its default (i.e. omitted from source). `DtoPropertyMeta`
correspondingly carries `hasComputedSegment()` as its own boolean flag (set whenever a computed
segment was detected at all), independent of whether `requiredFetchPaths()` happens to be empty -
an earlier version conflated the two (inferring "has a computed segment" from "has a non-empty
requiredFetchPaths list"), which broke exactly this explicit-empty case by falling through to the
ordinary scalar `.select(...)` path and failing at runtime with `PersistenceException: Property
not found - idBadge` (`idBadge` isn't a real Ebean property, so it can't be selected).
- Implemented in `ebean-annotation` (`DtoPath.requires()`), and `querybean-generator`
(`DtoMappingReader` computed-segment detection/validation, `DtoPropertyMeta.requiredFetchPaths()`/
`hasComputedSegment()`, `DtoMapperWriter.fetchGroupChainCalls()` bare-fetch emission). Test
coverage: `tests/test-dto-mapping` `ComputedPathDto`/`TestComputedPath` (happy path, `requires`
correctly fetches the dependency and the mapped value is correct), `ComputedPathNoFetchDto`/
`TestComputedPathNoFetch` (explicit `requires = {}`, genuinely nothing extra needed), and
`querybean-generator`'s `DtoMapperComputedPathTest` (negative case - omitting `requires` on a
computed segment is a compile-time `ERROR` diagnostic, verified via direct `javax.tools.JavaCompiler`
compilation, mirroring `DtoMapperFetchPathCollisionTest`).
- Known gap: the dedup between the computed segment's required fetch paths and existing
`pathSelect`/`nestedAssocPaths` keys in `DtoMapperWriter` is a simplified exact-path-string check
(skip emitting a duplicate `.fetch(path)`), not full collision detection like the existing
NESTED_ONE/MANY vs `@DtoPath` check - a bare `fetch(path)` and an existing `fetch(path,
"specific,props")` for the same path string are not merged/reconciled, just left as two separate
calls if that edge case arises.
- **Fixed: a single-hop `@DtoPath` rename through a computed/derived getter whose return type is
itself a registered nested DTO (`NESTED_ONE`/`NESTED_MANY`, not `SCALAR`) bypassed the
computed-segment validation above entirely.** E.g. `@DtoPath("primaryContact")` where the DTO
field's declared type is `ContactDto` (a type with its own `@DtoMapping(source = Contact.class,
target = ContactDto.class)`) and `getPrimaryContact()` is a computed getter with no backing
field on `Customer`. This resolves to a single-segment path, so `DtoMappingReader.resolveProperty()`
took its `properties.size() == 1` nested-lookup shortcut and returned early - before the
`computedFrom`/`requires()` validation block (added for the `SCALAR` case above) ever ran. The
generated `FetchGroup` then emitted a broken `fetch("primaryContact",
contactMapper.fetchGroup())` call (`"primaryContact"` isn't a real Ebean fetch path), failing at
runtime rather than compile time - the exact class of bug the `SCALAR` fix was meant to close off
entirely.
- Resolution: restructured `resolveProperty()` so the computed-segment detection/validation block
runs *before* the `properties.size() == 1` nested-lookup branch, so both `SCALAR` and
`NESTED_ONE`/`NESTED_MANY` paths share the same detection/validation. `DtoPropertyMeta` gained a
matching constructor overload for `NESTED_ONE`/`NESTED_MANY` carrying `computedSegment`/
`requiredFetchPaths`. In `DtoMapperWriter.fetchGroupChainCalls()`, a `NESTED_ONE`/`NESTED_MANY`
property with `hasComputedSegment()` true is routed into `extraFetchPaths` (the same bare
`.fetch(path)` mechanism as the `SCALAR` case) instead of emitting `fetch(path,
mapper.fetchGroup())` - since the nested mapper's own `FetchGroup` requirements can't be
meaningfully attached under a path name that doesn't exist on the source entity.
- Note the nested mapper's *own* fetch requirements (e.g. if `ContactDto` itself needed
`customer.billingAddress`) are **not** automatically propagated up through a computed segment -
only whatever the computed getter itself needs (via `requires()`) is fetched. The nested
mapper's `map(...)` call still works via plain Java method invocation regardless (Ebean
transparent lazy loading covers any gap), but relying on that silently reintroduces N+1 queries,
so the nested DTO used through a computed segment should ideally be a "leaf" shape needing
nothing beyond what `requires()` already declares.
- Implemented in `querybean-generator` (`DtoMappingReader.resolveProperty()` restructuring,
`DtoPropertyMeta`'s new constructor overload, `DtoMapperWriter.fetchGroupChainCalls()`). Test
coverage: `tests/test-dto-mapping` `ContactLeafDto`/`ComputedNestedDto`/`TestComputedNestedPath`
(happy path - generated `FetchGroup` is `.select("id").fetch("contacts")`, no broken
`fetch("primaryContact", ...)` call, and the mapped value is correct end-to-end), and
`querybean-generator`'s `DtoMapperComputedPathTest#dtoPathThroughComputedGetter_targetingNestedDto_withoutRequires_expectCompileError`
(negative case, mirroring the `SCALAR` one). The `NESTED_MANY` variant (a computed getter
returning a `List` of a type with its own registered nested DTO mapping) shares the identical
code path but had no dedicated regression test until later confirmed via `Customer
.getRecentContacts()` / `ComputedNestedListDto` / `TestComputedNestedListPath` (coverage only,
not a bug fix - passed cleanly first try, confirming the shared code path does work end-to-end
for both `NESTED_ONE` and `NESTED_MANY`).
- **Fixed: `@DtoRef` never checked for a computed/derived association getter at all.** Unlike
`@DtoPath`, `@DtoRef`'s association name (derived by stripping the `Id` suffix off the field
name, e.g. `primaryContactId` -> `primaryContact`) was never checked against `hasField(...)` -
so `@DtoRef` on a computed getter (e.g. `getPrimaryContact()` picking the first entry out of a
`contacts` collection) compiled cleanly and generated a broken `FetchGroup.select("primaryContact")`
call (`"primaryContact"` isn't a real Ebean property), failing at runtime rather than compile
time - the same class of bug as the original `@DtoPath` fix, just entirely unaddressed for
`@DtoRef`'s separate code path.
- Resolution: `@DtoRef` gained its own `requires()` attribute (dot-notation, same convention and
explicit-empty semantics as `@DtoPath#requires()`, using the same `DtoRefPrism.values.requires()
== null` omitted-vs-explicit-empty technique). `DtoMappingReader`'s `@DtoRef` branch now checks
`hasField(meta.source(), assocName)` and fails fast at compile time (`ctx.logError(...)`) when
the association has no backing field and `requires()` wasn't specified. `DtoPropertyMeta`'s
`REF` properties now carry `computedSegment`/`requiredFetchPaths` through the existing fields
(no new constructor needed - the full constructor already had the right shape).
`DtoMapperWriter.fetchGroupChainCalls()`'s `REF` case now checks `hasComputedSegment()` and
routes into `extraFetchPaths` (bare `.fetch(path)`) instead of `rootSelect.add(assoc)` when
true - the value expression itself (`source.getPrimaryContact().getId()`, null-guarded) is
unaffected, since it's plain Java method invocation regardless of whether the association name
is a real Ebean property.
- Implemented in `ebean-annotation` (`DtoRef.requires()`), and `querybean-generator`
(`DtoMappingReader`'s `@DtoRef` branch, `DtoMapperWriter.fetchGroupChainCalls()`'s `REF` case).
Test coverage: `tests/test-dto-mapping` `ComputedRefDto`/`TestComputedRefPath` (happy path -
generated `FetchGroup` is `.select("id").fetch("contacts")`, no broken `select("primaryContact")`
call, and the mapped id is correct end-to-end), and `querybean-generator`'s
`DtoMapperComputedPathTest#dtoRefThroughComputedGetter_withoutRequires_expectCompileError`
(negative case, mirroring the `@DtoPath` ones).
- **Fixed: `requires()` path values themselves were never validated against the source type's
real property graph.** `@DtoPath(requires = {...})`/`@DtoRef(requires = {...})` values are
handed straight through to `FetchGroup.fetch(...)` unmodified - a typo (e.g. `requires =
"contactz"` for the real `contacts` property) compiled cleanly, since only the *computed
segment itself* was checked against `hasField(...)`, not the developer-declared dependency
paths meant to fix it. That silently reintroduced the exact runtime `PersistenceException` the
whole `requires()` escape hatch exists to prevent, just one step removed and harder to spot.
- Resolution: added `DtoMappingReader.validateRequiresPath(...)`, which walks each dot-notation
segment of a declared `requires()` value from the source root (`meta.source()`), checking
`hasField(...)` at every hop exactly like `@DtoPath#value()`'s own segments are checked, and
unwrapping a `java.util.List`-typed intermediate hop to its element type (via
`listElementType(TypeMirror)`) so a collection segment followed by a further hop resolves
correctly - needed a new `getterReturnTypeMirror(...)` helper (returning the raw `TypeMirror`
rather than converting straight to `TypeElement`, which can't distinguish a `List` from any
other declared type) alongside the existing `getterReturnType(...)`. Called for every entry in
`pathPrism.requires()`/`refPrism.requires()` right after they're read, for both the `@DtoPath`
and `@DtoRef` branches. The already-validated real prefix (segments before the computed one in
a `@DtoPath#value()`) is intentionally *not* re-validated, since it was already checked while
walking `value()` itself.
- Implemented in `querybean-generator` (`DtoMappingReader.validateRequiresPath(...)`,
`getterReturnTypeMirror(...)`, called from both the `@DtoPath` and `@DtoRef` branches). Test
coverage: `querybean-generator`'s
`DtoMapperComputedPathTest#dtoPathRequires_withTypoInPathValue_expectCompileError` (negative
case - a typo'd `requires()` segment is a compile-time `ERROR` diagnostic); existing
`tests/test-dto-mapping`/`central-access` suites (real multi-segment `requires()` values like
`"currentMachine.organisationMachines"`) continue to pass unchanged, confirming the validation
doesn't false-positive on legitimate paths.
- **Fixed: a bare, full `requires()` fetch and a sibling property's narrowed `@DtoPath` fetch of
the exact same path silently conflicted, with the narrow one always (incorrectly) winning.**
`DtoMapperWriter.fetchGroupChainCalls()`'s dedup logic used to skip emitting a computed
segment's bare `fetch(path)` call whenever another property's `@DtoPath` already had a narrowed
`fetch(path, "specific,props")` entry for that exact path string - on the assumption the two
were interchangeable/redundant. They aren't: `FetchGroup`'s builder (`OrmQueryDetail.fetch(...)`)
keys fetch calls by path in a plain `Map` and **replaces** rather than merges same-path entries,
so whichever call format was emitted meant the *other* was silently discarded. Since the narrow
entry was always emitted first and the bare one skipped whenever it existed, the narrow selection
always won - meaning a computed getter's `requires()` declaration could be completely ignored
whenever an unrelated sibling `@DtoPath` happened to narrow-select the exact same path, leaving
whatever extra properties the computed getter actually touches unfetched (a silent lazy load, or
a hard `LazyInitialisationException` outside a persistence context).
- Resolution: reversed the priority - `fetchGroupChainCalls()` now skips a narrowed `pathSelect`
entry when `extraFetchPaths` (the computed segment's `requires()`) declares the exact same
path, letting the bare, full `fetch(path)` call win instead. This is always safe since a full
fetch is a superset of any narrower property selection - the narrow entry's own properties are
included within it regardless. The existing `nestedAssocPaths` priority (a `NESTED_ONE`/
`NESTED_MANY` property's full `fetch(path, mapper.fetchGroup())` always wins over a bare
`fetch(path)`) was correct already and left unchanged - a nested mapper's own `FetchGroup` is
strictly richer than either form and must not be replaced by either.
- Implemented in `querybean-generator` (`DtoMapperWriter.fetchGroupChainCalls()`). Test coverage:
`tests/test-dto-mapping` `FetchCollisionDto`/`TestFetchCollisionPath`, plus a new computed
getter `Customer.getBillingSummary()` (reads `billingAddress.getLine1()`, deliberately a
different `Address` property to the `city` narrowly selected by a sibling `@DtoPath` on the
same DTO) - confirmed to reproduce `LazyInitialisationException: Property not loaded: line1`
when the fix is reverted, and pass cleanly (correct `line1`-derived value, generated
`FetchGroup` is `.select("id").fetch("billingAddress")` with no narrowed variant at all) with
it in place.
- **Fixed: two `@DtoMixin` companion types targeting the same DTO class silently conflicted, with
the second-processed one winning.** `DtoMappingReader.collectMixins()` keyed a single
`mixinsByTarget` map by the target DTO's FQN, and `Map.put(...)` unconditionally overwrote any
existing entry - so if two mixin interfaces (e.g. a legitimate one plus an accidental duplicate,
or two independently-added mixins that both happened to target the same generated/unowned DTO)
both declared `@DtoMixin(SameDto.class)`, whichever was visited last by
`roundEnv.getElementsAnnotatedWith(...)` silently won, and *all* of the other mixin's
`@DtoPath`/`@DtoRef`/`@DtoConvert` overlays were discarded with no diagnostic at all.
- Resolution: `collectMixins()` now checks for an existing registration before storing a new one
and raises a compile `ERROR` naming both the target and the already-registered mixin's
qualified name, rather than silently overwriting it.
- Implemented in `querybean-generator` (`DtoMappingReader.collectMixins()`). Test coverage: new
negative compile-error test `DtoMapperComputedPathTest#duplicateDtoMixin_forSameTarget_expectCompileError`
(two minimal `@DtoMixin(FooDto.class)` interfaces both declaring a `bar()` method, compiled
together, asserting the `Duplicate @DtoMixin` diagnostic is raised); existing
`tests/test-dto-mapping` `TestDtoMixin` (single, legitimate mixin usage) continues to pass
unchanged.
- **Fixed: `@DtoRef` and `@DtoPath` both present on the same field silently conflicted, with
`@DtoRef` always (invisibly) winning.** `resolveProperty()` checked `refPrism != null` first and
returned immediately whenever present, so a field carrying both annotations at once - whether by
copy/paste mistake, a half-finished rename from one style to the other, or simple confusion
between the two escape hatches - had its `@DtoPath` completely ignored with no diagnostic at all.
- Resolution: `resolveProperty()` now resolves both prisms upfront and raises a compile `ERROR`
naming the field when both are present, rather than silently picking `@DtoRef` and discarding
`@DtoPath`.
- Implemented in `querybean-generator` (`DtoMappingReader.resolveProperty()`). Test coverage: new
negative compile-error test `DtoMapperComputedPathTest#dtoRefAndDtoPath_onSameField_expectCompileError`
(a field carrying both `@DtoRef` and `@DtoPath("bar.id")` over a real, non-computed association,
isolating the conflict diagnostic from the separate computed-getter `requires()` diagnostics).
- **Fixed: `@DtoConvert` on a `NESTED_ONE`/`NESTED_MANY` property was silently ignored.**
`resolveProperty()` resolves the property's `DtoConverterMeta` unconditionally up front (before
it's known whether the property will resolve to `SCALAR`/`REF`/`NESTED_ONE`/`NESTED_MANY`), but
only the `SCALAR`/`REF` `DtoPropertyMeta` constructors actually accept/store a converter - the
`NESTED_ONE`/`NESTED_MANY` constructor calls never took one, so a resolved converter was simply
dropped on the floor with no diagnostic. A developer adding `@DtoConvert` to a nested-DTO field
(e.g. hoping to post-process the nested mapper's result) would see it silently do nothing -
`DtoMapperWriter.propertyValueExpression()`'s `NESTED_ONE`/`NESTED_MANY` cases call straight into
`mapperFieldName(property) + ".map(...)"`/`".mapList(...)"` with no converter wrapping at all.
- Resolution: added `rejectConverterOnNested(...)`, called at each of the four call sites that
construct a `NESTED_ONE`/`NESTED_MANY` `DtoPropertyMeta` (the single-hop `@DtoPath`-rename
branch's two cases, and the plain non-`@DtoPath` branch's two cases) - raises a compile `ERROR`
naming the field whenever a converter was resolved for it, rather than silently discarding it.
- Implemented in `querybean-generator` (`DtoMappingReader.resolveProperty()`,
`rejectConverterOnNested()`). Test coverage: new negative compile-error test
`DtoMapperComputedPathTest#dtoConvertOnNestedOne_expectCompileError` (a `NESTED_ONE` field
carrying `@DtoConvert` over a legitimately nested, separately-`@DtoMapping`-registered type);
existing `tests/test-dto-mapping` suite (no nested property currently combines `@DtoConvert`
with `NESTED_ONE`/`NESTED_MANY`) continues to pass unchanged, confirming no false positives on
plain nested properties.
- **Fixed: `@DtoConvert(method = ...)` resolution ignored parameter arity/overloads.** The shared
`findMethod(type, name)` helper (also used for the builder's `build()` lookup and `@DtoMixin`
companion-method lookup) matches purely by simple name - the first `ExecutableElement` found -
with no arity or parameter-type check at all. For `@DtoConvert` specifically this is a real risk:
its documented contract is a method "taking the source property value and returning the
converted DTO property value" (i.e. exactly one parameter), but a shared/reusable conversion
utility class is a very plausible place to have multiple same-named overloads (e.g. `format
(Instant)` and `format(LocalDate)`) - `findMethod` would silently bind to whichever one
`ElementFilter.methodsIn` happened to return first, independent of which one the developer
actually meant, generating either a confusing arity/type-mismatch compile error in the generated
mapper or, if both overloads happened to be call-compatible, silently invoking the wrong one.
- Resolution: added a dedicated `findConverterMethod(...)` (used only by `resolveConverter()`,
leaving the shared `findMethod()` untouched for the builder/mixin call sites which have their
own, different arity expectations) that filters same-named candidates down to those taking
exactly one parameter. Zero matches raises a clear "not found ... taking exactly one
parameter" error; more than one match (multiple 1-arg overloads sharing the name) raises an
"ambiguous - N overloads take exactly one parameter" error, since `@DtoConvert` has no
parameter-type-based way to disambiguate and the developer must rename one of the overloads.
- Implemented in `querybean-generator` (`DtoMappingReader.resolveConverter()`,
`findConverterMethod()`). Test coverage: new negative compile-error tests
`DtoMapperComputedPathTest#dtoConvertMethod_withAmbiguousOverloads_expectCompileError` (two
same-named 1-arg overloads) and `#dtoConvertMethod_withWrongArity_expectCompileError` (a
same-named 0-arg method, no 1-arg candidate at all); existing `tests/test-dto-mapping`
converter usage (a single, unambiguous 1-arg method per converter type) continues to resolve
and pass unchanged.
## References
+1 -1
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<name>ebean api</name>
@@ -0,0 +1,137 @@
package io.ebean;
/**
* Runtime helpers used by generated {@link DtoMapper} implementations to safely resolve a
* primitive-typed DTO field whose value is derived from a multi-hop {@code @DtoPath} that
* traverses a nullable intermediate relation.
* <p>
* A {@code null}-guarded getter-chain (e.g. {@code source.getOrganisation() == null ? null :
* source.getOrganisation().getId()}) always types as the boxed wrapper (since one branch is the
* {@code null} literal). When the DTO's target field is a primitive (e.g. {@code long
* organisationId}), passing that boxed expression to the constructor auto-unboxes it - which
* throws a raw, unhelpful {@link NullPointerException} if the relation really is {@code null}.
* <p>
* These methods give the generated mapper a choice, controlled by {@code @DtoPath#failOnNull()}:
* default to the primitive's zero-equivalent value ({@code orZero} methods, the default), or
* throw a clear, descriptive exception naming the offending property path ({@code require}
* methods, opted into via {@code failOnNull = true}).
*
* @see io.ebean.annotation.DtoPath
*/
public final class DtoMapperSupport {
private DtoMapperSupport() {
}
/** Return {@code 0} if {@code value} is {@code null}, otherwise its unboxed value. */
public static long orZero(Long value) {
return value == null ? 0L : value;
}
/** Return {@code 0} if {@code value} is {@code null}, otherwise its unboxed value. */
public static int orZero(Integer value) {
return value == null ? 0 : value;
}
/** Return {@code 0} if {@code value} is {@code null}, otherwise its unboxed value. */
public static short orZero(Short value) {
return value == null ? 0 : value;
}
/** Return {@code 0} if {@code value} is {@code null}, otherwise its unboxed value. */
public static byte orZero(Byte value) {
return value == null ? 0 : value;
}
/** Return {@code 0.0} if {@code value} is {@code null}, otherwise its unboxed value. */
public static double orZero(Double value) {
return value == null ? 0.0 : value;
}
/** Return {@code 0.0f} if {@code value} is {@code null}, otherwise its unboxed value. */
public static float orZero(Float value) {
return value == null ? 0.0f : value;
}
/** Return {@code false} if {@code value} is {@code null}, otherwise its unboxed value. */
public static boolean orZero(Boolean value) {
return value != null && value;
}
/** Return {@code '\u0000'} if {@code value} is {@code null}, otherwise its unboxed value. */
public static char orZero(Character value) {
return value == null ? '\u0000' : value;
}
/** Return the unboxed value, or throw if {@code value} is {@code null}. */
public static long require(Long value, String path) {
if (value == null) {
throw failure(path);
}
return value;
}
/** Return the unboxed value, or throw if {@code value} is {@code null}. */
public static int require(Integer value, String path) {
if (value == null) {
throw failure(path);
}
return value;
}
/** Return the unboxed value, or throw if {@code value} is {@code null}. */
public static short require(Short value, String path) {
if (value == null) {
throw failure(path);
}
return value;
}
/** Return the unboxed value, or throw if {@code value} is {@code null}. */
public static byte require(Byte value, String path) {
if (value == null) {
throw failure(path);
}
return value;
}
/** Return the unboxed value, or throw if {@code value} is {@code null}. */
public static double require(Double value, String path) {
if (value == null) {
throw failure(path);
}
return value;
}
/** Return the unboxed value, or throw if {@code value} is {@code null}. */
public static float require(Float value, String path) {
if (value == null) {
throw failure(path);
}
return value;
}
/** Return the unboxed value, or throw if {@code value} is {@code null}. */
public static boolean require(Boolean value, String path) {
if (value == null) {
throw failure(path);
}
return value;
}
/** Return the unboxed value, or throw if {@code value} is {@code null}. */
public static char require(Character value, String path) {
if (value == null) {
throw failure(path);
}
return value;
}
private static IllegalStateException failure(String path) {
return new IllegalStateException(
"@DtoPath(\"" + path + "\") resolved to null via a nullable intermediate relation, but the"
+ " target DTO field is primitive and failOnNull=true - either handle the null case in"
+ " source data, use a boxed wrapper type for the DTO field, or remove failOnNull to"
+ " default to the primitive's zero-equivalent value instead.");
}
}
@@ -6,6 +6,7 @@ import org.jspecify.annotations.Nullable;
import java.sql.Connection;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
/**
* Query that maps an entity graph query result to a nested DTO graph, produced by
@@ -39,6 +40,28 @@ public interface MappedQuery<D> {
*/
PagedList<D> findPagedList();
/**
* Execute the query returning the result as a Stream of mapped DTOs.
* <p>
* Mirrors {@link QueryBuilder#findStream()} - the underlying entity graph query is streamed
* (supporting very large queries iterating any number of results, potentially using multiple
* persistence contexts internally) and each entity is mapped to its target DTO lazily as the
* stream is consumed, sharing one {@link DtoMapContext} across the whole stream so that
* repeated references to the same source entity still de-duplicate to the same DTO instance.
* <pre>{@code
*
* // use try with resources to ensure Stream is closed
*
* try (Stream<CustomerDto> stream = query.mapTo(CustomerDto.class).findStream()) {
* stream
* .map(...)
* .collect(...);
* }
*
* }</pre>
*/
Stream<D> findStream();
/**
* Execute the query returning a single mapped DTO, or {@code null} if there is no matching row.
*/
+1 -1
View File
@@ -6,7 +6,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<artifactId>ebean-bench</artifactId>
+28 -28
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<name>ebean bom</name>
@@ -89,25 +89,25 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core-type</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -125,13 +125,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-jackson-mapper</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-ddl-generator</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -155,37 +155,37 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>querybean-generator</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>kotlin-querybean-generator</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-test</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-redis</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-spring-txn</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<!-- platforms -->
@@ -193,91 +193,91 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-clickhouse</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-db2</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-h2</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-hana</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-mariadb</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-mysql</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-nuodb</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-oracle</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-postgres</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-postgis</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-postgis-types</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-pgvector</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-pgvector-types</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-sqlite</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-sqlserver</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+2 -2
View File
@@ -3,7 +3,7 @@
<parent>
<groupId>io.ebean</groupId>
<artifactId>ebean-parent</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<artifactId>ebean-core-json</artifactId>
<name>ebean-core-json</name>
@@ -16,7 +16,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
+2 -2
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<artifactId>ebean-core-type</artifactId>
@@ -16,7 +16,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
+7 -7
View File
@@ -3,7 +3,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<artifactId>ebean-core</artifactId>
@@ -22,13 +22,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core-json</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -52,7 +52,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core-type</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -157,21 +157,21 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-h2</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-sqlserver</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
@@ -18,12 +18,14 @@ final class InsertMetaOptionsPostgres implements InsertMetaOptions {
private final InsertMeta meta;
private final BeanDescriptor<?> desc;
private final String baseTable;
private final List<String> nonUpdatableColumns;
private final Map<String, String> sqlCache = new ConcurrentHashMap<>();
InsertMetaOptionsPostgres(InsertMeta meta, BeanDescriptor<?> desc) {
this.meta = meta;
this.desc = desc;
this.baseTable = desc.baseTable();
this.nonUpdatableColumns = InsertMetaOptionsSupport.nonUpdatableColumns(desc);
}
@Override
@@ -84,6 +86,7 @@ final class InsertMetaOptionsPostgres implements InsertMetaOptions {
private void setColumns(boolean withId, GenerateDmlRequest request, List<String> uniqueColumns) {
List<String> columns = request.columns();
columns.removeAll(uniqueColumns);
columns.removeAll(nonUpdatableColumns);
if (withId) {
BeanProperty idProperty = desc.idProperty();
if (idProperty != null && !idProperty.isEmbedded()) {
@@ -21,12 +21,14 @@ final class InsertMetaOptionsSqlite implements InsertMetaOptions {
private final InsertMeta meta;
private final BeanDescriptor<?> desc;
private final String baseTable;
private final List<String> nonUpdatableColumns;
private final Map<String, String> sqlCache = new ConcurrentHashMap<>();
InsertMetaOptionsSqlite(InsertMeta meta, BeanDescriptor<?> desc) {
this.meta = meta;
this.desc = desc;
this.baseTable = desc.baseTable();
this.nonUpdatableColumns = InsertMetaOptionsSupport.nonUpdatableColumns(desc);
}
@Override
@@ -85,6 +87,7 @@ final class InsertMetaOptionsSqlite implements InsertMetaOptions {
private void setColumns(boolean withId, GenerateDmlRequest request, List<String> uniqueColumns) {
List<String> columns = request.columns();
columns.removeAll(uniqueColumns);
columns.removeAll(nonUpdatableColumns);
if (withId) {
BeanProperty idProperty = desc.idProperty();
if (idProperty != null && !idProperty.isEmbedded()) {
@@ -0,0 +1,37 @@
package io.ebeaninternal.server.persist.dml;
import io.ebeaninternal.server.deploy.BeanDescriptor;
import io.ebeaninternal.server.deploy.BeanProperty;
import io.ebeaninternal.server.deploy.generatedproperty.GeneratedProperty;
import java.util.ArrayList;
import java.util.List;
/**
* Shared support for the platform specific insert on conflict do update generation.
*/
final class InsertMetaOptionsSupport {
private InsertMetaOptionsSupport() {
}
/**
* Return the columns that should be excluded from the generated "do update set" clause
* as they are not updatable, e.g. {@code @Column(updatable=false)} or a generated property
* that is insert only such as {@code @WhenCreated}/{@code @WhoCreated}.
*/
static List<String> nonUpdatableColumns(BeanDescriptor<?> desc) {
List<String> columns = new ArrayList<>();
for (BeanProperty prop : desc.propertiesNonTransient()) {
if (!prop.isDbUpdatable() || isInsertOnlyGenerated(prop)) {
columns.add(prop.dbColumn());
}
}
return columns;
}
private static boolean isInsertOnlyGenerated(BeanProperty prop) {
GeneratedProperty gen = prop.generatedProperty();
return gen != null && gen.includeInInsert() && !gen.includeInUpdate();
}
}
@@ -1,5 +1,6 @@
package io.ebeaninternal.server.querydefn;
import io.ebean.DtoMapContext;
import io.ebean.DtoMapper;
import io.ebean.MappedQuery;
import io.ebean.PagedList;
@@ -10,6 +11,7 @@ import io.ebeaninternal.api.SpiQuery;
import java.sql.Connection;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
/**
* Default implementation of {@link MappedQuery} backing {@code query.mapTo(dtoType)}.
@@ -94,6 +96,13 @@ public final class DefaultMappedQuery<T, D> implements MappedQuery<D> {
return new MappedPagedList<>(query.findPagedList(), m);
}
@Override
public Stream<D> findStream() {
DtoMapper<T, D> m = mapper();
DtoMapContext context = new DtoMapContext();
return query.findStream().map(source -> m.map(source, context));
}
@Override
public MappedQuery<D> usingMaster(boolean useMaster) {
query.usingMaster(useMaster);
+4 -4
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<name>ebean ddl generation</name>
@@ -28,14 +28,14 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core-type</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
@@ -65,7 +65,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-all</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
+2 -2
View File
@@ -3,7 +3,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
@@ -15,7 +15,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core-type</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
+4 -4
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<name>ebean net postgis types</name>
@@ -19,14 +19,14 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<!-- provided scope -->
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
@@ -54,7 +54,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-test</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<artifactId>ebean-opentelemetry</artifactId>
@@ -28,7 +28,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
@@ -71,21 +71,21 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-test</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>querybean-generator</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
+4 -4
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<name>ebean pgvector types</name>
@@ -19,14 +19,14 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<!-- provided scope -->
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
@@ -54,7 +54,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-test</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
+4 -4
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<name>ebean postgis types</name>
@@ -19,14 +19,14 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<!-- provided scope -->
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
@@ -62,7 +62,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-test</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<name>ebean querybean</name>
@@ -17,7 +17,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
@@ -59,14 +59,14 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-ddl-generator</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-test</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
@@ -80,7 +80,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>querybean-generator</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
+6 -6
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<artifactId>ebean-redis</artifactId>
@@ -29,35 +29,35 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-test</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>querybean-generator</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
+6 -6
View File
@@ -6,7 +6,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<artifactId>ebean-redisson</artifactId>
@@ -29,35 +29,35 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-test</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>querybean-generator</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
+3 -3
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<artifactId>ebean-spring-txn</artifactId>
@@ -28,7 +28,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
@@ -77,7 +77,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-test</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
+6 -6
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<name>ebean test</name>
@@ -33,20 +33,20 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-h2</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-ddl-generator</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -149,14 +149,14 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-jackson-mapper</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-all</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
@@ -0,0 +1,61 @@
package org.tests.insert;
import io.ebean.annotation.Index;
import io.ebean.annotation.WhenCreated;
import io.ebean.annotation.WhenModified;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.time.Instant;
/**
* Used to verify that insert on conflict do update excludes insert-only generated
* properties (such as {@code @WhenCreated}) from the generated update set clause.
*/
@Entity
public class EConflictWithCreated {
@Id
Long id;
@Index(unique = true)
String code;
@WhenCreated
Instant whenCreated;
@WhenModified
Instant whenUpdated;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public Instant getWhenCreated() {
return whenCreated;
}
public void setWhenCreated(Instant whenCreated) {
this.whenCreated = whenCreated;
}
public Instant getWhenUpdated() {
return whenUpdated;
}
public void setWhenUpdated(Instant whenUpdated) {
this.whenUpdated = whenUpdated;
}
}
@@ -307,6 +307,35 @@ class TestInsertOnConflict extends BaseTestCase {
assertThat(list.get(0).getWhenUpdated()).isEqualTo(bean.getWhenUpdated());
}
@ForPlatform({Platform.POSTGRES, Platform.YUGABYTE, Platform.SQLITE})
@Test
void insertOnConflictUpdate_excludesWhenCreatedFromUpdateSet() {
Database db = DB.getDefault();
db.truncate(EConflictWithCreated.class);
LoggedSql.start();
var bean = new EConflictWithCreated();
bean.setCode("abc");
db.insert(bean, ON_CONFLICT_UPDATE);
var bean2 = new EConflictWithCreated();
bean2.setCode("abc");
db.insert(bean2, ON_CONFLICT_UPDATE);
var sql = LoggedSql.stop();
assertThat(sql).hasSize(2);
// when_created is not included in the "do update set" clause - it is insert only
assertThat(sql.get(0)).contains("on conflict (code) do update set when_updated=excluded.when_updated");
assertThat(sql.get(0)).doesNotContain("when_created=excluded.when_created");
assertThat(sql.get(1)).contains("on conflict (code) do update set when_updated=excluded.when_updated");
assertThat(sql.get(1)).doesNotContain("when_created=excluded.when_created");
List<EConflictWithCreated> list = db.find(EConflictWithCreated.class).findList();
assertThat(list).hasSize(1);
// original whenCreated value is preserved rather than being overwritten by the 2nd insert
assertThat(list.get(0).getWhenCreated()).isEqualTo(bean.getWhenCreated());
}
@ForPlatform({Platform.POSTGRES, Platform.YUGABYTE})
@Test
void updateQueryReturning() throws SQLException {
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<name>kotlin querybean generator</name>
@@ -21,7 +21,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
@@ -35,7 +35,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
@@ -56,14 +56,14 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-h2</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-ddl-generator</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
+14 -14
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -16,67 +16,67 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-h2</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-clickhouse</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-db2</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-hana</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-hsqldb</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-mysql</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-mariadb</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-nuodb</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-oracle</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-sqlanywhere</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-sqlite</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-sqlserver</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+2 -2
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -16,7 +16,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+2 -2
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -16,7 +16,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+2 -2
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -16,7 +16,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<!-- Provided scope so that the H2HistoryTrigger can live in Ebean core
+2 -2
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -16,7 +16,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+2 -2
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -16,7 +16,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-h2</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+3 -3
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -16,13 +16,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-mysql</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+2 -2
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -16,7 +16,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+2 -2
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -16,7 +16,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+2 -2
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -16,7 +16,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+1 -1
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<artifactId>platforms</artifactId>
+2 -2
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -16,7 +16,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
+2 -2
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -16,7 +16,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+2 -2
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -16,7 +16,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+2 -2
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -16,7 +16,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+2 -2
View File
@@ -9,7 +9,7 @@
<groupId>io.ebean</groupId>
<artifactId>ebean-parent</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<packaging>pom</packaging>
<name>ebean parent</name>
@@ -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.6</ebean-annotation.version>
<ebean-annotation.version>8.9</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>
+2 -2
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<name>querybean generator</name>
@@ -45,7 +45,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
</dependencies>
@@ -104,6 +104,9 @@ class DtoMapperWriter {
if (!meta.converterDeps().isEmpty()) {
imports.add("io.ebean.DtoConverterManager");
}
if (meta.properties().stream().anyMatch(DtoPropertyMeta::usesMapperSupport)) {
imports.add("io.ebean.DtoMapperSupport");
}
if (variableProperties().stream().anyMatch(p -> p.kind() == DtoPropertyMeta.Kind.NESTED_MANY)) {
imports.add("java.util.List");
}
@@ -222,21 +225,41 @@ class DtoMapperWriter {
}
Set<String> nestedAssocPaths = new LinkedHashSet<>();
for (DtoPropertyMeta property : activeProperties) {
if (property.kind() == DtoPropertyMeta.Kind.NESTED_ONE || property.kind() == DtoPropertyMeta.Kind.NESTED_MANY) {
if ((property.kind() == DtoPropertyMeta.Kind.NESTED_ONE || property.kind() == DtoPropertyMeta.Kind.NESTED_MANY)
&& !property.hasComputedSegment()) {
nestedAssocPaths.add(property.sourcePropertyPath().get(0));
}
}
Set<String> rootSelect = new LinkedHashSet<>();
Map<String, List<String>> pathSelect = new LinkedHashMap<>();
Set<String> extraFetchPaths = new LinkedHashSet<>();
List<String> fetchCalls = new ArrayList<>();
for (DtoPropertyMeta property : activeProperties) {
switch (property.kind()) {
case NESTED_ONE:
case NESTED_MANY:
if (property.hasComputedSegment()) {
// a single-hop @DtoPath rename traversing a computed/derived getter (no backing
// field) that happens to target a nested DTO type - just as unfetchable via
// fetch(path, mapper.fetchGroup()) as the analogous SCALAR case, since "path" here
// isn't a real Ebean fetch path either. The nested mapper is still invoked directly
// against whatever the getter returns (see DtoMapperWriter#propertyValueExpression) -
// it's purely the FetchGroup derivation that must fall back to @DtoPath#requires().
extraFetchPaths.addAll(property.requiredFetchPaths());
break;
}
fetchCalls.add(String.format("fetch(\"%s\", %s.fetchGroup())",
property.sourcePropertyPath().get(0), mapperFieldName(property)));
break;
case SCALAR:
if (property.hasComputedSegment()) {
// the path traverses a computed/derived getter (no backing field) - its own segments
// past that point aren't real Ebean fetch paths, so don't add them to pathSelect/
// rootSelect at all; @DtoPath#requires() (plus the real prefix, if any) already names
// exactly what needs fetching instead - see DtoPropertyMeta#requiredFetchPaths().
extraFetchPaths.addAll(property.requiredFetchPaths());
break;
}
List<String> path = property.sourcePropertyPath();
if (path.size() == 1) {
rootSelect.add(path.get(0));
@@ -254,6 +277,15 @@ class DtoMapperWriter {
break;
case REF:
default:
if (property.hasComputedSegment()) {
// the association has no backing field (a computed/derived getter) - "assoc" isn't a
// real Ebean property name, so it can't be handed to FetchGroup.select(...) directly;
// @DtoRef#requires() already names exactly what needs fetching instead - see
// DtoPropertyMeta#requiredFetchPaths(). The value mapping itself (source.getAssoc().
// getId()) still works via plain Java method invocation regardless.
extraFetchPaths.addAll(property.requiredFetchPaths());
break;
}
String assoc = property.sourcePropertyPath().get(0);
if (!nestedAssocPaths.contains(assoc)) {
rootSelect.add(assoc);
@@ -262,8 +294,27 @@ class DtoMapperWriter {
}
}
for (var entry : pathSelect.entrySet()) {
if (extraFetchPaths.contains(entry.getKey())) {
// an unrelated computed-segment property also needs a bare, full fetch(path) at this
// exact same path (emitted below) - FetchGroup's builder REPLACES (not merges) same-path
// fetch calls (OrmQueryDetail.fetch(...) is a plain Map.put keyed by path), so emitting
// both a narrowed fetch(path, "props") here and a bare fetch(path) below would leave
// only whichever call happens to be added last in effect, silently discarding the other's
// requirement depending on emission order. Skip the narrow entry - a full fetch(path) is
// always a safe superset of any narrower property selection, so let the bare fetch below
// win deterministically instead of depending on iteration order.
continue;
}
fetchCalls.add(String.format("fetch(\"%s\", \"%s\")", entry.getKey(), String.join(",", entry.getValue())));
}
for (String extraPath : extraFetchPaths) {
// already covered by another property's NESTED_ONE/MANY fetch of the exact same path (a
// full nested mapper.fetchGroup()) - that's richer than a bare fetch(path) (which would
// replace it and lose the nested mapper's own fetch requirements), so it must win instead.
if (!nestedAssocPaths.contains(extraPath)) {
fetchCalls.add(String.format("fetch(\"%s\")", extraPath));
}
}
List<String> calls = new ArrayList<>();
if (!rootSelect.isEmpty()) {
calls.add(String.format("select(\"%s\")", String.join(",", rootSelect)));
@@ -135,7 +135,14 @@ class DtoMappingReader {
ctx.logError(element, "@DtoMixin value() must be a class type");
continue;
}
mixinsByTarget.put(target.getQualifiedName().toString(), (TypeElement) element);
String targetFqn = target.getQualifiedName().toString();
TypeElement existing = mixinsByTarget.get(targetFqn);
if (existing != null) {
ctx.logError(element, "Duplicate @DtoMixin for target %s - %s already declares a mixin for"
+ " it, only one @DtoMixin is allowed per target", targetFqn, existing.getQualifiedName());
continue;
}
mixinsByTarget.put(targetFqn, (TypeElement) element);
}
}
@@ -444,42 +451,178 @@ class DtoMappingReader {
String name = field.getSimpleName().toString();
DtoConverterMeta converter = resolveConverter(field, meta);
DtoRefPrism refPrism = prismOn(field, meta, DtoRefPrism::getInstanceOn);
DtoPathPrism pathPrism = prismOn(field, meta, DtoPathPrism::getInstanceOn);
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",
meta.targetFullName(), name);
}
if (refPrism != null) {
String assocName = (name.endsWith("Id") && name.length() > 2) ? name.substring(0, name.length() - 2) : name;
String assocGetter = getterName(meta.source(), assocName);
TypeElement assocType = getterReturnType(meta.source(), assocGetter);
String idGetter = getterName(assocType, "id");
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.REF, List.of(assocGetter, idGetter), List.of(assocName, "id"), null, converter);
boolean computedSegment = meta.source() == null || !hasField(meta.source(), assocName);
List<String> requiredFetchPaths = List.of();
if (computedSegment) {
// the association has no backing field on the source - a computed/derived getter (e.g.
// picking one entry out of a collection) rather than a real, fetchable Ebean relation, so
// its data dependencies can't be inferred automatically - same problem class, and same
// requires() escape hatch, as the analogous @DtoPath handling above. Unlike @DtoPath there's
// no "real prefix" to combine with, since @DtoRef only ever derives a single association
// name directly off the source (no dotted value() of its own).
if (refPrism.values.requires() == null) {
ctx.logError(field,
"@DtoRef on %s traverses '%s' which has no backing field on %s - it looks like a"
+ " computed/derived getter rather than a real, fetchable Ebean relation, so its data"
+ " dependencies can't be inferred automatically. Specify @DtoRef(requires = {...})"
+ " naming the real entity paths that must be fetched for it to execute safely, or"
+ " requires = {} if it genuinely needs nothing extra fetched, or remove @DtoRef and"
+ " compute this value another way (e.g. @DtoConvert).",
meta.targetFullName(), assocName, meta.source() != null ? meta.source().getSimpleName() : "?");
}
requiredFetchPaths = refPrism.requires();
String annotationDisplay = String.format("@DtoRef(requires = ...) on %s.%s", meta.targetFullName(), name);
for (String requiresPath : requiredFetchPaths) {
validateRequiresPath(field, meta.source(), requiresPath, annotationDisplay);
}
}
// @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);
}
DtoPathPrism pathPrism = prismOn(field, meta, DtoPathPrism::getInstanceOn);
if (pathPrism != null) {
List<String> getters = new ArrayList<>();
List<String> properties = new ArrayList<>();
TypeElement currentType = meta.source();
TypeElement computedDeclaringType = null;
int computedFrom = -1;
for (String segment : pathPrism.value().split("\\.")) {
String getter = getterName(currentType, segment);
getters.add(getter);
properties.add(segment);
if (computedFrom < 0 && (currentType == null || !hasField(currentType, segment))) {
computedFrom = properties.size() - 1;
computedDeclaringType = currentType;
}
currentType = currentType != null ? getterReturnType(currentType, getter) : null;
}
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.SCALAR, getters, properties, null, converter);
List<String> requiredFetchPaths = List.of();
if (computedFrom >= 0) {
// a segment with no backing field is a computed/derived getter, not a real, fetchable
// Ebean property - its own data dependencies (what it touches internally) can't be
// inferred from the path alone, so require the developer to spell them out explicitly
// via @DtoPath(requires = {...}) rather than silently generating a FetchGroup.fetch(...)
// call for a path segment Ebean doesn't actually recognise (which only fails at runtime).
// Computed here regardless of whether the path ultimately resolves to a plain SCALAR or a
// single-hop NESTED_ONE/MANY rename below - both cases share the exact same problem: a
// fake path segment that can't be handed to FetchGroup as a real fetch/select target.
String realPrefix = computedFrom == 0 ? null : String.join(".", properties.subList(0, computedFrom));
// pathPrism.requires() always returns List.of() whether the attribute was explicitly
// written as an empty array or omitted entirely - only pathPrism.values.requires() (which
// returns null for a defaulted/omitted member) can tell the two apart. That distinction
// matters here: an explicit requires = {} is the developer's way of confirming the
// computed getter genuinely needs nothing extra fetched, whereas omitting requires
// entirely means they haven't considered it yet - only the latter should fail the build.
if (pathPrism.values.requires() == null) {
String hint = realPrefix != null
? String.format(" (e.g. requires = \"%s\", or a deeper path under it your getter actually needs)", realPrefix)
: "";
ctx.logError(field,
"@DtoPath(\"%s\") on %s traverses '%s' which has no backing field on %s - it looks like"
+ " a computed/derived getter rather than a real, fetchable Ebean property, so its data"
+ " dependencies can't be inferred automatically. Specify @DtoPath(requires = {...})"
+ " naming the real entity paths that must be fetched for it to execute safely%s, or"
+ " requires = {} if it genuinely needs nothing extra fetched, or remove @DtoPath and"
+ " compute this value another way (e.g. @DtoConvert).",
pathPrism.value(), meta.targetFullName(), properties.get(computedFrom),
computedDeclaringType != null ? computedDeclaringType.getSimpleName() : "?", hint);
}
List<String> combined = new ArrayList<>();
if (realPrefix != null) {
combined.add(realPrefix);
}
combined.addAll(pathPrism.requires());
requiredFetchPaths = combined;
// realPrefix is already known-good (each of its segments was hasField-checked while
// walking value() above) - only the developer-declared requires() values themselves are
// unchecked and need validating here.
String annotationDisplay = String.format("@DtoPath(\"%s\").requires()", pathPrism.value());
for (String requiresPath : pathPrism.requires()) {
validateRequiresPath(field, meta.source(), requiresPath, annotationDisplay);
}
}
// a single-hop @DtoPath rename (e.g. @DtoPath("eboxStatus") on a field named "status")
// can still target a type with its own registered @DtoMapping - detect that the same way
// the plain (non-@DtoPath) branches below do, rather than always falling back to a raw
// scalar getter call that would fail to compile with a type mismatch against the nested
// DTO type. Multi-hop paths keep the existing scalar/flattening behaviour since fetch spec
// derivation for NESTED_ONE/MANY only supports a single association name. A computed
// segment here (the single segment has no backing field - e.g. @DtoPath("primaryContact")
// where getPrimaryContact() is a derived getter) is just as unfetchable as the SCALAR case
// above, so it carries the same computedFrom/requiredFetchPaths validation through - see
// DtoMapperWriter's NESTED_ONE/NESTED_MANY handling of DtoPropertyMeta#hasComputedSegment().
if (properties.size() == 1) {
TypeMirror fieldType = field.asType();
TypeMirror listElementType = listElementType(fieldType);
if (listElementType != null) {
DtoBeanMeta nested = lookupByTarget(listElementType);
if (nested != null) {
rejectConverterOnNested(field, converter, name, meta);
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.NESTED_MANY, getters, properties, nested,
computedFrom >= 0, requiredFetchPaths);
}
} else {
DtoBeanMeta nested = lookupByTarget(fieldType);
if (nested != null) {
rejectConverterOnNested(field, converter, name, meta);
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.NESTED_ONE, getters, properties, nested,
computedFrom >= 0, requiredFetchPaths);
}
}
}
// A multi-hop path can pass through a nullable intermediate relation - if the DTO field is
// 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);
}
TypeMirror fieldType = field.asType();
TypeMirror listElementType = listElementType(fieldType);
if (listElementType != null) {
DtoBeanMeta nested = lookupByTarget(listElementType);
if (nested != null) {
rejectConverterOnNested(field, converter, name, meta);
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.NESTED_MANY, List.of(getterName(meta.source(), name)), List.of(name), nested);
}
} else {
DtoBeanMeta nested = lookupByTarget(fieldType);
if (nested != null) {
rejectConverterOnNested(field, converter, name, meta);
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);
}
/**
* {@code @DtoConvert} only applies to {@code SCALAR}/{@code REF} value expressions
* ({@link DtoMapperWriter#propertyValueExpression}) - a {@code NESTED_ONE}/{@code NESTED_MANY}
* property's value comes entirely from its own nested mapper's {@code map(...)}/{@code
* mapList(...)} call, so a converter resolved for it would silently have no effect at all.
* Raise a compile error instead of letting the annotation quietly do nothing.
*/
private void rejectConverterOnNested(VariableElement field, DtoConverterMeta converter, String name, DtoBeanMeta meta) {
if (converter != null) {
ctx.logError(field, "@DtoConvert on %s.%s has no effect - it isn't supported on a nested DTO"
+ " graph property (NESTED_ONE/NESTED_MANY), only on SCALAR/REF properties. Remove"
+ " @DtoConvert, or perform the conversion inside the nested DTO's own mapper instead.",
meta.targetFullName(), name);
}
}
/**
* Resolve a prism instance for {@code field}, falling back to the corresponding
* {@code @DtoMixin} companion method (same simple name) when {@code field} carries no such
@@ -523,15 +666,54 @@ class DtoMappingReader {
return null;
}
String methodName = prism.method();
ExecutableElement method = findMethod(converterType, methodName);
ExecutableElement method = findConverterMethod(field, converterType, methodName);
if (method == null) {
ctx.logError(field, "@DtoConvert method \"%s\" not found on %s", methodName, converterType.getQualifiedName());
return null;
}
boolean isStatic = method.getModifiers().contains(Modifier.STATIC);
return new DtoConverterMeta(converterType.getQualifiedName().toString(), methodName, isStatic);
}
/**
* Resolve {@code @DtoConvert}'s {@code method()} on {@code converterType} - unlike
* {@link #findMethod}, this requires exactly one candidate taking a single parameter (the
* documented {@code @DtoConvert} contract: "taking the source property value and returning the
* converted DTO property value"). {@code findMethod} alone matches by simple name only, so a
* converter type with two same-named overloads (a very plausible shape for a shared conversion
* utility class, e.g. {@code format(Instant)} and {@code format(LocalDate)}) would silently bind
* to whichever one {@code ElementFilter.methodsIn} happens to return first, regardless of which
* one the developer actually intended - generating either a confusing compile error in the
* generated mapper (arity/type mismatch) or, worse, silently generating a call to the wrong
* overload if both happen to be call-compatible.
*/
private ExecutableElement findConverterMethod(VariableElement field, TypeElement converterType, String methodName) {
List<ExecutableElement> oneArgCandidates = new ArrayList<>();
boolean anyNameMatch = false;
for (ExecutableElement candidate : ElementFilter.methodsIn(converterType.getEnclosedElements())) {
if (!candidate.getSimpleName().contentEquals(methodName)) {
continue;
}
anyNameMatch = true;
if (candidate.getParameters().size() == 1) {
oneArgCandidates.add(candidate);
}
}
if (oneArgCandidates.size() == 1) {
return oneArgCandidates.get(0);
}
if (oneArgCandidates.isEmpty()) {
ctx.logError(field, "@DtoConvert method \"%s\" not found on %s taking exactly one parameter%s",
methodName, converterType.getQualifiedName(),
anyNameMatch ? " (a method with that name exists but doesn't take exactly one parameter)" : "");
return null;
}
ctx.logError(field, "@DtoConvert method \"%s\" on %s is ambiguous - %d overloads take exactly one"
+ " parameter, and @DtoConvert can't disambiguate by parameter type. Rename one of the"
+ " overloads so the reference is unambiguous.",
methodName, converterType.getQualifiedName(), oneArgCandidates.size());
return null;
}
private ExecutableElement findMethod(TypeElement type, String methodName) {
for (ExecutableElement method : ElementFilter.methodsIn(type.getEnclosedElements())) {
if (method.getSimpleName().contentEquals(methodName)) {
@@ -594,6 +776,29 @@ class DtoMappingReader {
return getName;
}
/**
* Whether {@code type} (searching {@code type} and its superclass chain) declares a field
* named {@code propertyName} - used to distinguish a real, fetchable Ebean bean property (which
* always has a backing field once enhanced) from a computed/derived getter with no backing
* storage at all (e.g. a hand-written method that filters/derives a value from other
* properties). {@code type == null} (unresolvable) conservatively returns {@code true} so an
* already-unresolvable segment doesn't also get flagged as "computed" - it'll already have
* fallen back to a guessed getter name via {@link #getterName}.
*/
private boolean hasField(TypeElement type, String propertyName) {
if (type == null) {
return true;
}
for (TypeElement current = type; current != null; current = superclassOf(current)) {
for (VariableElement f : ElementFilter.fieldsIn(current.getEnclosedElements())) {
if (f.getSimpleName().contentEquals(propertyName)) {
return true;
}
}
}
return false;
}
/**
* Whether a no-arg method named {@code methodName} exists on {@code type} (searching
* {@code type} and its superclass chain), optionally constrained to a specific return
@@ -621,16 +826,61 @@ class DtoMappingReader {
* which case later segments fall back to the guessed {@code getXxx()} name.
*/
private TypeElement getterReturnType(TypeElement type, String getterMethodName) {
TypeMirror mirror = getterReturnTypeMirror(type, getterMethodName);
return mirror != null ? asTypeElement(mirror) : null;
}
/**
* As {@link #getterReturnType(TypeElement, String)}, but returns the raw {@link TypeMirror}
* rather than converting it to a {@link TypeElement} - needed by {@link #validateRequiresPath}
* to detect a {@code java.util.List}-typed return (via {@link #listElementType(TypeMirror)}),
* which {@link #getterReturnType(TypeElement, String)}'s {@code asTypeElement} conversion can't
* distinguish from any other declared type.
*/
private TypeMirror getterReturnTypeMirror(TypeElement type, String getterMethodName) {
for (TypeElement current = type; current != null; current = superclassOf(current)) {
for (ExecutableElement method : ElementFilter.methodsIn(current.getEnclosedElements())) {
if (method.getParameters().isEmpty() && method.getSimpleName().contentEquals(getterMethodName)) {
return asTypeElement(method.getReturnType());
return method.getReturnType();
}
}
}
return null;
}
/**
* Validate that every dot-notation segment of a declared {@code @DtoPath(requires = ...)}/
* {@code @DtoRef(requires = ...)} path value names a real, fetchable Ebean property (one with a
* backing field) on {@code source} - these are meant to be real entity fetch paths handed
* straight through to {@code FetchGroup.fetch(...)}, so a typo here would otherwise silently
* reintroduce the exact "compiles cleanly, fails at runtime with {@code PersistenceException}"
* problem the {@code requires()} escape hatch itself exists to prevent - it just wouldn't be
* caught until much later, since {@code requires()} paths are trusted verbatim rather than
* walked/checked like {@code @DtoPath#value()}'s own segments are. Handles a {@code List}-typed
* intermediate hop (e.g. {@code "currentMachine.organisationMachines"}) by unwrapping to the
* element type via {@link #listElementType(TypeMirror)}, mirroring how a real fetch path can
* traverse a collection.
*/
private void validateRequiresPath(Element field, TypeElement source, String requiresPath, String annotationDisplay) {
TypeElement currentType = source;
for (String segment : requiresPath.split("\\.")) {
if (currentType == null) {
return; // already unresolvable upstream - don't cascade a confusing secondary error
}
if (!hasField(currentType, segment)) {
ctx.logError(field,
"%s names '%s' (in \"%s\") which has no backing field on %s - check for a typo, every"
+ " requires() path segment must be a real, fetchable Ebean property.",
annotationDisplay, segment, requiresPath, currentType.getSimpleName());
return;
}
String getter = getterName(currentType, segment);
TypeMirror returnMirror = getterReturnTypeMirror(currentType, getter);
TypeMirror elementType = returnMirror != null ? listElementType(returnMirror) : null;
currentType = asTypeElement(elementType != null ? elementType : returnMirror);
}
}
private TypeElement superclassOf(TypeElement type) {
return asTypeElement(type.getSuperclass());
}
@@ -25,18 +25,59 @@ class DtoPropertyMeta {
private final List<String> sourcePropertyPath;
private final DtoBeanMeta nested;
private final DtoConverterMeta converter;
private final boolean primitiveTarget;
private final boolean failOnNull;
private final boolean computedSegment;
private final List<String> requiredFetchPaths;
DtoPropertyMeta(String dtoFieldName, Kind kind, List<String> sourceGetterPath, List<String> sourcePropertyPath, DtoBeanMeta nested) {
this(dtoFieldName, kind, sourceGetterPath, sourcePropertyPath, nested, null);
}
/**
* {@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
* {@link #hasComputedSegment()}. Just as unfetchable via {@code FetchGroup.fetch(path, ...)} as
* the analogous {@link Kind#SCALAR} case, so it carries the same
* {@code computedSegment}/{@code requiredFetchPaths} through to {@code DtoMapperWriter}.
*/
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);
}
DtoPropertyMeta(String dtoFieldName, Kind kind, List<String> sourceGetterPath, List<String> sourcePropertyPath, DtoBeanMeta nested, DtoConverterMeta converter) {
this(dtoFieldName, kind, sourceGetterPath, sourcePropertyPath, nested, converter, false, false);
}
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());
}
/**
* Full constructor - {@code primitiveTarget}/{@code failOnNull} only matter for a multi-hop
* ({@code sourceGetterPath.size() > 1}) {@link Kind#SCALAR}/{@link Kind#REF} property whose DTO
* field type is a Java primitive, per {@code @DtoPath#failOnNull()} - see
* {@link #sourceValueExpression(String)}. {@code computedSegment} is {@code true} only for a
* {@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.
*/
DtoPropertyMeta(String dtoFieldName, Kind kind, List<String> sourceGetterPath, List<String> sourcePropertyPath,
DtoBeanMeta nested, DtoConverterMeta converter, boolean primitiveTarget, boolean failOnNull,
boolean computedSegment, List<String> requiredFetchPaths) {
this.dtoFieldName = dtoFieldName;
this.kind = kind;
this.sourceGetterPath = sourceGetterPath;
this.sourcePropertyPath = sourcePropertyPath;
this.nested = nested;
this.converter = converter;
this.primitiveTarget = primitiveTarget;
this.failOnNull = failOnNull;
this.computedSegment = computedSegment;
this.requiredFetchPaths = requiredFetchPaths;
}
String dtoFieldName() {
@@ -79,11 +120,40 @@ class DtoPropertyMeta {
return converter;
}
/**
* {@code true} if this {@code @DtoPath} traverses a segment with no backing field (a computed/
* derived getter rather than a real, fetchable Ebean property) - in which case
* {@link #sourcePropertyPath()} must NOT be used to derive a {@code .fetch(path, "props")}
* ({@link Kind#SCALAR}) or {@code .fetch(path, mapper.fetchGroup())} ({@link Kind#NESTED_ONE}/
* {@link Kind#NESTED_MANY}) call (the path isn't a real Ebean fetch path), and
* {@link #requiredFetchPaths()} should be used instead (see {@code @DtoPath#requires()}).
*/
boolean hasComputedSegment() {
return computedSegment;
}
/**
* 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
* {@code @DtoPath#requires()} paths. Empty by default when {@link #hasComputedSegment()} is
* {@code false}; can also legitimately be empty when it's {@code true} (an explicit
* {@code @DtoPath(requires = {})} confirming nothing extra is needed).
*/
List<String> requiredFetchPaths() {
return requiredFetchPaths;
}
/**
* Return a source expression chaining {@link #sourceGetterPath()} getters off the given root
* variable. A single getter is a plain call, e.g. {@code s.getName()}; a multi-hop chain (from
* {@code @DtoPath} or {@code @DtoRef}) null-guards each intermediate hop, e.g.
* {@code (s.getBillingAddress() == null ? null : s.getBillingAddress().getLine1())}.
* <p>
* That null-guarded chain always types as the boxed wrapper (one ternary branch is the
* {@code null} literal) - when {@link #primitiveTarget} is set (the DTO field is a Java
* primitive), the whole chain is additionally wrapped in a {@code DtoMapperSupport} call so it
* safely resolves to the primitive's zero-equivalent value (the default), or throws a clear
* exception instead, per {@code @DtoPath#failOnNull()} - see {@code DtoMapperSupport}.
*/
String sourceValueExpression(String rootVariable) {
if (sourceGetterPath.size() == 1) {
@@ -91,7 +161,23 @@ class DtoPropertyMeta {
}
StringBuilder sb = new StringBuilder();
appendGuardedChain(sb, rootVariable, 0);
return sb.toString();
String chain = sb.toString();
if (!primitiveTarget) {
return chain;
}
return failOnNull
? "DtoMapperSupport.require(" + chain + ", \"" + String.join(".", sourcePropertyPath) + "\")"
: "DtoMapperSupport.orZero(" + chain + ")";
}
/**
* {@code true} if {@link #sourceValueExpression(String)} wraps its chain in a
* {@code DtoMapperSupport} call - i.e. this is a multi-hop {@link Kind#SCALAR}/{@link Kind#REF}
* property whose DTO field type is primitive. Used to conditionally import
* {@code io.ebean.DtoMapperSupport} only when actually referenced.
*/
boolean usesMapperSupport() {
return primitiveTarget && sourceGetterPath.size() > 1;
}
private void appendGuardedChain(StringBuilder sb, String prefix, int index) {
@@ -105,3 +191,4 @@ class DtoPropertyMeta {
sb.append(')');
}
}
@@ -0,0 +1,814 @@
package io.ebean.querybean.generator;
import org.junit.jupiter.api.Test;
import javax.tools.Diagnostic;
import javax.tools.DiagnosticCollector;
import javax.tools.JavaCompiler;
import javax.tools.JavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Regression test for {@code @DtoPath#requires()} - a {@code @DtoPath} traversing a segment with
* no backing field (a computed/derived getter, not a real fetchable Ebean property) must fail
* fast at compile time when {@code requires()} isn't specified, rather than compiling cleanly and
* failing later at runtime because the generated {@code FetchGroup} doesn't fetch whatever the
* getter itself needs internally (see docs/dto-mapping-design.md, "computed/derived getter"
* limitation).
* <p>
* Compiles a minimal in-memory source set directly through {@code javax.tools.JavaCompiler} with
* this module's {@link Processor} registered explicitly - no external compile-testing dependency
* required (mirrors {@link DtoMapperFetchPathCollisionTest}).
*/
class DtoMapperComputedPathTest {
@Test
void dtoPathThroughComputedGetter_withoutRequires_expectCompileError() throws IOException {
Path sourceDir = Files.createTempDirectory("dto-computed-src");
Path outDir = Files.createTempDirectory("dto-computed-out");
writeSource(sourceDir, "org.tests.computed.Bar",
"package org.tests.computed;\n"
+ "\n"
+ "public class Bar {\n"
+ " private Long id;\n"
+ " private String name;\n"
+ "\n"
+ " public Long getId() { return id; }\n"
+ " public String getName() { return name; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.computed.Foo",
"package org.tests.computed;\n"
+ "\n"
+ "import java.util.List;\n"
+ "\n"
+ "public class Foo {\n"
+ " private List<Bar> bars;\n"
+ "\n"
+ " public List<Bar> getBars() { return bars; }\n"
+ "\n"
+ " // computed/derived getter - no backing 'firstBar' field\n"
+ " public Bar getFirstBar() { return bars.isEmpty() ? null : bars.get(0); }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.computed.FooDto",
"package org.tests.computed;\n"
+ "\n"
+ "import io.ebean.annotation.DtoPath;\n"
+ "\n"
+ "public class FooDto {\n"
+ " @DtoPath(\"firstBar.name\")\n"
+ " private final String firstBarName;\n"
+ "\n"
+ " public FooDto(String firstBarName) {\n"
+ " this.firstBarName = firstBarName;\n"
+ " }\n"
+ "\n"
+ " public String getFirstBarName() { return firstBarName; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.computed.package-info",
"@DtoMapping(source = Foo.class, target = FooDto.class)\n"
+ "package org.tests.computed;\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 due to the missing @DtoPath(requires = ...)");
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("no backing field")
&& msg.contains("computed/derived getter")
&& msg.contains("requires"));
assertTrue(matched, "expected the computed-getter requires() error message, got: " + errors);
}
}
/**
* Same defect as above, but exercising the {@code NESTED_ONE} branch rather than {@code SCALAR}
* - a single-hop {@code @DtoPath} rename whose target field type matches a separately registered
* nested DTO mapping. Prior to the fix, this case bypassed the computed-segment validation
* entirely (the nested-lookup branch returned early before it ran).
*/
@Test
void dtoPathThroughComputedGetter_targetingNestedDto_withoutRequires_expectCompileError() throws IOException {
Path sourceDir = Files.createTempDirectory("dto-computed-nested-src");
Path outDir = Files.createTempDirectory("dto-computed-nested-out");
writeSource(sourceDir, "org.tests.computednested.Bar",
"package org.tests.computednested;\n"
+ "\n"
+ "public class Bar {\n"
+ " private Long id;\n"
+ " private String name;\n"
+ "\n"
+ " public Long getId() { return id; }\n"
+ " public String getName() { return name; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.computednested.BarDto",
"package org.tests.computednested;\n"
+ "\n"
+ "public class BarDto {\n"
+ " private final Long id;\n"
+ " private final String name;\n"
+ "\n"
+ " public BarDto(Long id, String name) {\n"
+ " this.id = id;\n"
+ " this.name = name;\n"
+ " }\n"
+ "\n"
+ " public Long getId() { return id; }\n"
+ " public String getName() { return name; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.computednested.Foo",
"package org.tests.computednested;\n"
+ "\n"
+ "import java.util.List;\n"
+ "\n"
+ "public class Foo {\n"
+ " private List<Bar> bars;\n"
+ "\n"
+ " public List<Bar> getBars() { return bars; }\n"
+ "\n"
+ " // computed/derived getter - no backing 'firstBar' field\n"
+ " public Bar getFirstBar() { return bars.isEmpty() ? null : bars.get(0); }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.computednested.FooDto",
"package org.tests.computednested;\n"
+ "\n"
+ "import io.ebean.annotation.DtoPath;\n"
+ "\n"
+ "public class FooDto {\n"
+ " @DtoPath(\"firstBar\")\n"
+ " private final BarDto firstBar;\n"
+ "\n"
+ " public FooDto(BarDto firstBar) {\n"
+ " this.firstBar = firstBar;\n"
+ " }\n"
+ "\n"
+ " public BarDto getFirstBar() { return firstBar; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.computednested.package-info",
"@DtoMapping(source = Bar.class, target = BarDto.class)\n"
+ "@DtoMapping(source = Foo.class, target = FooDto.class)\n"
+ "package org.tests.computednested;\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 due to the missing @DtoPath(requires = ...)");
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("no backing field")
&& msg.contains("computed/derived getter")
&& msg.contains("requires"));
assertTrue(matched, "expected the computed-getter requires() error message, got: " + errors);
}
}
/**
* Same defect class as the {@code @DtoPath} cases above, but for {@code @DtoRef} - a computed
* association getter with no backing field used via {@code @DtoRef} without {@code requires()}
* must fail fast at compile time rather than compile cleanly and fail later at runtime.
*/
@Test
void dtoRefThroughComputedGetter_withoutRequires_expectCompileError() throws IOException {
Path sourceDir = Files.createTempDirectory("dto-ref-computed-src");
Path outDir = Files.createTempDirectory("dto-ref-computed-out");
writeSource(sourceDir, "org.tests.refcomputed.Bar",
"package org.tests.refcomputed;\n"
+ "\n"
+ "public class Bar {\n"
+ " private Long id;\n"
+ "\n"
+ " public Long getId() { return id; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.refcomputed.Foo",
"package org.tests.refcomputed;\n"
+ "\n"
+ "import java.util.List;\n"
+ "\n"
+ "public class Foo {\n"
+ " private List<Bar> bars;\n"
+ "\n"
+ " public List<Bar> getBars() { return bars; }\n"
+ "\n"
+ " // computed/derived getter - no backing 'firstBar' field\n"
+ " public Bar getFirstBar() { return bars.isEmpty() ? null : bars.get(0); }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.refcomputed.FooDto",
"package org.tests.refcomputed;\n"
+ "\n"
+ "import io.ebean.annotation.DtoRef;\n"
+ "\n"
+ "public class FooDto {\n"
+ " @DtoRef\n"
+ " private final Long firstBarId;\n"
+ "\n"
+ " public FooDto(Long firstBarId) {\n"
+ " this.firstBarId = firstBarId;\n"
+ " }\n"
+ "\n"
+ " public Long getFirstBarId() { return firstBarId; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.refcomputed.package-info",
"@DtoMapping(source = Foo.class, target = FooDto.class)\n"
+ "package org.tests.refcomputed;\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 due to the missing @DtoRef(requires = ...)");
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("no backing field")
&& msg.contains("computed/derived getter")
&& msg.contains("requires"));
assertTrue(matched, "expected the computed-getter requires() error message, got: " + errors);
}
}
/**
* A {@code requires()} path value with a typo'd segment (not the computed segment itself, the
* developer-declared dependency path) must also fail fast at compile time - {@code requires()}
* values are handed straight through to {@code FetchGroup.fetch(...)}, so an unchecked typo
* there would silently reintroduce the exact runtime {@code PersistenceException} the whole
* escape hatch exists to prevent.
*/
@Test
void dtoPathRequires_withTypoInPathValue_expectCompileError() throws IOException {
Path sourceDir = Files.createTempDirectory("dto-requires-typo-src");
Path outDir = Files.createTempDirectory("dto-requires-typo-out");
writeSource(sourceDir, "org.tests.requirestypo.Bar",
"package org.tests.requirestypo;\n"
+ "\n"
+ "public class Bar {\n"
+ " private Long id;\n"
+ " private String name;\n"
+ "\n"
+ " public Long getId() { return id; }\n"
+ " public String getName() { return name; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.requirestypo.Foo",
"package org.tests.requirestypo;\n"
+ "\n"
+ "import java.util.List;\n"
+ "\n"
+ "public class Foo {\n"
+ " private List<Bar> bars;\n"
+ "\n"
+ " public List<Bar> getBars() { return bars; }\n"
+ "\n"
+ " // computed/derived getter - no backing 'firstBar' field\n"
+ " public Bar getFirstBar() { return bars.isEmpty() ? null : bars.get(0); }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.requirestypo.FooDto",
"package org.tests.requirestypo;\n"
+ "\n"
+ "import io.ebean.annotation.DtoPath;\n"
+ "\n"
+ "public class FooDto {\n"
+ " // 'barz' is a typo for the real 'bars' property\n"
+ " @DtoPath(value = \"firstBar.name\", requires = \"barz\")\n"
+ " private final String firstBarName;\n"
+ "\n"
+ " public FooDto(String firstBarName) {\n"
+ " this.firstBarName = firstBarName;\n"
+ " }\n"
+ "\n"
+ " public String getFirstBarName() { return firstBarName; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.requirestypo.package-info",
"@DtoMapping(source = Foo.class, target = FooDto.class)\n"
+ "package org.tests.requirestypo;\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 due to the typo'd requires() path value");
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("no backing field")
&& msg.contains("barz")
&& msg.contains("typo"));
assertTrue(matched, "expected the requires() typo error message, got: " + errors);
}
}
/**
* Two {@code @DtoMixin} companion types targeting the same DTO class must fail fast at compile
* time - previously the second registration silently overwrote the first in
* {@code mixinsByTarget}, so whichever mixin was processed last would win with no diagnostic at
* all, silently discarding the other mixin's {@code @DtoPath}/{@code @DtoRef}/{@code @DtoConvert}
* overlays.
*/
@Test
void duplicateDtoMixin_forSameTarget_expectCompileError() throws IOException {
Path sourceDir = Files.createTempDirectory("dto-mixin-dup-src");
Path outDir = Files.createTempDirectory("dto-mixin-dup-out");
writeSource(sourceDir, "org.tests.mixindup.FooDto",
"package org.tests.mixindup;\n"
+ "\n"
+ "public class FooDto {\n"
+ " private final String bar;\n"
+ "\n"
+ " public FooDto(String bar) {\n"
+ " this.bar = bar;\n"
+ " }\n"
+ "\n"
+ " public String getBar() { return bar; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.mixindup.FooMixinA",
"package org.tests.mixindup;\n"
+ "\n"
+ "import io.ebean.annotation.DtoMixin;\n"
+ "\n"
+ "@DtoMixin(FooDto.class)\n"
+ "interface FooMixinA {\n"
+ " String bar();\n"
+ "}\n");
writeSource(sourceDir, "org.tests.mixindup.FooMixinB",
"package org.tests.mixindup;\n"
+ "\n"
+ "import io.ebean.annotation.DtoMixin;\n"
+ "\n"
+ "@DtoMixin(FooDto.class)\n"
+ "interface FooMixinB {\n"
+ " String bar();\n"
+ "}\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 due to the duplicate @DtoMixin for the same target");
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("Duplicate @DtoMixin") && msg.contains("FooDto"));
assertTrue(matched, "expected the duplicate @DtoMixin error message, got: " + errors);
}
}
/**
* A field carrying both {@code @DtoRef} and {@code @DtoPath} at once must fail fast at compile
* time - previously {@code resolveProperty()} checked {@code @DtoRef} first and returned early,
* silently ignoring any {@code @DtoPath} also present on the same field with no diagnostic,
* discarding whichever rename/path semantics the developer actually intended.
*/
@Test
void dtoRefAndDtoPath_onSameField_expectCompileError() throws IOException {
Path sourceDir = Files.createTempDirectory("dto-ref-path-conflict-src");
Path outDir = Files.createTempDirectory("dto-ref-path-conflict-out");
writeSource(sourceDir, "org.tests.refpathconflict.Bar",
"package org.tests.refpathconflict;\n"
+ "\n"
+ "public class Bar {\n"
+ " private Long id;\n"
+ "\n"
+ " public Long getId() { return id; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.refpathconflict.Foo",
"package org.tests.refpathconflict;\n"
+ "\n"
+ "public class Foo {\n"
+ " private Bar bar;\n"
+ "\n"
+ " public Bar getBar() { return bar; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.refpathconflict.FooDto",
"package org.tests.refpathconflict;\n"
+ "\n"
+ "import io.ebean.annotation.DtoPath;\n"
+ "import io.ebean.annotation.DtoRef;\n"
+ "\n"
+ "public class FooDto {\n"
+ " @DtoRef\n"
+ " @DtoPath(\"bar.id\")\n"
+ " private final Long barId;\n"
+ "\n"
+ " public FooDto(Long barId) {\n"
+ " this.barId = barId;\n"
+ " }\n"
+ "\n"
+ " public Long getBarId() { return barId; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.refpathconflict.package-info",
"@DtoMapping(source = Foo.class, target = FooDto.class)\n"
+ "package org.tests.refpathconflict;\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 due to both @DtoRef and @DtoPath on the same field");
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("both @DtoRef and @DtoPath") && msg.contains("mutually exclusive"));
assertTrue(matched, "expected the @DtoRef/@DtoPath conflict error message, got: " + errors);
}
}
/**
* {@code @DtoConvert} on a {@code NESTED_ONE} (or {@code NESTED_MANY}) property must fail fast
* at compile time - previously the converter was resolved but simply never wired into the
* {@code NESTED_ONE}/{@code NESTED_MANY} {@link DtoPropertyMeta} constructor calls, so the
* annotation silently had zero effect (the nested mapper's own {@code map(...)} call always
* fully determines the value), with no diagnostic telling the developer it was ignored.
*/
@Test
void dtoConvertOnNestedOne_expectCompileError() throws IOException {
Path sourceDir = Files.createTempDirectory("dto-convert-nested-src");
Path outDir = Files.createTempDirectory("dto-convert-nested-out");
writeSource(sourceDir, "org.tests.convertnested.Bar",
"package org.tests.convertnested;\n"
+ "\n"
+ "public class Bar {\n"
+ " private Long id;\n"
+ "\n"
+ " public Long getId() { return id; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.convertnested.BarDto",
"package org.tests.convertnested;\n"
+ "\n"
+ "public class BarDto {\n"
+ " private final Long id;\n"
+ "\n"
+ " public BarDto(Long id) {\n"
+ " this.id = id;\n"
+ " }\n"
+ "\n"
+ " public Long getId() { return id; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.convertnested.Foo",
"package org.tests.convertnested;\n"
+ "\n"
+ "public class Foo {\n"
+ " private Bar bar;\n"
+ "\n"
+ " public Bar getBar() { return bar; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.convertnested.BarConverter",
"package org.tests.convertnested;\n"
+ "\n"
+ "public class BarConverter {\n"
+ " public static BarDto identity(BarDto dto) { return dto; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.convertnested.FooDto",
"package org.tests.convertnested;\n"
+ "\n"
+ "import io.ebean.annotation.DtoConvert;\n"
+ "\n"
+ "public class FooDto {\n"
+ " @DtoConvert(value = BarConverter.class, method = \"identity\")\n"
+ " private final BarDto bar;\n"
+ "\n"
+ " public FooDto(BarDto bar) {\n"
+ " this.bar = bar;\n"
+ " }\n"
+ "\n"
+ " public BarDto getBar() { return bar; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.convertnested.package-info",
"@DtoMapping(source = Bar.class, target = BarDto.class)\n"
+ "@DtoMapping(source = Foo.class, target = FooDto.class)\n"
+ "package org.tests.convertnested;\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 due to @DtoConvert on a NESTED_ONE property");
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("@DtoConvert") && msg.contains("has no effect")
&& msg.contains("nested DTO graph property"));
assertTrue(matched, "expected the @DtoConvert-on-nested error message, got: " + errors);
}
}
/**
* A {@code @DtoConvert(method = ...)} reference to a converter type with two overloads sharing
* that name, both taking exactly one parameter, must fail fast at compile time rather than
* silently binding to whichever overload {@code ElementFilter.methodsIn} happens to return
* first (unrelated to which one the developer actually meant) - {@code @DtoConvert} has no way
* to disambiguate by parameter type since it's declared by name alone.
*/
@Test
void dtoConvertMethod_withAmbiguousOverloads_expectCompileError() throws IOException {
Path sourceDir = Files.createTempDirectory("dto-convert-ambiguous-src");
Path outDir = Files.createTempDirectory("dto-convert-ambiguous-out");
writeSource(sourceDir, "org.tests.convertambiguous.Foo",
"package org.tests.convertambiguous;\n"
+ "\n"
+ "public class Foo {\n"
+ " private String name;\n"
+ "\n"
+ " public String getName() { return name; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.convertambiguous.NameConverter",
"package org.tests.convertambiguous;\n"
+ "\n"
+ "public class NameConverter {\n"
+ " public static String format(String value) { return value; }\n"
+ " public static String format(Object value) { return String.valueOf(value); }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.convertambiguous.FooDto",
"package org.tests.convertambiguous;\n"
+ "\n"
+ "import io.ebean.annotation.DtoConvert;\n"
+ "\n"
+ "public class FooDto {\n"
+ " @DtoConvert(value = NameConverter.class, method = \"format\")\n"
+ " private final String name;\n"
+ "\n"
+ " public FooDto(String name) {\n"
+ " this.name = name;\n"
+ " }\n"
+ "\n"
+ " public String getName() { return name; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.convertambiguous.package-info",
"@DtoMapping(source = Foo.class, target = FooDto.class)\n"
+ "package org.tests.convertambiguous;\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 due to the ambiguous @DtoConvert method overloads");
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("ambiguous") && msg.contains("format") && msg.contains("2 overloads"));
assertTrue(matched, "expected the ambiguous @DtoConvert overload error message, got: " + errors);
}
}
/**
* A {@code @DtoConvert(method = ...)} reference to a method that exists on the converter type
* but doesn't take exactly one parameter (e.g. a zero-arg or two-arg overload sharing the name)
* must fail fast at compile time with a clear message, rather than {@code findMethod} matching
* it anyway and generating a call the compiler will reject with an unrelated arity-mismatch
* error in the generated mapper source.
*/
@Test
void dtoConvertMethod_withWrongArity_expectCompileError() throws IOException {
Path sourceDir = Files.createTempDirectory("dto-convert-arity-src");
Path outDir = Files.createTempDirectory("dto-convert-arity-out");
writeSource(sourceDir, "org.tests.convertarity.Foo",
"package org.tests.convertarity;\n"
+ "\n"
+ "public class Foo {\n"
+ " private String name;\n"
+ "\n"
+ " public String getName() { return name; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.convertarity.NameConverter",
"package org.tests.convertarity;\n"
+ "\n"
+ "public class NameConverter {\n"
+ " public static String format() { return \"\"; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.convertarity.FooDto",
"package org.tests.convertarity;\n"
+ "\n"
+ "import io.ebean.annotation.DtoConvert;\n"
+ "\n"
+ "public class FooDto {\n"
+ " @DtoConvert(value = NameConverter.class, method = \"format\")\n"
+ " private final String name;\n"
+ "\n"
+ " public FooDto(String name) {\n"
+ " this.name = name;\n"
+ " }\n"
+ "\n"
+ " public String getName() { return name; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.convertarity.package-info",
"@DtoMapping(source = Foo.class, target = FooDto.class)\n"
+ "package org.tests.convertarity;\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 due to the wrong-arity @DtoConvert method");
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("not found on") && msg.contains("exactly one parameter"));
assertTrue(matched, "expected the wrong-arity @DtoConvert error message, got: " + errors);
}
}
private void writeSource(Path sourceDir, String fqn, String content) {
try {
Path pkgDir = sourceDir.resolve(fqn.substring(0, fqn.lastIndexOf('.')).replace('.', '/'));
Files.createDirectories(pkgDir);
String simpleName = fqn.substring(fqn.lastIndexOf('.') + 1);
Path file = pkgDir.resolve(simpleName + ".java");
try (Writer writer = Files.newBufferedWriter(file)) {
writer.write(content);
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<artifactId>tests</artifactId>
+1 -1
View File
@@ -5,7 +5,7 @@
<parent>
<artifactId>tests</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<artifactId>test-dto-mapping</artifactId>
@@ -49,4 +49,51 @@ public class Customer extends Model {
public List<Contact> getContacts() {
return contacts;
}
/**
* Computed/derived getter (no backing field) - not a real Ebean property. Exercises
* {@code @DtoPath#requires()}: a {@code @DtoPath} traversing this segment must explicitly name
* {@code "contacts"} as a required fetch, since this method's own data dependency (the
* {@code contacts} collection) can't be inferred from the path string alone.
*/
public Contact getPrimaryContact() {
return contacts.isEmpty() ? null : contacts.get(0);
}
/**
* Computed/derived getter (no backing field) deriving purely from {@code id} - which is always
* fetched as a matter of course, so unlike {@link #getPrimaryContact()} this one genuinely needs
* nothing extra fetched. Exercises {@code @DtoPath(requires = {})} (explicit empty array,
* confirming "nothing extra needed") as distinct from omitting {@code requires} entirely (a
* compile error).
*/
public String getIdBadge() {
return "CUST-" + id;
}
/**
* Computed/derived getter (no backing field) reading {@code billingAddress.line1} - deliberately
* a different {@code Address} property to {@code city} (used narrowly elsewhere, see
* {@code FetchCollisionDto}), to exercise the priority between a bare, full {@code requires()}
* fetch of {@code billingAddress} and a sibling property's narrowed {@code fetch("billingAddress",
* "city")}: {@code FetchGroup}'s builder replaces (not merges) same-path fetch calls, so if the
* narrowed selection silently won, {@code line1} would never be loaded and calling this getter
* outside a persistence context would throw {@code LazyInitialisationException} instead of
* returning a value.
*/
public String getBillingSummary() {
return billingAddress == null ? null : billingAddress.getLine1() + ", " + billingAddress.getCity();
}
/**
* Computed/derived getter (no backing field) returning a {@code List} - the {@code NESTED_MANY}
* counterpart to {@link #getPrimaryContact()}'s {@code NESTED_ONE} case. Exercises
* {@code @DtoPath#requires()} through a computed getter whose return type is a {@code List} of
* a type with its own registered nested DTO mapping - same underlying code path as
* {@code getPrimaryContact()} (single-hop computed segment, {@code DtoMapperWriter}'s
* {@code NESTED_ONE}/{@code NESTED_MANY} branch), just the collection variant.
*/
public List<Contact> getRecentContacts() {
return contacts.isEmpty() ? List.of() : contacts.subList(0, Math.min(2, contacts.size()));
}
}
@@ -0,0 +1,42 @@
package org.tests.dtomapping;
import io.ebean.annotation.DtoPath;
/**
* Regression coverage for a single-hop {@code @DtoPath} rename traversing a computed/derived
* getter whose return type matches a <b>registered nested DTO mapping</b> - a variant of
* {@link ComputedPathDto} that exercises {@code DtoMapperWriter}'s {@code NESTED_ONE}/
* {@code NESTED_MANY} branch rather than its {@code SCALAR} branch.
* <p>
* {@code primaryContact} traverses {@code Customer#getPrimaryContact()} (no backing field - see
* {@link org.tests.dtomapping.model.Customer}), but its return type ({@code Contact}) has its own
* registered {@code @DtoMapping} to {@link ContactLeafDto} - so the field type here is
* {@code ContactLeafDto}, not a plain scalar. Without the fix, this single-hop case bypassed the
* computed-segment {@code requires()} validation entirely (the {@code NESTED_ONE} lookup returned
* early before it ran) and would have generated a broken {@code fetch("primaryContact",
* contactLeafMapper.fetchGroup())} call - {@code "primaryContact"} isn't a real Ebean fetch path,
* so that would fail at runtime with {@code PersistenceException: No property found}.
* <p>
* {@code @DtoMapping(source = Customer.class, target = ComputedNestedDto.class)} is declared on
* {@code package-info.java}.
*/
public class ComputedNestedDto {
private final Long id;
@DtoPath(value = "primaryContact", requires = "contacts")
private final ContactLeafDto primaryContact;
public ComputedNestedDto(Long id, ContactLeafDto primaryContact) {
this.id = id;
this.primaryContact = primaryContact;
}
public Long getId() {
return id;
}
public ContactLeafDto getPrimaryContact() {
return primaryContact;
}
}
@@ -0,0 +1,42 @@
package org.tests.dtomapping;
import io.ebean.annotation.DtoPath;
import java.util.List;
/**
* Regression coverage for a single-hop {@code @DtoPath} rename traversing a computed/derived
* getter whose return type is a {@code List} matching a <b>registered nested DTO mapping</b> -
* the {@code NESTED_MANY} counterpart to {@link ComputedNestedDto}'s {@code NESTED_ONE} case.
* <p>
* {@code recentContacts} traverses {@code Customer#getRecentContacts()} (no backing field - see
* {@link org.tests.dtomapping.model.Customer}), returning a {@code List<Contact>} - its element
* type has its own registered {@code @DtoMapping} to {@link ContactLeafDto}, so the field type
* here is {@code List<ContactLeafDto>}. Shares the exact same codegen path as
* {@link ComputedNestedDto} ({@code DtoMapperWriter}'s {@code NESTED_ONE}/{@code NESTED_MANY}
* branch both route computed segments to {@code extraFetchPaths} identically) - this is coverage
* confirming the {@code NESTED_MANY} variant actually works end-to-end, not a bug fix.
* <p>
* {@code @DtoMapping(source = Customer.class, target = ComputedNestedListDto.class)} is declared
* on {@code package-info.java}.
*/
public class ComputedNestedListDto {
private final Long id;
@DtoPath(value = "recentContacts", requires = "contacts")
private final List<ContactLeafDto> recentContacts;
public ComputedNestedListDto(Long id, List<ContactLeafDto> recentContacts) {
this.id = id;
this.recentContacts = recentContacts;
}
public Long getId() {
return id;
}
public List<ContactLeafDto> getRecentContacts() {
return recentContacts;
}
}
@@ -0,0 +1,41 @@
package org.tests.dtomapping;
import io.ebean.annotation.DtoPath;
/**
* Regression coverage for {@code @DtoPath#requires()} - {@code primaryContactLastName} traverses
* {@code Customer#getPrimaryContact()}, a computed/derived getter with no backing field (it picks
* the first entry out of the {@code contacts} collection). Since the generator can't infer that
* this getter's own data dependency is {@code contacts}, {@code requires = "contacts"} declares it
* explicitly so the generated {@code FetchGroup} includes a bare {@code fetch("contacts")} call -
* without it, {@code getPrimaryContact()} would run against an unfetched/lazy collection and either
* fail or trigger an extra lazy-load query at map time.
* <p>
* See {@link org.tests.dtomapping.TestComputedPath} and {@code
* DtoMapperComputedPathTest} (querybean-generator module) for the companion negative case - the
* same shape of {@code @DtoPath} but with {@code requires} omitted, which must be a compile-time
* error rather than a silent runtime failure.
* <p>
* {@code @DtoMapping(source = Customer.class, target = ComputedPathDto.class)} is declared on
* {@code package-info.java}.
*/
public class ComputedPathDto {
private final Long id;
@DtoPath(value = "primaryContact.lastName", requires = "contacts")
private final String primaryContactLastName;
public ComputedPathDto(Long id, String primaryContactLastName) {
this.id = id;
this.primaryContactLastName = primaryContactLastName;
}
public Long getId() {
return id;
}
public String getPrimaryContactLastName() {
return primaryContactLastName;
}
}
@@ -0,0 +1,36 @@
package org.tests.dtomapping;
import io.ebean.annotation.DtoPath;
/**
* Regression coverage for {@code @DtoPath(requires = {})} - {@code idBadge} traverses {@code
* Customer#getIdBadge()}, a computed/derived getter with no backing field, but one that genuinely
* needs nothing extra fetched (it derives purely from {@code id}, which is always fetched as a
* matter of course). The explicit empty array confirms that to the generator, as opposed to
* omitting {@code requires} entirely (a compile error - see {@link ComputedPathDto} for the case
* that does need a real fetch path, and {@code DtoMapperComputedPathTest} in the
* querybean-generator module for the negative/omitted case).
* <p>
* {@code @DtoMapping(source = Customer.class, target = ComputedPathNoFetchDto.class)} is declared
* on {@code package-info.java}.
*/
public class ComputedPathNoFetchDto {
private final Long id;
@DtoPath(value = "idBadge", requires = {})
private final String idBadge;
public ComputedPathNoFetchDto(Long id, String idBadge) {
this.id = id;
this.idBadge = idBadge;
}
public Long getId() {
return id;
}
public String getIdBadge() {
return idBadge;
}
}
@@ -0,0 +1,41 @@
package org.tests.dtomapping;
import io.ebean.annotation.DtoRef;
/**
* Regression coverage for {@code @DtoRef} traversing a computed/derived association getter (no
* backing field) - a variant of {@link ComputedPathDto} that exercises {@code @DtoRef}'s
* {@code Kind.REF} branch rather than {@code @DtoPath}'s {@code SCALAR}/{@code NESTED_ONE}
* branches.
* <p>
* {@code primaryContactId} derives its association name ("primaryContact") from the field name,
* which resolves to {@code Customer#getPrimaryContact()} - a computed getter with no backing
* field (see {@link org.tests.dtomapping.model.Customer}). Without the fix, {@code @DtoRef} never
* checked whether the association had a backing field at all, so this compiled cleanly and
* generated a broken {@code FetchGroup.select("primaryContact")} call - {@code "primaryContact"}
* isn't a real Ebean property, so that would fail at runtime with {@code PersistenceException: No
* property found}.
* <p>
* {@code @DtoMapping(source = Customer.class, target = ComputedRefDto.class)} is declared on
* {@code package-info.java}.
*/
public class ComputedRefDto {
private final Long id;
@DtoRef(requires = "contacts")
private final Long primaryContactId;
public ComputedRefDto(Long id, Long primaryContactId) {
this.id = id;
this.primaryContactId = primaryContactId;
}
public Long getId() {
return id;
}
public Long getPrimaryContactId() {
return primaryContactId;
}
}
@@ -0,0 +1,36 @@
package org.tests.dtomapping;
/**
* Minimal "leaf" DTO for {@link org.tests.dtomapping.model.Contact} - deliberately has no further
* nested relations of its own (unlike {@link ContactDto}, which also maps {@code customer}), so
* {@link ComputedNestedDto}'s bare {@code fetch("contacts")} (everything {@code Contact} itself
* owns, no deeper paths) is sufficient to satisfy it without needing to reason about propagating a
* nested mapper's own fetch requirements up through a computed getter - see {@link ComputedNestedDto}.
* <p>
* {@code @DtoMapping(source = Contact.class, target = ContactLeafDto.class)} is declared on
* {@code package-info.java}.
*/
public class ContactLeafDto {
private final Long id;
private final String firstName;
private final String lastName;
public ContactLeafDto(Long id, String firstName, String lastName) {
this.id = id;
this.firstName = firstName;
this.lastName = lastName;
}
public Long getId() {
return id;
}
public String getFirstName() {
return firstName;
}
public String getLastName() {
return lastName;
}
}
@@ -13,12 +13,14 @@ public class ContactMixinDto {
private final String firstName;
private final boolean active;
private final String secretCode;
private final CustomerRefDto owner;
public ContactMixinDto(long id, String firstName, boolean active, String secretCode) {
public ContactMixinDto(long id, String firstName, boolean active, String secretCode, CustomerRefDto owner) {
this.id = id;
this.firstName = firstName;
this.active = active;
this.secretCode = secretCode;
this.owner = owner;
}
public long getId() {
@@ -36,4 +38,8 @@ public class ContactMixinDto {
public String getSecretCode() {
return secretCode;
}
public CustomerRefDto getOwner() {
return owner;
}
}
@@ -7,9 +7,9 @@ import io.ebean.annotation.DtoPath;
/**
* Overlays {@code @DtoPath}/{@code @DtoConvert} onto {@link ContactMixinDto}, which carries no
* annotations of its own - mirrors avaje-jsonb's {@code @Json.MixIn} mechanism. Method names
* match {@link ContactMixinDto}'s field names ({@code active}, {@code secretCode}); the querybean
* generator matches each mixin method to the corresponding target property by name and applies
* whichever annotations are present as if declared on the target field itself.
* match {@link ContactMixinDto}'s field names ({@code active}, {@code secretCode}, {@code owner});
* the querybean generator matches each mixin method to the corresponding target property by name
* and applies whichever annotations are present as if declared on the target field itself.
*/
@DtoMixin(ContactMixinDto.class)
interface ContactMixinDtoMixin {
@@ -20,4 +20,12 @@ interface ContactMixinDtoMixin {
@DtoConvert(value = SecretCipher.class, method = "decode")
String secretCode();
/**
* A single-hop {@code @DtoPath} rename onto a nested (registered {@code @DtoMapping}) type -
* {@code owner} is renamed from {@code Contact#getCustomer()}, resolving to
* {@code CustomerRefDto} via its own generated mapper rather than a raw scalar getter call.
*/
@DtoPath("customer")
CustomerRefDto owner();
}
@@ -0,0 +1,49 @@
package org.tests.dtomapping;
import io.ebean.annotation.DtoPath;
/**
* Regression coverage for the priority between a bare, full {@code requires()} fetch and a
* sibling property's narrowed {@code @DtoPath} fetch of the exact same path - see
* {@link org.tests.dtomapping.model.Customer#getBillingSummary()}.
* <p>
* {@code billingCity} narrows the {@code billingAddress} fetch down to just {@code city} (a plain
* {@code @DtoPath("billingAddress.city")}). {@code billingSummary} traverses the computed
* {@code getBillingSummary()} getter (no backing field), declaring {@code requires =
* "billingAddress"} - a bare, full fetch of the exact same path. {@code FetchGroup}'s builder
* replaces (not merges) same-path fetch calls, so without prioritizing the full fetch over the
* narrow one, {@code line1} (needed internally by {@code getBillingSummary()}, but not by
* {@code billingCity}) would silently never be loaded - calling {@code getBillingSummary()} would
* then throw {@code LazyInitialisationException} rather than return a value.
* <p>
* {@code @DtoMapping(source = Customer.class, target = FetchCollisionDto.class)} is declared on
* {@code package-info.java}.
*/
public class FetchCollisionDto {
private final Long id;
@DtoPath("billingAddress.city")
private final String billingCity;
@DtoPath(value = "billingSummary", requires = "billingAddress")
private final String billingSummary;
public FetchCollisionDto(Long id, String billingCity, String billingSummary) {
this.id = id;
this.billingCity = billingCity;
this.billingSummary = billingSummary;
}
public Long getId() {
return id;
}
public String getBillingCity() {
return billingCity;
}
public String getBillingSummary() {
return billingSummary;
}
}
@@ -0,0 +1,31 @@
package org.tests.dtomapping;
import io.ebean.annotation.DtoPath;
/**
* Regression coverage for a primitive-typed DTO field derived via a multi-hop {@code @DtoPath}
* through a <b>nullable</b> ToOne relation ({@code Customer#getBillingAddress()} can be
* {@code null}) - the generated null-guarded getter chain (e.g. {@code (x == null ? null :
* x.getId())}) types as boxed {@code Long}, which would auto-unbox to a {@code
* NullPointerException} when passed to a primitive constructor parameter if not handled.
* <p>
* {@code billingAddressId} exercises the default behaviour (silently defaults to {@code 0}) - see
* {@link org.tests.dtomapping.PrimitiveNullPathFailOnNullDto} for the {@code failOnNull = true}
* counterpart, and {@link org.tests.dtomapping.TestPrimitiveNullPath} for both cases.
* <p>
* {@code @DtoMapping(source = Contact.class, target = PrimitiveNullPathDto.class)} is declared
* on {@code package-info.java}.
*/
public class PrimitiveNullPathDto {
@DtoPath("customer.billingAddress.id")
private final long billingAddressId;
public PrimitiveNullPathDto(long billingAddressId) {
this.billingAddressId = billingAddressId;
}
public long getBillingAddressId() {
return billingAddressId;
}
}
@@ -0,0 +1,26 @@
package org.tests.dtomapping;
import io.ebean.annotation.DtoPath;
/**
* Companion to {@link PrimitiveNullPathDto} isolating {@code @DtoPath(failOnNull = true)} in its
* own DTO type - kept separate so a null intermediate hop's exception doesn't also abort
* construction of {@link PrimitiveNullPathDto}'s default (silently-zero) field in the same
* constructor call. See {@link org.tests.dtomapping.TestPrimitiveNullPath}.
* <p>
* {@code @DtoMapping(source = Contact.class, target = PrimitiveNullPathFailOnNullDto.class)} is
* declared on {@code package-info.java}.
*/
public class PrimitiveNullPathFailOnNullDto {
@DtoPath(value = "customer.billingAddress.id", failOnNull = true)
private final long billingAddressId;
public PrimitiveNullPathFailOnNullDto(long billingAddressId) {
this.billingAddressId = billingAddressId;
}
public long getBillingAddressId() {
return billingAddressId;
}
}
@@ -0,0 +1,34 @@
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 static org.assertj.core.api.Assertions.assertThat;
/**
* Regression test for {@link ComputedNestedListDto} - see its javadoc.
*/
class TestComputedNestedListPath {
@Test
void mapTo_whenComputedGetterReturnsListTargetingNestedDto_requiresFetchesItsDependency() {
Customer customer = new Customer("ComputedNestedListCo");
customer.save();
Contact alice = new Contact("Alice", "Smith", customer);
alice.save();
Contact bob = new Contact("Bob", "Jones", customer);
bob.save();
ComputedNestedListDto dto = DB.find(Customer.class)
.where().idEq(customer.getId())
.mapTo(ComputedNestedListDto.class)
.findOne();
assertThat(dto).isNotNull();
assertThat(dto.getRecentContacts()).hasSize(2);
assertThat(dto.getRecentContacts().get(0).getFirstName()).isEqualTo("Alice");
assertThat(dto.getRecentContacts().get(1).getFirstName()).isEqualTo("Bob");
}
}
@@ -0,0 +1,32 @@
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 static org.assertj.core.api.Assertions.assertThat;
/**
* Regression test for {@link ComputedNestedDto} - see its javadoc.
*/
class TestComputedNestedPath {
@Test
void mapTo_whenComputedGetterTargetsNestedDto_requiresFetchesItsDependency() {
Customer customer = new Customer("ComputedNestedCo");
customer.save();
Contact contact = new Contact("Bob", "Jones", customer);
contact.save();
ComputedNestedDto dto = DB.find(Customer.class)
.where().idEq(customer.getId())
.mapTo(ComputedNestedDto.class)
.findOne();
assertThat(dto).isNotNull();
assertThat(dto.getPrimaryContact()).isNotNull();
assertThat(dto.getPrimaryContact().getFirstName()).isEqualTo("Bob");
assertThat(dto.getPrimaryContact().getLastName()).isEqualTo("Jones");
}
}
@@ -0,0 +1,32 @@
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 static org.assertj.core.api.Assertions.assertThat;
/**
* Regression test for {@link ComputedPathDto} (the {@code @DtoPath#requires()} happy path) - see
* its javadoc, and {@code DtoMapperComputedPathTest} (querybean-generator module) for the
* companion negative/compile-error case.
*/
class TestComputedPath {
@Test
void mapTo_whenPathTraversesComputedGetter_requiresFetchesItsDependency() {
Customer customer = new Customer("ComputedPathCo");
customer.save();
Contact contact = new Contact("Alice", "Smith", customer);
contact.save();
ComputedPathDto dto = DB.find(Customer.class)
.where().idEq(customer.getId())
.mapTo(ComputedPathDto.class)
.findOne();
assertThat(dto).isNotNull();
assertThat(dto.getPrimaryContactLastName()).isEqualTo("Smith");
}
}
@@ -0,0 +1,28 @@
package org.tests.dtomapping;
import io.ebean.DB;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Customer;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Regression test for {@link ComputedPathNoFetchDto} (the {@code @DtoPath(requires = {})} explicit
* empty case) - see its javadoc.
*/
class TestComputedPathNoFetch {
@Test
void mapTo_whenComputedGetterNeedsNoExtraFetch_explicitEmptyRequiresWorks() {
Customer customer = new Customer("NoExtraFetchCo");
customer.save();
ComputedPathNoFetchDto dto = DB.find(Customer.class)
.where().idEq(customer.getId())
.mapTo(ComputedPathNoFetchDto.class)
.findOne();
assertThat(dto).isNotNull();
assertThat(dto.getIdBadge()).isEqualTo("CUST-" + customer.getId());
}
}
@@ -0,0 +1,30 @@
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 static org.assertj.core.api.Assertions.assertThat;
/**
* Regression test for {@link ComputedRefDto} - see its javadoc.
*/
class TestComputedRefPath {
@Test
void mapTo_whenDtoRefTraversesComputedGetter_requiresFetchesItsDependency() {
Customer customer = new Customer("ComputedRefCo");
customer.save();
Contact contact = new Contact("Alice", "Smith", customer);
contact.save();
ComputedRefDto dto = DB.find(Customer.class)
.where().idEq(customer.getId())
.mapTo(ComputedRefDto.class)
.findOne();
assertThat(dto).isNotNull();
assertThat(dto.getPrimaryContactId()).isEqualTo(contact.getId());
}
}
@@ -34,6 +34,10 @@ class TestDtoMixin {
assertThat(dto.isActive()).isTrue();
// instance @DtoConvert declared on the mixin method, resolved via DtoConverterManager
assertThat(dto.getSecretCode()).isEqualTo("secret");
// @DtoPath("customer") rename on the mixin, resolving to the nested CustomerRefDto mapper
// rather than a raw (type-mismatched) scalar getter call
assertThat(dto.getOwner()).isNotNull();
assertThat(dto.getOwner().getName()).isEqualTo("Acme");
}
@Test
@@ -0,0 +1,37 @@
package org.tests.dtomapping;
import io.ebean.DB;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Address;
import org.tests.dtomapping.model.Customer;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Regression test for {@link FetchCollisionDto} - see its javadoc. Proves the fix for the
* priority between a bare, full {@code requires()} fetch and a sibling property's narrowed
* {@code @DtoPath} fetch of the exact same path: without it, this test fails with
* {@code LazyInitialisationException} ("line1" unfetched) rather than the assertions below.
*/
class TestFetchCollisionPath {
@Test
void mapTo_whenBareRequiresPathCollidesWithNarrowedSiblingFetch_fullFetchWins() {
Address address = new Address("221B Baker Street", "London");
address.save();
Customer customer = new Customer("FetchCollisionCo");
customer.setBillingAddress(address);
customer.save();
FetchCollisionDto dto = DB.find(Customer.class)
.where().idEq(customer.getId())
.mapTo(FetchCollisionDto.class)
.findOne();
assertThat(dto).isNotNull();
assertThat(dto.getBillingCity()).isEqualTo("London");
// only satisfiable if billingAddress was fully (not narrowly) fetched - line1 isn't part of
// the sibling billingCity property's own narrowed fetch("billingAddress", "city")
assertThat(dto.getBillingSummary()).isEqualTo("221B Baker Street, London");
}
}
@@ -0,0 +1,47 @@
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 static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Regression test for {@link PrimitiveNullPathDto} - see its javadoc.
*/
class TestPrimitiveNullPath {
@Test
void mapTo_whenNullableRelationHopIsNull_defaultsPrimitiveToZero() {
Customer customer = new Customer("NoBillingAddressCo");
// deliberately leave billingAddress null
customer.save();
Contact contact = new Contact("Jane", "Doe", customer);
contact.save();
PrimitiveNullPathDto dto = DB.find(Contact.class)
.where().idEq(contact.getId())
.mapTo(PrimitiveNullPathDto.class)
.findOne();
assertThat(dto).isNotNull();
assertThat(dto.getBillingAddressId()).isZero();
}
@Test
void mapTo_whenNullableRelationHopIsNull_andFailOnNull_throws() {
Customer customer = new Customer("NoBillingAddressCo2");
customer.save();
Contact contact = new Contact("John", "Doe", customer);
contact.save();
assertThatThrownBy(() -> DB.find(Contact.class)
.where().idEq(contact.getId())
.mapTo(PrimitiveNullPathFailOnNullDto.class)
.findOne())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("customer.billingAddress.id");
}
}
@@ -14,6 +14,7 @@ import org.tests.dtomapping.model.query.QCustomer;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -197,6 +198,47 @@ class TestQueryMapTo {
}
}
@Test
void mapTo_findStream_expectLazilyMappedDtoStream() {
Customer customerA = new Customer("StreamCoA");
customerA.save();
Customer customerB = new Customer("StreamCoB");
customerB.save();
List<String> names;
try (var stream = DB.find(Customer.class)
.where().in("name", "StreamCoA", "StreamCoB")
.orderBy().asc("name")
.mapTo(CustomerDto.class)
.findStream()) {
names = stream.map(CustomerDto::getName).collect(Collectors.toList());
}
assertThat(names).containsExactly("StreamCoA", "StreamCoB");
}
@Test
void mapTo_findStream_expectIdentityDedupSharedAcrossStream() {
Customer customer = new Customer("StreamDedupCo");
customer.save();
new Contact("Jane", "Doe", customer).save();
new Contact("John", "Doe", customer).save();
// both contacts share the same underlying Customer instance - the shared DtoMapContext used
// across the whole findStream() call should still de-duplicate to the same nested DTO
List<ContactDto> dtos;
try (var stream = DB.find(Contact.class)
.where().eq("customer", customer)
.orderBy().asc("firstName")
.mapTo(ContactDto.class)
.findStream()) {
dtos = stream.collect(Collectors.toList());
}
assertThat(dtos).hasSize(2);
assertThat(dtos.get(0).getCustomer()).isSameAs(dtos.get(1).getCustomer());
}
@Test
void mapTo_withFilterMany_expectFilteredContactsInDtoGraph() {
Customer customer = new Customer("FilterManyCo");
@@ -50,6 +50,15 @@
@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 = PrimitiveNullPathDto.class)
@DtoMapping(source = Contact.class, target = PrimitiveNullPathFailOnNullDto.class)
@DtoMapping(source = Customer.class, target = ComputedPathDto.class)
@DtoMapping(source = Customer.class, target = ComputedPathNoFetchDto.class)
@DtoMapping(source = Contact.class, target = ContactLeafDto.class)
@DtoMapping(source = Customer.class, target = ComputedNestedDto.class)
@DtoMapping(source = Customer.class, target = ComputedNestedListDto.class)
@DtoMapping(source = Customer.class, target = ComputedRefDto.class)
@DtoMapping(source = Customer.class, target = FetchCollisionDto.class)
package org.tests.dtomapping;
import io.ebean.annotation.DtoMapping;
+1 -1
View File
@@ -5,7 +5,7 @@
<parent>
<artifactId>tests</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<artifactId>test-java16</artifactId>
+3 -3
View File
@@ -6,7 +6,7 @@
<parent>
<artifactId>tests</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
<version>18.3.0</version>
</parent>
<artifactId>test-kotlin</artifactId>
@@ -40,7 +40,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -53,7 +53,7 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-test</artifactId>
<version>18.2.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>