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
86 changed files with 2622 additions and 250 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>
+282 -1
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
@@ -701,6 +724,264 @@ callers must consume it via try-with-resources to ensure the underlying resource
`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.");
}
}
+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();
}
}
+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,63 +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;
}
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.
// 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) {
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.NESTED_MANY, getters, properties, nested);
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) {
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.NESTED_ONE, getters, properties, nested);
rejectConverterOnNested(field, converter, name, meta);
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.NESTED_ONE, getters, properties, nested,
computedFrom >= 0, requiredFetchPaths);
}
}
}
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.SCALAR, getters, properties, null, converter);
// 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
@@ -544,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)) {
@@ -615,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
@@ -642,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;
}
}
@@ -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());
}
}
@@ -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");
}
}
@@ -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>