Compare commits

..
Author SHA1 Message Date
robin.bygrave 0683a5637b MappedQuery - add findEach() findEachWhile() to MappedQuery (and promote to StreamableQuery) 2026-07-20 23:09:42 +12:00
robin.bygrave 49c2a1f79e MappedQuery - add findEach() findEachWhile() to MappedQuery (and promote to StreamableQuery) 2026-07-20 23:04:35 +12:00
4f6168dfed Add FinableQuery, StreamableQuery common interfaces for Query, DtoQuery, MappedQuery, SqlQuery (#3864)
* Add FinableQuery, StreamableQuery common interfaces for Query, DtoQuery, MappedQuery, SqlQuery

* QueryBean.cancel() added to support the new common FindableQuery interface

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-20 22:44:42 +12:00
robin.bygrave a03a098e1b Tests: fix tests for Postgres any syntax 2026-07-20 22:06:30 +12:00
Rob BygraveandGitHub 78f17fcd46 Merge pull request #3863 from ebean-orm/feature/mapped-query-cancellable
Change MappedQuery to extend CancelableQuery - make it cancelable
2026-07-20 22:04:15 +12:00
Rob BygraveandGitHub 6895a4c057 Merge pull request #3862 from ebean-orm/dependabot/maven/ebean-spring-txn/com.fasterxml.jackson.core-jackson-databind-2.22.1
Build(deps-dev): Bump com.fasterxml.jackson.core:jackson-databind from 2.22.0 to 2.22.1 in /ebean-spring-txn
2026-07-20 21:59:24 +12:00
robin.bygrave 2362c0816a Change MappedQuery to extend CancelableQuery - make it cancelable 2026-07-20 21:57:22 +12:00
robin.bygrave 37567e8fe8 Change MappedQuery to extend CancelableQuery - make it cancelable 2026-07-20 21:50:59 +12:00
robin.bygrave ce8dc36cf7 Actual version 18.3.0 deployed to central 2026-07-17 10:20:02 +12:00
dependabot[bot]andGitHub 05f5291359 Build(deps-dev): Bump com.fasterxml.jackson.core:jackson-databind
Bumps [com.fasterxml.jackson.core:jackson-databind](https://github.com/FasterXML/jackson) from 2.22.0 to 2.22.1.
- [Commits](https://github.com/FasterXML/jackson/commits)

---
updated-dependencies:
- dependency-name: com.fasterxml.jackson.core:jackson-databind
  dependency-version: 2.22.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-16 22:10:37 +00:00
Rob BygraveandGitHub 1173962398 Merge pull request #3861 from ebean-orm/feture/dto-more
dto mapping: add DtoConverters
2026-07-17 10:09:49 +12:00
robin.bygrave 56f86e0263 dto mapping: add DtoConverters 2026-07-17 10:01:54 +12:00
Rob BygraveandGitHub 8751a1a04d Merge pull request #3860 from ebean-orm/feature/insert-on-conflict-pk-fallback
With InsertOnConflict fallback to PK constraint when no Unique constr…
2026-07-17 00:04:00 +12:00
robin.bygrave 14e6e3f824 With InsertOnConflict fallback to PK constraint when no Unique constraint and id value provided
This is for the case where the primary key is a supplied value (like a string, iso code, serial
number, bar code etc)
2026-07-16 23:51:29 +12:00
Rob BygraveandGitHub a9d37b83d7 Merge pull request #3859 from ebean-orm/feature/findOneOrThrow
enh: Add findOneOrThrow() with decent derived message for EntityNotFo…
2026-07-16 23:28:12 +12:00
robin.bygrave 857e92583d enh: Add findOneOrThrow() with decent derived message for EntityNotFoundException
Adding this as I am seeing a LOT of cases where ebean can derive a good enough
error message (id case, plus single unique key case) that it is worth adding
this syntax sugar.

Yes we have discussed this a well it's not so useful for the multi-lingual /
 non-english case and also marginal when the error message needs to be built
 case.
2026-07-16 23:15:15 +12:00
Rob BygraveandGitHub 14056bd7fb Merge pull request #3858 from ebean-orm/feature/3643-fetch-id-only-fk
Fold id-only *ToOne fetches into parent select, avoiding unneeded join (#3643)
2026-07-16 22:33:12 +12:00
robin.bygrave 149ed8318d Fold id-only *ToOne fetches into parent select, avoiding unneeded join (#3643)
When a *ToOne association is fetched with only its id property, the
foreign key column already exists on the owning table so no join is
required. Previously this always generated an unnecessary join.

- OrmQueryProperties: add includesExactly() and withAddedInclude() to
  support adding an include without mutating a shared/cached instance
  (the included set and its precomputed hash prefix are immutable
  since #3761/#3763, so an "add" must produce a new instance).

- OrmQueryDetail: add convertIdFetches(), a bottom-up pass over
  fetchPaths that replaces an id-only *ToOne fetch with its FK
  property folded into the parent's select, removing the now-unneeded
  fetch path. Skips exported (mappedBy) one-to-one associations, which
  have no local FK column and always require a join. Tracks paths
  still depended on by a surviving child fetch so their join isn't
  incorrectly folded away.

- Add TestFetchIdOnly covering PathProperties, select(), and fetch()
  usages, plus a control case confirming the join is retained when
  more than the id is fetched.

- Update EqlParserTest, TestSubQuery and TestMergeCustomer assertions
  to reflect the new join-free SQL.
2026-07-16 22:28:42 +12:00
Rob BygraveandGitHub 2fe4c6c456 Merge pull request #3857 from ebean-orm/feature/dto-mapper-computed
dto mapping: support calculated getter methods with and without requi…
2026-07-16 22:07:52 +12:00
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 BygraveandGitHub 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
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
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
132 changed files with 4938 additions and 716 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
+67
View File
@@ -139,6 +139,35 @@ while keeping DTOs as plain, framework-unattached classes.
querybean-generator codegen support (static/instance dispatch, constructor wiring deduplicated by converter
type). Test coverage: `tests/test-dto-mapping` `TestDtoConvert`.*
- **Type-pair (package-level) custom scalar conversion**
Motivated by real hand-written mapper code (`EboxMapper`, central-access): the same conversion repeats
across many unrelated properties on one target - `DateUtils.toCalendar(...)` on ~9 fields,
`parseEnum(EnumType.class, value)` on ~3 - under today's `@DtoConvert` every one of those properties must
carry its own repeated annotation. MapStruct solves this by letting a conversion method be defined once
(in the mapper or a `uses = {...}` helper) and auto-applying it to *every* property whose source/target
types match that method's signature - no per-field wiring. Proposed: a package-level, repeatable
`@DtoConverters({ConverterType.class, ...})` (sibling to `@DtoMapping` in `package-info.java`) - the
generator indexes every public static/instance method on the referenced type(s) by `(paramType ->
returnType)`, then for any property whose source getter type doesn't already match the target field type
and which carries no explicit per-property `@DtoConvert`, looks up that type pair and wires it in
automatically (same static-vs-instance/`DtoConverterManager` dispatch rules as `@DtoConvert` today). An
explicit per-property `@DtoConvert` always overrides the type-level default. Deliberately no built-in
conversions shipped by Ebean itself (no implicit `Enum.valueOf`/`.name()`) - the app still owns
exception/null-handling semantics (e.g. `parseEnum`'s catch-and-null-on-bad-value), just declares it once
instead of per-field.
**Status: implemented.** `@DtoConverters(ConverterType.class, ...)` (a single non-repeatable annotation
taking a `Class<?>[]`, `@Target({PACKAGE, MODULE})`) is registered once per package/module alongside
`@DtoMapping`. The generator indexes every public, single-arg, non-void method on each referenced type by
exact `(paramType -> returnType)`; any SCALAR property (plain or `@DtoPath`-renamed) with no explicit
`@DtoConvert` and a source/target type mismatch is auto-wired to the matching method (a duplicate/ambiguous
type pair across the registered types is a compile-time processor error). List-element-wise conversion and
`@DtoRef` (FK-id) properties are out of scope. Test coverage:
`tests/test-dto-mapping/.../TestDtoConverters.java` (`UuidConverters`/`UuidShortCodeConverter`,
`ContactTypeConverterDto`) - covers same-name auto-dispatch, `@DtoPath`-renamed auto-dispatch, and explicit
`@DtoConvert` overriding the registered default.
*Inspiration: `EboxMapper` (central-access) hand-written pattern; MapStruct type-signature-matched
conversion methods.*
- **`@DtoMixin` for DTOs that cannot be annotated directly**
Some DTOs are generated (e.g. from an OpenAPI spec) and not editable/annotatable, so `@DtoPath`/
`@DtoConvert`/`@DtoRef` cannot always be placed directly on the DTO. Introduce a `@DtoMixin(Target.class)`
@@ -229,6 +258,44 @@ instead (see "Recipe: adding extra caller-supplied fields after mapping" in
*Inspiration: `UserService`/`User` (central-access).*
*Status: implemented.*
- **Setter-based (mutable JavaBean) target construction**
Motivated by `EboxMapper` (central-access): its target types (`Ebox`, `MachineSummaryInfo`, from
`nz.co.eroad.schema.eroadtypes`, JAXB/XSD-generated legacy SOAP shapes) are plain mutable JavaBeans - a
public no-arg constructor plus a `void setXxx(...)` setter per property - neither a positional constructor
match nor a RecordBuilder-style fluent builder (see section G above). The generator currently only
recognizes those two construction strategies, so this common third shape (typical of JAXB/XSD-generated
and many hand-written mutable POJOs) can't be targeted by `@DtoMapping` at all today. Proposed: detect a
no-arg constructor plus a `void setXxx(propertyType)` setter per mapped property as a third construction
strategy, generating `Target target = new Target(); target.setX(...); ...; return target;` (mirroring the
existing `build = AUTO | ALWAYS | NEVER` override precedent from section G for explicit control over which
strategy applies). Would also unblock the `mapToBuilder()`-style "populate ignored/derived properties after
the generated mapping, before finishing construction" pattern for these targets (currently only available
for builder-shaped targets) - relevant to `EboxMapper`'s `machineSummaryInfo` (a genuinely composite,
multi-association derived value, out of reach of `@DtoConvert`/`@DtoPath` regardless of this gap, but a
natural fit for the same "map base fields via codegen, then set the derived one by hand" pattern already
used for `Fleet.assignedMachines`/`assignedDrivers`).
*Inspiration: `EboxMapper` (central-access); JAXB/XSD-generated SOAP DTO shapes generally.*
**Status: implemented.** `@DtoMapping(setter = AUTO | ALWAYS | NEVER)` mirrors `builder()`'s override
precedent. Detection requires a public no-arg constructor plus a public `setXxx(...)` setter for every
mapped property - either `void` or fluent-style (returning the target type itself, e.g. `public Target
setXxx(...) { ...; return this; }`); the generated code always calls the setter as a bare statement and
discards any return value, so either shape works identically. A builder, when selected, always takes
priority over setter-based construction. Under the default `AUTO`, setter-based construction is only
attempted when the target has no positional constructor matching the mapped properties (arity-based) and
no builder was selected - existing positional-constructor and builder-shaped targets are entirely
unaffected. `ALWAYS` requires the shape (codegen-time error otherwise); `NEVER` always uses a positional
constructor. Generated shape: `Target target = new Target(); target.setX(...); ...; return target;` (a
`computeIfAbsent(...)`-wrapped block-lambda variant when the target is nested elsewhere in the graph).
Deliberately **no** `mapToBuilder(...)`-style post-construction accessor is generated for this strategy -
the returned target is already the final, fully mutable instance (setters are required to be `public`), so
a caller can already call e.g. `dto.setExternalRef(...)` directly on the mapped result, exactly the pattern
`EboxMapper` already uses by hand; this is unlike the builder strategy, where the intermediate builder is
otherwise unreachable after its one-shot `build()` call. Test coverage:
`tests/test-dto-mapping/.../TestDtoSetterConstruction.java` (`ContactSetterDto`) - covers auto-detected
setter-chain construction plus post-construction population of two `@DtoIgnore` properties (a plain scalar
and a `List`) via their public setters; plus `ContactSetterFluentDto` - covers the fluent-setter-return-shape
variant.
### H. Record entity sources
- **Record-style (bare/fluent) accessors on the source (entity) side**
+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.");
}
}
+2 -95
View File
@@ -1,16 +1,9 @@
package io.ebean;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import javax.sql.DataSource;
import java.sql.Connection;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Stream;
/**
* Query for performing native SQL queries that return DTO Bean's.
@@ -43,12 +36,7 @@ import java.util.stream.Stream;
* }</pre>
*/
@NullMarked
public interface DtoQuery<T> extends CancelableQuery {
/**
* Execute the query returning a list.
*/
List<T> findList();
public interface DtoQuery<T> extends StreamableQuery<DtoQuery<T>, T> {
/**
* Execute the query iterating a row at a time.
@@ -59,56 +47,6 @@ public interface DtoQuery<T> extends CancelableQuery {
*/
QueryIterator<T> findIterate();
/**
* Execute the query returning a Stream.
* <p>
* Note that the Stream holds resources related to the underlying
* resultSet and potentially connection and MUST be closed. We should use
* the Stream in a <em>try with resource block</em>.
*/
Stream<T> findStream();
/**
* Execute the query iterating a row at a time.
* <p>
* This streaming type query is useful for large query execution as only 1 row needs to be held in memory.
* </p>
*/
void findEach(Consumer<T> consumer);
/**
* Execute the query iterating the results and batching them for the consumer.
* <p>
* This runs like findEach streaming results from the database but just collects the results
* into batches to pass to the consumer.
*
* @param batch The number of dto beans to collect before given them to the consumer
* @param consumer The consumer to process the batch of DTO beans
*/
void findEach(int batch, Consumer<List<T>> consumer);
/**
* Execute the query iterating a row at a time with the ability to stop consuming part way through.
* <p>
* Returning false after processing a row stops the iteration through the query results.
* </p>
* <p>
* This streaming type query is useful for large query execution as only 1 row needs to be held in memory.
* </p>
*/
void findEachWhile(Predicate<T> consumer);
/**
* Execute the query returning a single bean.
*/
@Nullable
T findOne();
/**
* Execute the query returning an optional bean.
*/
Optional<T> findOneOrEmpty();
/**
* Bind all the parameters using index positions.
* <p>
@@ -214,38 +152,6 @@ public interface DtoQuery<T> extends CancelableQuery {
*/
DtoQuery<T> setBufferFetchSizeHint(int bufferFetchSizeHint);
/**
* Use the explicit transaction to execute the query.
*/
DtoQuery<T> usingTransaction(Transaction transaction);
/**
* Execute the query using the given connection.
*/
DtoQuery<T> usingConnection(Connection connection);
/**
* Ensure that the master DataSource is used if there is a read only data source
* being used (that is using a read replica database potentially with replication lag).
* <p>
* When the database is configured with a read-only DataSource via
* say {@link io.ebean.DatabaseBuilder#readOnlyDataSource(DataSource)} then
* by default when a query is run without an active transaction, it uses the read-only data
* source. We use {@code usingMaster()} to instead ensure that the query is executed
* against the master data source.
*/
default DtoQuery<T> usingMaster() {
return usingMaster(true);
}
/**
* Ensure the master DataSource is used when useMaster is true. Otherwise, the read only
* data source can be used if defined.
*
* @see #usingMaster()
*/
DtoQuery<T> usingMaster(boolean useMaster);
/**
* Return a PagedList for this query using firstRow and maxRows.
* <p>
@@ -281,6 +187,7 @@ public interface DtoQuery<T> extends CancelableQuery {
*
* @return The PagedList
*/
@Override
PagedList<T> findPagedList();
}
@@ -10,6 +10,7 @@ import java.sql.Timestamp;
import java.util.*;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.function.Supplier;
/**
* List of Expressions that make up a where or having clause.
@@ -421,6 +422,26 @@ public interface ExpressionList<T> {
*/
Optional<T> findOneOrEmpty();
/**
* Execute the query returning a single bean or throwing a {@link jakarta.persistence.EntityNotFoundException}
* if there is no matching bean.
*
* @see Query#findOneOrThrow()
*/
default T findOneOrThrow() {
return query().findOneOrThrow();
}
/**
* Execute the query returning a single bean or throwing the exception produced by the
* given supplier if there is no matching bean.
*
* @see Query#findOneOrThrow(Supplier)
*/
default T findOneOrThrow(Supplier<? extends RuntimeException> exceptionSupplier) {
return query().findOneOrThrow(exceptionSupplier);
}
/**
* Execute find row count query in a background thread.
* <p>
@@ -0,0 +1,89 @@
package io.ebean;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import jakarta.persistence.EntityNotFoundException;
import javax.sql.DataSource;
import java.sql.Connection;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
/**
* Common find operations shared by the query types that can execute and return
* results - {@link SqlQuery}, {@link DtoQuery}, {@link MappedQuery} and {@link QueryBuilder}.
*
* @param <SELF> The query type (used for method chaining)
* @param <T> The type of the result
*/
@NullMarked
public interface FindableQuery<SELF extends FindableQuery<SELF, T>, T> extends CancelableQuery {
/**
* Execute the query returning the list of results.
*/
List<T> findList();
/**
* Execute the query returning a single result, or {@code null} if there is no matching row.
* <p>
* If more than 1 row is found for this query then a PersistenceException is thrown.
*/
@Nullable
T findOne();
/**
* Execute the query returning an optional result.
*/
Optional<T> findOneOrEmpty();
/**
* Execute the query returning a single result or throwing a
* {@link jakarta.persistence.EntityNotFoundException} if there is no matching row.
*/
default T findOneOrThrow() {
return findOneOrEmpty().orElseThrow(() -> new EntityNotFoundException("Not found"));
}
/**
* Execute the query returning a single result or throwing the exception produced
* by the given supplier if there is no matching row.
*/
default T findOneOrThrow(Supplier<? extends RuntimeException> exceptionSupplier) {
return findOneOrEmpty().orElseThrow(exceptionSupplier);
}
/**
* Execute the query using the given transaction.
*/
SELF usingTransaction(Transaction transaction);
/**
* Execute the query using the given connection.
*/
SELF usingConnection(Connection connection);
/**
* Ensure that the master DataSource is used if there is a read only data source
* being used (that is using a read replica database potentially with replication lag).
* <p>
* When the database is configured with a read-only DataSource via
* say {@link DatabaseBuilder#readOnlyDataSource(DataSource)} then
* by default when a query is run without an active transaction, it uses the read-only data
* source. We use {@code usingMaster()} to instead ensure that the query is executed
* against the master data source.
*/
default SELF usingMaster() {
return usingMaster(true);
}
/**
* Ensure the master DataSource is used when useMaster is true. Otherwise, the read only
* data source can be used if defined.
*
* @see #usingMaster()
*/
SELF usingMaster(boolean useMaster);
}
@@ -1,11 +1,10 @@
package io.ebean;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.sql.Connection;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Stream;
/**
@@ -22,11 +21,12 @@ import java.util.stream.Stream;
* @param <D> the target DTO type
*/
@NullMarked
public interface MappedQuery<D> {
public interface MappedQuery<D> extends StreamableQuery<MappedQuery<D>, D> {
/**
* Execute the query returning the mapped DTO list.
*/
@Override
List<D> findList();
/**
@@ -38,6 +38,7 @@ public interface MappedQuery<D> {
* ({@link PagedList#getTotalCount()}, {@link PagedList#hasNext()}, etc.) reflects the
* underlying entity query and is unaffected by the DTO mapping.
*/
@Override
PagedList<D> findPagedList();
/**
@@ -60,33 +61,51 @@ public interface MappedQuery<D> {
*
* }</pre>
*/
@Override
Stream<D> findStream();
/**
* Execute the query returning a single mapped DTO, or {@code null} if there is no matching row.
* Execute the query processing the mapped DTOs one at a time.
* <p>
* Mirrors {@link QueryBuilder#findEach(Consumer)} - the underlying entity graph query is
* streamed one entity at a time and each entity is mapped to its target DTO lazily as it is
* consumed, sharing one {@link DtoMapContext} across the whole callback so that repeated
* references to the same source entity still de-duplicate to the same DTO instance.
* <p>
* This method is appropriate to process very large query results as the mapped DTOs are
* consumed one at a time and do not need to be held in memory (unlike {@link #findList()}).
*
* @param consumer the consumer used to process the mapped DTOs.
*/
@Nullable
D findOne();
@Override
void findEach(Consumer<D> consumer);
/**
* Execute findEach streaming query batching the mapped DTOs for consuming.
* <p>
* Mirrors {@link QueryBuilder#findEach(int, Consumer)} - typically used when we want to do
* further processing on the mapped DTOs in batch form, for example 100 at a time. Each batch
* shares one {@link DtoMapContext} with the rest of the query so that repeated references to
* the same source entity still de-duplicate to the same DTO instance.
*
* @param batch The number of mapped DTOs processed in the batch
* @param consumer Process the batch of mapped DTOs
*/
@Override
void findEach(int batch, Consumer<List<D>> consumer);
/**
* Execute the query returning an optional mapped DTO.
* Execute the query using callbacks to process the resulting mapped DTOs one at a time,
* with the ability to stop processing part way through.
* <p>
* Mirrors {@link QueryBuilder#findEachWhile(Predicate)} - returning {@code false} after
* processing a DTO stops the iteration through the query results. Sharing one
* {@link DtoMapContext} across the whole callback so that repeated references to the same
* source entity still de-duplicate to the same DTO instance.
*
* @param consumer the consumer used to process the mapped DTOs, returning {@code false} to
* stop processing.
*/
Optional<D> findOneOrEmpty();
/**
* Ensure the master DataSource is used when useMaster is true. Otherwise, the read only
* data source can be used if defined.
*/
MappedQuery<D> usingMaster(boolean useMaster);
/**
* Use the explicit transaction to execute the query.
*/
MappedQuery<D> usingTransaction(Transaction transaction);
/**
* Execute the query using the given connection.
*/
MappedQuery<D> usingConnection(Connection connection);
@Override
void findEachWhile(Predicate<D> consumer);
}
+1 -1
View File
@@ -151,7 +151,7 @@ import org.jspecify.annotations.Nullable;
* @param <T> the type of Entity bean this query will fetch.
*/
@NullMarked
public interface Query<T> extends CancelableQuery, QueryBuilder<Query<T>, T> {
public interface Query<T> extends QueryBuilder<Query<T>, T> {
/**
* The lock type (strength) to use with query FOR UPDATE row locking.
@@ -2,8 +2,7 @@ package io.ebean;
import org.jspecify.annotations.Nullable;
import javax.sql.DataSource;
import java.sql.Connection;
import jakarta.persistence.EntityNotFoundException;
import java.sql.Timestamp;
import java.util.List;
import java.util.Map;
@@ -11,8 +10,6 @@ import java.util.Optional;
import java.util.Set;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Stream;
/**
* Build and execute an ORM query.
@@ -20,7 +17,7 @@ import java.util.stream.Stream;
* @param <SELF> The type of the builder
* @param <T> The entity bean type
*/
public interface QueryBuilder<SELF extends QueryBuilder<SELF, T>, T> extends QueryBuilderProjection<SELF, T> {
public interface QueryBuilder<SELF extends QueryBuilder<SELF, T>, T> extends QueryBuilderProjection<SELF, T>, StreamableQuery<SELF, T> {
/**
* Set root table alias.
@@ -146,48 +143,16 @@ public interface QueryBuilder<SELF extends QueryBuilder<SELF, T>, T> extends Que
*/
SELF copy();
/**
* Execute the query using the given transaction.
*/
SELF usingTransaction(Transaction transaction);
/**
* Execute this query using immutable bean cache values for matching bean types.
*/
SELF using(ImmutableBeanCache<?> beanCache);
/**
* Execute the query using the given connection.
*/
SELF usingConnection(Connection connection);
/**
* Execute the query using the given database.
*/
SELF usingDatabase(Database database);
/**
* Ensure that the master DataSource is used if there is a read only data source
* being used (that is using a read replica database potentially with replication lag).
* <p>
* When the database is configured with a read-only DataSource via
* say {@link io.ebean.config.DatabaseConfig#setReadOnlyDataSource(DataSource)} then
* by default when a query is run without an active transaction, it uses the read-only data
* source. We we use {@code usingMaster()} to instead ensure that the query is executed
* against the master data source.
*/
default SELF usingMaster() {
return usingMaster(true);
}
/**
* Ensure the master DataSource is used when useMaster is true. Otherwise, the read only
* data source can be used if defined.
*
* @see #usingMaster()
*/
SELF usingMaster(boolean useMaster);
/**
* Set the base table to use for this query.
* <p>
@@ -679,88 +644,24 @@ public interface QueryBuilder<SELF extends QueryBuilder<SELF, T>, T> extends Que
boolean exists();
/**
* Execute the query returning either a single bean or null (if no matching
* bean is found).
* <p>
* If more than 1 row is found for this query then a PersistenceException is
* thrown.
* <p>
* This is useful when your predicates dictate that your query should only
* return 0 or 1 results.
* Execute the query returning a single bean or throwing a {@link jakarta.persistence.EntityNotFoundException}
* if there is no matching bean.
* <p>
* This is a convenience alternative to:
* <pre>{@code
*
* // assuming the sku of products is unique...
* Product product =
* new QProduct()
* .sku.equalTo("aa113")
* .findOne();
* ...
* query.findOneOrEmpty()
* .orElseThrow(() -> new EntityNotFoundException(...));
* }</pre>
* <p>
* It is also useful with finding objects by their id when you want to specify
* further join information to optimise the query.
* <p>
* <pre>{@code
*
* // Fetch order 42 and additionally fetch join its order details...
* Order order =
* new QOrder()
* .fetch("details") // eagerly load the order details
* .id.equalTo(42)
* .findOne();
*
* // the order details were eagerly loaded
* List<OrderDetail> details = order.getDetails();
* ...
* }</pre>
* The exception message is a best effort - it uses the id when this is effectively a
* find-by-id query, or the single equality predicate when the query is filtered by what
* looks like a natural/unique key, otherwise a generic "not found" message.
*/
@Nullable
T findOne();
/**
* Execute the query returning an optional bean.
*/
Optional<T> findOneOrEmpty();
/**
* Execute the query returning the list of objects.
* <p>
* This query will execute against the EbeanServer that was used to create it.
* <p>
* <pre>{@code
*
* List<Customer> customers =
* new QCustomer()
* .name.ilike("rob%")
* .findList();
*
* }</pre>
*
* @see Query#findList()
*/
List<T> findList();
/**
* Execute the query returning the result as a Stream.
* <p>
* Note that this can support very large queries iterating
* any number of results. To do so internally it can use
* multiple persistence contexts.
* </p>
* <pre>{@code
*
* // use try with resources to ensure Stream is closed
*
* try (Stream<Customer> stream = query.findStream()) {
* stream
* .map(...)
* .collect(...);
* }
*
* }</pre>
*/
Stream<T> findStream();
@Override
default T findOneOrThrow() {
return findOneOrEmpty().orElseThrow(() ->
new EntityNotFoundException(getBeanType().getSimpleName() + " not found"));
}
/**
* Execute the query returning the set of objects.
@@ -905,84 +806,6 @@ public interface QueryBuilder<SELF extends QueryBuilder<SELF, T>, T> extends Que
*/
<A> Set<A> findSingleAttributeSet();
/**
* Execute the query processing the beans one at a time.
* <p>
* This method is appropriate to process very large query results as the
* beans are consumed one at a time and do not need to be held in memory
* (unlike #findList #findSet etc)
* <p>
* Note that internally Ebean can inform the JDBC driver that it is expecting larger
* resultSet and specifically for MySQL this hint is required to stop it's JDBC driver
* from buffering the entire resultSet. As such, for smaller resultSets findList() is
* generally preferable.
* <p>
* Compared with #findEachWhile this will always process all the beans where as
* #findEachWhile provides a way to stop processing the query result early before
* all the beans have been read.
* <p>
* This method is functionally equivalent to findIterate() but instead of using an
* iterator uses the Consumer interface which is better suited to use with closures.
*
* <pre>{@code
*
* new QCustomer()
* .status.equalTo(Status.NEW)
* .orderBy().id.asc()
* .findEach((Customer customer) -> {
*
* // do something with customer
* System.out.println("-- visit " + customer);
* });
*
* }</pre>
*
* @param consumer the consumer used to process the queried beans.
*/
void findEach(Consumer<T> consumer);
/**
* Execute findEach streaming query batching the results for consuming.
* <p>
* This query execution will stream the results and is suited to consuming
* large numbers of results from the database.
* <p>
* Typically, we use this batch consumer when we want to do further processing on
* the beans and want to do that processing in batch form, for example - 100 at
* a time.
*
* @param batch The number of beans processed in the batch
* @param consumer Process the batch of beans
*/
void findEach(int batch, Consumer<List<T>> consumer);
/**
* Execute the query using callbacks to a visitor to process the resulting
* beans one at a time.
* <p>
* This method is functionally equivalent to findIterate() but instead of using an
* iterator uses the Predicate interface which is better suited to use with closures.
*
* <pre>{@code
*
* new QCustomer()
* .status.equalTo(Status.NEW)
* .orderBy().id.asc()
* .findEachWhile((Customer customer) -> {
*
* // do something with customer
* System.out.println("-- visit " + customer);
*
* // return true to continue processing or false to stop
* return (customer.getId() < 40);
* });
*
* }</pre>
*
* @param consumer the consumer used to process the queried beans.
*/
void findEachWhile(Predicate<T> consumer);
/**
* Return versions of a @History entity bean.
* <p>
@@ -1048,33 +871,4 @@ public interface QueryBuilder<SELF extends QueryBuilder<SELF, T>, T> extends Que
*/
<K> FutureMap<K,T> findFutureMap();
/**
* Return a PagedList for this query using firstRow and maxRows.
* <p>
* The benefit of using this over findList() is that it provides functionality to get the
* total row count etc.
* <p>
* If maxRows is not set on the query prior to calling findPagedList() then a
* PersistenceException is thrown.
* <p>
* <pre>{@code
*
* PagedList<Order> pagedList =
* new QOrder()
* .setFirstRow(50)
* .setMaxRows(20)
* .findPagedList();
*
* // fetch the total row count in the background
* pagedList.loadRowCount();
*
* List<Order> orders = pagedList.getList();
* int totalRowCount = pagedList.getTotalRowCount();
*
* }</pre>
*
* @return The PagedList
*/
PagedList<T> findPagedList();
}
+19 -53
View File
@@ -3,6 +3,7 @@ package io.ebean;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import jakarta.persistence.EntityNotFoundException;
import javax.sql.DataSource;
import java.io.Serializable;
import java.sql.Connection;
@@ -11,6 +12,7 @@ import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.function.Supplier;
/**
* Query object for performing native SQL queries that return SqlRow or directly read
@@ -41,44 +43,7 @@ import java.util.function.Predicate;
* }</pre>
*/
@NullMarked
public interface SqlQuery extends Serializable, CancelableQuery {
/**
* Execute the query using the given transaction.
*/
SqlQuery usingTransaction(Transaction transaction);
/**
* Execute the query using the given connection.
*/
SqlQuery usingConnection(Connection connection);
/**
* Ensure that the master DataSource is used if there is a read only data source
* being used (that is using a read replica database potentially with replication lag).
* <p>
* When the database is configured with a read-only DataSource via
* say {@link io.ebean.DatabaseBuilder#readOnlyDataSource(DataSource)}then
* by default when a query is run without an active transaction, it uses the read-only data
* source. We use {@code usingMaster()} to instead ensure that the query is executed
* against the master data source.
*/
default SqlQuery usingMaster() {
return usingMaster(true);
}
/**
* Ensure the master DataSource is used when useMaster is true. Otherwise, the read only
* data source can be used if defined.
*
* @see #usingMaster()
*/
SqlQuery usingMaster(boolean useMaster);
/**
* Execute the query returning a list.
*/
List<SqlRow> findList();
public interface SqlQuery extends Serializable, FindableQuery<SqlQuery, SqlRow> {
/**
* Execute the SqlQuery iterating a row at a time.
@@ -99,16 +64,6 @@ public interface SqlQuery extends Serializable, CancelableQuery {
*/
void findEachWhile(Predicate<SqlRow> consumer);
/**
* Execute the query returning a single row or null.
* <p>
* If this query finds 2 or more rows then it will throw a
* PersistenceException.
* </p>
*/
@Nullable
SqlRow findOne();
/**
* Execute the query reading each row from ResultSet using the RowConsumer.
* <p>
@@ -139,11 +94,6 @@ public interface SqlQuery extends Serializable, CancelableQuery {
*/
void findEachRow(RowConsumer consumer);
/**
* Execute the query returning an optional row.
*/
Optional<SqlRow> findOneOrEmpty();
/**
* Set one of more positioned parameters.
* <p>
@@ -391,6 +341,22 @@ public interface SqlQuery extends Serializable, CancelableQuery {
*/
Optional<T> findOneOrEmpty();
/**
* Return the single value or throw a {@link jakarta.persistence.EntityNotFoundException}
* if there is no matching row.
*/
default T findOneOrThrow() {
return findOneOrEmpty().orElseThrow(() -> new EntityNotFoundException("Not found"));
}
/**
* Return the single value or throw the exception produced by the given supplier
* if there is no matching row.
*/
default T findOneOrThrow(Supplier<? extends RuntimeException> exceptionSupplier) {
return findOneOrEmpty().orElseThrow(exceptionSupplier);
}
/**
* Return the list of values.
*/
@@ -0,0 +1,101 @@
package io.ebean;
import org.jspecify.annotations.NullMarked;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Stream;
/**
* A {@link FindableQuery} that additionally supports streaming results and paging.
*
* @param <SELF> The query type (used for method chaining)
* @param <T> The type of the result
*/
@NullMarked
public interface StreamableQuery<SELF extends StreamableQuery<SELF, T>, T> extends FindableQuery<SELF, T> {
/**
* Execute the query returning the result as a Stream.
* <p>
* Note that this can support very large queries iterating any number of results.
* To do so internally it can use multiple persistence contexts.
* <p>
* Note that the Stream holds resources related to the underlying resultSet and
* potentially connection and MUST be closed. We should use the Stream in a
* <em>try with resource block</em>.
* <pre>{@code
*
* // use try with resources to ensure Stream is closed
*
* try (Stream<T> stream = query.findStream()) {
* stream
* .map(...)
* .collect(...);
* }
*
* }</pre>
*/
Stream<T> findStream();
/**
* Return a PagedList for this query using firstRow and maxRows.
* <p>
* The benefit of using this over findList() is that it provides functionality to get the
* total row count etc.
* <p>
* If maxRows is not set on the query prior to calling findPagedList() then a
* PersistenceException is thrown.
*
* @return The PagedList
*/
PagedList<T> findPagedList();
/**
* Execute the query processing the results one at a time.
* <p>
* This method is appropriate to process very large query results as the results are
* consumed one at a time and do not need to be held in memory (unlike {@link #findList()}).
* <p>
* Note that internally Ebean can inform the JDBC driver that it is expecting a larger
* resultSet and specifically for MySQL this hint is required to stop its JDBC driver
* from buffering the entire resultSet. As such, for smaller resultSets findList() is
* generally preferable.
* <p>
* Compared with {@link #findEachWhile(Predicate)} this will always process all the results
* whereas findEachWhile() provides a way to stop processing the query result early before
* all the results have been read.
*
* @param consumer the consumer used to process the queried results.
*/
void findEach(Consumer<T> consumer);
/**
* Execute findEach streaming query batching the results for consuming.
* <p>
* This query execution will stream the results and is suited to consuming
* large numbers of results from the database.
* <p>
* Typically, we use this batch consumer when we want to do further processing on
* the results and want to do that processing in batch form, for example - 100 at
* a time.
*
* @param batch The number of results processed in the batch
* @param consumer Process the batch of results
*/
void findEach(int batch, Consumer<List<T>> consumer);
/**
* Execute the query using callbacks to process the resulting results one at a time,
* with the ability to stop processing part way through.
* <p>
* Returning {@code false} after processing a result stops the iteration through the
* query results.
*
* @param consumer the consumer used to process the queried results, returning
* {@code false} to stop processing.
*/
void findEachWhile(Predicate<T> consumer);
}
+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>
@@ -1335,6 +1335,24 @@ public class DefaultExpressionList<T> implements SpiExpressionList<T> {
return null;
}
/**
* Return a "propertyName: value" description when this expression list is a single
* simple equality predicate (a candidate natural/unique key), otherwise null.
* <p>
* Used to build a decent default message for {@code findOneOrThrow()} when the query
* isn't a simple find-by-id.
*/
@Nullable
public String singleEqDescription() {
if (list.size() == 1 && list.get(0) instanceof SimpleExpression) {
SimpleExpression simple = (SimpleExpression) list.get(0);
if (simple.isOpEquals()) {
return simple.getPropName() + ": " + simple.getValue();
}
}
return null;
}
@Override
public ExpressionList<T> clear() {
list.clear();
@@ -4,11 +4,9 @@ import io.ebean.InsertOptions;
import io.ebeaninternal.server.deploy.BeanDescriptor;
import io.ebeaninternal.server.deploy.BeanProperty;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* Postgres specific generation of insert on conflict.
@@ -18,12 +16,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
@@ -49,10 +49,7 @@ final class InsertMetaOptionsPostgres implements InsertMetaOptions {
meta.sql(request, !withId, baseTable, false);
request.append(" on conflict ");
List<String> uniqueColumns = desc.uniqueProps().stream()
.flatMap(Arrays::stream)
.map(BeanProperty::dbColumn)
.collect(Collectors.toList());
List<String> uniqueColumns = InsertMetaOptionsSupport.uniqueColumns(desc, withId);
String constraintName = options.constraint();
if (constraintName != null) {
@@ -84,6 +81,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()) {
@@ -4,11 +4,9 @@ import io.ebean.InsertOptions;
import io.ebeaninternal.server.deploy.BeanDescriptor;
import io.ebeaninternal.server.deploy.BeanProperty;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* SQLite specific generation of insert on conflict.
@@ -21,12 +19,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
@@ -55,10 +55,7 @@ final class InsertMetaOptionsSqlite implements InsertMetaOptions {
meta.sql(request, !withId, baseTable, false);
request.append(" on conflict (");
List<String> uniqueColumns = desc.uniqueProps().stream()
.flatMap(Arrays::stream)
.map(BeanProperty::dbColumn)
.collect(Collectors.toList());
List<String> uniqueColumns = InsertMetaOptionsSupport.uniqueColumns(desc, withId);
String cols = options.uniqueColumns();
if (cols != null) {
@@ -85,6 +82,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,75 @@
package io.ebeaninternal.server.persist.dml;
import io.ebeaninternal.server.deploy.BeanDescriptor;
import io.ebeaninternal.server.deploy.BeanProperty;
import io.ebeaninternal.server.deploy.BeanPropertyAssocOne;
import io.ebeaninternal.server.deploy.generatedproperty.GeneratedProperty;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* Shared support for the platform specific insert on conflict do update generation.
*/
final class InsertMetaOptionsSupport {
private InsertMetaOptionsSupport() {
}
/**
* Return the columns to use as the on conflict target.
* <p>
* Uses columns explicitly mapped as unique via {@code @Column(unique=true)} or
* {@code @Index(unique=true)}. When there are none of those, falls back to the primary
* key column(s) given the primary key constraint is inherently unique. This fallback only
* applies when the id value is included in the insert (withId) as an insert relying on a
* database generated id (identity/sequence) is never going to naturally conflict on that id.
*/
static List<String> uniqueColumns(BeanDescriptor<?> desc, boolean withId) {
List<String> uniqueColumns = desc.uniqueProps().stream()
.flatMap(Arrays::stream)
.map(BeanProperty::dbColumn)
.collect(Collectors.toList());
if (uniqueColumns.isEmpty() && withId) {
uniqueColumns = idColumns(desc);
}
return uniqueColumns;
}
private static List<String> idColumns(BeanDescriptor<?> desc) {
BeanProperty idProperty = desc.idProperty();
if (idProperty == null) {
return List.of();
}
if (idProperty.isEmbedded() && idProperty instanceof BeanPropertyAssocOne) {
List<String> columns = new ArrayList<>();
for (BeanProperty embedded : ((BeanPropertyAssocOne<?>) idProperty).properties()) {
columns.add(embedded.dbColumn());
}
return columns;
}
return List.of(idProperty.dbColumn());
}
/**
* 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();
}
}
@@ -11,6 +11,8 @@ import io.ebeaninternal.api.SpiQuery;
import java.sql.Connection;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Stream;
/**
@@ -90,6 +92,11 @@ public final class DefaultMappedQuery<T, D> implements MappedQuery<D> {
return Optional.ofNullable(findOne());
}
@Override
public D findOneOrThrow() {
return mapper().map(query.findOneOrThrow());
}
@Override
public PagedList<D> findPagedList() {
DtoMapper<T, D> m = mapper();
@@ -103,6 +110,27 @@ public final class DefaultMappedQuery<T, D> implements MappedQuery<D> {
return query.findStream().map(source -> m.map(source, context));
}
@Override
public void findEach(Consumer<D> consumer) {
DtoMapper<T, D> m = mapper();
DtoMapContext context = new DtoMapContext();
query.findEach(source -> consumer.accept(m.map(source, context)));
}
@Override
public void findEach(int batch, Consumer<List<D>> consumer) {
DtoMapper<T, D> m = mapper();
DtoMapContext context = new DtoMapContext();
query.findEach(batch, sourceBatch -> consumer.accept(m.mapList(sourceBatch, context)));
}
@Override
public void findEachWhile(Predicate<D> consumer) {
DtoMapper<T, D> m = mapper();
DtoMapContext context = new DtoMapContext();
query.findEachWhile(source -> consumer.test(m.map(source, context)));
}
@Override
public MappedQuery<D> usingMaster(boolean useMaster) {
query.usingMaster(useMaster);
@@ -120,4 +148,9 @@ public final class DefaultMappedQuery<T, D> implements MappedQuery<D> {
query.usingConnection(connection);
return this;
}
@Override
public void cancel() {
query.cancel();
}
}
@@ -23,6 +23,7 @@ import io.ebeaninternal.server.query.NativeSqlQueryPlanKey;
import io.ebeaninternal.server.rawsql.SpiRawSql;
import io.ebeaninternal.server.transaction.ExternalJdbcTransaction;
import jakarta.persistence.EntityNotFoundException;
import jakarta.persistence.PersistenceException;
import java.sql.Connection;
import java.sql.Timestamp;
@@ -1656,6 +1657,30 @@ public class DefaultOrmQuery<T> extends AbstractQuery implements SpiQuery<T> {
return server.findOneOrEmpty(this);
}
@Override
public final T findOneOrThrow() {
return findOneOrEmpty().orElseThrow(() -> new EntityNotFoundException(notFoundMessage()));
}
/**
* Build a decent default "not found" message using the id (if this is effectively a
* find-by-id query) or, failing that, a single simple equality predicate (a likely
* natural/unique key). Falls back to a generic message for anything more complex.
*/
private String notFoundMessage() {
String type = beanDescriptor.type().getSimpleName();
if (isFindById()) {
return type + " not found for id: " + id;
}
if (whereExpressions != null) {
String desc = whereExpressions.singleEqDescription();
if (desc != null) {
return type + " not found for " + desc;
}
}
return type + " not found";
}
@Override
public final FutureIds<T> findFutureIds() {
return server.findFutureIds(this);
@@ -7,6 +7,7 @@ import io.ebeaninternal.api.SpiExpressionList;
import io.ebeaninternal.api.SpiQueryManyJoin;
import io.ebeaninternal.server.deploy.BeanDescriptor;
import io.ebeaninternal.server.deploy.BeanPropertyAssoc;
import io.ebeaninternal.server.deploy.BeanPropertyAssocOne;
import io.ebeaninternal.server.el.ElPropertyDeploy;
import io.ebeaninternal.server.el.ElPropertyValue;
@@ -358,6 +359,54 @@ public final class OrmQueryDetail implements Serializable {
}
}
/**
* After sorting, fold any fetch path that only fetches a *ToOne association's id
* into a plain select of the parent path instead - the foreign key column is already
* present on the owning table so the join can be avoided entirely.
* <p>
* Iterates the fetch paths backwards (deepest first) so that a path already folded
* away does not stop a shallower ancestor also being folded, while a path that must
* remain a real join protects its own parent (added to {@code nonRemovable}) from
* being folded, as the parent's join is required to reach it.
*/
private void convertIdFetches(BeanDescriptor<?> desc) {
Set<String> nonRemovable = new HashSet<>();
String[] paths = fetchPaths.keySet().toArray(new String[0]);
int i = paths.length;
while (i-- > 0) {
String path = paths[i];
ElPropertyDeploy el = desc.elPropertyDeploy(path);
OrmQueryProperties prop = fetchPaths.get(path);
if (nonRemovable.contains(path)) {
// a still-existing child fetch depends on this join, don't remove
} else if (el == null) {
throw new PersistenceException("Invalid fetch path " + path + " from " + desc.fullName());
} else if (el.beanProperty() instanceof BeanPropertyAssocOne) {
BeanPropertyAssocOne<?> assoc = (BeanPropertyAssocOne<?>) el.beanProperty();
// exported (mappedBy) one-to-one has no local foreign key column - it always needs the join
if (assoc.hasForeignKeyConstraint() && !assoc.isOneToOneExported()) {
if (prop.includesExactly(assoc.targetDescriptor().idName())) {
String parentPath = prop.getParentPath();
OrmQueryProperties parentProp = parentPath == null ? baseProps : fetchPaths.get(parentPath);
if (parentProp != null && parentProp.hasProperties()) {
OrmQueryProperties newParentProp = parentProp.withAddedInclude(assoc.name());
if (parentPath == null) {
baseProps = newParentProp;
} else {
fetchPaths.put(parentPath, newParentProp);
}
fetchPaths.remove(path);
prop = null;
}
}
}
}
if (prop != null && prop.getParentPath() != null) {
nonRemovable.add(prop.getParentPath());
}
}
}
/**
* Mark 'fetch joins' to 'many' properties over to 'query joins' where needed.
*
@@ -375,6 +424,7 @@ public final class OrmQueryDetail implements Serializable {
boolean fetchJoinFirstMany = allowOne;
sortFetchPaths(beanDescriptor, addIds);
convertIdFetches(beanDescriptor);
List<FetchEntry> pairs = sortByFetchPreference(beanDescriptor);
for (FetchEntry pair : pairs) {
@@ -151,6 +151,22 @@ public final class OrmQueryProperties implements Serializable {
: buildImmutableQueryPlanHashSuffix(sourceFetchConfig);
}
/**
* Copy constructor with a replacement included set (used by {@link #withAddedInclude(String)}).
*/
private OrmQueryProperties(OrmQueryProperties source, Set<String> replacementIncluded) {
this.fetchConfig = source.fetchConfig;
this.parentPath = source.parentPath;
this.path = source.path;
this.allProperties = source.allProperties;
this.cache = source.cache;
this.filterMany = source.filterMany;
this.markForQueryJoin = source.markForQueryJoin;
this.included = immutableIncluded(replacementIncluded);
this.immutableHashPrefix = buildImmutableQueryPlanHashPrefix(path, this.included);
this.immutableHashSuffix = source.immutableHashSuffix;
}
private static Set<String> immutableIncluded(Set<String> included) {
if (included == null) {
return null;
@@ -412,6 +428,37 @@ public final class OrmQueryProperties implements Serializable {
return included == null || included.contains(propName);
}
/**
* Return true if the included properties are exactly the single given property.
* <p>
* Used to detect a fetch/select of a *ToOne association that only includes the
* target's id property - a candidate for folding into the parent select as a plain
* foreign key property (avoiding an unnecessary join).
*/
boolean includesExactly(String property) {
return included != null && included.size() == 1 && included.contains(property);
}
/**
* Return a new instance with the given property added to the included set.
* <p>
* Used to fold an id-only *ToOne fetch into this select as a plain foreign key
* property. A new instance is returned (rather than mutating {@link #included} in
* place) as this instance's included set is immutable and may be shared/cached
* (e.g. via FetchGroup reuse).
*/
OrmQueryProperties withAddedInclude(String property) {
if (allProperties) {
return this;
}
Set<String> newIncluded = new LinkedHashSet<>();
if (included != null) {
newIncluded.addAll(included);
}
newIncluded.add(property);
return new OrmQueryProperties(this, newIncluded);
}
/**
* Mark this path as needing to be a query join.
*/
+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>
@@ -738,6 +738,11 @@ public abstract class QueryBean<T, R extends QueryBean<T, R>> implements IQueryB
return root;
}
@Override
public void cancel() {
query.cancel();
}
@Override
public final R usingTransaction(Transaction transaction) {
query.usingTransaction(transaction);
+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>
+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>
<artifactId>ebean-spring-txn</artifactId>
@@ -28,14 +28,14 @@
<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>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.22.0</version>
<version>2.22.1</version>
<scope>test</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>
@@ -629,7 +629,7 @@ class EqlParserTest extends BaseTestCase {
List<OrderDetail> details = query.findList();
assertThat(details).isNotEmpty();
assertSql(query).contains("select sum(t0.order_qty), t1.id from o_order_detail t0 join o_order t1 on t1.id = t0.order_id group by t1.id");
assertSql(query).contains("select sum(t0.order_qty), t0.order_id from o_order_detail t0 group by t0.order_id");
}
@Test
@@ -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;
}
}
@@ -43,6 +43,34 @@ class TestInsertOnConflict extends BaseTestCase {
.build());
}
@ForPlatform({Platform.POSTGRES, Platform.YUGABYTE, Platform.SQLITE})
@Test
void insertOnConflictUpdate_fallsBackToPrimaryKey_whenNoUniqueColumnsMapped() {
Database db = DB.getDefault();
LoggedSql.start();
var entity1 = new EStrIdBean();
entity1.setId("fallback-1");
entity1.setName("Example");
// no uniqueColumns()/constraint() explicitly set - id is not mapped @Column(unique=true)
// or @Index(unique=true) so this should fall back to using the primary key (id) column
db.insert(entity1, ON_CONFLICT_UPDATE);
var entity2 = new EStrIdBean();
entity2.setId("fallback-1");
entity2.setName("Updated");
db.insert(entity2, ON_CONFLICT_UPDATE);
var sql = LoggedSql.stop();
assertThat(sql).hasSize(2);
assertThat(sql.get(0)).contains("on conflict (id) do update set");
assertThat(sql.get(1)).contains("on conflict (id) do update set");
EStrIdBean found = db.find(EStrIdBean.class, "fallback-1");
assertThat(found).isNotNull();
assertThat(found.name()).isEqualTo("Updated");
}
@ForPlatform({Platform.POSTGRES, Platform.YUGABYTE, Platform.SQLITE})
@Test
void insertOnConflictUpdateExplicitTransaction() {
@@ -307,6 +335,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 {
@@ -93,7 +93,7 @@ public class TestMergeCustomer extends BaseTestCase {
List<String> sql = LoggedSql.stop();
assertThat(sql).hasSize(6);
assertSql(sql.get(0)).contains("select t0.id, t2.id, t1.id from mcustomer t0 left join maddress t2 on t2.id = t0.shipping_address_id left join maddress t1 on t1.id = t0.billing_address_id where t0.id = ?");
assertSql(sql.get(0)).contains("select t0.id, t0.billing_address_id, t0.shipping_address_id from mcustomer t0 where t0.id = ?");
assertSql(sql.get(1)).contains("update maddress set street=?, city=?, version=? where id=? and version=?");
assertSqlBind(sql, 2, 3);
assertThat(sql.get(5)).contains("update mcustomer set name=?, version=?, shipping_address_id=?, billing_address_id=? where id=? and version=?");
@@ -122,7 +122,7 @@ public class TestMergeCustomer extends BaseTestCase {
List<String> sql = LoggedSql.stop();
assertThat(sql).hasSize(8);
assertSql(sql.get(0)).contains("select t0.id, t2.id, t1.id from mcustomer t0 left join maddress t2 on t2.id = t0.shipping_address_id left join maddress t1 on t1.id = t0.billing_address_id where t0.id = ?");
assertSql(sql.get(0)).contains("select t0.id, t0.billing_address_id, t0.shipping_address_id from mcustomer t0 where t0.id = ?");
assertSql(sql.get(1)).contains("insert into maddress (id, street, city, version) values (?,?,?,?)");
assertSqlBind(sql.get(2));
assertThat(sql.get(4)).contains("update maddress set street=?, city=?, version=? where id=? and version=?");
@@ -155,7 +155,7 @@ public class TestMergeCustomer extends BaseTestCase {
List<String> sql = LoggedSql.stop();
assertThat(sql).hasSize(9);
assertSql(sql.get(0)).contains("select t0.id, t2.id, t1.id from mcustomer t0 left join maddress t2 on t2.id = t0.shipping_address_id left join maddress t1 on t1.id = t0.billing_address_id where t0.id = ?");
assertSql(sql.get(0)).contains("select t0.id, t0.billing_address_id, t0.shipping_address_id from mcustomer t0 where t0.id = ?");
// Additional check to see if the address with the unknown UUID is 'insert' or 'update'
assertSql(sql.get(1)).contains("select t0.id from maddress t0 where t0.id = ?");
@@ -317,7 +317,7 @@ public class TestMergeCustomer extends BaseTestCase {
List<String> sql = LoggedSql.stop();
if (isPersistBatchOnCascade()) {
assertSql(sql.get(0)).contains("select t0.id, t3.id, t1.id, t2.id from mcustomer t0 left join maddress t3 on t3.id = t0.shipping_address_id left join maddress t1 on t1.id = t0.billing_address_id left join mcontact t2 on t2.customer_id = t0.id where t0.id = ?");
assertSql(sql.get(0)).contains("select t0.id, t0.shipping_address_id, t0.billing_address_id, t1.id from mcustomer t0 left join mcontact t1 on t1.customer_id = t0.id where t0.id = ?");
if (isH2() || isHana()) {
// with nested OneToMany .. we need a second query to read the contact message ids
assertSql(sql.get(1)).contains("select t0.contact_id, t0.id from mcontact_message t0 where (t0.contact_id) in (?,?,?,?,?,?,?,?,?,?)");
@@ -0,0 +1,64 @@
package org.tests.query;
import io.ebean.DB;
import io.ebean.Query;
import io.ebean.text.PathProperties;
import io.ebean.xtest.BaseTestCase;
import org.junit.jupiter.api.Test;
import org.tests.model.basic.Order;
import org.tests.model.basic.ResetBasicData;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Fetching only the id of a *ToOne association should not require a join - the
* foreign key column is already present on the owning table (issue #3643).
*/
public class TestFetchIdOnly extends BaseTestCase {
@Test
public void test_withFetchPath() {
ResetBasicData.reset();
Query<Order> query = DB.find(Order.class)
.apply(PathProperties.parse("status,customer(id)"));
query.findList();
assertSql(query.getGeneratedSql()).contains("select t0.id, t0.status, t0.kcustomer_id from o_order t0");
}
@Test
public void test_withSelect() {
ResetBasicData.reset();
Query<Order> query = DB.find(Order.class)
.select("status, customer");
query.findList();
assertSql(query.getGeneratedSql()).contains("select t0.id, t0.status, t0.kcustomer_id from o_order t0");
}
@Test
public void test_withFetch() {
ResetBasicData.reset();
Query<Order> query = DB.find(Order.class)
.select("status")
.fetch("customer", "id");
query.findList();
assertSql(query.getGeneratedSql()).contains("select t0.id, t0.status, t0.kcustomer_id from o_order t0");
}
@Test
public void test_withFetch_whenIncludesMoreThanId_expectJoin() {
ResetBasicData.reset();
Query<Order> query = DB.find(Order.class)
.select("status")
.fetch("customer", "id, name");
query.findList();
assertThat(query.getGeneratedSql()).contains("join");
}
}
@@ -0,0 +1,70 @@
package org.tests.query;
import io.ebean.DB;
import io.ebean.xtest.BaseTestCase;
import jakarta.persistence.EntityNotFoundException;
import org.junit.jupiter.api.Test;
import org.tests.model.basic.Customer;
import org.tests.model.basic.ResetBasicData;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Tests for {@code findOneOrThrow()} - a convenience alternative to
* {@code findOneOrEmpty().orElseThrow(...)} that produces a decent default message
* when the query is effectively a find-by-id or single natural/unique key lookup.
*/
class TestFindOneOrThrow extends BaseTestCase {
@Test
void findOneOrThrow_whenFound_returnsBean() {
ResetBasicData.reset();
Customer existing = DB.find(Customer.class).setMaxRows(1).findList().get(0);
Customer found = DB.find(Customer.class).setId(existing.getId()).findOneOrThrow();
assertThat(found.getId()).isEqualTo(existing.getId());
}
@Test
void findOneOrThrow_whenNotFoundById_messageIncludesId() {
ResetBasicData.reset();
assertThatThrownBy(() -> DB.find(Customer.class).setId(999999).findOneOrThrow())
.isInstanceOf(EntityNotFoundException.class)
.hasMessage("Customer not found for id: 999999");
}
@Test
void findOneOrThrow_whenNotFoundBySingleEqPredicate_messageIncludesPredicate() {
ResetBasicData.reset();
assertThatThrownBy(() -> DB.find(Customer.class)
.where().eq("name", "NonExistentCustomerXYZ")
.findOneOrThrow())
.isInstanceOf(EntityNotFoundException.class)
.hasMessage("Customer not found for name: NonExistentCustomerXYZ");
}
@Test
void findOneOrThrow_whenNotFoundByMultiplePredicates_fallsBackToGenericMessage() {
ResetBasicData.reset();
assertThatThrownBy(() -> DB.find(Customer.class)
.where().eq("name", "NonExistentCustomerXYZ").eq("id", 999999)
.findOneOrThrow())
.isInstanceOf(EntityNotFoundException.class)
.hasMessage("Customer not found");
}
@Test
void findOneOrThrow_withSupplier_usesSuppliedException() {
ResetBasicData.reset();
assertThatThrownBy(() -> DB.find(Customer.class).setId(999999)
.findOneOrThrow(() -> new IllegalStateException("custom message")))
.isInstanceOf(IllegalStateException.class)
.hasMessage("custom message");
}
}
@@ -462,7 +462,11 @@ public class TestQueryFilterMany extends BaseTestCase {
// nested "group.name" reference forces this to a query join so the filter is applied
// as a genuine WHERE clause (not misapplied to a LEFT JOIN's ON clause)
assertThat(sql).hasSize(2);
assertSql(sql.get(1)).contains(" from contact t0 left join contact_group t1 on t1.id = t0.group_id where (t0.customer_id) in (");
if (isPostgresCompatible()) {
assertSql(sql.get(1)).contains(" from contact t0 left join contact_group t1 on t1.id = t0.group_id where (t0.customer_id) = any(");
} else {
assertSql(sql.get(1)).contains(" from contact t0 left join contact_group t1 on t1.id = t0.group_id where (t0.customer_id) in (");
}
assertSql(sql.get(1)).contains(" and t1.name = ? and t0.cretime is not null");
}
}
@@ -58,7 +58,11 @@ public class TestQueryFilterManySimple extends BaseTestCase {
// nested "customer.status" reference forces this to a query join so the filter is
// applied as a genuine WHERE clause (not misapplied to a LEFT JOIN's ON clause)
assertThat(sql).hasSize(2);
assertThat(sql.get(1)).contains("from o_order t0 join o_customer t1 on t1.id = t0.kcustomer_id where t0.order_date is not null and (t0.kcustomer_id) in (");
if (isPostgresCompatible()) {
assertThat(sql.get(1)).contains("from o_order t0 join o_customer t1 on t1.id = t0.kcustomer_id where t0.order_date is not null and (t0.kcustomer_id) = any(");
} else {
assertThat(sql.get(1)).contains("from o_order t0 join o_customer t1 on t1.id = t0.kcustomer_id where t0.order_date is not null and (t0.kcustomer_id) in (");
}
assertThat(sql.get(1)).contains(" and t1.status = ?");
}
}
@@ -75,9 +75,9 @@ public class TestSubQuery extends BaseTestCase {
Query<OrderDetail> debugSq = sq.copy();
debugSq.findSingleAttribute();
if (isPostgresCompatible()) {
assertThat(debugSq.getGeneratedSql()).isEqualTo("select t1.id from o_order_detail t0 join o_order t1 on t1.id = t0.order_id where t0.product_id = any(?)");
assertThat(debugSq.getGeneratedSql()).isEqualTo("select t0.order_id from o_order_detail t0 where t0.product_id = any(?)");
} else {
assertSql(debugSq.getGeneratedSql()).isEqualTo("select t1.id from o_order_detail t0 join o_order t1 on t1.id = t0.order_id where t0.product_id in (?)");
assertSql(debugSq.getGeneratedSql()).isEqualTo("select t0.order_id from o_order_detail t0 where t0.product_id in (?)");
}
Query<Order> query = DB.find(Order.class).select("shipDate").where().isIn("id", sq).query();
+2 -2
View File
@@ -15,8 +15,8 @@ mvn -T 4 clean package
mvn -T 4 deploy -pl '!composites,!platforms' -Pcentral -DskipTests
## git commit, git tag, git push --tags
git commit -am 'Version 18.2.0'
git tag 18.2.0
git commit -am 'Version 18.3.0'
git tag 18.3.0
git push --tags
### convert to javax
+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.11</ebean-annotation.version>
<ebean-ddl-runner.version>2.3</ebean-ddl-runner.version>
<ebean-migration-auto.version>1.2</ebean-migration-auto.version>
<ebean-migration.version>14.3.0</ebean-migration.version>
+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>
@@ -34,6 +34,7 @@ interface Constants {
String DTO_PATH = "io.ebean.annotation.DtoPath";
String DTO_REF = "io.ebean.annotation.DtoRef";
String DTO_CONVERT = "io.ebean.annotation.DtoConvert";
String DTO_CONVERTERS = "io.ebean.annotation.DtoConverters";
String DTO_MIXIN = "io.ebean.annotation.DtoMixin";
String DTO_CONVERTER_MANAGER = "io.ebean.DtoConverterManager";
String METAINF_SERVICES_DTOMAPPERREGISTER = "META-INF/services/io.ebean.config.DtoMapperRegister";
@@ -18,6 +18,7 @@ class DtoBeanMeta {
private final List<DtoPropertyMeta> properties = new ArrayList<>();
private final List<DtoMapperVariant> variants = new ArrayList<>();
private DtoBuilderMeta builderMeta;
private DtoSetterMeta setterMeta;
/** Cycle detection state - see {@link DtoMappingReader#checkForCycles()}. */
enum Visit { WHITE, GRAY, BLACK }
@@ -32,11 +33,13 @@ class DtoBeanMeta {
*/
private boolean nestedElsewhere;
DtoBeanMeta(TypeElement source, TypeElement target, String mapperPackage) {
DtoBeanMeta(TypeElement source, TypeElement target, String mapperPackage, String mapperName) {
this.source = source;
this.target = target;
this.mapperPackage = mapperPackage;
this.mapperShortName = target.getSimpleName().toString() + "Mapper";
this.mapperShortName = mapperName != null && !mapperName.isEmpty()
? mapperName
: target.getSimpleName() + "Mapper";
this.mapperFullName = mapperPackage + "." + mapperShortName;
}
@@ -97,6 +100,20 @@ class DtoBeanMeta {
this.builderMeta = builderMeta;
}
/**
* The detected+selected setter-based construction path for this target, or {@code null} if not
* applicable - see {@link DtoMappingReader#resolveSetter(DtoBeanMeta, String)}. Mutually
* exclusive with {@link #builderMeta()} - a builder, when selected, always takes priority (see
* {@link DtoMappingReader#resolveAndValidate()}).
*/
DtoSetterMeta setterMeta() {
return setterMeta;
}
void setterMeta(DtoSetterMeta setterMeta) {
this.setterMeta = setterMeta;
}
/**
* Return the distinct instance-dispatch {@code @DtoConvert} converters used by this mapper's
* properties (static-dispatch converters need no field/constructor wiring, so are excluded),
@@ -36,7 +36,7 @@ final class DtoMapperVariant {
return suffix;
}
/** Dto field names (of {@code NESTED_ONE}/{@code NESTED_MANY} properties) this variant excludes. */
/** Dto field names ({@code NESTED_ONE}/{@code NESTED_MANY}, or non-primitive {@code SCALAR}) this variant excludes. */
Set<String> excludedProperties() {
return excludedProperties;
}
@@ -41,6 +41,7 @@ class DtoMapperWriter {
writeConstructors();
writeFetchGroupMethod();
writeMapMethod();
writeMapToBuilderMethod();
if (!meta.variants().isEmpty()) {
writeBuildMethod();
writeVariantAccessors();
@@ -60,6 +61,9 @@ class DtoMapperWriter {
Set<String> allReferenced = new LinkedHashSet<>();
allReferenced.add(meta.sourceFullName());
allReferenced.add(meta.targetFullName());
if (meta.builderMeta() != null) {
allReferenced.add(meta.builderMeta().builderTypeFullName());
}
for (DtoPropertyMeta property : meta.properties()) {
if (property.nested() != null) {
allReferenced.add(property.nested().sourceFullName());
@@ -104,9 +108,16 @@ class DtoMapperWriter {
if (!meta.converterDeps().isEmpty()) {
imports.add("io.ebean.DtoConverterManager");
}
if (variableProperties().stream().anyMatch(p -> p.kind() == DtoPropertyMeta.Kind.NESTED_MANY)) {
if (meta.properties().stream().anyMatch(DtoPropertyMeta::usesMapperSupport)) {
imports.add("io.ebean.DtoMapperSupport");
}
if (variableProperties().stream().anyMatch(DtoPropertyMeta::isListTarget)
|| meta.properties().stream().anyMatch(p -> p.isIgnored() && p.isListTarget())) {
imports.add("java.util.List");
}
if (meta.builderMeta() != null) {
addImportIfNeeded(imports, meta.builderMeta().builderTypeFullName());
}
for (String imp : imports) {
writer.append("import %s;", imp).eol();
}
@@ -216,30 +227,66 @@ class DtoMapperWriter {
private List<String> fetchGroupChainCalls(Set<String> excludedFieldNames) {
List<DtoPropertyMeta> activeProperties = new ArrayList<>();
for (DtoPropertyMeta property : meta.properties()) {
if (!excludedFieldNames.contains(property.dtoFieldName())) {
if (!excludedFieldNames.contains(property.dtoFieldName()) && !property.isIgnored()) {
activeProperties.add(property);
}
}
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));
} else if (property.kind() == DtoPropertyMeta.Kind.SCALAR && property.isListTarget()
&& !property.hasComputedSegment() && property.sourcePropertyPath().size() == 1) {
// a single-segment SCALAR property whose DTO field is a List with no registered nested
// DTO mapping of its own (e.g. @DtoConvert reducing a ToMany association) - still fully
// fetches its association path via a bare fetch(path) call below, so any other property
// targeting a sub-path of the same association must not also try a narrower select.
nestedAssocPaths.add(property.sourcePropertyPath().get(0));
}
}
Set<String> rootSelect = new LinkedHashSet<>();
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));
if (property.isListTarget()) {
// a single-segment path whose DTO field type is a List, but with no registered
// nested DTO mapping of its own (e.g. a @DtoConvert-backed property reducing a
// ToMany association to a simpler element type) - the source side is still a real
// ToMany association, which needs an actual fetch/join (like NESTED_MANY below),
// not a plain root select() (that only works for scalar columns on the base table).
fetchCalls.add(String.format("fetch(\"%s\")", path.get(0)));
} else {
rootSelect.add(path.get(0));
}
} else {
String fetchPath = String.join(".", path.subList(0, path.size() - 1));
if (nestedAssocPaths.contains(fetchPath)) {
@@ -254,6 +301,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 +318,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)));
@@ -340,7 +415,18 @@ class DtoMapperWriter {
writer.append(" if (source == null) {").eol();
writer.append(" return null;").eol();
writer.append(" }").eol();
if (!variableProps.isEmpty()) {
if (meta.builderMeta() != null) {
// the base (full) mapping is always exactly mapToBuilder(...).build() - shares that one
// chain rather than re-emitting a second copy of it here, whether or not named variants
// exist (build(...), below, is only ever needed for a named variant's own partial view)
if (meta.nestedElsewhere()) {
writer.append(" // dedup using DtoMapContext, same %s instance can be reached via more than one path in the graph", sourceShort).eol();
writer.append(" return context.computeIfAbsent(%s.class, source, s -> mapToBuilder(s, context).build());", targetShort).eol();
} else {
writer.append(" // DtoMapContext for nested mappers only").eol();
writer.append(" return mapToBuilder(source, context).build();").eol();
}
} else if (!variableProps.isEmpty()) {
// named variants exist - route construction through the shared build(...) method, this
// (base) mapping includes every variable property (each still evaluated inline, at its own
// declared position, inside build() - see buildCallArgs())
@@ -354,43 +440,137 @@ class DtoMapperWriter {
}
} else if (meta.nestedElsewhere()) {
writer.append(" // dedup using DtoMapContext, same %s instance can be reached via more than one path in the graph", sourceShort).eol();
writeConstructionExpression(
String.format(" return context.computeIfAbsent(%s.class, source, s -> ", targetShort),
"s", variableProps, ")");
writeConstructionExpression(true, variableProps);
} else if (nestedProperties().isEmpty()) {
writer.append(" // skip DtoMapContext, only ever a top-level mapping").eol();
writeConstructionExpression(" return ", "source", variableProps, "");
writeConstructionExpression(false, variableProps);
} else {
writer.append(" // DtoMapContext for nested mappers only").eol();
writeConstructionExpression(" return ", "source", variableProps, "");
writeConstructionExpression(false, variableProps);
}
writer.append(" }").eol();
}
/**
* Emit the target construction expression - either a positional constructor call or, when
* {@link DtoBeanMeta#builderMeta()} is present (see {@code @DtoMapping(builder = ...)}), a
* {@code Target.builder()....build()} fluent chain. {@code linePrefix} is written before the
* expression starts (e.g. {@code " return "} or a {@code computeIfAbsent(...)} lambda
* opener); {@code wrapperClosing} is any additional closing needed for an enclosing call this
* expression is nested inside (e.g. {@code ")"} to close an enclosing
* {@code computeIfAbsent(...)} call, or {@code ""} for a plain {@code return}) - the trailing
* {@code ";"} is always added here. Every property - including ones in {@code variableProps} -
* is evaluated inline, in true {@link DtoBeanMeta#properties()} declared order; a variable
* property's expression is simply guarded by its {@code includeXxx} boolean parameter (see
* {@link #writeBuildMethod()}), so no property's evaluation is ever hoisted out of its declared
* position (only non-empty when named variants exist - see {@link #variableProperties()}).
* Additionally expose a {@code mapToBuilder(source)} accessor - returning the target's detected
* builder (see {@link DtoBeanMeta#builderMeta()}) one step before its final {@code build()}
* call - only when the target is constructed via a builder in the first place. Lets a caller set
* an {@code @DtoIgnore} property's real value (typically sourced from another query/service call
* entirely outside this mapper's own fetch graph) before finishing construction, e.g.
* {@code mapper.mapToBuilder(source).assignedMachines(loadMachines(source)).build()}.
* <p>
* When named variants exist, the actual guarded builder chain (each variable property behind
* its own {@code includeXxx} ternary) is written exactly once, in a private flagged overload -
* both this public (always-full) accessor and the shared {@link #writeBuildMethod()} (used by
* each variant's own partial view) delegate to it, rather than each keeping their own copy.
*/
private void writeConstructionExpression(String linePrefix, String rootVariable, Set<DtoPropertyMeta> variableProps, String wrapperClosing) {
String targetShort = Split.shortName(meta.targetFullName());
private void writeMapToBuilderMethod() {
if (meta.builderMeta() == null) {
writer.append("%snew %s(", linePrefix, targetShort).eol();
writeConstructionArgs(rootVariable, variableProps, ")" + wrapperClosing + ";");
} else {
writer.append("%s%s.builder()", linePrefix, targetShort).eol();
writeBuilderChain(rootVariable, variableProps);
writer.append(" .build()%s;", wrapperClosing).eol();
return;
}
String sourceShort = Split.shortName(meta.sourceFullName());
String targetShort = Split.shortName(meta.targetFullName());
String builderShort = meta.builderMeta().builderTypeShortName();
Set<DtoPropertyMeta> variableProps = variableProperties();
writer.append(" /**").eol();
writer.append(" * Map to the target's builder, one step before its final build() call - lets a caller").eol();
writer.append(" * set an @DtoIgnore property's real value before finishing construction.").eol();
writer.append(" */").eol();
writer.append(" public %s mapToBuilder(%s source) {", builderShort, sourceShort).eol();
writer.append(" return mapToBuilder(source, new DtoMapContext());").eol();
writer.append(" }").eol().eol();
writer.append(" public %s mapToBuilder(%s source, DtoMapContext context) {", builderShort, sourceShort).eol();
writer.append(" if (source == null) {").eol();
writer.append(" return null;").eol();
writer.append(" }").eol();
if (variableProps.isEmpty()) {
writer.append(" return %s.builder()", targetShort).eol();
writeBuilderChain("source", variableProps);
writer.append(" ;").eol();
} else {
// this (base) mapping always includes every variable property - delegate to the shared
// flagged overload below rather than re-emitting the chain here
writer.append(" return mapToBuilder(source, context%s);", buildCallArgs(variableProps)).eol();
}
writer.append(" }").eol().eol();
if (!variableProps.isEmpty()) {
writer.append(" private %s mapToBuilder(%s source, DtoMapContext context%s) {",
builderShort, sourceShort, buildMethodParams(variableProps)).eol();
writer.append(" return %s.builder()", targetShort).eol();
writeBuilderChain("source", variableProps);
writer.append(" ;").eol();
writer.append(" }").eol().eol();
}
}
/**
* Emit the target construction expression - a positional constructor call, a
* {@code Target.builder()....build()} fluent chain (when {@link DtoBeanMeta#builderMeta()} is
* present, see {@code @DtoMapping(builder = ...)}), or a setter-based
* {@code Target target = new Target(); target.setX(...); ...; return target;} statement block
* (when {@link DtoBeanMeta#setterMeta()} is present, see {@code @DtoMapping(setter = ...)}).
* <p>
* {@code computeIfAbsent} selects between a plain {@code return EXPR;} (the common case) and a
* {@code return context.computeIfAbsent(Target.class, source, s -> EXPR)} wrapper (only used
* when {@link DtoBeanMeta#nestedElsewhere()}, so the same source instance reached via more than
* one path in the graph maps to the same target instance) - the setter-based case needs its own
* block-lambda form (see {@link #writeSetterConstruction}) since, unlike the other two
* strategies, it's a multi-statement construction rather than a single expression. Every
* property - including ones in {@code variableProps} - is evaluated inline, in true
* {@link DtoBeanMeta#properties()} declared order; a variable property's expression is simply
* guarded by its {@code includeXxx} ternary (see {@link #writeBuildMethod()}), so no property's
* evaluation is ever hoisted out of its declared position (only non-empty when named variants
* exist - see {@link #variableProperties()}).
*/
private void writeConstructionExpression(boolean computeIfAbsent, Set<DtoPropertyMeta> variableProps) {
String targetShort = Split.shortName(meta.targetFullName());
String rootVariable = computeIfAbsent ? "s" : "source";
if (meta.setterMeta() != null) {
writeSetterConstruction(computeIfAbsent, targetShort, rootVariable, variableProps);
return;
}
String returnPrefix = computeIfAbsent
? String.format(" return context.computeIfAbsent(%s.class, source, s -> ", targetShort)
: " return ";
String closing = computeIfAbsent ? ")" : "";
if (meta.builderMeta() == null) {
writer.append("%snew %s(", returnPrefix, targetShort).eol();
writeConstructionArgs(rootVariable, variableProps, ")" + closing + ";");
} else {
writer.append("%s%s.builder()", returnPrefix, targetShort).eol();
writeBuilderChain(rootVariable, variableProps);
writer.append(" .build()%s;", closing).eol();
}
}
/**
* Emit setter-based (mutable JavaBean) construction - see {@code @DtoMapping(setter = ...)},
* {@link DtoSetterMeta}. Unlike a positional constructor call or a builder chain (both single
* expressions), this is a multi-statement local-variable-then-setter-calls block, so the
* {@code computeIfAbsent(...)}-wrapped (nested-elsewhere) case needs an explicit block lambda
* (curly braces + its own {@code return}) rather than a single continued expression.
*/
private void writeSetterConstruction(boolean computeIfAbsent, String targetShort, String rootVariable, Set<DtoPropertyMeta> variableProps) {
String indent = computeIfAbsent ? " " : " ";
if (computeIfAbsent) {
writer.append(" return context.computeIfAbsent(%s.class, source, s -> {", targetShort).eol();
}
writer.append("%s%s target = new %s();", indent, targetShort, targetShort).eol();
for (DtoPropertyMeta property : meta.properties()) {
writer.append("%starget.%s(%s);", indent, setterName(property.dtoFieldName()),
constructionValueExpression(property, rootVariable, variableProps)).eol();
}
writer.append("%sreturn target;", indent).eol();
if (computeIfAbsent) {
writer.append(" });").eol();
}
}
/** {@code referenceCode} -> {@code setReferenceCode} (standard JavaBean setter naming convention) - mirrors {@code DtoMappingReader.setterName}. */
private String setterName(String propertyName) {
return "set" + Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1);
}
private void writeConstructionArgs(String rootVariable, Set<DtoPropertyMeta> variableProps, String closing) {
@@ -420,6 +600,10 @@ class DtoMapperWriter {
* to be excluded by a variant.
*/
private String constructionValueExpression(DtoPropertyMeta property, String rootVariable, Set<DtoPropertyMeta> variableProps) {
if (property.isIgnored()) {
// permanently excluded (see DtoIgnore) - never resolved from source, regardless of variant
return defaultValueFor(property);
}
String expression = propertyValueExpression(property, rootVariable);
return variableProps.contains(property)
? includeFlagName(property) + " ? " + expression + " : " + defaultValueFor(property)
@@ -470,10 +654,25 @@ class DtoMapperWriter {
String targetShort = Split.shortName(meta.targetFullName());
writer.append(" private %s build(%s source, DtoMapContext context%s) {",
targetShort, sourceShort, buildMethodParams(variableProps)).eol();
writeConstructionExpression(" return ", "source", variableProps, "");
if (meta.builderMeta() != null) {
// reuse the very same flagged builder chain as the private mapToBuilder(...) overload
// (see writeMapToBuilderMethod()) - no second copy of it here
writer.append(" return mapToBuilder(source, context%s).build();", buildMethodArgs(variableProps)).eol();
} else {
writeConstructionExpression(false, variableProps);
}
writer.append(" }").eol().eol();
}
/** Forward each variable property's own {@code includeXxx} parameter unchanged - used when one method delegates its flags to another (e.g. {@code build()} delegating to the flagged {@code mapToBuilder(...)} overload). */
private String buildMethodArgs(Set<DtoPropertyMeta> variableProps) {
StringBuilder sb = new StringBuilder();
for (DtoPropertyMeta property : variableProps) {
sb.append(", ").append(includeFlagName(property));
}
return sb.toString();
}
private String buildMethodParams(Set<DtoPropertyMeta> variableProps) {
StringBuilder sb = new StringBuilder();
for (DtoPropertyMeta property : variableProps) {
@@ -489,12 +688,13 @@ class DtoMapperWriter {
}
/**
* The empty/default value a variant supplies for a property it excludes - {@code null} for a
* {@code NESTED_ONE}, {@code List.of()} for a {@code NESTED_MANY} (always valid since
* {@code NESTED_MANY} only ever derives from a {@code java.util.List<X>} target property).
* The empty/default value a variant supplies for a property it excludes - {@code List.of()}
* when the property's DTO field type is a {@code List} ({@link DtoPropertyMeta.Kind#NESTED_MANY},
* always list-shaped, or a {@link DtoPropertyMeta.Kind#SCALAR}/{@code @DtoConvert}-backed
* property whose field happens to be a {@code List}), {@code null} otherwise.
*/
private String defaultValueFor(DtoPropertyMeta property) {
return property.kind() == DtoPropertyMeta.Kind.NESTED_MANY ? "List.of()" : "null";
return property.isListTarget() ? "List.of()" : "null";
}
/**
@@ -504,6 +704,13 @@ class DtoMapperWriter {
* {@code DtoMapContext} identity-dedup wrapper used by the base mapping when
* {@link DtoBeanMeta#nestedElsewhere()} - named variants are only ever used as an independent,
* top-level {@code query.mapTo(...)} result, never nested inside another DTO graph.
* <p>
* When the target uses a builder, each variant additionally gets its own named
* {@code mapToBuilder<Suffix>(source, context)} accessor (e.g. {@code mapToBuilderNoFleets(...)})
* - not a raw flags parameter, and not a method on the variant's own (deliberately {@code
* private}, inaccessible-by-type-from-outside) inner class - {@code mapToBuilder(...)} isn't
* part of the {@link DtoMapper} interface (builder types vary per target), so there'd be no way
* for external code to call it on a {@code DtoMapper<SOURCE, TARGET>}-typed variant view at all.
*/
private void writeVariantAccessors() {
Set<DtoPropertyMeta> variableProps = variableProperties();
@@ -516,11 +723,35 @@ class DtoMapperWriter {
writer.append(" return %s;", variant.fieldName()).eol();
writer.append(" }").eol().eol();
}
if (meta.builderMeta() != null) {
for (DtoMapperVariant variant : meta.variants()) {
writeVariantMapToBuilderMethods(variant, variableProps, sourceShort);
}
}
for (DtoMapperVariant variant : meta.variants()) {
writeVariantInnerClass(variant, variableProps, sourceShort, targetShort);
}
}
/**
* Write one named variant's {@code mapToBuilder<Suffix>(source, context)} (+ single-arg
* convenience overload) - delegates to the same shared, once-written flagged
* {@code mapToBuilder(source, context, includeXxx...)} overload (see
* {@link #writeMapToBuilderMethod()}) with this variant's own excluded properties fixed to
* {@code false}, so no property's chain expression is ever duplicated per variant.
*/
private void writeVariantMapToBuilderMethods(DtoMapperVariant variant, Set<DtoPropertyMeta> variableProps, String sourceShort) {
String builderShort = meta.builderMeta().builderTypeShortName();
writer.append(" /** {@code mapToBuilder(...)} for the {%s} variant - see {@link #%s()}. */",
variant.name(), variant.name()).eol();
writer.append(" public %s mapToBuilder%s(%s source, DtoMapContext context) {", builderShort, variant.suffix(), sourceShort).eol();
writer.append(" return mapToBuilder(source, context%s);", variantBuildArgs(variant, variableProps)).eol();
writer.append(" }").eol().eol();
writer.append(" public %s mapToBuilder%s(%s source) {", builderShort, variant.suffix(), sourceShort).eol();
writer.append(" return mapToBuilder%s(source, new DtoMapContext());", variant.suffix()).eol();
writer.append(" }").eol().eol();
}
private void writeVariantInnerClass(DtoMapperVariant variant, Set<DtoPropertyMeta> variableProps, String sourceShort, String targetShort) {
writer.append(" private final class %s implements DtoMapper<%s, %s> {", variant.innerClassName(), sourceShort, targetShort).eol().eol();
writer.append(" @Override").eol();
@@ -52,6 +52,16 @@ class DtoMappingReader {
/** {@code builder()} attribute value of each target's base (unnamed) registration. */
private final Map<String, String> builderModeByTarget = new LinkedHashMap<>();
/** {@code setter()} attribute value of each target's base (unnamed) registration. */
private final Map<String, String> setterModeByTarget = new LinkedHashMap<>();
/**
* {@code @DtoConverters}-registered type-pair conversion methods, keyed by
* {@link #converterKey(TypeMirror, TypeMirror)} (exact parameter type -> exact return type) -
* see {@link #collectTypeConverters(RoundEnvironment)}/{@link #autoTypeConverter}.
*/
private final Map<String, ExecutableElement> typeConverters = new LinkedHashMap<>();
private static final class RawVariant {
private final Element declaringElement;
private final TypeElement source;
@@ -108,6 +118,7 @@ class DtoMappingReader {
}
}
collectMixins(roundEnv);
collectTypeConverters(roundEnv);
}
/**
@@ -135,10 +146,100 @@ 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);
}
}
/**
* Discover package/module-level {@code @DtoConverters({ConverterType.class, ...})}
* declarations (proactively, via {@code roundEnv.getElementsAnnotatedWith(...)} - same
* rationale as {@link #collectMixins}: a package-level annotation isn't tied to any specific
* field being resolved, so it can't be discovered lazily the way a per-property
* {@code @DtoConvert} is). Indexes every public, single-parameter, non-{@code void}-returning
* method declared directly on each referenced type by its exact (parameter type, return type)
* pair - see {@link #autoTypeConverter}.
*/
private void collectTypeConverters(RoundEnvironment roundEnv) {
TypeElement annotationType = ctx.elementUtils().getTypeElement(Constants.DTO_CONVERTERS);
if (annotationType == null) {
return;
}
for (Element element : roundEnv.getElementsAnnotatedWith(annotationType)) {
DtoConvertersPrism prism = DtoConvertersPrism.getInstanceOn(element);
if (prism == null) {
continue;
}
for (TypeMirror converterTypeMirror : prism.value()) {
TypeElement converterType = asTypeElement(converterTypeMirror);
if (converterType == null) {
ctx.logError(element, "@DtoConverters value() must be class types");
continue;
}
registerTypeConverterMethods(converterType);
}
}
}
/**
* Index every public, single-parameter, non-{@code void}-returning method declared directly on
* {@code converterType} by its exact (parameter type, return type) pair, logging an error for
* any pair that would otherwise ambiguously match more than one registered method (across this
* or any other {@code @DtoConverters}-registered type).
*/
private void registerTypeConverterMethods(TypeElement converterType) {
for (ExecutableElement method : ElementFilter.methodsIn(converterType.getEnclosedElements())) {
if (!method.getModifiers().contains(Modifier.PUBLIC) || method.getParameters().size() != 1
|| method.getReturnType().getKind() == TypeKind.VOID) {
continue;
}
TypeMirror paramType = method.getParameters().get(0).asType();
TypeMirror returnType = method.getReturnType();
String key = converterKey(paramType, returnType);
ExecutableElement existing = typeConverters.get(key);
if (existing != null) {
ctx.logError(method, "@DtoConverters: ambiguous type-pair conversion (%s -> %s) - %s.%s and %s.%s"
+ " both match this exact type pair; only one registered method per type pair is allowed",
paramType, returnType, ((TypeElement) existing.getEnclosingElement()).getQualifiedName(),
existing.getSimpleName(), converterType.getQualifiedName(), method.getSimpleName());
continue;
}
typeConverters.put(key, method);
}
}
/**
* Auto-apply a {@code @DtoConverters}-registered type-pair conversion when {@code explicit} is
* {@code null} (no per-property {@code @DtoConvert} present) and {@code sourceType} doesn't
* already exactly match {@code targetType}. Matched by exact type equality on both sides - no
* widening/supertype matching - so which method (if any) applies stays fully predictable. Never
* overrides an explicit per-property {@code @DtoConvert} - only ever consulted when
* {@code explicit} is already {@code null}.
*/
private DtoConverterMeta autoTypeConverter(DtoConverterMeta explicit, TypeMirror sourceType, TypeMirror targetType) {
if (explicit != null || sourceType == null || typeConverters.isEmpty()
|| ctx.typeUtils().isSameType(sourceType, targetType)) {
return explicit;
}
ExecutableElement method = typeConverters.get(converterKey(sourceType, targetType));
if (method == null) {
return null;
}
TypeElement converterType = (TypeElement) method.getEnclosingElement();
boolean isStatic = method.getModifiers().contains(Modifier.STATIC);
return new DtoConverterMeta(converterType.getQualifiedName().toString(), method.getSimpleName().toString(), isStatic);
}
private String converterKey(TypeMirror paramType, TypeMirror returnType) {
return paramType.toString() + "->" + returnType.toString();
}
private void addAnnotatedElements(RoundEnvironment roundEnv, String annotationTypeName, Set<Element> elements) {
TypeElement annotationType = ctx.elementUtils().getTypeElement(annotationTypeName);
if (annotationType != null) {
@@ -187,6 +288,7 @@ class DtoMappingReader {
resolveVariants();
for (DtoBeanMeta meta : byTargetName.values()) {
resolveBuilder(meta, builderModeByTarget.get(meta.targetFullName()));
resolveSetter(meta, setterModeByTarget.get(meta.targetFullName()));
}
List<DtoBeanMeta> result = excludeCycles();
markNestedElsewhere(result);
@@ -197,7 +299,8 @@ class DtoMappingReader {
* Attach each raw {@code @DtoMapping(name = ..., exclude = ...)} variant registration (see
* {@link #register}) to its base mapping's {@link DtoBeanMeta}, validating that a base exists,
* variant names are unique per target, the variant's source matches the base's source, and
* each excluded name resolves to a {@code NESTED_ONE}/{@code NESTED_MANY} property.
* each excluded name resolves to a {@code NESTED_ONE}/{@code NESTED_MANY} property, or a
* non-primitive {@code SCALAR} property.
*/
private void resolveVariants() {
for (var entry : variantsByTarget.entrySet()) {
@@ -232,9 +335,12 @@ class DtoMappingReader {
/**
* Validate and resolve one variant's {@code exclude()} property names against {@code meta}'s
* already-resolved properties - only {@code NESTED_ONE}/{@code NESTED_MANY} properties can be
* excluded (there's no type-safe "absent" value for an arbitrary scalar type). Returns
* {@code null} (with error(s) already logged) if anything doesn't resolve.
* already-resolved properties - a {@code NESTED_ONE}/{@code NESTED_MANY} property can always be
* excluded (falls back to {@code null}/{@code List.of()}); a plain {@link DtoPropertyMeta.Kind#SCALAR}
* property (e.g. a {@code @DtoConvert}-backed property with no registered nested DTO mapping of
* its own) can also be excluded as long as its DTO field type isn't a Java primitive - there's
* no type-safe "absent" value for those. Returns {@code null} (with error(s) already logged) if
* anything doesn't resolve.
*/
private Set<String> resolveExcludedProperties(DtoBeanMeta meta, RawVariant raw) {
if (raw.exclude().isEmpty()) {
@@ -250,18 +356,36 @@ class DtoMappingReader {
ctx.logError(raw.declaringElement(), "@DtoMapping(name = \"%s\") exclude(\"%s\") does not match"
+ " any property on target %s", raw.name(), propName, meta.targetFullName());
ok = false;
} else if (property.kind() != DtoPropertyMeta.Kind.NESTED_ONE && property.kind() != DtoPropertyMeta.Kind.NESTED_MANY) {
} else if (property.isIgnored()) {
ctx.logError(raw.declaringElement(), "@DtoMapping(name = \"%s\") exclude(\"%s\") on target %s is"
+ " not a nested ToOne/ToMany DTO property - only nested properties can be excluded (there's"
+ " no type-safe \"absent\" value for an arbitrary scalar type)", raw.name(), propName, meta.targetFullName());
+ " already @DtoIgnore - it's permanently excluded from every mapping already, remove it from"
+ " this variant's exclude() list", raw.name(), propName, meta.targetFullName());
ok = false;
} else if (isExcludable(property)) {
result.add(propName);
} else if (property.kind() == DtoPropertyMeta.Kind.SCALAR) {
ctx.logError(raw.declaringElement(), "@DtoMapping(name = \"%s\") exclude(\"%s\") on target %s"
+ " is a primitive property - there's no type-safe \"absent\" value for it, so it can't be"
+ " excluded from a named variant", raw.name(), propName, meta.targetFullName());
ok = false;
} else {
result.add(propName);
ctx.logError(raw.declaringElement(), "@DtoMapping(name = \"%s\") exclude(\"%s\") on target %s is"
+ " not a nested ToOne/ToMany DTO property or a non-primitive scalar - only those can be"
+ " excluded (there's no type-safe \"absent\" value for an arbitrary scalar type)", raw.name(), propName, meta.targetFullName());
ok = false;
}
}
return ok ? result : null;
}
/** {@code true} if {@code property} can be excluded from a named variant - see {@link #resolveExcludedProperties}. */
private boolean isExcludable(DtoPropertyMeta property) {
if (property.kind() == DtoPropertyMeta.Kind.NESTED_ONE || property.kind() == DtoPropertyMeta.Kind.NESTED_MANY) {
return true;
}
return property.kind() == DtoPropertyMeta.Kind.SCALAR && !property.isPrimitiveTarget();
}
private DtoPropertyMeta findProperty(DtoBeanMeta meta, String name) {
for (DtoPropertyMeta property : meta.properties()) {
if (property.dtoFieldName().equals(name)) {
@@ -337,6 +461,111 @@ class DtoMappingReader {
return new DtoBuilderMeta(builderType.getQualifiedName().toString());
}
/**
* Decide (and, if applicable, detect) the setter-based construction path for {@code meta} - see
* {@code @DtoMapping(setter = ...)} and {@link DtoSetterMeta}. A no-op if a builder was already
* selected for {@code meta} (see {@link #resolveBuilder}) - a builder always takes priority.
* {@code NEVER} always uses a positional constructor. {@code ALWAYS} requires a matching
* no-arg-constructor-plus-setters shape to be found (a codegen error otherwise). {@code AUTO}
* (the default) only attempts detection when the target has no positional constructor matching
* the mapped properties (arity-based - see {@link #hasPositionalConstructor}), silently falling
* back to a positional constructor call if no matching setter shape is found either.
*/
private void resolveSetter(DtoBeanMeta meta, String setterMode) {
if (meta.builderMeta() != null) {
return;
}
String mode = (setterMode == null || setterMode.isEmpty()) ? "AUTO" : setterMode;
if ("NEVER".equals(mode)) {
return;
}
boolean required = "ALWAYS".equals(mode);
if (!required && hasPositionalConstructor(meta.target(), meta.properties().size())) {
return;
}
DtoSetterMeta setter = detectSetter(meta, required);
if (setter != null) {
meta.setterMeta(setter);
}
}
/**
* Detect a plain mutable-JavaBean shape on {@code meta.target()}: a public no-arg constructor
* plus a void {@code setXxx(...)} setter for every one of {@code meta}'s properties. Returns
* {@code null} if any part of that shape is missing - logging a codegen error only when
* {@code required} (i.e. {@code setter = ALWAYS}); silent otherwise (i.e. {@code setter = AUTO},
* where a missing shape just means "use a positional constructor instead").
*/
private DtoSetterMeta detectSetter(DtoBeanMeta meta, boolean required) {
TypeElement target = meta.target();
if (!hasPublicNoArgConstructor(target)) {
if (required) {
ctx.logError(target, "@DtoMapping(setter = ALWAYS) on target %s but no public no-arg"
+ " constructor was found", meta.targetFullName());
}
return null;
}
for (DtoPropertyMeta property : meta.properties()) {
if (findSetter(target, property.dtoFieldName()) == null) {
if (required) {
ctx.logError(target, "@DtoMapping(setter = ALWAYS) on target %s but no public"
+ " \"%s(...)\" setter method was found", meta.targetFullName(), setterName(property.dtoFieldName()));
}
return null;
}
}
return new DtoSetterMeta();
}
private boolean hasPublicNoArgConstructor(TypeElement type) {
for (ExecutableElement ctor : ElementFilter.constructorsIn(type.getEnclosedElements())) {
if (ctor.getParameters().isEmpty() && ctor.getModifiers().contains(Modifier.PUBLIC)) {
return true;
}
}
return false;
}
/** {@code true} if {@code type} has any declared constructor with exactly {@code arity} parameters - the shape a positional constructor call assumes. */
private boolean hasPositionalConstructor(TypeElement type, int arity) {
for (ExecutableElement ctor : ElementFilter.constructorsIn(type.getEnclosedElements())) {
if (ctor.getParameters().size() == arity) {
return true;
}
}
return false;
}
/**
* Find a public single-arg {@code setXxx(...)} method on {@code type} - either {@code void}
* or fluent-style (returning {@code type} itself, e.g. {@code public Target setXxx(...) { ...;
* return this; }}). Either shape is accepted since the generated code always calls the setter
* as a bare statement and discards any return value.
*/
private ExecutableElement findSetter(TypeElement type, String propertyName) {
String setterName = setterName(propertyName);
for (ExecutableElement method : ElementFilter.methodsIn(type.getEnclosedElements())) {
if (method.getSimpleName().contentEquals(setterName) && method.getParameters().size() == 1
&& method.getModifiers().contains(Modifier.PUBLIC) && isVoidOrFluentReturn(method, type)) {
return method;
}
}
return null;
}
/** {@code true} if {@code method} returns {@code void}, or returns {@code declaringType} itself (fluent-style setter). */
private boolean isVoidOrFluentReturn(ExecutableElement method, TypeElement declaringType) {
TypeMirror returnType = method.getReturnType();
return returnType.getKind() == TypeKind.VOID
|| (returnType.getKind() == TypeKind.DECLARED
&& ctx.typeUtils().isSameType(ctx.typeUtils().erasure(returnType), ctx.typeUtils().erasure(declaringType.asType())));
}
/** {@code referenceCode} -> {@code setReferenceCode} (standard JavaBean setter naming convention). */
private String setterName(String propertyName) {
return "set" + Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1);
}
private ExecutableElement findStaticNoArgMethod(TypeElement type, String name) {
for (ExecutableElement method : ElementFilter.methodsIn(type.getEnclosedElements())) {
if (method.getSimpleName().contentEquals(name) && method.getModifiers().contains(Modifier.STATIC)
@@ -393,8 +622,10 @@ class DtoMappingReader {
return;
}
String mapperPackage = resolveMapperPackage(prism, targetType, declaringElement);
byTargetName.put(targetFqn, new DtoBeanMeta(sourceType, targetType, mapperPackage));
String mapperName = prism.mapperName() == null ? "" : prism.mapperName().trim();
byTargetName.put(targetFqn, new DtoBeanMeta(sourceType, targetType, mapperPackage, mapperName));
builderModeByTarget.put(targetFqn, prism.builder());
setterModeByTarget.put(targetFqn, prism.setter());
} else {
variantsByTarget.computeIfAbsent(targetFqn, t -> new ArrayList<>())
.add(new RawVariant(declaringElement, sourceType, name, prism.exclude()));
@@ -442,63 +673,217 @@ class DtoMappingReader {
private DtoPropertyMeta resolveProperty(VariableElement field, DtoBeanMeta meta) {
String name = field.getSimpleName().toString();
DtoIgnorePrism ignorePrism = prismOn(field, meta, DtoIgnorePrism::getInstanceOn);
DtoConverterMeta converter = resolveConverter(field, meta);
DtoRefPrism refPrism = prismOn(field, meta, DtoRefPrism::getInstanceOn);
DtoPathPrism pathPrism = prismOn(field, meta, DtoPathPrism::getInstanceOn);
if (ignorePrism != null) {
return resolveIgnoredProperty(field, meta, name, converter, refPrism, pathPrism);
}
if (refPrism != null && pathPrism != null) {
ctx.logError(field, "%s.%s carries both @DtoRef and @DtoPath - these are mutually exclusive"
+ " (@DtoRef always wins silently otherwise), remove whichever doesn't apply",
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, false, false);
}
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;
TypeElement lastOwnerType = null;
String lastGetter = 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;
}
lastOwnerType = currentType;
lastGetter = getter;
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).
boolean isListTarget = listElementType(field.asType()) != null;
DtoConverterMeta pathConverter = isListTarget ? converter
: autoTypeConverter(converter, lastOwnerType != null ? getterReturnTypeMirror(lastOwnerType, lastGetter) : null, field.asType());
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.SCALAR, getters, properties, null, pathConverter,
field.asType().getKind().isPrimitive(), pathPrism.failOnNull(), computedFrom >= 0, requiredFetchPaths,
isListTarget, false);
}
TypeMirror fieldType = field.asType();
TypeMirror listElementType = listElementType(fieldType);
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);
String getter = getterName(meta.source(), name);
DtoConverterMeta scalarConverter = listElementType != null ? converter
: autoTypeConverter(converter, getterReturnTypeMirror(meta.source(), getter), fieldType);
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.SCALAR, List.of(getter), List.of(name), null, scalarConverter,
fieldType.getKind().isPrimitive(), listElementType != null);
}
/**
* Resolve a {@code @DtoIgnore} property - permanently excluded from every mapping (base and
* every named variant alike), always given its empty default rather than resolved from any
* source getter/path at all (see {@code DtoIgnore}'s javadoc). Validates it isn't combined with
* {@code @DtoRef}/{@code @DtoPath}/{@code @DtoConvert} (all pointless - there's no source
* expression to convert/redirect when nothing is ever read from the source) and isn't a
* primitive-typed field (no type-safe "absent" value for it).
*/
private DtoPropertyMeta resolveIgnoredProperty(VariableElement field, DtoBeanMeta meta, String name,
DtoConverterMeta converter, DtoRefPrism refPrism, DtoPathPrism pathPrism) {
if (converter != null || refPrism != null || pathPrism != null) {
ctx.logError(field, "@DtoIgnore on %s.%s cannot be combined with @DtoConvert/@DtoRef/@DtoPath -"
+ " an @DtoIgnore property is never resolved from the source at all, so there's no source"
+ " expression for those to act on. Remove @DtoIgnore or the other annotation(s).",
meta.targetFullName(), name);
}
if (field.asType().getKind().isPrimitive()) {
ctx.logError(field, "@DtoIgnore cannot be applied to primitive-typed property %s.%s - there's"
+ " no type-safe \"absent\" value for it", meta.targetFullName(), name);
}
return DtoPropertyMeta.ignored(name, listElementType(field.asType()) != null);
}
/**
* {@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);
}
}
/**
@@ -544,15 +929,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 +1039,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 +1089,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,83 @@ 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;
private final boolean listTarget;
private final boolean ignored;
DtoPropertyMeta(String dtoFieldName, Kind kind, List<String> sourceGetterPath, List<String> sourcePropertyPath, DtoBeanMeta nested) {
this(dtoFieldName, kind, sourceGetterPath, sourcePropertyPath, nested, null);
}
/**
* {@code @DtoIgnore} factory - a property permanently excluded from every mapping (base and
* every named variant alike), always given its empty default rather than resolved from any
* source getter/path at all - see {@code DtoIgnore}'s javadoc. Kept as {@link Kind#SCALAR} with
* empty getter/property paths since {@link DtoMapperWriter} short-circuits on {@link #isIgnored()}
* before ever consulting them.
*/
static DtoPropertyMeta ignored(String dtoFieldName, boolean listTarget) {
return new DtoPropertyMeta(dtoFieldName, Kind.SCALAR, List.of(), List.of(), null, null, false, false, false, List.of(), listTarget, true);
}
/**
* {@code NESTED_ONE}/{@code NESTED_MANY} constructor variant for a single-hop {@code @DtoPath}
* rename that traverses a computed/derived getter segment (no backing field) - see
* {@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, false, false);
}
DtoPropertyMeta(String dtoFieldName, Kind kind, List<String> sourceGetterPath, List<String> sourcePropertyPath, DtoBeanMeta nested, DtoConverterMeta converter) {
this(dtoFieldName, kind, sourceGetterPath, sourcePropertyPath, nested, converter, false, false);
}
/**
* Plain (no {@code @DtoPath}) {@link Kind#SCALAR} constructor variant that also records whether
* the DTO field type is a {@code java.util.List} - see {@code listTarget} on the full
* constructor.
*/
DtoPropertyMeta(String dtoFieldName, Kind kind, List<String> sourceGetterPath, List<String> sourcePropertyPath,
DtoBeanMeta nested, DtoConverterMeta converter, boolean primitiveTarget, boolean listTarget) {
this(dtoFieldName, kind, sourceGetterPath, sourcePropertyPath, nested, converter, primitiveTarget, false, false, List.of(), listTarget, false);
}
/**
* 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. {@code listTarget} is {@code true} when this property's DTO
* field type is a {@code java.util.List} - relevant only for {@link Kind#SCALAR} (e.g. a
* {@code @DtoConvert}-backed {@code List} property with no registered nested DTO mapping of its
* own, like a fleet list populated from ad-hoc SQL) - see
* {@code DtoMapperWriter#defaultValueFor}.
*/
DtoPropertyMeta(String dtoFieldName, Kind kind, List<String> sourceGetterPath, List<String> sourcePropertyPath,
DtoBeanMeta nested, DtoConverterMeta converter, boolean primitiveTarget, boolean failOnNull,
boolean computedSegment, List<String> requiredFetchPaths, boolean listTarget, boolean ignored) {
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;
this.listTarget = listTarget;
this.ignored = ignored;
}
String dtoFieldName() {
@@ -79,11 +144,70 @@ 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;
}
/**
* {@code true} when this property's DTO field type is a Java primitive - a primitive property
* has no type-safe "absent" value, so can never be excluded by a named
* {@code @DtoMapping(name = ..., exclude = ...)} variant.
*/
boolean isPrimitiveTarget() {
return primitiveTarget;
}
/**
* {@code true} when this property's DTO field type is a {@code java.util.List} - set for
* {@link Kind#NESTED_MANY} (always list-shaped) and also for a {@link Kind#SCALAR}/
* {@code @DtoConvert}-backed property whose DTO field happens to be a {@code List} with no
* registered nested DTO mapping of its own (e.g. a fleet list populated from ad-hoc SQL) -
* used to pick {@code List.of()} rather than {@code null} as the excluded/empty default for a
* named variant, see {@code DtoMapperWriter#defaultValueFor}.
*/
boolean isListTarget() {
return listTarget || kind == Kind.NESTED_MANY;
}
/**
* {@code true} when this property is marked {@code @DtoIgnore} - permanently excluded from
* every mapping (base and every named variant alike), always given its empty default rather
* than resolved from any source getter/path.
*/
boolean isIgnored() {
return ignored;
}
/**
* Real entity paths that must be added to the {@code FetchGroup} to support this property's
* computed/derived getter segment - the real prefix path (if any) followed by the declared
* {@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 +215,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 +245,4 @@ class DtoPropertyMeta {
sb.append(')');
}
}
@@ -0,0 +1,35 @@
package io.ebean.querybean.generator;
/**
* Marks a detected setter-based (mutable JavaBean) construction path for a {@link DtoBeanMeta}'s
* target - see {@code @DtoMapping(setter = ...)}.
* <p>
* Detected via the plain JavaBean convention (the shape JAXB/XSD-generated legacy SOAP types
* commonly follow): a public no-arg constructor plus a public {@code setXxx(propertyType)} setter
* for every one of the target's mapped properties - either {@code void} or fluent-style
* (returning the target type itself, e.g. {@code public Target setXxx(...) { ...; return this; }})
* are both accepted, since the generated code always calls the setter as a bare statement and
* discards any return value. When present (and either explicitly
* requested via {@code setter = ALWAYS}, or no builder was selected and the target has no
* positional constructor matching the mapped properties under {@code setter = AUTO}), the
* generated mapper constructs the target via {@code Target target = new Target();
* target.setX(...); ...; return target;} instead of a positional constructor call or a builder
* chain.
* <p>
* A pure marker (mirrors {@link DtoBuilderMeta}) - unlike a builder, a setter-constructed target
* needs no separate intermediate type name, since the setters are called directly on the target
* type itself.
* <p>
* Deliberately no {@code mapToBuilder(...)}-style post-construction accessor is generated for this
* strategy: the returned target is already the final, fully mutable instance (its setters are
* required to be {@code public} - see above), so a caller can already override an
* {@code @DtoIgnore}/derived property directly, e.g.
* {@code Ebox ebox = mapper.map(source); ebox.setMachineSummaryInfo(loadSummary(source));} -
* exactly the pattern already used by hand-written mappers like central-access's
* {@code EboxMapper}. This is unlike the builder strategy, where the intermediate builder object
* is distinct from (and otherwise inaccessible after) the final, one-shot-{@code build()}ed,
* often-immutable target - {@code mapToBuilder(...)} exists there specifically to expose that
* otherwise-unreachable intermediate step.
*/
final class DtoSetterMeta {
}
@@ -42,6 +42,7 @@ public class Processor extends AbstractProcessor implements Constants {
annotations.add(DTO_MAPPING);
annotations.add(DTO_MAPPING_LIST);
annotations.add(DTO_MIXIN);
annotations.add(DTO_CONVERTERS);
return annotations;
}
@@ -5,7 +5,9 @@
@GeneratePrism(io.ebean.annotation.DtoRef.class)
@GeneratePrism(io.ebean.annotation.DtoMapping.class)
@GeneratePrism(io.ebean.annotation.DtoConvert.class)
@GeneratePrism(io.ebean.annotation.DtoConverters.class)
@GeneratePrism(io.ebean.annotation.DtoMixin.class)
@GeneratePrism(io.ebean.annotation.DtoIgnore.class)
package io.ebean.querybean.generator;
import io.avaje.prism.GeneratePrism;
@@ -0,0 +1,887 @@
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);
}
}
/**
* A named variant may exclude a plain non-primitive {@code SCALAR} property (not just
* {@code NESTED_ONE}/{@code NESTED_MANY}) - see docs/dto-mapping-requirements.md "Section G"
* follow-up - but a primitive-typed scalar property still can't be excluded (no type-safe
* "absent" value).
*/
@Test
void excludeVariant_onPrimitiveScalarProperty_expectCompileError() throws IOException {
Path sourceDir = Files.createTempDirectory("dto-variant-primitive-src");
Path outDir = Files.createTempDirectory("dto-variant-primitive-out");
writeSource(sourceDir, "org.tests.variantprimitive.Foo",
"package org.tests.variantprimitive;\n"
+ "\n"
+ "public class Foo {\n"
+ " private String name;\n"
+ " private int score;\n"
+ "\n"
+ " public String getName() { return name; }\n"
+ " public int getScore() { return score; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.variantprimitive.FooDto",
"package org.tests.variantprimitive;\n"
+ "\n"
+ "public class FooDto {\n"
+ " private final String name;\n"
+ " private final int score;\n"
+ "\n"
+ " public FooDto(String name, int score) {\n"
+ " this.name = name;\n"
+ " this.score = score;\n"
+ " }\n"
+ "\n"
+ " public String getName() { return name; }\n"
+ " public int getScore() { return score; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.variantprimitive.package-info",
"@DtoMapping(source = Foo.class, target = FooDto.class)\n"
+ "@DtoMapping(source = Foo.class, target = FooDto.class, name = \"noScore\", exclude = \"score\")\n"
+ "package org.tests.variantprimitive;\n"
+ "\n"
+ "import io.ebean.annotation.DtoMapping;\n");
DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, Locale.getDefault(), null)) {
List<Path> sourceFiles;
try (var walk = Files.walk(sourceDir)) {
sourceFiles = walk.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList());
}
Iterable<? extends JavaFileObject> compilationUnits =
fileManager.getJavaFileObjectsFromPaths(sourceFiles);
List<String> options = List.of(
"-d", outDir.toString(),
"-classpath", System.getProperty("java.class.path"),
"-processor", Processor.class.getName());
JavaCompiler.CompilationTask task = compiler.getTask(
null, fileManager, diagnostics, options, null, compilationUnits);
boolean success = task.call();
assertFalse(success, "compilation should fail excluding a primitive scalar property from a variant");
List<Diagnostic<? extends JavaFileObject>> errors = diagnostics.getDiagnostics().stream()
.filter(d -> d.getKind() == Diagnostic.Kind.ERROR)
.collect(Collectors.toList());
assertFalse(errors.isEmpty(), "expected at least one compile ERROR diagnostic");
boolean matched = errors.stream()
.map(d -> d.getMessage(Locale.getDefault()))
.anyMatch(msg -> msg.contains("primitive property") && msg.contains("can't be"));
assertTrue(matched, "expected the primitive-scalar-exclusion error message, got: " + errors);
}
}
}
+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>
@@ -5,6 +5,8 @@ import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import java.util.UUID;
@Entity
public class Contact extends Model {
@@ -39,6 +41,13 @@ public class Contact extends Model {
*/
private String secretCode;
/**
* Stand-in for a common recurring type pair (e.g. {@code UUID <-> String}) - exercises
* {@code @DtoConverters} package-level type-pair auto-dispatch (requirement r21), where no
* per-property {@code @DtoConvert} is needed at all unless overriding the registered default.
*/
private UUID referenceCode;
public Contact(String firstName, String lastName, Customer customer) {
this.firstName = firstName;
this.lastName = lastName;
@@ -104,4 +113,12 @@ public class Contact extends Model {
public void setSecretCode(String secretCode) {
this.secretCode = secretCode;
}
public UUID getReferenceCode() {
return referenceCode;
}
public void setReferenceCode(UUID referenceCode) {
this.referenceCode = referenceCode;
}
}
@@ -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,34 @@
package org.tests.dtomapping;
/**
* Plain DTO mapped from {@link org.tests.dtomapping.model.Address} - registered with an explicit
* {@code mapperName()} override (see {@code package-info.java}) so the generated mapper doesn't
* collide with the pre-existing hand-written {@link AddressSummaryDtoMapper} class of the default
* expected name - mirroring the real-world scenario that motivated {@code mapperName()}
* (central-access's legacy hand-written {@code FleetMapper} occupying the name a generated
* {@code CFleet -> Fleet} mapper would otherwise need).
*/
public class AddressSummaryDto {
private final Long id;
private final String line1;
private final String city;
public AddressSummaryDto(Long id, String line1, String city) {
this.id = id;
this.line1 = line1;
this.city = city;
}
public Long getId() {
return id;
}
public String getLine1() {
return line1;
}
public String getCity() {
return city;
}
}
@@ -0,0 +1,17 @@
package org.tests.dtomapping;
/**
* Stand-in for a pre-existing hand-written mapper class that already occupies the default
* generated-mapper name ({@code AddressSummaryDtoMapper}, derived from the {@code
* AddressSummaryDto} target's simple name). Deliberately unrelated to {@code io.ebean.DtoMapper} -
* its only purpose is to occupy the name so the {@code @DtoMapping(..., mapperName = "...")}
* override registered in {@code package-info.java} is proven necessary (without the override,
* codegen would emit a second, conflicting {@code AddressSummaryDtoMapper} class and fail to
* compile).
*/
public class AddressSummaryDtoMapper {
public static String legacyDescribe() {
return "legacy hand-written mapper - not the generated one";
}
}
@@ -0,0 +1,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;
}
}

Some files were not shown because too many files have changed in this diff Show More