Compare commits

...
Author SHA1 Message Date
robin.bygrave 47ef0deffb ORM Update - add usingTransaction() to support using explicit transaction 2026-07-21 10:10:00 +12:00
Rob Bygraveandrobin.bygrave 0d0ec79f1e SqlUpdate - add usingTransaction() to support using explicit transaction (#3867)
Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-21 09:59:23 +12:00
Rob Bygraveandrobin.bygrave 1737ae13db Modify SqlQuery.TypedQuery to implement FindableQuery (common interface) (#3866)
Add usingConnection() and canel() to support common FindableQuery interface

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-20 23:27:51 +12:00
Rob Bygraveandrobin.bygrave 17b641b163 MappedQuery - add findEach() findEachWhile() to MappedQuery (and promote to StreamableQuery) (#3865)
* MappedQuery - add findEach() findEachWhile() to MappedQuery (and promote to StreamableQuery)

* MappedQuery - add findEach() findEachWhile() to MappedQuery (and promote to StreamableQuery)

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-20 23:12:37 +12:00
Rob Bygraveandrobin.bygrave 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 Bygrave 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 Bygrave 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] 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 Bygrave 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 Bygrave 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 Bygrave 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 Bygrave 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 Bygrave 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 Bygrave 5317fb0d5c Merge pull request #3856 from ebean-orm/feature/dto-failOnNull
dto mapping: Add @DtoPath( failOnNull=true) option for null -> primit…
2026-07-16 19:27:15 +12:00
robin.bygrave 022958f417 dto mapping: need ebean-annotation 8.7 2026-07-16 18:56:54 +12:00
robin.bygrave 3431eee81a dto mapping: Add @DtoPath( failOnNull=true) option for null -> primitive handling 2026-07-16 18:26:40 +12:00
robin.bygrave 470326830a Bump test versions to 18.3.0 2026-07-16 18:25:17 +12:00
Rob Bygraveandrobin.bygrave b6225b6ae4 InsertOnConflict - change to exclude non-updatable and generated on insert only (e.g. @WhenCreated) (#3855)
* Version 18.3.0

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

---------

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

* DtoQuery - Support DtoPath renaming a ToOne or ToMany

* MappedQuery - Add findStream() support

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-16 17:04:22 +12:00
robin.bygrave d083b27e91 Version 18.3.0 2026-07-16 16:57:52 +12:00
Rob Bygraveandrobin.bygrave 30314f163d DtoQuery - add usingMaster, usingTransaction, usingConnection options. (#3853)
Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-16 15:50:07 +12:00
robin.bygrave b065b030bc Bump ebean-agent to 18.3.0 2026-07-16 10:33:29 +12:00
robin.bygrave 837ba91126 Tests: Improve Redis testing - flush redis after test runs 2026-07-16 09:55:26 +12:00
robin.bygrave e18c664c98 Tests: Improve RedissonCacheFactoryTest 2026-07-16 08:54:17 +12:00
Rob Bygraveandrobin.bygrave 636bb28df3 #2540 Add DTO mapping from Entity beans via generated mapper code (#3852)
* #2540 Add DTO mapping from Entity beans via generated mapper code

A large feature, refer to the docs / guides etc. This is a bit like MapStruct in that it generates mapping code via annotation processing but it ALSO creates an optimised FetchGroup for the destination DTO graph and it also implements a generic interface such that it can be fluently used via query.mapTo().

The value is that it automates the mapping code from entity -> dto plus optimises the query used to support building the DTO graph.

Expected to be better to use than MapStruct for ebean orm users.

* Bump ebean-annotation to 8.6 with the dto annotations

* Adjust the build order to via test dependency on ebean-api

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-16 08:40:41 +12:00
Rob Bygraveandrobin.bygrave 84a311e521 Bug: filterMany() expressions when additional nested fetch has predicates in wrong place (#3851)
* Bug: filterMany() expressions when additional nested fetch has predicates in wrong place

When a filterMany() expression only references the many-root's own properties (e.g. .eq("status", ...)),
but the query also has an additional nested fetch beneath that many-path (e.g. .fetch("orders.customer")
or .fetch("contacts.group")), the filter predicate was incorrectly attached to the last/deepest join's
ON clause instead of the many-root's own join — silently misapplying the filter.

Fix: A hybrid approach:

- If the filterMany expression references only root-level properties → attach the predicate directly on the many-root's own join (correct).

 - If it references deeper/nested properties requiring "extra joins" (a separate existing mechanism) → fall back to the original end-of-subtree behavior, since no SqlTreeNode exists to attach to.

 - includeFilterMany() made idempotent so both mechanisms coexist safely.

* Bug: filterMany() with nested-property predicates silently ineffective
filterMany() expressions referencing a nested/dotted property (e.g.
.eq("group.name", "x"), alone or mixed with root properties) were
attached to a LEFT JOIN's ON clause via the "extra join" mechanism.

That only nulls the extra join's own columns on non-match - it does
not exclude the parent/many-side row, so the predicate had no actual
filtering effect (confirmed by data, not just SQL shape).

## Fix:

force any many-property fetch whose filterMany expression
references a nested property onto a query-join (secondary select
restoring filterMany's original always-fetchQuery behaviour for this
with a genuine WHERE clause) instead of leaving it as an inline JOIN, case.

- SpiExpressionValidation: track all visited property names (allProperties()), not just unknown ones.
- OrmQueryProperties: filterManyHasNestedProperty() walks the filterMany expression for any dotted property reference.
- OrmQueryDetail.markQueryJoins(): route nested-property filterMany chunks to markForQueryJoin() instead of the inline fetch-join slot, without consuming that slot for other many-paths.

Also simplifies the earlier root-only-predicate join-placement fix
(CQueryPredicates/DefaultDbSqlContext/SqlTreeNodeManyRoot) now that
a nested-property filterMany can never reach the JOIN-based code
path: removed the now-unreachable "deepest path" fallback branch,
the includeFilterMany() idempotency guard, and the redundant fallback
call in SqlTreeNodeManyRoot - isFilterManyAttachPoint() remains as
the single, always-used attach mechanism.

Updated TestQueryFilterMany/TestQueryFilterManySimple assertions to
expect the corrected query-join (2 statement) behaviour.

* Simplify SpiExpressionValidation with boolean nestedProperty flag

* Tidy up comments

* Fix OrmQueryProperties.filterManyHasNestedProperty() with real nested property check

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-15 23:16:18 +12:00
Rob Bygraveandrobin.bygrave 791ac13750 Fix findCount()/exists() wrapping a raw CTE (WITH clause) for SQL Server (#3850)
SQL Server does not support a WITH clause (CTE) nested inside a subquery
or derived table - only at the start of a statement. buildRowCountQuery()
and buildExistsQuery() wrap raw sql as `select count(*) from (<sql>)` /
`select case when exists(<sql>) then ...`, which breaks when <sql> starts
with a CTE header (e.g. RawSqlBuilder.withPlaceholders() queries), causing
"Incorrect syntax near the keyword 'with'" on SQL Server.

Add CQueryBuilder.topLevelSelectStart()/splitCteHeader() to detect and
hoist a leading WITH clause in front of the wrapping SELECT rather than
wrapping it along with the rest of the query. This is a no-op for the
common case (no leading CTE) and is portable across platforms.

- ebean-core: CQueryBuilder - hoist leading CTE header in
  wrapSelectCount()/wrapSelectExists()

- Add unit tests in CQueryBuilderTest reproducing the exact failing SQL

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-14 15:10:57 +12:00
Rob Bygraveandrobin.bygrave d54a24b27d #3848 Fix regression for Sql Server and Oracle with exists() introduced in 18.2.0 (#3849)
* #3848 Fix regression for Sql Server and Oracle with exists() introduced in 18.2.0

Fix #3848: exists() generates invalid scalar SQL on SQL Server and Oracle

Query.exists() generated `select exists(<subquery>)`, which is valid on
Postgres/H2/MySQL but rejected by SQL Server and Oracle since both only
support EXISTS as a predicate, not as a directly selectable scalar boolean
expression.                                                                                                                                                                              ┃
                                                                                                                                                                                            ┃
Add DatabasePlatform.existsWithCaseWhen/existsFromClause capability flags
and use them in CQueryBuilder.wrapSelectExists() to generate
`select case when exists(<subquery>) then 1 else 0 end` on platforms that                                                                                                                ┃
need it, with an additional ` from dual` suffix for Oracle (which requires                                                                                                               ┃
a FROM clause on every select). CQueryExists reads the boolean result via                                                                                                                ┃
ResultSet.getBoolean(1), which correctly interprets the resulting 0/1 int.

* Fix tests TestInsertCheckUnique for Oracle and SQL Server

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-14 10:13:16 +12:00
Rob Bygraveandrobin.bygrave 73c9ff8d80 Throw exception on duplicate Database registration (#3838) (#3847)
* Throw exception on duplicate Database registration (#3838)

DatabaseFactory.create() with register(true) previously returned the
existing Database instance when another instance was already registered
under the same name (added in #3759). This silently caused two
independently created Database instances (e.g. with different
DataSourceConfig.url values) to collide/share state, since the
second "instance" was actually the first one in disguise.

Change this to throw an IllegalStateException instead, forcing
callers to either use a unique DatabaseConfig name or setRegister(false)
when the Database is not intended to be registered/looked up by name.

* Also handle deregistration on shutdown

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-14 00:34:49 +12:00
Rob Bygraveandrobin.bygrave bdbe743f56 #3275: Fix NPE when update/delete with @IdClass mapping (#3846)
## Root cause:

For @IdClass composite keys, the "id" is cached only in EntityBeanIntercept.ownerId(), populated only when reading from DB or during insert derivation. A fresh bean instance passed straight to DB.update()/DB.delete() had this null, causing an NPE in BindableIdEmbedded.dmlBind().

## Fix:

dmlBind() now defensively derives the id (reusing the existing insert-time derivation logic, extracted into a shared deriveId() helper) when it's null but derivable — covering both update and delete, since they share the same DML bind path.

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-13 23:10:54 +12:00
Rob Bygraveandrobin.bygrave ed2026b858 #3110: Fix cache key with enum type (#3845)
## Root cause:

Finder.byId() on a @Cache entity converts the id to a String cache key via IdBinder.cacheKey() = scalarType.format(value). For enums, format() returns .name() (e.g. "APPROVED"), not the db-mapped value. On a  cache hit, BeanDescriptorCacheHelp.loadBeanDirect() converts that cache-key string back via desc.convertId() → IdBinderSimple.convertId() → scalarType.toBeanType(), which expects a db value (e.g. "2" for @EnumValue("2")),  not the enum's Java name — causing Integer.parseInt("APPROVED") to crash in EnumToDbIntegerMap.getBeanValue().

## Fix:

IdBinderSimple.convertId() now special-cases String input to use scalarType.parse() (the true inverse of  format()) instead of toBeanType() (the inverse of toJdbcType()/db-value). This mirrors an existing, already-correct pattern in IdBinderEmbedded.convertId(), which already handles String cache-key round-tripping via parse(). Verified  this doesn't affect other id types (Long/Integer/etc.) since parse() and toBeanType() are equivalent for them — the mismatch only exists for Enum-mapped types.

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-13 22:42:16 +12:00
Rob Bygraveandrobin.bygrave 8d21f91f62 #2477: Support @DbArray inside an @Embeddable (#3844)
Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-13 21:45:11 +12:00
Rob Bygraveandrobin.bygrave 6d1d58b8c2 #1989: Add findPagedList() support to DtoQuery (when based on orm query) (#3843)
Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-13 20:34:25 +12:00
Rob Bygraveandrobin.bygrave a943ee9225 #2263: MySql DDL - generate inline comments for MySql DDL generation (#3842)
Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-13 15:56:06 +12:00
Rob Bygraveandrobin.bygrave 94f252f423 #1700: Add support for @OrderColumn on @ManyToMany (#3841)
Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-13 15:55:03 +12:00
Rob Bygrave 275f8ad9f9 #2393: @OrderColumn was not maintained for @ElementCollection — no order column populated on insert and no order by on fetch. (#3840)
## Root cause:
AnnotationAssocManys only parsed @OrderColumn inside the @OneToMany branch, and even if parsed, BeanDescriptorManager.checkMappedByOneToMany() returned early for element collections before reaching the order-column setup. The element collection's synthetic descriptor also isn't registered in deployInfoMap, so it couldn't reuse the existing @OneToMany lookup path.

## Fix (in ebean-core):

- BeanDescriptorManager: extracted makeOrderColumn(DeployBeanPropertyAssocMany, DeployBeanDescriptor) so the synthetic order property can be built against an explicit target descriptor, not just one looked up via deployInfoMap.

- AnnotationAssocManys.readElementCollection(): now reads @OrderColumn, sets fetchOrderBy, and builds the order property directly on the element descriptor before it's converted to a runtime BeanDescriptor.

- SaveManyElementCollection: binds a sequential 0-based index as the order column value on insert (element collections delete-then-reinsert the whole collection in list order, so this is a natural fit).

DDL generation and the fetch order by needed no changes - both already generalize over any BeanDescriptor's order column property once it exists.

Added EcolPerson / TestElementCollectionOrderColumn covering insert population, order preserved on reload, and order by present on fetch-join SQL.

Known limitation: @OrderColumn on a Map-based element collection (SaveManyElementCollectionMap) is not wired up - not part of this issue and not a standard JPA combination (Maps use @MapKeyColumn).
2026-07-13 15:11:49 +12:00
Rob Bygrave 9ac3bd71f6 Issue #2840: @DbJson/@DbJsonB Map<String,Object> fields ignored mutationDetection and always used ModifyAware-wrapper dirty checking — so NONE (and HASH) had no effect, unlike plain Object-typed @DbJson fields which correctly honored these modes. (#3839)
Root cause: The built-in ScalarTypeJsonMap/List/Set types (used for Map<String,Object>, simple List/Set) hard-coded mutable()=true and isDirty() to always do a ModifyAware check, regardless of the configured MutationDetection. Only SOURCE accidentally worked because it happened to route through BeanPropertyJsonMapper.

 Fix (in ebean-core, io.ebeaninternal.server.type):

 - ScalarTypeJsonValue now derives mutable (false only for NONE) and keepSource/jsonMapper() (true for HASH/SOURCE) from the property's MutationDetection, instead of a single hard-coded keepSource boolean.

- ScalarTypeJsonMap, ScalarTypeJsonMapEnum, ScalarTypeJsonList, ScalarTypeJsonSet, and ScalarTypeJsonCollectionValue now accept/pass through MutationDetection instead of a raw boolean.

- DefaultTypeManager.dbJsonType() passes the property's own mutationDetection() straight through for these collection types (per your choice: DEFAULT still always means ModifyAware for collections — only an explicit NONE/HASH/SOURCE annotation changes behavior; the DatabaseConfig-wide default is not applied to these types).

- @DbArray fallback call sites (PlatformArrayTypeJsonList/Set) updated to pass MutationDetection.DEFAULT, preserving unchanged behavior.
2026-07-13 10:24:25 +12:00
Rob Bygraveandrobin.bygrave bb46c88db1 Fix findVersions() unbound bind parameters on sql2011 history platforms (#3837)
On standards-based platforms (MariaDB, SQL Server, Oracle, DB2, HANA) the root table always generates a 'for system_time between ? and ?' clause for TemporalMode.VERSIONS, but bind values were only supplied  for findVersionsBetween(), leaving findVersions() with 2 unbound placeholders and shifted bind positions ("Parameter at position N is not set").

 Match the bind guard to the SQL generation condition and default start/end to epoch/now when not explicit.

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-07 21:05:22 +12:00
Rob Bygraveandrobin.bygrave 28e6108315 Bump mysql driver to mysql-connector-j (#3836)
Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-07 10:17:09 +12:00
robin.bygrave c20d1cdf9b Fix CI test execution with redis reused across ebean-redis and ebean-redisson modules 2026-07-07 10:09:46 +12:00
robin.bygrave 007409d21c Bump postgresql to 42.7.11 2026-07-07 09:21:14 +12:00
Rob Bygrave 8b61069b3f #3441 Handle type coercion in hits against bean cache (#3835) 2026-07-06 22:04:09 +12:00
Rob BygraveandRoland Praml a82dcb2509 Fix #3041 check uniqueness cache skipclean (#3834)
* checkUniqueness supports queryCache and skipClean

* Keep the DB.checkUniqueness() as per original (not overload there)

---------

Co-authored-by: Roland Praml <roland.praml@foconis.de>
2026-07-06 22:03:52 +12:00
Rob BygraveandRoland Praml 08bb170cb2 Fix #3156 inherited property access (#3833)
* Properties of inherited models can be used on different child models

* Adjust typos only

---------

Co-authored-by: Roland Praml <roland.praml@foconis.de>
2026-07-06 21:08:35 +12:00
Rob Bygraveandrobin.bygrave a2f954a60e #3529 - Fix for M2M property is empty in preDelete of BeanPersistAdapter (#3830)
Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-06 19:47:37 +12:00
Andrey Glushkov 1d654e350b ebean-redisson - initial commit (#3711) (#3829)
* ebean-redisson - initial commit

* Thread.sleep(150) in IntegrationTest - ebean-redisson

* Fix two tenant-aware cache bugs in ebean-redisson + add test coverage

CacheCodec.getMapKeyDecoder() was returning only the tenant portion of
"id:tenantId" Redis field names (substring after the first colon).  This
caused every tenant-aware getAll() call to miss: the decoded key did not
match the lookup key, so the DuelCache near-cache warm-up path also
failed to populate.

RedissonCache.getAll() was returning String keys instead of the original
key objects passed in.  This broke the ServerCache contract and caused
DuelCache.near.putAll() to store entries under plain strings, making all
subsequent near.get(originalId) calls miss even when the remote cache had
the data.

Additional issues found while reviewing against ebean-redis:
- RServerCacheNotify.notify() was calling listener.notify() locally,
  causing a second redundant cache invalidation on the originating node
  (ebean-redis does not do this).
- processTableNotify() had no null-guard on listener, risking NPE if a
  remote table-mod message arrived before createCacheNotify() was called.
- errorOnWrite() was throwing RuntimeException; cache writes must be
  best-effort and only log on failure.

Tests added:
- RedissonCacheTest: direct cache tests for all operations (put/get,
  getAll, putAll, remove, removeAll, clear, statistics, TTL, maxSize trim)
- RedissonCacheFactoryTest: factory tests covering cache type creation,
  DuelCache for near caches, query-cache singleton, and cross-factory
  cluster notification
- CacheCodecTest: key encoder/decoder regression test that pins the
  "full string returned" behaviour for both plain and tenant-aware keys
- SerializableCodecTest, VersionGatedCodecTest: codec round-trip tests
- TenantAwareCacheTest: integration test that creates a tenant-aware
  Database and verifies that single-bean finds and multi-ID findList()
  calls are isolated per tenant at the Redis level

* 18.2.0 - updated version

* RedissonCache/FactoryTest: start Redis container directly in @BeforeAll

Tests were skipped whenever they ran before the integration tests triggered
DB/Redis container startup. Each test class now calls
RedissonTestFixtures.startRedis() (RedisContainer.builder("latest").start())
which is idempotent and requires no other test class to run first.
assumeTrue(isReachable()) remains as a fallback for Docker-less environments.
2026-07-06 19:43:40 +12:00
robin.bygrave 6dd1763e54 Fix test TestRawSqlWithPlaceholders for Postgres HAVING clause limitation
Postgres having can't use column alias from select clause
2026-07-06 16:42:51 +12:00
Rob Bygraveandrobin.bygrave b32f3bcad6 Fix test-java16 parent etc (#3831)
Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-06 16:23:56 +12:00
Rob Bygrave e525d4e159 Version 18.2.0 2026-07-03 19:38:05 +12:00
Rob Bygraveandrobin.bygrave 4e639cb88a Add deletePermanent() - hard delete for soft delete capable beans (#3828)
Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-03 18:23:18 +12:00
Rob Bygraveandrobin.bygrave fc78b2af80 Add RawSqlBuilder.withPlaceholders() for CTEs and other complex SQL (#3649) (#3827)
* Add RawSqlBuilder.withPlaceholders() for CTEs and other complex SQL (#3649)

RawSqlBuilder.parse() uses keyword-based scanning to locate SELECT
columns and the WHERE/HAVING injection points. This fails for CTEs,
window functions and other complex SQL where "select"/"from" keywords
appear in places the scanner doesn't expect.

withPlaceholders(sql) skips SELECT/FROM column parsing entirely and
only locates the ${where}, ${andWhere}, ${having} and ${andHaving}
placeholder positions, requiring explicit columnMapping() calls (as
with unparsed()). This lets dynamic where()/having() expressions be
injected into otherwise unparseable SQL.

Also fixes two bugs in the underlying placeholder-position splitting:
- static SQL following a ${having}/${andHaving} placeholder (e.g. a
  trailing ORDER BY) was silently dropped when both a where and a
  having placeholder were present
- using only ${having}/${andHaving} (no where placeholder) caused a
  dynamically added HAVING clause to be appended after trailing
  static SQL, producing invalid SQL

Changes:
- RawSqlBuilder.withPlaceholders(sql) + SpiRawSqlService.withPlaceholders()
- DRawSqlParser.parseAsTemplate() / parseTemplate() - placeholder-position
  only parsing, correctly splitting preWhere/preHaving/trailing SQL
- CQueryBuilderRawSql - skip column-list and "select" prefix handling
  in template mode (signalled by an empty preFrom)
- Unit tests in DRawSqlServiceTest covering where/andWhere/having/andHaving
  placeholder combinations, including the two fixed edge cases
- Integration tests in TestRawSqlWithPlaceholders (ebean-test) covering
  CTE queries with dynamic where/having and verifying generated SQL

* Add examples test using query beans

* Add docs guides for RawSql

* Add ${orderBy} ${andOrderBy}

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-03 18:22:42 +12:00
Rob Bygraveandrobin.bygrave 29647ebe1a Fire BeanPersistController when only ManyToMany collection changes (#3652) (#3826)
When only a @ManyToMany collection is modified and saved, the parent bean has no dirty scalar properties so BeanPersistController preUpdate/postUpdate were never invoked — only the junction table rows were written.

Fix mirrors the existing preElementCollectionUpdate() mechanism: add
preManyToManyUpdate() on PersistRequestBean and call it from
SaveManyBeans.saveAssocManyIntersection() when the intersection actually
changes (vanillaCollection, forcedUpdate, or a tracked BeanCollection
with non-empty additions or removals). The !dirty guard in
preManyToManyUpdate() prevents double-firing when the bean itself is
also dirty.

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-03 11:43:16 +12:00
Rob Bygraveandrobin.bygrave 38146fdce5 Support option to have always non-null @Embeddable(s) refereneces (#3825)
(#3702)

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-02 22:02:15 +12:00
Rob Bygraveandrobin.bygrave 864aff6c3b Implement query.exists() via select exists(...) (#3824)
* Implement query.exists() via select exists(...)

Previously query.exists() was implemented by running a findIds() limited to 1 row and checking whether the list was empty. This PR replaces that with a dedicated select exists(select 1 ...) execution path.

## Changes:

• CQueryExists — new query executor, analogous to CQueryCount, that runs select exists(...) and returns the boolean result directly from the JDBC ResultSet
• CQueryBuilder.buildExistsQuery() — builds select exists(select 1 ...) SQL, reusing the query plan cache on repeat calls
• CQueryEngine.findExists() / DefaultOrmQueryEngine / OrmQueryEngine interface — wire the new executor through the standard query engine stack, including SQL logging, summary logging, and query cache put
• DefaultServer.exists() — delegate to request.findExists() instead of findIds()

## Tests added to TestQueryExists:

• testExistsBoolean_returnsFalse — verifies false is returned when no rows match
• testExistsBoolean_withJoin — exercises the join SQL path in buildExistsQuery; also checks false when the join yields no match
• testExistsBoolean_queryPlanReuse — runs the same query twice and asserts the generated SQL is identical, confirming the plan cache is hit on the second call

* Fix TestQueryExists only

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-02 18:32:50 +12:00
Rob Bygraveandrobin.bygrave a161a42742 #3676 Add DbMigration.setAddForeignKeySkipCheck(true) (#3822)
Provides an option to include IDE -- @formatter:off style comments into the db migration generated sql.

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-02 17:45:32 +12:00
Rob Bygraveandrobin.bygrave 24da374aa8 Fix #3821 fetchQuery secondary tables not included in query cache dep… (#3823)
* Fix #3821 fetchQuery secondary tables not included in query cache dependent tables

When a query uses fetchQuery("path"), the secondary table(s) must be registered as dependent tables in the query cache entry so that updates to those tables properly invalidate the cache.

## Fix:
In CQueryPlan, after building the SQL-tree dependent tables, walk the query's OrmQueryDetail for any fetchQuery paths and resolve each path through the root BeanDescriptor to its target entity's baseTable(). These tables are merged into dependentTables at plan construction time (once, then cached in the plan).

This means the fix applies uniformly whether paths were specified via fetchQuery("path") calls or via query.select(fetchGroup) — both populate the same OrmQueryDetail.

### Tests added to TestQueryCacheTableDependency:

• fetchQuery_oneToMany_invalidatesQueryCacheOnSecondaryTableUpdate — updating a contact invalidates a Customer query cache that includes fetchQuery("contacts"); asserts the refreshed result contains the updated phone number

• fetchQuery_manyToOne_invalidatesQueryCacheOnSecondaryTableUpdate — updating a root record invalidates an ECacheChild query cache that includes fetchQuery("root")

* Ensure tests cleanup their test data

* query.secondaryQuery() (called during prepareQuery()) removes fetchQuery paths from OrmQueryDetail before CQueryPlan is built. The existing buildDependentTables iterated detail.entries() looking for isQueryFetch() entries — but they were already gone.

Changes:

1. OrmQueryRequest.java — added secondaryQueries() getter to expose the already-stored SpiQuerySecondary field.
2. CQueryPlan.java — rewrote buildDependentTables to accept SpiQuerySecondary instead of OrmQueryDetail. It now iterates secondary.queryJoins() (the paths already removed from detail) and adds each path's base table to the dependent tables set.

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-02 17:44:10 +12:00
Rob Bygraveandrobin.bygrave 398848378d Fix asOf() queries bypassing bean cache isolation (#3820)
* Fix asOf() queries bypassing bean cache isolation

asOf() queries must never read from or write to the bean cache since the cache holds only current state. Previously, lazy loads and secondary queries spawned by an asOf parent could return stale  current data from the cache instead of the historical snapshot.

- DefaultServer.findId(): skip bean cache check when isAsOfQuery()
  so the findById fast-path doesn't return current state for temporal queries

- DLoadContext: force useBeanCache=CacheMode.OFF when asOf != null so hitCache=false for all lazy load batch cache checks, and propagate CacheMode.OFF to secondary (+query) joins

- LoadBeanRequest.configureQuery(): when !loadCache (parent context disabled cache), explicitly set CacheMode.OFF on the lazy-load SQL query — previously left at CacheMode.AUTO which caused cache reads/writes even when the parent had cache disabled

Fixes #3713

* Simplify asOf to CacheMode.OFF

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-02 09:06:36 +12:00
Rob Bygraveandrobin.bygrave 688eee532c Fix #3817 incorrect type deserialization for @DbJson field in generic superclass (#3819)
In createJsonObjectMapperType(), remove the instanceof DeployBeanProperty
branch that overrode ownerType() with getField().getDeclaringClass().

For a generic superclass B<T>, getDeclaringClass() returns B with T
unresolved, so Jackson treated the field as Object and deserialized to
LinkedHashMap. DeployBeanProperty.ownerType() already returns
desc.getBeanType() (the concrete subclass), which gives Jackson the full
supertype context needed to resolve T correctly.

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-02 00:08:47 +12:00
Rob BygraveandIliya Ivanov 88d7b5e85a Support @ManyToOne inside @Embeddable for query predicates and joins (#3816)
* Make embeddable diggable

* WIP

* WIP

* WIP

* WIP

* WIP

* Support @ManyToOne inside @Embeddable for query predicates and joins

 Fix path resolution for @ManyToOne associations nested inside @Embeddable
 classes. Previously, a query like .where().eq("address.country.name", "NZ")
 on an entity with an @Embedded EAddr (containing @ManyToOne Country country)
 would throw PersistenceException: Embedded Property country.name not found.

 Changes:
 - BeanPropertyAssocOne.buildElPropertyValue: correctly resolve @ManyToOne
   paths inside embedded by extracting first segment, validating via
   embeddedPropsMap, then delegating deeper traversal to the overridden
   property; restore embedded flag on ElPropertyChain for scalar leaves
 - BeanPropertyAssoc.targetDescriptor(): lazy-init fix for override copies
   created by BeanEmbeddedMetaFactory (initialise() is not called on them)
 - BeanDescriptor.extraJoin(): restore !assocProp.isEmbedded() guard to
   prevent null-table ExtraJoin for composite keys and other embeddables
 - ElPropertyChainBuilder/ElPropertyChain: restore embedded field and prefix
   computation so alias resolution works correctly for paths through embedded
   (e.g. outer.datePeriod.date1 → prefix "outer" not "outer.datePeriod")
 - SqlTreeBuilder.buildExtraJoins: remove erroneous removeAll on
   orderByIncludes that broke Formula2 placeholder resolution and distinct
   on aggregation queries with many-side order-by joins
 - SqlTreeAlias.addJoin: fix indentation
 - Tests: add three test cases to TestEmbeddedManyToOne covering WHERE
   predicate through embedded FK column, through association property
   (requiring a JOIN), and combined with fetch

* Throw PersistenceException with unknown path

---------

Co-authored-by: Iliya Ivanov <i.ivanov@proforge.org>
2026-07-01 23:33:40 +12:00
Rob Bygraveandrobin.bygrave 985e3b12b1 Fix ON_CONFLICT_NOTHING cascade causing FK violation (#3712) (#3818)
When inserting a bean with InsertOptions.ON_CONFLICT_NOTHING that has cascade children (OneToMany / exported OneToOne), a unique constraint conflict caused the parent insert to be silently skipped (0 rows), but Ebean still cascaded and attempted to insert the children — resulting in a FK violation.

Changes

• BeanDescriptor.hasCascadeChildren() — returns true when the bean has save-cascade children (OneToMany or exported OneToOne) that hold a FK back to this bean.
• PersistRequestBean.setInsertOptions() — when ON_CONFLICT_NOTHING is used and the bean has cascade children, sets skipBatchForTopLevel = true so the parent INSERT executes immediately (non-batched). This ensures the row count is known before any cascade runs. Beans without cascade children are unaffected and continue to batch normally.
• PersistRequestBean.checkRowCount() — when the parent INSERT returns 0 rows under ON_CONFLICT_NOTHING, sets insertConflictSkipped = true and returns early, leaving the bean unmarked as loaded/persisted.
• DmlHandler.checkRowCount() — skips postExecute() when the insert was conflict-skipped.
• DefaultPersister.insert() — guards saveAssocMany() with !request.isInsertConflictSkipped(), preventing cascade saves when the parent was not actually inserted.

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-01 23:32:02 +12:00
Andrey Glushkov 942bab5efb Fix: DuplicateKeyException when cascade-saving through a @ManyToOne to an unmodifiable bean (address #3813) (#3814)
* Fix DuplicateKeyException with unmodifiable cascaded bean

* Fix broken test TestDeleteByQuery.maxDelete_when_beanCaching_expect_selectThenDelete
2026-07-01 22:43:33 +12:00
Rob Bygrave c114ebaec5 Merge pull request #3815 from ebean-orm/feature/add-sqlite-ioc
Sqlite: Add Insert On Conflict support (#3770)
2026-07-01 22:02:22 +12:00
robin.bygrave 9acc555e8e Sqlite: Add Insert On Conflict support (#3770) 2026-07-01 21:58:16 +12:00
Rob Bygrave 73a646e7dc Merge pull request #3812 from dragkes/feature/invalidate-one-to-one-and-collection-cache
Invalidate one to one and collection cache (address #3811)
2026-07-01 21:21:19 +12:00
Rob Bygrave bdda502d55 Merge pull request #3810 from ebean-orm/tenant-support-for-query-plans
Tenant support for query plans
2026-07-01 21:08:33 +12:00
robin.bygrave 6d447a6c1a Add back the rollback() into CQueryBindCapture 2026-07-01 21:04:59 +12:00
robin.bygrave 16872fdb30 Add back the rollback() into CQueryBindCapture 2026-07-01 21:00:19 +12:00
robin.bygrave ec9303762e Fix the CQueryPlanManager conflict edit? 2026-07-01 20:50:34 +12:00
Andrey Glushkov a59c70054b Tests 2026-07-01 11:48:28 +03:00
Andrey Glushkov 25bce83afc Merge remote-tracking branch 'origin/master' into feature/invalidate-one-to-one-and-collection-cache
# Conflicts:
#	ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanDescriptorCacheHelp.java
2026-07-01 11:46:30 +03:00
Rob Bygrave e372579b15 Merge branch 'master' into tenant-support-for-query-plans 2026-07-01 20:33:31 +12:00
robin.bygrave f470782481 Whitespace and remove excess comments only 2026-07-01 20:29:36 +12:00
Andrey Glushkov d7b9f6688a Invalidate inverse association caches on owning-side FK change 2026-07-01 11:25:27 +03:00
Rob Bygraveandrobin.bygrave 66bcfd107d Add tenant-partitioned L2 caches (#2956) (#3809)
* Add tenant-partitioned L2 caches (#2956)

 When `tenantPartitionedCache` is enabled, each tenant gets its own
 cache namespace (keys include the tenant id), improving cache-hit
 ratio by preventing cross-tenant key collisions.

 Refactor BeanDescriptorCacheHelp to abstract base class with two
 concrete subclasses:
 - BeanDescriptorCacheHelpFixed - static cache refs (original behaviour)
 - BeanDescriptorCacheHelpPartitioned - per-request Supplier<> lookup
   so the correct tenant-scoped cache is resolved on each access

 Fix background-thread invalidation: clear(name) in partitioned mode
 now scans all matching cache entries by key prefix rather than calling
 tenantProvider.currentId() which is null/wrong on background executor
 threads.

 Add SpiCacheManager.clearTenant(tenantId) to allow removal of all
 cache entries for a deactivated tenant, preventing unbounded memory
 growth in long-running multi-tenant deployments.

 Also load cache settings (cacheMaxSize, cacheMaxIdleTime, etc.) from
 application properties - these were previously missing from loadSettings().

* Fix versions in test-java16

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-01 20:04:08 +12:00
Roland Praml 1c0a811c01 Tenant support for query plans 2024-10-07 15:07:38 +02:00
411 changed files with 22370 additions and 1200 deletions
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-clickhouse</artifactId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-db2</artifactId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-h2</artifactId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-hana</artifactId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-mariadb</artifactId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-mysql</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
</dependencies>
+7 -7
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.1.0</version>
<version>18.3.0</version>
<relativePath>../..</relativePath>
</parent>
@@ -14,7 +14,7 @@
<properties>
<postgis.jdbc.version>2023.1.0</postgis.jdbc.version>
<postgres.jdbc.version>42.7.2</postgres.jdbc.version>
<postgres.jdbc.version>42.7.11</postgres.jdbc.version>
</properties>
<dependencies>
@@ -22,13 +22,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -47,19 +47,19 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-net-postgis-types</artifactId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-nuodb</artifactId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-oracle</artifactId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -47,19 +47,19 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-pgvector-types</artifactId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -47,19 +47,19 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-postgis-types</artifactId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-sqlite</artifactId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-sqlserver</artifactId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -42,13 +42,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -60,13 +60,13 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-all</artifactId>
<version>18.1.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.1.0</version>
<version>18.3.0</version>
</parent>
<artifactId>composites</artifactId>
+3 -1
View File
@@ -54,7 +54,8 @@ Ebean is an ORM library for Java and Kotlin focused on relational data access, t
| `exists()` | Efficient existence checks | `new QCustomer().email.equalTo(email).exists();` |
| `findOne()` | Unique/single-row retrieval | `new QCustomer().id.equalTo(id).findOne();` |
| `findList()` | List retrieval | `new QCustomer().findList();` |
| `asDto(...).findList()` | DTO projection reads | `new QOrder().asDto(OrderSummary.class).findList();` |
| `asDto(...).findList()` | Flat DTO projection reads | `new QOrder().asDto(OrderSummary.class).findList();` |
| `mapTo(...).findList()` | Nested DTO graph projection reads | `new QCustomer().mapTo(CustomerDto.class).findList();` |
### Entity mapping and lifecycle annotations
@@ -188,6 +189,7 @@ database.save(customer);
| Model entity beans correctly | [entity-bean-creation.md](guides/entity-bean-creation.md) |
| Use Lombok safely with entities | [lombok-with-ebean-entity-beans.md](guides/lombok-with-ebean-entity-beans.md) |
| Write type-safe query bean queries | [writing-ebean-query-beans.md](guides/writing-ebean-query-beans.md) |
| Map nested entity graphs to DTO graphs | [mapping-entity-graphs-to-dtos.md](guides/mapping-entity-graphs-to-dtos.md) |
| Persist changes and manage transactions | [persisting-and-transactions-with-ebean.md](guides/persisting-and-transactions-with-ebean.md) |
| Build test entities quickly | [testing-with-testentitybuilder.md](guides/testing-with-testentitybuilder.md) |
+990
View File
@@ -0,0 +1,990 @@
# Nested DTO Mapping — API Design
Design spike for the accepted requirements in [dto-mapping-requirements.md](./dto-mapping-requirements.md),
covering issue #2540. This captures the concrete API shape, annotations, and open-question decisions made
during design review — before implementation begins.
## Two source-vs-target mapping pipelines
Ebean now has (or will have) two distinct DTO pipelines. It's important callers can tell which one they're
using:
1. **`asDto(Dto.class)`** (existing) — a `DtoQuery`, executed directly against a flat SQL `ResultSet`.
One row -> one DTO, via constructor/setter matching. No nested ToOne/ToMany support, no entity graph
involved.
2. **`mapTo(Dto.class)`** (new) — runs the normal ORM entity query (joins/fetches as usual), producing an
*unmodifiable entity graph*, then maps that Java object graph into a DTO graph. Supports nested
ToOne/ToMany, identity-aware de-duplication, and derives its own fetch spec from the DTO shape.
## Proposed API
```java
// Existing flat DtoQuery pipeline — unchanged
new QUser().valid.eq(true)
.select(firstName, lastName)
.asDto(UserInfo.class)
.findList();
```
```java
// NEW: nested DTO graph pipeline
public class CustomerDto {
Long id;
String name;
AddressDto billingAddress; // ToOne -> nested DTO, matched by property name "billingAddress"
List<ContactDto> contacts; // ToMany -> nested DTO list, matched by property name "contacts"
@DtoPath("billingAddress.line1")
String billingLine1; // renamed / flattened path
}
public class ContactDto {
Long id;
String firstName;
String lastName;
@DtoRef
Long customerId; // id-only back-reference, avoids re-embedding CustomerDto (cycle)
}
List<CustomerDto> dtos = new QCustomer()
.status.eq(Status.ACTIVE)
.mapTo(CustomerDto.class)
.findList();
```
Computed values (e.g. `cityOrUnknown` derived from `coalesce(billingAddress.city, 'Unknown')`) are
**not** modeled with a `@Formula2` annotation directly on the DTO - that was explored and rejected
(see "Formula2-on-DTO scope" below). Instead they're modeled as a plain matching field on the DTO,
sourced from an `@Entity @View` entity that itself carries the `@Formula2` - see "Computed/aggregate
properties" below for the worked example.
`mapTo(CustomerDto.class)`:
- Introspects `CustomerDto` (recursively, at codegen time) to derive the `select(...)`/`.fetch(...)` spec
automatically from the DTO's declared shape.
- Forces `setUnmodifiable(true)` under the hood — gives fail-fast + a cheap, non-mutable source graph
(satisfies the fail-fast requirement without a separate flag).
- Runs the query, then runs a mapper over the resulting entity graph, de-duplicating DTO instances by id
for repeated nested references (identity-aware, mirrors the source entity graph's own de-duplication).
## Decisions made
### Fetch spec: auto-derived from DTO shape
The DTO's declared structure (fields, nested DTO types, `@DtoPath` overrides) is the single source of truth
for what gets selected/fetched from the database. Callers do not need to separately maintain a `.fetch(...)`
spec in parallel with the DTO — this directly addresses the original issue's pain point (DTO and query
projection drifting out of sync).
### Entry point naming: `mapTo(Dto.class)`
Chosen over `asGraph(...)` / `into(...)` / overloading `findList(Class)`. Reads clearly as "map the
resulting entity graph to this DTO type" and is unambiguous against the existing `asDto(...)` (flat,
SQL-row-based) mechanism.
### Cycle handling: codegen-time DAG check + `@DtoRef` escape hatch
Because the fetch spec and mapper are both derived from the *static* DTO type graph (not live object
traversal), cycle detection is a compile-time/codegen-time concern, not a runtime one. This is stronger
than the common approach in the ecosystem:
- **MapStruct** does not auto-detect cycles. It offers an opt-in `@Context` "cycle guard" pattern (an
identity map of already-mapped source -> target objects) that the developer must wire up manually to
avoid infinite recursion mapping bidirectional object graphs.
- **Blaze-Persistence / QueryDSL / JOOQ record mapping** avoid the problem architecturally: view/projection
types are required to be a strict tree; a back-reference is modeled as an id or a much shallower type,
never the same full view type again.
Ebean's approach: fail the build at annotation-processing time if a DTO's declared type graph is not a DAG,
with a clear error message. Provide `@DtoRef` as an explicit escape hatch for intentional back-references
(e.g. `Contact.customer`) — it maps only the id, not the full nested DTO, breaking the cycle by design
rather than by runtime guard.
### `@DtoPath` / `@DtoRef`: parallels for readers coming from MapStruct or Blaze-Persistence
Neither annotation is a novel concept - both map onto things MapStruct and Blaze-Persistence users will
already recognise, which is worth spelling out explicitly so it's easy to "grok fast":
- **`@DtoPath("billingAddress.line1")` is Ebean's equivalent of MapStruct's dot-path `source` flattening**
— e.g. `@Mapping(target = "line1", source = "billingAddress.line1")`. MapStruct auto-generates a
null-safe chain of getter calls for a dotted `source`; `@DtoPath` does exactly the same thing, just
declared on the DTO field itself rather than on a mapper method parameter list. It is also close to
Blaze-Persistence's `@Mapping("billingAddress.line1")` on an `@EntityView` attribute, which is a JPQL
path expression evaluated the same way — Blaze's placement (directly on the target view property) is
actually the closer analogue of the two, since Ebean's `@DtoPath` is likewise placed on the DTO field.
The difference from Blaze: `@DtoPath` is restricted to plain getter-chain navigation (no arbitrary JPQL/
SQL expression) - see "Formula2-on-DTO scope" below for the boundary and why full expression support is
deliberately deferred.
- **`@DtoRef` has no dedicated equivalent in either tool** - both MapStruct and Blaze would express the
same "just the id" mapping as a plain dot-path to `.id` (`@Mapping(source = "customer.id")` / Blaze
`@Mapping("customer.id")`), with no special marker for it. What `@DtoRef` adds beyond that shorthand is
*intent*: it tells the codegen this property is a deliberate cycle-breaking reference, so (a) it adds
just the association's own name (not a dotted `.id` path) to the generated fetch spec's root
`select(...)` - reading the FK column directly with no join, and skipped entirely if that same
association is already fully fetched by a `NESTED_ONE`/`NESTED_MANY` property elsewhere on the same DTO
(see `DtoMapperWriter.fetchGroupChainCalls()`'s `case REF` branch) - and (b) it participates in the
codegen-time DAG cycle check above as an explicit "this is fine, don't flag it" signal, rather than
requiring a suppression escape hatch bolted on afterwards.
**Bug found and fixed while building the aggregation worked example below:** the original implementation
excluded `REF` properties from the fetch spec *entirely*, on the assumption the id is "already available
off an unfetched reference without triggering a fetch/lazy load". That assumption is only true when some
*other* property on the same DTO happens to also fetch that association (as was always the case in the
existing hand-built examples). Tested directly against a bare `@ManyToOne` with no other fetch of it:
accessing `.getCustomer().getId()` in that case triggers a full lazy-reload of the owning row (extra SQL,
not free) - and for an aggregation query it's worse, since the property being grouped by must be selected
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).
**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
to `InterceptReadWrite` (the mutable/updatable variant), which additionally carries a `ReentrantLock`, four
transient collaborator references (`NodeUsageCollector`, `PersistenceContext`, `BeanLoader`,
`PreGetterCallback`), a `byte[] flags` array (per-property loaded+changed+dirty+orig-value-set state),
`Object[] origValues`, `Exception[] loadErrors`, `MutableValueInfo[]`/`MutableValueNext[]`, and several more
scalar bookkeeping fields. None of that is needed for a bean that will only ever be read, so
`setUnmodifiable(true)` graphs carry meaningfully less per-instance overhead than normal fetched entities -
relevant here because `mapTo(Dto.class)` forces `setUnmodifiable(true)` on its underlying query, making the
*source* graph for a DTO mapping cheaper than the equivalent normal (writable) entity graph would be.
### Ad-hoc computed/formula properties: model as `@Entity @View`/`@Sql`, not ad-hoc SQL-on-DTO
The "fully ad-hoc SQL-on-DTO" stretch goal above (closer to Blaze's arbitrary `@Mapping` expressions) doesn't
need to be built as a bespoke DTO-annotation-processing feature. Ebean already supports modelling read-only,
computed, or view-backed data as ordinary entities via `@Entity` + `@View` (backed by a SQL view, e.g. one
with aggregates/computed columns) or `@Entity` + `@Sql` (backed by arbitrary `RawSql`, no base table). Given
that, the Blaze-Persistence-style "an entity view attribute backed by an arbitrary SQL expression" need can
usually be satisfied by:
1. Modelling the computed/derived shape as its own `@Entity @View` (or `@Sql`) "read entity" - the SQL
expression/aggregation lives in the view definition, not in a new annotation-processed DTO mechanism.
`@View`'s `name()` doesn't have to point at a genuinely separate database view - it can just point at
an *existing* table (e.g. `@View(name = "contact")` on a second entity class reading the same table as
`Contact`) purely to mark the entity as view-like/read-only, in which case Ebean's DDL generator emits
**no new table or view at all** for it - it's just a second lens onto the same physical data.
2. Mapping *that* entity into a plain DTO using the existing, already-implemented `@DtoMapping` machinery -
no ad-hoc-SQL-on-DTO support required, since there's no computed expression left to resolve at the DTO
layer at all; it's just another entity-to-DTO mapping.
3. This read entity benefits from the same `setUnmodifiable(true)`/`InterceptReadOnly` memory efficiency
above when used purely as `mapTo(...)` input, so there's no meaningful cost to preferring this over a
hypothetical native ad-hoc-SQL-on-DTO feature.
This significantly narrows (and may eliminate) the case for a dedicated ad-hoc-SQL-on-DTO mechanism - it
remains listed as an open stretch goal below primarily for the case where a computed value's SQL is genuinely
one-off/DTO-specific and not worth promoting to a standalone `@View`/`@Sql` entity.
**Worked example** (`tests/test-dto-mapping`): `ContactSummary` is `@Entity @View(name = "contact")` (no new
DDL - reads the same table as `Contact`) with `@Formula2("concat(firstName, ' ', lastName)")` computing
`fullName`; `ContactSummaryDto` is a plain two-field DTO; `@DtoMapping(source = ContactSummary.class, target
= ContactSummaryDto.class)` generates `ContactSummaryDtoMapper` exactly like any other entity→DTO pair - the
formula property is just selected like any other field (`select("id,fullName")` in the generated
`fetchGroup()`). See `TestContactSummaryDtoMapping`.
### Aggregate/group-by computed properties: `@Sum`/`@Aggregation` as `@Entity @View`, same pattern
Ebean's `@Sum` (shorthand for `@Aggregation("sum($1)")`) and `@Aggregation("count(...)"/"sum(...)"/"avg(...)"/
"min(...)"/"max(...)")` are the group-by parallel to the formula pattern above - the same `@Entity @View`
approach applies, just with an implicit `GROUP BY` instead of a per-row computed column. Ebean auto-derives
the `GROUP BY` clause from whichever non-aggregate properties end up in the query's `select()`/`fetch()` - so
a second `@Entity @View(name = <same base table>)` entity with one or more `@Sum`/`@Aggregation` properties
plus a `@ManyToOne` grouping key becomes a per-parent rollup, with **no new table/view and no explicit
`.groupBy()` call required**. This is Ebean's parallel to Blaze-Persistence entity view correlated aggregate
mappings, e.g. `@Mapping("SIZE(contacts)")` / `@Mapping("SUM(contacts.engagementScore)")` on an `@EntityView`.
**Nuance found while building the worked example, and since fixed: `@DtoRef` originally didn't fit the
grouping key.** `@DtoRef` was originally excluded from the generated `select()`/`fetch()` spec entirely, on
the premise that the id is already available off an unfetched reference for an ordinary entity graph. That
premise doesn't hold for an aggregation query: the `@ManyToOne` *is* the property being grouped by, so if
it's never selected, the query has nothing to group by. It turned out the premise didn't fully hold for
ordinary entity graphs either - see the `@DtoRef` bug writeup above. Fixed so `@DtoRef` now adds the
association's own name to the root `select(...)` (reading the FK column directly, no join) - which both
supplies the grouping key here and fixes the general-case gap.
**Worked example** (`tests/test-dto-mapping`): `ContactStats` is `@Entity @View(name = "contact")` (no new
DDL - reads the same table as `Contact`/`ContactSummary`) with `@Aggregation("count(id)") contactCount` and
`@Sum Integer engagementScore` (a new nullable field added to `Contact` purely to have something to sum),
grouped by its `@ManyToOne customer`. `ContactStatsDto` is a flat 3-field DTO (`customerId`, `contactCount`,
`engagementScore`), with `customerId` mapped via plain `@DtoRef`. The generated `ContactStatsDtoMapper`:
```java
this.fetchGroup = FetchGroup.of(ContactStats.class)
.select("customer,contactCount,engagementScore")
.build();
...
// skip DtoMapContext, only ever a top-level mapping
return new ContactStatsDto(
(source.getCustomer() == null ? null : source.getCustomer().getId()),
source.getContactCount(),
source.getEngagementScore());
```
confirmed (via `LoggedSql`) to produce `select t0.customer_id, count(t0.id), sum(t0.engagement_score) from
contact t0 ... group by t0.customer_id` - **no join**, one row per customer, correctly summed and counted.
See `TestContactStatsDtoMapping`.
### Formula2-on-DTO scope (v1): existing entity formulas only
`@Formula2` on a DTO property in v1 only pulls in a formula **already declared on the source entity** (or
a reachable associated entity) — it does not support fully ad-hoc SQL declared directly on the DTO with no
matching entity property. Fully ad-hoc SQL-on-DTO (closer to Blaze's arbitrary `@Mapping` expressions) is a
separate, larger stretch goal to revisit once the core graph-mapping mechanism is proven.
**Attempted and rejected for v1.** A narrower version was implemented (`@Formula2(value)` resolved exactly
like `@DtoPath` - a dot-path getter chain - plus a codegen-time validation that the resolved entity property
is itself `@Formula2`/`@Formula`-annotated) but was rejected: for the common case (a DTO field with the same
name as the entity's formula property) it generated **identical code to a plain unannotated field** - the
only difference was the validation, which wasn't judged enough distinct value to justify a new annotation
surface. Not implemented. The only way `@Formula2`-on-DTO would add real value is the full ad-hoc-SQL
capability described above, which remains an open stretch goal.
### Mapper implementation strategy: codegen, not reflection (native-image constraint)
Native-image support is a core Ebean requirement, so the entity-graph -> DTO-graph mapper must not rely on
runtime reflection or `MethodHandles`. This ruled out an initial reflection-based spike:
- Ebean's existing flat `DtoQuery` (`DtoMetaConstructor`) already uses `MethodHandles` via
`Lookups.getLookup()`, but there is no `reflect-config.json` / native-image reachability metadata shipped
for it anywhere in the repo. That existing approach is not a clean precedent to copy for a bigger,
native-image-first feature.
- Instead, the approach mirrors `querybean-generator`, which already generates real `.java` source for
`Q*` query bean types (not reflection) — consistent with the wider avaje-ecosystem convention
(avaje-inject / avaje-jsonb are explicitly reflection-free via compile-time codegen).
**Implementation sequencing:** hand-write the mapper in the exact shape the annotation processor will
eventually generate (plain Java, direct getter/constructor/setter calls, zero reflection) for one concrete
example first, to validate the mapping algorithm and API shape quickly without ever introducing throwaway
reflective code. That hand-written mapper then becomes the target/acceptance-test shape for the
`querybean-generator` annotation processor that automates producing it.
### Codegen target: Java first
The mapper generation (requirement r2) targets `querybean-generator` (the existing APT module that already
generates `Q*` query beans, reusing its `PropertyMeta` / `ProcessingContext` machinery). Kotlin parity via
`kotlin-querybean-generator` is deferred to a later phase — not blocking initial delivery.
### Mapper composition: one mapper per entity/DTO pair, generic `DtoMapper<SOURCE, TARGET>` interface
Rather than one large mapper inlining every nested DTO type, each entity/DTO pair gets its own small
mapper class - mirroring MapStruct's per-type mapper generation. All mappers implement a shared generic
interface (prototyped as `org.tests.dtomapping.DtoMapper<SOURCE, TARGET>` in the spike, expected to move to
`io.ebean` as a public type once solidified):
```java
public interface DtoMapper<SOURCE, TARGET> {
TARGET map(SOURCE source);
default List<TARGET> mapList(List<SOURCE> source) { ... }
}
```
A parent mapper composes nested mappers via **constructor injection**, not a static singleton:
```java
public final class CustomerDtoMapper implements DtoMapper<Customer, CustomerDto> {
private final DtoMapper<Address, AddressDto> addressMapper;
public CustomerDtoMapper() {
this(new AddressDtoMapper());
}
public CustomerDtoMapper(DtoMapper<Address, AddressDto> addressMapper) {
this.addressMapper = addressMapper;
}
@Override
public CustomerDto map(Customer source) {
if (source == null) return null;
return new CustomerDto(source.getId(), source.getName(), addressMapper.map(source.getBillingAddress()));
}
}
```
Rationale:
- **Composability & reuse** - the same nested DTO type (e.g. `AddressDto`) used from multiple parent DTOs
reuses one generated mapper class rather than duplicating inline mapping logic.
- **Constructor injection over static state** - avoids a global mutable singleton; a no-arg constructor
gives the common case (default nested mapper), while an overload accepting the nested mapper explicitly
allows substitution (tests, customization) without touching global state.
- **Codegen-friendly** - this shape generates naturally: one top-level mapper class per DTO type, each
constructor-injecting the mappers for any nested DTO types it references.
## ToMany collections and identity de-duplication (dto-spike-tomany-identity)
Extending the spike (`ebean-test/src/test/java/org/tests/dtomapping/`) to a `Customer` with a
`List<Contact> contacts` ToMany, where each `Contact` has a `customer` back-reference, surfaced
two things worth recording.
### The `DtoMapper` interface threads a shared context
`DtoMapper<SOURCE, TARGET>` was extended so that mapping is always done against a `DtoMapContext`:
```java
public interface DtoMapper<SOURCE, TARGET> {
TARGET map(SOURCE source, DtoMapContext context);
default TARGET map(SOURCE source) {
return map(source, new DtoMapContext());
}
default List<TARGET> mapList(List<SOURCE> source, DtoMapContext context) { ... }
default List<TARGET> mapList(List<SOURCE> source) {
return mapList(source, new DtoMapContext());
}
}
```
`DtoMapContext` is an identity-keyed cache of already-mapped source -> target instances, created
once per top-level `mapList(...)`/`map(...)` call and threaded through every nested `map(...)`
call. This lets repeated references to the *same* source entity instance - which Ebean's own
persistence context already de-duplicates within one query (`contact.getCustomer() == customer`
for the enclosing `Customer`, confirmed by an existing test) - map to the *same* target DTO
instance, rather than each producing an equal-but-distinct copy. This is what makes the mapped
DTO output "graph shaped" rather than "tree of copies shaped", and is required for r1/r3.
### Bug found and fixed: the cache must be partitioned by target type, not just source identity
The first cut of `DtoMapContext` was a single `IdentityHashMap<Object, Object>` keyed only by the
source instance. This breaks as soon as the *same* source instance legitimately needs to map to
*two different target types* within one graph - which happens immediately with a back-reference:
- The top-level `CustomerDtoMapper` maps a `Customer` -> full `CustomerDto`.
- The nested `ContactDtoMapper`, mapping `contact.getCustomer()` (the *same* `Customer` instance,
by identity), maps it -> shallow `CustomerRefDto` (the `@DtoRef`-style escape hatch that avoids
the `Customer -> Contact -> Customer` cycle).
With a single un-partitioned identity map, whichever mapper runs first "wins" the cache slot for
that `Customer` instance, and the other mapper incorrectly receives the wrong-typed cached result
(a `ClassCastException` at best, silently wrong data at worst). This was caught by a failing test
during the spike and fixed by partitioning the cache per target type:
```java
public final class DtoMapContext {
private final Map<Class<?>, Map<Object, Object>> mappedByType = new HashMap<>();
public <S, T> T computeIfAbsent(Class<T> targetType, S source, Function<S, T> mappingFunction) {
Map<Object, Object> mapped = mappedByType.computeIfAbsent(targetType, t -> new IdentityHashMap<>());
// ... existing/create/put ...
}
}
```
Each generated mapper passes its own target DTO `Class` as the first argument, so `Customer ->
CustomerDto` and `Customer -> CustomerRefDto` are cached independently even though the key
(`Customer` instance) is identical. **This is an implementation detail the codegen must get
right** - worth flagging explicitly when `dto-codegen-mapper` starts, since it's easy to
regress if the generator is written from scratch without this test coverage in front of it.
### Codegen optimization: skip the `DtoMapContext` cache for types that are never nested elsewhere
`DtoMapContext.computeIfAbsent` only ever produces a cache *hit* when the exact same source
instance is presented to `map()` more than once within one top-level call - which can only happen
when the target type is reachable via more than one path in the graph, i.e. it's used as a
`NESTED_ONE`/`NESTED_MANY` property by some *other* `@DtoMapping` pair (e.g. `CustomerRefDto`
reached from many `Contact`s via a shared `Customer`, or `AddressDto` shared as `billingAddress`
across customers). A type that's only ever a top-level `mapTo(...)`/`mapList(...)` entry point can
never receive the same source instance twice within one call - Ebean's own query engine already
de-duplicates root entity instances - so the cache lookup/insert there is pure overhead with a
guaranteed-never-hit `IdentityHashMap`.
Since all `@DtoMapping` pairs are resolved together at codegen time (`DtoMappingReader.
resolveAndValidate()`), it's straightforward to compute this: after cycle exclusion, walk every
surviving `DtoBeanMeta`'s properties and mark any `nested()` target as `nestedElsewhere()`. The
generated `map()` method then branches per mapper:
```java
// CustomerDto - never nested by another mapper, only a mapTo()/mapList() entry point
public CustomerDto map(Customer source, DtoMapContext context) {
if (source == null) return null;
// DtoMapContext for nested mappers only
return new CustomerDto(source.getId(), source.getName(),
billingAddressMapper.map(source.getBillingAddress(), context),
contactsMapper.mapList(source.getContacts(), context));
}
// AddressDto - nested under CustomerDto.billingAddress, so may be shared across customers
public AddressDto map(Address source, DtoMapContext context) {
if (source == null) return null;
// dedup using DtoMapContext, same Address instance can be reached via more than one path in the graph
return context.computeIfAbsent(AddressDto.class, source, s -> new AddressDto(
s.getId(), s.getLine1(), s.getCity()));
}
// ContactSummaryDto - flat, top-level only, no nested children at all
public ContactSummaryDto map(ContactSummary source, DtoMapContext context) {
if (source == null) return null;
// skip DtoMapContext, only ever a top-level mapping
return new ContactSummaryDto(source.getId(), source.getFullName());
}
```
Deliberately terse, single-line comments - just enough for a developer skimming generated code (e.g.
per the earlier `@Formula2`-on-DTO worked example) to know at a glance *why* a given mapper does or
doesn't use the cache, without spelling out the full reachability argument inline every time (that
lives here in the design doc instead). Note `CustomerDto`'s own construction skips the cache even
though it *has* nested children - `context` is still threaded down to `billingAddressMapper`/
`contactsMapper` since those target types (`AddressDto`, `ContactDto`) *are* nested elsewhere and
still need the identity cache for themselves - hence the distinct "for nested mappers only" wording
from the "only ever a top-level mapping" case (`ContactSummaryDto`), which has no children to thread
a context to at all.
### Fetching a ToOne back-reference used only for its FK/id needs the FK property fetched too
Confirmed (via a first-cut test failure) that if a ToMany's element type has a ToOne back to its
parent (e.g. `Contact.customer`), that FK property must itself be included in the fetch
(`.fetch("contacts", "id,firstName,lastName,customer")`) even when the mapper only reads the id
off the reference. Omitting it throws `LazyInitialisationException: Property not loaded:
customer` on the `getCustomer()` call itself (not merely on a property access on the returned
reference) - i.e. the earlier "ToOne reference access alone doesn't lazy load" finding
(dto-validate-fetch-pagination) only holds once the ToOne/FK property is itself part of the
fetch/select spec. This reinforces r6 (auto-deriving the fetch spec from DTO shape): the codegen
must include a ToOne property in the fetch spec whenever a DTO needing it (even just its id) is
reachable through a ToMany, not just at the top level.
### Test coverage added
- `TestCustomerDtoGraphMapping` extended to cover `contacts` ToMany mapping and to assert that
sibling `ContactDto`s under the same customer share the identical `CustomerRefDto` instance.
- `TestContactDtoGraphMapping` (new) - standalone `ContactDtoMapper` test focused specifically on
the identity de-dup guarantee and null-source handling.
## Codegen foundation: avaje-prisms adopted in querybean-generator (dto-codegen-mapper, step 1)
Before writing the DTO-mapper annotation-processing logic itself, adopted `avaje-prisms`
(`io.avaje:avaje-prisms`) in `querybean-generator` as the mechanism for reading the new
`@DtoPath`/`@DtoRef` annotations at APT time, replacing what would otherwise be more hand-rolled
`AnnotationMirror` walking (the existing pattern in `FindDbName.java`/`ReadModuleInfo.java`,
left as-is/unmigrated - only the *new* annotations use prisms).
This mirrors the proven pattern already used in two sibling projects in the same ecosystem -
`avaje-inject`'s `inject-generator` and `avaje-jsonb`'s `jsonb-generator` - both declare
`@GeneratePrism(SomeAnnotation.class)` once and get a generated `SomeAnnotationPrism` with
`isPresent(element)` / `getInstanceOn(element)` / `getOptionalOn(element)` and typed accessors
for every annotation member (correctly handling `Class`-valued members, avoiding the classic
`MirroredTypeException` dance).
Key property preserved: `querybean-generator` has **zero runtime/compile dependencies today**
(confirmed via `mvn dependency:list` returning "none"), matching annotations by FQN string
constants (`Constants.java`) rather than importing the actual annotation classes - deliberately
keeping the processor free of any dependency footprint for consumers. Adding `avaje-prisms` (to
generate the prism wrapper) and `ebean-annotation` (to reference `@DtoPath`/`@DtoRef` as literal
`Class` values in `@GeneratePrism(...)`) as `optional` dependencies preserves this: `mvn
dependency:list -DincludeScope=runtime` confirms every one of these (plus their own transitive
deps: `avaje-prism-core`, `avaje-spi-service`, `avaje-spi-core`) is marked `(optional)`, so none
of it propagates to a project that depends on `querybean-generator` (whether as a normal
dependency or via `annotationProcessorPaths`).
New annotations were added to the separate `ebean-annotation` repo (`io.ebean.annotation`
package, alongside `@Formula2`), not this repo:
```java
@DtoPath("billingAddress.line1")
String billingLine1; // rename/flatten a DTO property from a nested source path
@DtoRef
Integer customerId; // id-only back-reference, breaks what would otherwise be a graph cycle
```
Both use `@Target({FIELD, METHOD})` and `RetentionPolicy.CLASS` - visible to the annotation
processor (including across module boundaries, since `CLASS` retention survives in the compiled
`.class` file) but absent from runtime reflection, consistent with DTOs remaining plain,
framework-free types with no runtime footprint.
Wiring changes in `querybean-generator`:
- `pom.xml`: added `avaje-prisms` (`optional`, plus `annotationProcessorPaths` entry) and
`ebean-annotation` (`optional`) dependencies; removed the previous `-proc:none` compiler arg
(which would have suppressed `avaje-prisms`' own processor from running to generate the prism
source) - annotation processing is now scoped to exactly `avaje-prisms` via the explicit
`annotationProcessorPaths` list, so no other processor is auto-discovered.
- `module-info.java`: added `requires static io.avaje.prism;` and `requires static
io.ebean.annotation;` (`static` = compile-time only, matching the `optional` Maven scope).
- New `package-info.java` declaring `@GeneratePrism(DtoPath.class)` and
`@GeneratePrism(DtoRef.class)`, generating `DtoPathPrism`/`DtoRefPrism` into
`target/generated-sources/annotations`.
Verified: full `querybean-generator` build + existing test suite pass unchanged, and a downstream
full rebuild (`ebean-test` with `-am`) - which exercises the existing Q-bean codegen - also
passes with no regressions.
### Trigger mechanism: `@DtoMapping(source, target)` on a neutral package-info.java
Considered and rejected: putting a `source`/entity-referencing annotation directly on the DTO
class itself (e.g. `@Dto(Customer.class)` on `CustomerDto`). Rejected because DTO types are
often owned/generated elsewhere (e.g. from an OpenAPI spec) and must not be forced to reference
an internal persistence/entity type - that would leak internal domain types into a
public-facing/generated DTO module.
Instead, adopted the same pattern `avaje-jsonb` uses for external/foreign types it doesn't own
(`@Json.Import`): a repeatable annotation declared on a *neutral* holder - a `package-info.java`
- naming the `source` entity and `target` DTO as a pair:
```java
@DtoMapping(source = Customer.class, target = CustomerDto.class)
@DtoMapping(source = Contact.class, target = ContactDto.class)
package org.example.dto;
```
`@DtoMapping` (new, in `ebean-annotation`) is `@Target({PACKAGE, MODULE})`,
`@Retention(SOURCE)` (pure codegen trigger, never needed at runtime - unlike `@DtoPath`/
`@DtoRef` which need `CLASS` retention to remain visible to the DTO field itself),
`@Repeatable(DtoMapping.List.class)` following Java's own repeatable-annotation idiom. Neither
the entity nor the DTO needs any annotation of its own.
**Generated mapper package placement** - also modeled directly on `avaje-jsonb`'s handling of
`@Json.Import` for external types (`AdapterName`/`ProcessingContext.isImported`): defaults to the
target DTO's own package, *unless* the source or target type belongs to a different Java module
than the one being processed, in which case the generated mapper is placed in a package derived
from the processing module's own name instead - avoiding a JPMS "split package" violation that
would occur from generating source into a package owned by another module. An explicit
`mapperPackage` attribute is available to override this for edge cases. Same-module (or
non-modular/unnamed-module) projects are unaffected and just get the mapper alongside the DTO.
### mapTo(Class) dispatch: Class-token API + generated compile-time-safe registry
The original API sketch above (`mapTo(CustomerDto.class)`) predates the native-image/no-reflection
decision. Rather than switching to an instance-based API (`mapTo(new CustomerDtoMapper())`),
decided to keep the `Class`-token shape and generate a compile-time-safe registry to resolve it -
no reflection, no `Class.forName`, just literal `Class` comparisons generated at build time, e.g.:
```java
<S, D> DtoMapper<S, D> mapperFor(Class<S> sourceType, Class<D> targetType) {
if (sourceType == Customer.class && targetType == CustomerDto.class) {
return (DtoMapper<S, D>) new CustomerDtoMapper();
}
if (sourceType == Contact.class && targetType == ContactDto.class) {
return (DtoMapper<S, D>) new ContactDtoMapper();
}
return null;
}
```
Dispatch is keyed on the **(source, target) pair**, not target alone - this matches how
`@DtoMapping(source, target)` pairs are declared, allows the same DTO type to be mapped from more
than one source entity without ambiguity, and lets `query.mapTo(dtoType)` fail fast with a clear
`PersistenceException` (rather than an incorrect match) when `query.getBeanType()` doesn't pair
with the requested DTO.
This mirrors the existing, already-proven `EbeanEntityRegister`/`EntityClassRegister` mechanism
(`SimpleModuleInfoWriter.java`) that `querybean-generator` already generates per module for entity
classes - a `List<Class<?>>` built from literal `SomeEntity.class` references, registered via
`META-INF/services` (`ServiceLoader`, itself native-image-friendly with no extra reflection
config needed for simple no-arg-constructor implementations). The DTO mapper registry follows the
same per-module aggregation + `META-INF/services` registration shape, giving `mapTo(Class)` a
concrete generated implementation to dispatch through at runtime without reflection anywhere in
the chain.
### mapTo(Dto.class) runtime wiring (implemented)
`query.mapTo(dtoType)` returns a `MappedQuery<D>` (`findList()`/`findOne()`/`findOneOrEmpty()`/
`findStream()`/`findPagedList()`/`usingMaster(boolean)`/`usingTransaction(Transaction)`/`usingConnection(Connection)`).
On first use it resolves the generated `DtoMapper<S, D>` for the query's `(getBeanType(), dtoType)`
pair via a `DtoMapperManager` (a `ServiceLoader`-backed aggregator over all generated
`DtoMapperRegister`s, analogous to `DtoBeanManager`), then:
- applies `mapper.fetchGroup()` to the query via `query.select(fetchGroup)` - the fetch/select spec
is entirely derived from the DTO's declared shape, no manual `.select()`/`.fetch()` needed;
- forces `query.setUnmodifiable(true)` - the resulting entity graph is read-only input to the
mapper, and any DTO property whose source wasn't actually fetched fails fast with
`LazyInitialisationException` rather than silently lazy loading or returning `null`;
- executes the query and maps the result(s) via `mapper.map(...)`/`mapper.mapList(...)`.
An unregistered `(source, dtoType)` pair throws a `PersistenceException` with a suggested
`@DtoMapping` fix, at first use (i.e. `findList()`/`findOne()`), not at `mapTo(dtoType)` call time.
`MappedQuery<D>.usingMaster(boolean)`, `.usingTransaction(Transaction)`, and `.usingConnection(Connection)`
all delegate directly to the underlying entity query, mirroring `Query`/`QueryBuilder`. This lets a
caller retry against the master data source after a read-replica failure by calling
`usingMaster(true)` on the *same* `MappedQuery` instance and re-invoking a find method - there's no
need to rebuild the query and call `.mapTo(...)` again.
`MappedQuery<D>.findStream()` mirrors `QueryBuilder#findStream()` - the underlying entity query is
streamed (supporting very large result sets, potentially using multiple persistence contexts
internally) and each entity is mapped to its target DTO lazily as the stream is consumed. One
`DtoMapContext` is shared across the whole stream (not per-element), so identity de-duplication of
nested DTOs (e.g. several `Contact`s sharing the same `Customer`) still holds even when the source
entities are never materialized into one `List` at all. As with the entity-level `findStream()`,
callers must consume it via try-with-resources to ensure the underlying resources are closed.
## Still open / to revisit during implementation
- Whether `.fetch(...)` calls can still be layered on top of a `mapTo(Dto.class)` query for explicit
overrides. Currently the mapper's `fetchGroup()` is the *only* source of the fetch spec - any
`.select()`/`.fetch()` calls made before `.mapTo(...)` are overwritten by it.
- Whether `@DtoPath`/`@DtoRef` need additional attributes beyond a bare path/marker (e.g. an explicit
target type on `@DtoRef` for disambiguation) once real DTOs with more complex shapes are codegen'd.
- Behavior when a DTO property has no matching entity property and no `@DtoPath`/`@Formula2` override
(fail at codegen time, most likely, consistent with the "fail fast" philosophy).
- `@Formula2`-on-DTO mapping to Blaze-Persistence/QueryDSL-style computed properties - not yet
implemented (see requirements doc); a narrower validation-only variant was attempted and rejected
as not distinct enough from `@DtoPath` (see "Formula2-on-DTO scope" above). The broader ad-hoc-SQL
case likely doesn't need a dedicated DTO feature at all - see "Ad-hoc computed/formula properties"
above for the `@Entity @View`/`@Sql` alternative.
- **Fetch-path collision between a `NESTED_ONE`/`NESTED_MANY` property and a `@DtoPath` property -
found and fixed**: `DtoMapperWriter.fetchGroupChainCalls()` builds one `.fetch(path, ...)`
chain-call per distinct fetch path, but the underlying `OrmQueryDetail.fetch(...)` unconditionally
**overwrites** (rather than merges) any existing entry for the same path key. If a DTO declared a
`NESTED_ONE`/`NESTED_MANY` property AND a `@DtoPath` property whose fetch-path prefix is the *exact
same* path (e.g. a nested `AddressDto billingAddress` alongside `@DtoPath("billingAddress.line1")`
on the same DTO - both resolve to fetch path `"billingAddress"`), the generator would emit two
`.fetch("billingAddress", ...)` calls and the second would silently discard the first's selected
properties. Merging wasn't practical - the nested property's `.fetch(path, mapper.fetchGroup())`
call passes another mapper's own pre-built, immutable, shared `FetchGroup`, so there's no clean way
to splice an extra scalar property into it at the call site. Fixed instead with a **fail-fast
compile-time error**: `DtoMapperWriter` now detects the collision and raises a clear
`ctx.logError(...)` (annotation-processor `ERROR` diagnostic, fails the compile) naming the
colliding property and fetch path, and suggesting the two ways out - move the property onto the
nested DTO type instead, or pick a `@DtoPath` that reaches a different, non-colliding path (as
`ContactDto.customerCity` already does deliberately, per its own comment, using a 3-segment path).
Verified empirically by compiling a small reproduction with a colliding `@DtoPath` and confirming
the expected error fires; a permanent regression test
(`DtoMapperFetchPathCollisionTest` in `querybean-generator`) now runs this same repro directly
through `javax.tools.JavaCompiler` with the `Processor` registered, asserting the compile fails with
the expected diagnostic message.
- **Compile-time verification of `select(...).asDto(...)` (r6, aspirational) - explored and closed as
rejected**: raw SQL is an opaque `String` at compile time, and even the typed query-bean
`.select(...)` form only type-checks against the *entity* - the match to the target DTO's constructor
still happens at runtime via reflection (`DtoQueryPlanConstructor`), and the `.asDto(...)` call site
can be arbitrarily distant from the `.select(...)` call, so there's no fixed AST shape an annotation
processor could reliably verify (unlike QueryDSL, whose compile-time safety actually comes from typed
`Projections.constructor(...)`/generated Q-type constructor calls, not from checking a select-list
against a DTO). `mapTo(Dto.class)` already closes the underlying gap in the tractable direction - it
derives the select/fetch spec *from* the DTO's declared shape at APT time, so it is compile-time safe
by construction. Recommend `mapTo()` whenever compile-time-checked DTO projection matters, and treat
`asDto()`/`findDto()` as the flexible, runtime-checked escape hatch for raw/dynamic SQL. See
`dto-mapping-requirements.md` requirement r6.
- **Custom property conversion (`@DtoConvert`/`@DtoMixin`, r13/r14) - implemented**: motivated by a
real hand-written mapper (`DriverMapper`, central-access) needing both a dependency-free scalar
coercion (`short` -> `boolean`) and a dependency-backed conversion (AES decryption via an injected
cipher). Final design (see `dto-mapping-requirements.md` section E), as built:
- `@DtoConvert(value = ConverterType.class, method = "name")` on a DTO property (combinable with
`@DtoPath`); the generator dispatches on whether the referenced method is `static` - static means a
direct inlined static call (no registration, covers common reusable coercions), instance means
dispatch via a new `DtoConverterManager.get(ConverterType.class).method(...)` call, with the
resolved instance wired as a real constructor parameter/field on the generated mapper (same shape
as existing nested-mapper constructor injection). Multiple properties on the same mapper sharing
the same converter type are deduplicated to a single constructor parameter/field
(`DtoBeanMeta.converterDeps()`).
- `DtoConverterManager` (`ebean-api`, `io.ebean` package) is a small, narrowly-scoped static put/get
bridge - the app registers an already-DI-constructed converter singleton (e.g. built by
avaje-inject) *before* building the `Database`. This is a deliberate, narrow exception to the
general no-static-mutable-state convention: `ServiceLoader`-discovered, no-arg-constructed
generated code (`EbeanDtoMapperRegister`) has no other way to reach an already-DI-constructed
singleton. `DtoConverterManager.get(type)` throws immediately if nothing was registered for that
type, so a missing converter fails fast at Database-startup time (an eager field initializer on
`EbeanDtoMapperRegister`, and equally on each mapper's own no-arg constructor, which resolves the
same way via `DtoConverterManager.get(...)` for standalone/test construction), not lazily on first
use - `DtoMapperRegister`'s `mapperFor(...)` signature and `DtoMapperManager` are otherwise
completely unchanged, as originally planned.
- Two alternatives were explored and rejected first: (a) a `DtoMapContext.service(Class)` lookup -
wrong lifetime, `DtoMapContext` is a short-lived per-call identity-cache only; (b) a
`ServiceLoader`-discovered `DtoConverterSource` SPI mirroring `DtoMapperRegister` itself - can't
bridge to an *already* DI-constructed dependency without reconstructing/duplicating it.
- `@DtoMixin(Target.class)` - a companion type overlaying `@DtoPath`/`@DtoConvert`/`@DtoRef`
annotations onto a DTO that can't be annotated directly (e.g. OpenAPI-generated). Discovered via
`roundEnv.getElementsAnnotatedWith(...)` (added to `Processor.getSupportedAnnotationTypes()`,
since - unlike `@DtoPath`/`@DtoRef`/`@DtoConvert` - a mixin doesn't annotate an already-iterated
field of a known `@DtoMapping` target, so it can't be found lazily). `DtoMappingReader` resolves
each target property's annotations from the field itself first, falling back to a same-named
method on the registered mixin (`DtoMappingReader.prismOn(...)`) - directly mirrors avaje-jsonb's
proven `@Json.MixIn` mechanism.
- Implemented in `ebean-annotation` (`DtoConvert`, `DtoMixin`), `ebean-api` (`DtoConverterManager`),
and `querybean-generator` (`DtoConverterMeta`, `DtoBeanMeta.converterDeps()`,
`DtoMappingReader`/`DtoMapperWriter`/`DtoMapperRegisterWriter` changes). Test coverage:
`tests/test-dto-mapping` `TestDtoConvert` (static + instance dispatch, fail-fast unregistered-type
check) and `TestDtoMixin` (mixin overlay, including instance-dispatch conversion resolved purely
from mixin-declared annotations). The instance-dispatch converter is registered via a
`DatabaseConfigProvider` (ServiceLoader hook run before the `Database` is built) rather than a test
`@BeforeAll`, since `EbeanDtoMapperRegister`'s mapper fields (including any needing
`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
- Requirements: [dto-mapping-requirements.md](./dto-mapping-requirements.md)
- Issue: https://github.com/ebean-orm/ebean/issues/2540
- MapStruct cycle mapping: https://mapstruct.org/documentation/stable/reference/html/#mapping-object-cycles
+337
View File
@@ -0,0 +1,337 @@
# Nested DTO Mapping — Requirements
Design requirements distilled from [issue #2540 "Support nested DTO mapping"](https://github.com/ebean-orm/ebean/issues/2540),
reviewed against comparable features in QueryDSL (`@QueryProjection`) and Blaze-Persistence (`@EntityView`).
## Context
Ebean already supports:
- Partial/flat DTO queries via `DB.findDto(...)` and `query.select(...).asDto(Dto.class)`.
- `@Formula` / `@Formula2` — path-based, auto-joined computed SQL expressions, but only on managed entities.
- `query.setUnmodifiable(true)` — builds a read-only, non-lazy-loading entity graph (`InterceptReadOnly`,
see PR #2626). Accessing an unloaded property throws `LazyInitializationException`; mutating throws
`UnmodifiableEntityException`.
Unlike Hibernate, Ebean does dirty-detection on the bean itself (no dynamic proxies), so there is very little
extra cost to an entity-graph query versus a DTO query. This makes an **unmodifiable entity graph** a cheap,
natural intermediate representation to map *from* when producing a DTO graph — we don't need Blaze/Hibernate's
proxy-based `EntityView` mechanism to get the performance benefit they are chasing.
The goal is nested DTO graph support (DTOs containing ToOne/ToMany child DTOs), not just today's flat DTOs,
while keeping DTOs as plain, framework-unattached classes.
## Accepted Requirements
### A. Nested DTO graphs
- **Support nested DTO graphs (ToOne/ToMany)**
Allow mapping a query result into a DTO graph where DTO fields are themselves DTOs (ToOne) or
`List`/`Set<Dto>` (ToMany), not just flat DTOs. Use the existing `setUnmodifiable(true)` entity graph as
the intermediate, de-duplicated, identity-consistent source to map from.
*Inspiration: Blaze `@EntityView` subviews/subview collections; Jimmer fetcher DTOs.*
- **Auto-generated entity → DTO graph mapper**
Given an unmodifiable entity graph plus a target nested DTO type, generate (via annotation processing,
reflection-free) a mapper that walks the graph and populates the DTO graph, matching properties by
name/type with override annotations for renames, computed values, and collection element types.
*Inspiration: Blaze `@EntityView` + subview mapping; conceptually similar to MapStruct but Ebean-generated
and graph/identity aware.*
- **Identity-aware de-duplication in nested collections**
When mapping nested collections referencing the same underlying entity instance multiple times, reuse the
same DTO instance (mirrors Blaze/Jimmer identity semantics) rather than producing independent copies.
*Inspiration: Blaze/Jimmer identity handling.*
### B. Formula-style DTO annotations
- **`@Formula2`-like annotations on DTO fields**
Bring the existing `@Formula` / `@Formula2` concept (auto-joined, path-based computed SQL expressions) to
DTO classes so a DTO field can request a computed/aggregated value with the join auto-derived, instead of
only being available on managed entities.
*Inspiration: User suggestion; Ebean `@Formula2`; Blaze `@Mapping` computed expressions.*
*Status: a narrower version (pulling in an existing entity-level `@Formula2` by path) was implemented and
then rejected - for the common same-name case it generated code identical to a plain unannotated field, so
the annotation added no real value beyond a codegen-time validation. See `docs/dto-mapping-design.md`
("Formula2-on-DTO scope" and "Ad-hoc computed/formula properties" sections). The broader goal - arbitrary
ad-hoc computed SQL on a DTO field - is better served by modelling the computed value as its own
`@Entity @View`/`@Sql` read entity and mapping *that* into a plain DTO, reusing the existing (already
accepted) nested-DTO mapping machinery rather than a new DTO-level annotation.
- **Path-based property mapping annotation on DTO**
Allow a DTO field or constructor param to be annotated with a source path expression (e.g. `parent.name`)
so Ebean can auto-derive the select clause plus joins for nested/renamed properties, reducing manual
constructor wiring for non-trivial mappings.
*Inspiration: Blaze `@Mapping`; QueryDSL constructor expressions.*
### C. Compile-time safety
- **Compile-time verification of `select(...).asDto(...)` mapping** *(explored, rejected as impractical -
`mapTo()` accepted as the alternative)*
Today `select(props).asDto(Dto.class)` is only checked at runtime. Explored an annotation-processor
based mechanism to verify at compile time that selected properties match the DTO constructor or setters,
mirroring QueryDSL's `@QueryProjection` compile-time Q-type generation. Rejected as impractical: raw SQL
is an opaque `String` at compile time, and even the typed query-bean `.select(...)` form only
type-checks against the *entity* - the match to the target DTO still happens at runtime via reflection
(`DtoQueryPlanConstructor`), and the `.asDto(...)` call site can be arbitrarily distant from the
`.select(...)` call, so there's no fixed AST shape an annotation processor could reliably verify.
QueryDSL's actual compile-time safety comes from a different mechanism entirely - typed
`Projections.constructor(...)`/generated Q-type constructor calls, not from checking an
independently-built select-list against a DTO. `mapTo(Dto.class)` (see section A) already closes the
underlying gap in the opposite, tractable direction: it derives the select/fetch spec *from* the DTO's
declared shape at APT time, so it is compile-time safe by construction, with no separate select-list to
drift out of sync. Recommendation: document `mapTo()` as the compile-time-safe answer for DTO
projections, and treat `asDto()`/`findDto()` explicitly as the flexible, runtime-checked escape hatch
for raw/dynamic SQL.
*Inspiration: QueryDSL `@QueryProjection` compile-time Q-type generation.*
- **Fail-fast on unmapped or lazy property access**
Ensure a clear, documented, minimal-ceremony way to fail fast if code touches a property not included in
the query projection, instead of silently lazy loading or returning null. `query.setUnmodifiable(true)`
already satisfies this (throws `LazyInitializationException`) — document/promote it as the answer, and
evaluate whether a lighter-weight flag decoupled from full unmodifiable/read-only semantics is needed.
*Inspiration: Original issue ask; already solved via `setUnmodifiable()` (PR #2626 `InterceptReadOnly`).*
### D. Fetch strategy and performance
- **Fetch strategy control for DTO graph relationships**
Existing entity query fetch hints (join vs. select/subselect secondary query, `+query`/`+lazy`) should
transparently carry over when the target of the query is a DTO graph rather than an entity graph.
`query.mapTo(Dto.class)` applies the DTO-derived `FetchGroup` only when the query has no
`select()`/`fetch()` already set - a manually tuned fetch spec always takes precedence and is never
overridden, allowing manual query optimisation when needed (at the cost of falling back to the
existing fail-fast-on-unmapped-property behaviour if the manual spec doesn't cover what the DTO needs).
*Inspiration: Blaze FETCH/SELECT/SUBSELECT fetch strategies.*
- **Pagination support for DTO graph queries**
Confirm existing pagination works unchanged when projecting into nested DTO graphs.
*Inspiration: Blaze pagination and keyset pagination support.*
### E. Custom property conversion
- **Per-property custom scalar conversion (`@DtoConvert`)**
Motivated by real hand-written mapper code (`DriverMapper`, central-access) doing per-property scalar
coercion (`short` -> `boolean`) and dependency-backed conversion (AES decryption via an injected cipher).
Introduce a `@DtoConvert(value = ConverterType.class, method = "name")` annotation (combinable with
`@DtoPath` for source-getter override) on a DTO property. The generator dispatches based on whether the
referenced method is `static`:
- **Static method** -> a direct static call is inlined (`ConverterType.method(source.getX())`), zero
ceremony, no registration - covers common, reusable, dependency-free scalar coercions (e.g.
`short`/`boolean`, enum <-> `String`) that could apply across many unrelated entity/DTO pairs.
- **Instance method** -> dispatched via `DtoConverterManager.get(ConverterType.class).method(source.getX())`
and wired as a real constructor parameter/field on the generated mapper (same shape as existing
nested-mapper constructor injection) - covers conversions needing a real dependency (e.g. a cipher).
`DtoConverterManager` is a small, deliberately-scoped static put/get bridge: the app registers an
already-DI-constructed converter singleton (e.g. built by avaje-inject) *before* building the `Database`.
This is a narrow, accepted exception to the general no-static-mutable-state convention - it exists solely
to bridge an already-DI-constructed singleton into `ServiceLoader`-discovered, no-arg-constructed generated
code, which cannot otherwise reach a DI container. `DtoConverterManager.get(type)` throws immediately if
nothing was registered, so a missing converter fails fast at Database-startup time (an eager field
initializer on the generated `EbeanDtoMapperRegister`), not lazily on first use.
*Design exploration considered and rejected two alternatives first: (a) a `DtoMapContext.service(Class)`
lookup - rejected because `DtoMapContext` is a short-lived per-call identity-cache only, wrong lifetime for
a real singleton dependency; (b) a `ServiceLoader`-discovered `DtoConverterSource` SPI mirroring
`DtoMapperRegister` itself - rejected because a `ServiceLoader`-instantiated (no-arg) source cannot bridge
to an *already* DI-constructed dependency (e.g. a cipher needing config/secrets) without reconstructing it
itself, duplicating/bypassing the app's own DI-managed instance.*
*Inspiration: `DriverMapper` (central-access) hand-written pattern; MapStruct qualified converter methods.*
*Status: implemented - `@DtoConvert` (ebean-annotation), `io.ebean.DtoConverterManager` (ebean-api), and
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)`
companion interface/type, discovered by scanning the compilation round and overlaying its per-property
annotations onto the real target's properties by name-match - directly mirrors avaje-jsonb's
`@Json.MixIn` mechanism (`KingfisherMixin`/`CrewMateMixIn` pattern), a proven prior-art solution to the
exact same "can't annotate a generated/unowned type" problem.
*Inspiration: avaje-jsonb `@Json.MixIn`.*
*Status: implemented - `@DtoMixin` (ebean-annotation) and querybean-generator round-scanning/overlay
support (matches mixin methods to target properties by name, applying whichever of `@DtoPath`/`@DtoRef`/
`@DtoConvert` is present as if declared on the target field itself). Test coverage: `tests/test-dto-mapping`
`TestDtoMixin`.*
### F. DI-friendly manual mapper usage
- **Public `DtoMapperManager` with `get(Class<T> mapperType)` for DI**
Motivated by `DriverMapper`/`DriverService` (central-access): `DriverMapper` is a hand-written
`@Component` constructor-injected into `DriverService`. Moved `DtoMapperManager` from internal
(`io.ebeaninternal.server.dto`) to public `io.ebean` - unchanged `mapperFor(source, dto)`, plus a new
`get(Class<T> mapperType)` keyed by the generated mapper's own concrete class (e.g.
`manager.get(CustomerDtoMapper.class)`), for direct/concrete-typed DI injection. `DtoMapperRegister`
gained a default `mapperOfType(Class<T>)` method (non-breaking); the generator emits the real if-chain
body (mirrors `mapperFor`'s if-chain). `DtoMapperManager` has zero `Database` dependency (constructor
only does `ServiceLoader.load(DtoMapperRegister.class)`), so it can be constructed standalone,
independent of/before a `Database` - e.g. as an avaje-inject bean.
*Inspiration: `DriverMapper`/`DriverService` (central-access).*
*Status: implemented.*
- **`DtoMapperManager` sharing via `DatabaseBuilder.putServiceObject`**
So `query.mapTo()` and application-injected mappers share the exact same `DtoMapperManager` instance
(and hence the same underlying generated mapper singletons) rather than each independently constructing
its own, `InternalConfiguration` checks `config.getServiceObject(DtoMapperManager.class)` first (mirrors
the existing `AutoMigrationRunner`/`GeoTypeProvider` `putServiceObject`/`getServiceObject` pattern),
falling back to constructing a default `new DtoMapperManager()` if none was supplied.
*Inspiration: user proposal following `DriverMapper`/`DriverService` review.*
*Status: implemented.*
*Rejected: generator-emitted `builder(source)` method* - `DriverMapper` exposes `builder(cDriver)`
returning a partially-populated `DriverBuilder` so callers can add extra caller-supplied fields (e.g.
fleets) before `build()`. Rejected as a generator feature - `Driver`/`DriverSummary` already use
avaje-recordbuilder's `@RecordBuilder`, which generates `Target.builder(existingInstance)`
(seed-from-instance). The same effect is already achievable with zero ebean changes:
`mapper.map(source)` then `Builder.builder(mapped).extraField(x).build()`. Documented as a recipe
instead (see "Recipe: adding extra caller-supplied fields after mapping" in
`docs/guides/mapping-entity-graphs-to-dtos.md`).
### G. Large-target construction and shape variants
- **Builder-based target construction for large DTOs**
Motivated by `UserService`/`User` (central-access): `User` is a 24-field OpenAPI-generated record with
a `@RecordBuilder`-generated `UserBuilder`, hand-mapped via a long fluent builder chain rather than a
positional constructor to stay readable/refactor-safe. The generator auto-detects a RecordBuilder-style
builder on the target (static `Target.builder()` + fluent per-property setters + `build()`) and uses
`Target.builder().prop(x)....build()` instead of `new Target(a, b, c, ...)` whenever (a) a builder is
detected and (b) the target has more than a threshold number of properties (default 5), falling back to
the positional constructor otherwise. An explicit `@DtoMapping` attribute (`builder = AUTO | ALWAYS |
NEVER`) overrides the heuristic in either direction. Applies regardless of whether the target class is
hand-authored or foreign/generated (e.g. an OpenAPI record) - `@DtoMapping` is already declared
externally via `package-info.java`, not on the target class, so this was already compatible with
foreign target types.
*Inspiration: `UserService`/`User` (central-access).*
*Status: implemented.*
- **Named mapper variants excluding nested paths, sharing one generated class**
Motivated by `UserService`/`User` (central-access): `CUser` -> `User` is mapped in two shapes - with
nested `fleets` (`findUserByGid`) and without (`findAll`, bulk listing) - to avoid an unnecessary
join/fetch on the common bulk-listing path. Keeps the existing "shape always derived from declaration,
fetch spec always wins" philosophy (rejected relaxing that rule / rejected a runtime
is-property-loaded auto-skip check as less deterministic). The same `(source, target)` pair can be
declared more than once in `package-info.java` via a named variant, e.g.
`@DtoMapping(source = CUser.class, target = User.class)` (base/full) plus
`@DtoMapping(source = CUser.class, target = User.class, name = "noFleets", exclude = "fleets")`
(variant). Both variants are generated into the **same** mapper class (one class per target, not one
per variant) and share a single private `build(source, context, boolean includeXxx, ...)` method
containing the common field population written once; each excluded nested path becomes a `boolean
includeXxx` parameter of that shared method rather than a precomputed value, so `build()` still
evaluates every property - included or excluded - inline, at its own declared field position (a
`includeFleets ? fleetsMapper.mapList(...) : List.of()` ternary in place, not hoisted out as a
pre-evaluated call argument). This preserves the DTO's declared property order as the true evaluation
order regardless of which properties a variant happens to exclude. The base `map()` passes `true` for
every flag; each named variant (exposed as a same-named accessor, e.g. `noFleets()`, returning a single
shared/cached instance of its own small `DtoMapper<SOURCE, TARGET>`-implementing inner class - not
reconstructed per call) passes `false` for the paths it excludes and omits that path from its own
`fetchGroup`. Selected via a new `query.mapTo(Class<D> dtoType, DtoMapper<T, D> mapper)` overload
taking an already-resolved mapper instance directly (e.g. `query.mapTo(User.class,
userMapper.noFleets())`) - no string-based variant lookup, and no changes needed to
`DtoMapperRegister`/`DtoMapperManager`.
*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**
Ebean supports entity beans declared as Java `record`s (e.g. `public record CourseRecordEntity(@Id long id,
String name, String notes) {}` - see `test-java16`), whose only accessor shape is the bare component name
(`active()`, `name()`, `id()`) - never `getXxx()`/`isXxx()`. This bare-accessor convention isn't limited to
an actual `record` type though - an ordinary class can just as easily expose bare/fluent-style accessors
with no `get`/`is` prefix at all. The generator resolves the real accessor for each source type (the direct
source, or an intermediate `@DtoPath`/`@DtoRef` association type) by checking which shape actually exists as
a method, in order: (1) `isXxx()` returning `boolean` (JavaBean boolean convention), (2) `getXxx()` (JavaBean
convention), (3) the bare `propertyName()` itself - falling back to a guessed `getXxx()` only if none of the
three are found. Resolution is entirely name/existence-based (no dependency on whether the type is actually
a `record`). The Ebean bean-property name used in generated `FetchGroup.select(...)`/`.fetch(...)` calls is
tracked directly from the original property/segment name (not reverse-parsed from the resolved accessor's
method name), so it's correct regardless of which of the three accessor shapes was used.
*Inspiration: Ebean's own record-entity support (`test-java16`); user-reported gap during review.*
*Status: implemented.*
## Rejected Requirements
These were considered and explicitly rejected as out of scope:
- **DTO as interface / dynamic proxy views** — Blaze `@EntityView` defines views as interfaces backed by
runtime proxies. This conflicts with Ebean's preference for plain, framework-unattached DTO classes.
- **Updatable or creatable entity views (persist through DTO)** — Blaze's `@UpdatableEntityView` /
`@CreatableEntityView` cascade persist/update through the view. This would duplicate Ebean's existing
entity persistence model and introduce a second, ambiguous dirty-checking/cascade model.
- **New predicate/filter DSL for subview collections** — Blaze allows filter expressions directly in
`@Mapping` (e.g. filtering a collection by an attribute value). Ebean already has typed query bean
predicates and `.filterMany()` for filtering child collections in queries; no new embedded filter
expression language is needed on the DTO itself.
## References
- Issue: https://github.com/ebean-orm/ebean/issues/2540
- PR #2626: `InterceptReadOnly` / `InterceptReadWrite` split enabling the unmodifiable entity graph fast path
- Ebean docs: https://ebean.io/docs/query/option#unmodifiable
- QueryDSL: `@QueryProjection` (constructor-based, compile-time-checked projections)
- Blaze-Persistence Entity Views: https://persistence.blazebit.com/documentation/1.6/entity-view/manual/en_US/
+1
View File
@@ -14,6 +14,7 @@ Key guides (fetch and follow when performing the relevant task):
- Migrate to `Database.builder()`: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/migrating-to-database-builder.md
- Migrate JSON APIs from Jackson core to avaje-json-core: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/migrating-json-jackson-core-to-avaje-json-core.md
- Write queries with query beans: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/writing-ebean-query-beans.md
- Mapping entity graphs to DTOs (`mapTo`): https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/mapping-entity-graphs-to-dtos.md
- Derived / formula properties (`@Formula`, `@Formula2`): https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/derived-formula-properties.md
- Persisting and transactions: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/persisting-and-transactions-with-ebean.md
- Query metrics and naming: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/ebean-query-metrics.md
+4
View File
@@ -47,7 +47,9 @@ existing Maven project. Complete the steps in order.
| Guide | Description |
|-------|-------------|
| [Write Ebean queries with query beans](writing-ebean-query-beans.md) | Step-by-step guidance for AI agents to write type-safe Ebean queries; choose the right terminal method; tune `select()` / `fetch()` / `fetchQuery()`; and project to DTOs when entity beans are not the right output |
| [Mapping entity graphs to DTOs (`mapTo`)](mapping-entity-graphs-to-dtos.md) | Map a nested entity graph query result to a nested DTO graph via `query.mapTo(Dto.class)`; `@DtoPath`/`@DtoRef` for renamed/flattened/id-only properties; identity-aware de-dup via `DtoMapContext`; computed/aggregate DTO values via `@Entity @View` + `@Formula2`/`@Sum`/`@Aggregation`; comparison with the flat `asDto()` pipeline |
| [Immutable bean cache for read-only references](immutable-bean-cache.md) | Use `ImmutableBeanCache` and `ImmutableBeanCaches.loading(...)` to resolve assoc-one references in read-only/unmodifiable queries, including secondary `fetchQuery`/`fetchLazy` loads |
| [Using `RawSql` with Ebean](using-rawsql-with-ebean.md) | Choose between `RawSqlBuilder.parse()`, `unparsed()`, and `withPlaceholders()`; the `${where}`/`${andWhere}`/`${having}`/`${andHaving}` placeholder reference for CTEs, window functions, and subqueries; column mapping; and using `RawSql` with query beans |
## Persisting & transactions
@@ -152,6 +154,7 @@ Key guides (fetch and follow these when performing the relevant task):
- Database configuration: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/add-ebean-postgres-database-config.md
- Migrate to `Database.builder()`: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/migrating-to-database-builder.md
- Write queries with query beans: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/writing-ebean-query-beans.md
- Mapping entity graphs to DTOs (`mapTo`): https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/mapping-entity-graphs-to-dtos.md
- Immutable bean cache for read-only references: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/immutable-bean-cache.md
- Ebean OpenTelemetry tracing: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/add-ebean-opentelemetry.md
- Query metrics and naming: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/ebean-query-metrics.md
@@ -181,6 +184,7 @@ Key guides (fetch and follow these when performing the relevant task):
- Database configuration: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/add-ebean-postgres-database-config.md
- Migrate to `Database.builder()`: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/migrating-to-database-builder.md
- Write queries with query beans: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/writing-ebean-query-beans.md
- Mapping entity graphs to DTOs (`mapTo`): https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/mapping-entity-graphs-to-dtos.md
- Persisting and transactions: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/persisting-and-transactions-with-ebean.md
- Test container setup: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/add-ebean-postgres-test-container.md
- DB migration generation: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/add-ebean-db-migration-generation.md
+1 -1
View File
@@ -101,7 +101,7 @@ Inside the `<dependencies>` block, add the PostgreSQL JDBC driver:
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.8</version>
<version>42.7.11</version>
</dependency>
```
@@ -0,0 +1,785 @@
# Guide: Mapping entity graphs to DTOs — `query.mapTo(Dto.class)`
## Purpose
`query.mapTo(SomeDto.class)` maps an entity query result to a **nested DTO graph**
DTO fields can themselves be DTOs (`ToOne`) or `List<Dto>`/`Set<Dto>` (`ToMany`), not
just flat scalar columns. Ebean generates the mapper (reflection-free), automatically
derives the query's `select()`/`fetch()` spec from the target DTO's declared shape, and
forces `setUnmodifiable(true)` so any property the mapper needs but wasn't fetched fails
fast with `LazyInitialisationException` instead of silently lazy loading.
This is distinct from the existing flat `asDto(Dto.class)` — see
[Quick comparison](#quick-comparison-mapto-vs-asdto-vs-plain-entity-query) below.
```java
Optional<CustomerDto> dto = new QCustomer()
.id.eq(customerId)
.mapTo(CustomerDto.class)
.findOneOrEmpty();
```
```java
List<CustomerDto> dtos = DB.find(Customer.class)
.where().eq("status", Status.ACTIVE)
.mapTo(CustomerDto.class) // no .select()/.fetch() needed - derived from CustomerDto's shape
.findList();
```
---
## Quick comparison: `mapTo()` vs `asDto()` vs plain entity query
| | `mapTo(Dto.class)` | `asDto(Dto.class)` | Plain entity query |
|---|---|---|---|
| Shape | Nested DTO **graph** (ToOne/ToMany) | Flat, single-row DTO | Entity graph |
| Fetch spec | Auto-derived from the DTO's declared shape | Whatever `select()`/SQL you write | Whatever `select()`/`fetch()` you write |
| Mismatch caught | At compile time (unregistered pair fails fast at first use; codegen fails fast on structural problems) | At runtime (reflection-based constructor/setter matching) | N/A (real entity properties) |
| Identity/de-dup | Yes - repeated source instances map to the same DTO instance (`DtoMapContext`) | N/A (one row in, one DTO out) | Yes (entity/persistence-context identity) |
| Backing pipeline | Executes the entity ORM query, `setUnmodifiable(true)`, maps the resulting graph | Executes SQL directly against a flat `ResultSet` | Executes the entity ORM query |
| Best for | API/read-model responses that mirror a **nested** entity shape | Flat summary rows, reports, native/vendor SQL | Data you intend to mutate and save back |
See also [writing-ebean-query-beans.md](writing-ebean-query-beans.md) (Step 8/9) for
`asDto()` and the general query-shape decision guide.
---
## Basic usage
### 1. Declare a plain DTO
DTOs are plain classes with **no framework attachment** — no annotations required for
the common case (properties matched to the source entity by name):
```java
public class CustomerDto {
private final Long id;
private final String name;
private final AddressDto billingAddress; // nested ToOne
private final List<ContactDto> contacts; // nested ToMany
public CustomerDto(Long id, String name, AddressDto billingAddress, List<ContactDto> contacts) {
this.id = id;
this.name = name;
this.billingAddress = billingAddress;
this.contacts = contacts;
}
public Long getId() { return id; }
public String getName() { return name; }
public AddressDto getBillingAddress() { return billingAddress; }
public List<ContactDto> getContacts() { return contacts; }
}
```
A constructor whose parameters match (by name) a source entity/DTO property is used
for mapping — same shape convention as the existing `DtoQuery`. Getters are used to
read the source's properties — a bare/fluent accessor like `active()` is resolved
automatically too, not just `getActive()`/`isActive()` (useful both for Ebean's own
record entity beans and for ordinary classes that just expose bare-name accessors).
### 2. Register the (source, target) pair
Declare each entity → DTO pair with `@DtoMapping` on a `package-info.java` (a neutral
holder — see [Why `package-info.java`?](#why-package-infojava)):
```java
@DtoMapping(source = Customer.class, target = CustomerDto.class)
@DtoMapping(source = Address.class, target = AddressDto.class)
@DtoMapping(source = Contact.class, target = ContactDto.class)
package org.example.dto;
import io.ebean.annotation.DtoMapping;
```
This triggers `querybean-generator` (the existing annotation processor) to generate a
`CustomerDtoMapper implements DtoMapper<Customer, CustomerDto>` for each pair — no new
Maven/Gradle setup beyond what query beans already require.
### 3. Query with `mapTo(...)`
```java
List<CustomerDto> dtos = DB.find(Customer.class)
.where().eq("status", Status.ACTIVE)
.mapTo(CustomerDto.class)
.findList();
CustomerDto one = new QCustomer().id.eq(id).mapTo(CustomerDto.class).findOne();
Optional<CustomerDto> maybe = new QCustomer().id.eq(id).mapTo(CustomerDto.class).findOneOrEmpty();
```
`mapTo(...)` works the same from a query bean (`QCustomer`) or a plain `DB.find(...)`/
`ExpressionList` query.
### Paging - `findPagedList()`
`findPagedList()` mirrors `Query#findPagedList()` — the underlying entity query is paged
as normal and each page's result is mapped to the target DTO list:
```java
PagedList<CustomerDto> paged = DB.find(Customer.class)
.where().eq("status", Status.ACTIVE)
.orderBy().asc("name")
.setFirstRow(0)
.setMaxRows(50)
.mapTo(CustomerDto.class)
.findPagedList();
int totalRowCount = paged.getTotalCount(); // page metadata - unaffected by DTO mapping
List<CustomerDto> page1 = paged.getList(); // mapped DTOs for this page
```
Page metadata (`getTotalCount()`, `getTotalPageCount()`, `hasNext()`, `hasPrev()`,
`loadCount()`, ...) reflects the underlying entity query directly; only `getList()`
is mapped (once, cached) to the DTO type.
### An unregistered pair fails fast
If `(Customer.class, SomeDto.class)` was never declared via `@DtoMapping`, the first
`mapTo(SomeDto.class)` call throws immediately:
```
PersistenceException: No DtoMapper registered mapping Customer -> SomeDto
- check @DtoMapping(source = Customer.class, target = SomeDto.class) is declared
on a package-info.java processed by querybean-generator
```
---
## Auto-derived fetch spec
You never write `.select()`/`.fetch()` for a `mapTo(...)` query — the generated mapper
exposes a `fetchGroup()` built directly from the DTO's declared shape, and `mapTo(...)`
applies it automatically:
```java
public CustomerDtoMapper() {
this(new AddressDtoMapper(), new ContactDtoMapper());
}
public CustomerDtoMapper(DtoMapper<Address, AddressDto> billingAddressMapper,
DtoMapper<Contact, ContactDto> contactsMapper) {
this.fetchGroup = FetchGroup.of(Customer.class)
.select("id,name")
.fetch("billingAddress", billingAddressMapper.fetchGroup())
.fetch("contacts", contactsMapper.fetchGroup())
.build();
}
```
Each nested DTO gets its own generated mapper (mirroring MapStruct's per-type mapper
generation), wired together via constructor injection — mappers are stateless and
substitutable, not static singletons. Mapper instances are constructed once, in
dependency order, and reused — see [DtoMapperManager](#one-mapper-instance-per-pair)
below.
---
## Nested collections and identity-aware de-duplication
When the same source entity instance is reachable via more than one path in the graph
(e.g. two `Contact`s sharing the same `Customer`, or the same `Address` referenced from
two paths), the mapper reuses the **same** target DTO instance rather than creating
duplicate-but-equal copies — mirroring the identity semantics the entity graph already
has:
```java
List<CustomerDto> dtos = DB.find(Customer.class).mapTo(CustomerDto.class).findList();
CustomerDto customer = dtos.get(0);
// both contacts share the exact same customer.billingAddress AddressDto instance
assertThat(customer.getContacts().get(0).getCustomer())
.isSameAs(customer.getContacts().get(1).getCustomer());
```
This is done via a `DtoMapContext` threaded through every nested `map(...)` call within
one top-level `mapList(...)`/`findList()` invocation. The generated code only pays for
this when it can actually matter — a DTO that's never nested under another DTO skips
`DtoMapContext` entirely (there's nothing else in scope to de-duplicate against):
```java
// AddressDto is nested under CustomerDto (reachable via multiple contacts) - dedup needed
// dedup using DtoMapContext, same Address instance can be reached via more than one path in the graph
return context.computeIfAbsent(AddressDto.class, source, s -> new AddressDto(...));
// ContactSummaryDto is only ever mapped as a top-level query result - no dedup possible
// skip DtoMapContext, only ever a top-level mapping
return new ContactSummaryDto(source.getId(), source.getFullName());
// CustomerDto has nested mappers (billingAddress, contacts) but is never itself nested
// DtoMapContext for nested mappers only
return new CustomerDto(source.getId(), source.getName(), ...);
```
The generated comment tells you at a glance which of the three cases applies — useful
when debugging why a `DtoMapContext` is (or isn't) in the generated code for a
particular mapper.
---
## Using generated mappers directly (outside `query.mapTo()`)
Every generated `XxxDtoMapper` is a plain public class — you don't need `ServiceLoader`,
a registry, or a `Database` just to construct or call one directly (though
`DtoMapperManager`, below, is available if you want a shared, DI-friendly lookup). It
always has a public no-arg constructor (delegating to defaults for any nested mappers/
`@DtoConvert` converters) plus an explicit constructor taking those dependencies directly,
and implements `DtoMapper<SOURCE, TARGET>`'s `map(...)`/`mapList(...)`:
```java
CustomerDtoMapper mapper = new CustomerDtoMapper();
CustomerDto dto = mapper.map(customer); // any Customer you already have on hand
List<CustomerDto> dtos = mapper.mapList(customers);
```
This works on **any** entity graph, not just one that just came out of a `mapTo(...)`
query — e.g. entities you loaded with a plain `.fetch(...)` query, entities you just
`.save()`d, or entities built by hand in a test. The only requirement is that whatever the
mapper reads (via plain getters) is actually populated — there's no lazy-loading fallback.
### Testing the mapping in isolation
Because mappers are plain, constructor-injected classes, you can unit test the mapping
logic itself — independent of `query.mapTo()`, the DTO-pair registry, and (for
`@DtoConvert` instance-dispatch converters) `DtoConverterManager` — by passing a test
double straight into the explicit constructor:
```java
SecretCipher upperCasingTestCipher = String::toUpperCase;
ContactConversionDto dto = new ContactConversionDtoMapper(upperCasingTestCipher).map(contact);
assertThat(dto.getSecretCode()).isEqualTo("SHH");
```
No `DtoConverterManager.put(...)` registration needed for this kind of test — the real
production wiring (`DtoConverterManager.get(SecretCipher.class)`) only happens in the
generated no-arg constructor, which the explicit-constructor call above bypasses entirely.
See `TestCustomerDtoGraphMapping` (mapper called directly against a manually queried
graph) and `TestMapperManualUsage` (mapper called directly against hand-built/just-saved
entities, plus the converter test-double case above) in `tests/test-dto-mapping`.
### `DtoMapperManager` — resolving a generated mapper for dependency injection
`new CustomerDtoMapper()` is enough for a single mapper, but if your application wants a
single shared instance of *every* generated mapper (mirroring how `query.mapTo()` resolves
them internally) - e.g. to wire one up for constructor injection into a service, replacing
a hand-written mapper class - use `io.ebean.DtoMapperManager`:
```java
DtoMapperManager manager = new DtoMapperManager(); // ServiceLoader discovery only - no Database needed
CustomerDtoMapper mapper = manager.get(CustomerDtoMapper.class);
```
`DtoMapperManager` has no dependency on `Database` at all - its constructor only does
`ServiceLoader.load(DtoMapperRegister.class)` - so it can be constructed independently,
before (or entirely without) a `Database`, e.g. as a bean in an avaje-inject (or any DI
framework's) dependency graph:
```java
@Factory
class DtoMapperFactory {
@Bean
DtoMapperManager dtoMapperManager() {
return new DtoMapperManager();
}
@Bean
CustomerDtoMapper customerDtoMapper(DtoMapperManager manager) {
return manager.get(CustomerDtoMapper.class);
}
}
```
If you also want `query.mapTo(...)` to use that *exact same* manager instance (so there's
only ever one instance of each generated mapper, whichever path resolves it), register it
via `DatabaseBuilder.putServiceObject` before building the `Database` - this is the same
`putServiceObject`/`getServiceObject` mechanism already used for things like
`AutoMigrationRunner`:
```java
DtoMapperManager sharedManager = new DtoMapperManager();
Database db = Database.builder()
.putServiceObject(DtoMapperManager.class, sharedManager)
.build();
// query.mapTo(...) against `db` now resolves mappers via `sharedManager`
```
If nothing is registered via `putServiceObject`, the `Database` builds its own default
`DtoMapperManager` instance instead - registering one is entirely optional. A standalone
`DtoMapperManager()` construction bypasses the `DatabaseConfigProvider` hook (that hook is
specifically about `Database` startup ordering), so if any of your mappers need a
`@DtoConvert` instance-dispatch converter, register it via `DtoConverterManager.put(...)`
yourself first, exactly as you would before building a `Database`. See
`TestDtoMapperManager` and `TestDtoMapperManagerSharing` in `tests/test-dto-mapping`.
### Recipe: adding extra caller-supplied fields after mapping
Sometimes a target DTO needs a field that isn't sourced from the entity graph at all - e.g.
populated from a separate query or business rule, only when a caller-supplied flag is set.
Rather than the generator supporting partial/builder-based mapping directly, if your DTO is
a record with a "seed from instance" builder (e.g. via `avaje-recordbuilder`'s
`@RecordBuilder`, which generates `Target.builder(existingInstance)`), just map the
graph-sourced fields as usual and layer the extra field on afterwards:
```java
Driver base = mapper.map(cDriver);
Driver full = DriverBuilder.builder(base).fleets(fleets).build();
```
No generator changes needed - the mapped instance is simply the seed for the builder.
---
## Large targets: builder-based construction and named variants
Two features aimed at large, builder-shaped target DTOs (typically OpenAPI-generated records
with a generated builder), where a positional constructor call is unwieldy and a single query
needs to populate the target in more than one shape.
### Builder-based construction (`builder = AUTO | ALWAYS | NEVER`)
If the target has a static no-arg `Target.builder()` factory returning a type with a fluent
(returns-itself) setter per property plus a `build()` method - the shape
`avaje-recordbuilder`'s `@RecordBuilder` generates - the generated mapper can construct the
target via `Target.builder().prop(x)....build()` instead of `new Target(a, b, c, ...)`:
```java
public record User(Long id, String name, String email, /* ... 21 more fields */) {
public static UserBuilder builder() {
return UserBuilder.builder();
}
}
```
```java
@DtoMapping(source = CUser.class, target = User.class)
package org.example.dto;
```
By default (`builder = AUTO`), the generator auto-detects a matching builder and uses it only
once the target has more than 5 properties, falling back to a positional constructor for
smaller DTOs. Override explicitly either direction:
```java
@DtoMapping(source = CUser.class, target = User.class, builder = DtoMapping.Builder.ALWAYS)
```
`builder = ALWAYS` is a codegen-time error if no matching builder shape is found; `builder =
NEVER` always uses a positional constructor even if a builder is detected. This applies
regardless of whether the target is hand-authored or foreign/generated - `@DtoMapping` is
already declared externally via `package-info.java`, so no annotation on the target itself is
needed either way.
### Named variants excluding nested paths (`name=`, `exclude=`)
The same `(source, target)` pair can be registered more than once - one base mapping (leaving
`name()` empty) plus any number of named variants, each excluding one or more nested
ToOne/ToMany properties:
```java
@DtoMapping(source = CUser.class, target = User.class)
@DtoMapping(source = CUser.class, target = User.class, name = "noFleets", exclude = "fleets")
package org.example.dto;
```
Both variants are generated into the **same** mapper class (one class per target, not one per
variant) - the generated `noFleets()` accessor returns a single shared/cached `DtoMapper<CUser,
User>` view (not reconstructed per call), omitting `fleets` from both its mapped output (`null`
for a ToOne, `List.of()` for a ToMany) and its own `fetchGroup()`. Each excluded property is still
evaluated inline at its own declared field position internally (guarded by a boolean flag) - a
variant's exclusions never change the evaluation order of the DTO's other properties. Select it
with the `query.mapTo(Class, DtoMapper)` overload, which takes an already-resolved mapper instance
directly - no string-based lookup:
```java
UserMapper userMapper = new UserMapper();
// full shape, with fleets fetched/mapped
List<User> withFleets = DB.find(CUser.class)
.mapTo(User.class, userMapper) // or plain .mapTo(User.class)
.findList();
// bulk listing shape - fleets excluded from both the fetch spec and the output
List<User> noFleets = DB.find(CUser.class)
.mapTo(User.class, userMapper.noFleets())
.findList();
```
Only nested ToOne/ToMany properties can be excluded - a scalar or `@DtoRef` property can't be,
since there's no type-safe "absent" value for an arbitrary scalar type. Named variants are
scoped to independent, top-level query results only - unlike the base mapping, they don't
participate in `DtoMapContext` identity de-duplication when nested elsewhere in a graph, since a
variant is never intended to be nested inside another DTO's mapping.
---
## `@DtoPath` — renamed or flattened properties
By default a DTO property is matched to the source entity property (or nested DTO
mapper) of the **same name**. `@DtoPath` overrides that, allowing a DTO property to be
renamed and/or flattened from a nested path using dot-notation:
```java
public class ContactDto {
private final long id;
private final String firstName;
private final String lastName;
@DtoPath("customer.billingAddress.city")
private final String customerCity; // flattened, 2 hops through customer
// constructor / getters ...
}
```
The generated mapper reads the path with a null-guard at each hop and adds the
necessary joins to the fetch spec automatically:
```java
(s.getCustomer() == null ? null
: (s.getCustomer().getBillingAddress() == null ? null
: s.getCustomer().getBillingAddress().getCity()))
```
`@DtoPath` is purely a compile-time/codegen-time hint — the DTO class itself carries no
runtime dependency on the annotation.
### Fetch-path collisions are a compile-time error
A `@DtoPath` whose fetch path is identical to a nested `ToOne`/`ToMany` property's own
fetch path on the *same* DTO (e.g. a nested `customer` field alongside
`@DtoPath("customer.name")` — both resolve to fetch path `"customer"`) fails the build
with a clear error, rather than silently discarding one side's fetched properties:
```
error: @DtoPath property 'customerName' on FooDto resolves to fetch path 'customer',
which collides with the nested mapping already using that same fetch path - Ebean's
fetch spec can only carry one set of properties per path, so one silently discards
the other. Move 'customerName' onto the nested DTO type instead, or choose a
@DtoPath that reaches into a different, non-colliding path.
```
Fix it either way it suggests: move the property onto the nested DTO type, or choose a
`@DtoPath` that reaches a different path (as `customerCity` above does deliberately,
using a 3-segment path through `customer.billingAddress` rather than colliding with a
plain `customer` nested field).
---
## `@DtoRef` — id-only back-references (breaking cycles)
The DTO graph derived from a set of DTO types must form a DAG — codegen fails if it
doesn't. `@DtoRef` is the explicit escape hatch for an intentional back-reference, e.g.
a `Contact` DTO referencing its parent `Customer` by id only, rather than re-embedding
a full `CustomerDto` (which would recreate the `Customer → Contact → Customer` cycle):
```java
public class ContactDto {
private final long id;
@DtoRef
private final Long customerId; // id-only, no nested CustomerDto re-embedded
// constructor / getters ...
}
```
The generated fetch spec adds the association to the **root** `select(...)` rather
than a nested `.fetch(...)` — this reads the foreign-key column directly off the base
table (no SQL join):
```java
this.fetchGroup = FetchGroup.of(ContactStats.class)
.select("customer,contactCount,engagementScore") // "customer" -> FK column, no join
.build();
```
```java
(source.getCustomer() == null ? null : source.getCustomer().getId())
```
If the same association is *also* independently nested-fetched elsewhere on the DTO
(e.g. `ContactDto` has both a nested `customer` field **and** `@DtoRef Long
customerId`), the generator recognizes the association is already covered and doesn't
add a redundant/duplicate select — no join is added twice.
---
## `@DtoConvert` — custom property conversion
Some properties need more than a plain getter copy — a scalar coercion (`short` to
`boolean`), an enum-to-`String` mapping, or a conversion needing a real dependency (e.g.
decrypting a value with a cipher). `@DtoConvert(value = ConverterType.class, method =
"name")` covers both, combinable with `@DtoPath` when the source value also needs a
path/rename override:
```java
public class ContactDto {
@DtoPath("status")
@DtoConvert(value = ContactConversions.class, method = "toActive")
private final boolean active; // Contact.status (Short) -> boolean
@DtoConvert(value = SecretCipher.class, method = "decode")
private final String secretCode; // decrypted via a registered SecretCipher
// constructor / getters ...
}
```
The generator resolves the referenced method at codegen time and dispatches one of two
ways, purely based on whether it's `static`:
- **Static method** — inlined as a direct static call
(`ContactConversions.toActive(source.getStatus())`). No registration needed at all —
use this for common, reusable, dependency-free coercions.
- **Instance method** — the generated mapper resolves one shared instance via
`DtoConverterManager.get(SecretCipher.class)`, wired as a constructor
parameter/field (the same shape as nested-mapper constructor injection), then calls
`secretCipher.decode(source.getSecretCode())`. Use this when the conversion needs a
real dependency.
### Registering an instance-dispatch converter
`DtoConverterManager` is a small, deliberately-scoped static put/get bridge — register
an already-constructed converter instance (e.g. built by your DI container) **before**
building the `Database`:
```java
AES256Cipher cipher = ...; // already DI-constructed
DtoConverterManager.put(SecretCipher.class, cipher::decrypt); // or a small adapter class
Database db = DatabaseFactory.create(...); // generated mappers resolve converters from here
```
If nothing is registered for a required type, `DtoConverterManager.get(...)` throws a
`PersistenceException` immediately — this happens as an eager field initializer on the
generated `EbeanDtoMapperRegister`, so a missing registration fails fast at `Database`
build time, not lazily on first `mapTo(...)` call.
> **Testing tip:** since `EbeanDtoMapperRegister`'s mapper fields are all constructed
> together when the `Database` starts, register converters via a `DatabaseConfigProvider`
> (a `ServiceLoader` hook that runs before the `Database` is built) rather than a test
> `@BeforeAll`, so registration always happens before *any* test triggers startup —
> regardless of which test class runs first.
## `@DtoMixin` — overlaying annotations onto a DTO you can't edit
Some DTOs are generated elsewhere (e.g. from an OpenAPI spec, regenerated on every
build) and can't be annotated directly. `@DtoMixin(Target.class)` overlays
`@DtoPath`/`@DtoRef`/`@DtoConvert` from a separate companion type instead — directly
mirroring avaje-jsonb's `@Json.MixIn` mechanism. Declare a companion interface (or
class) whose method names match the target DTO's property names:
```java
// ContactMixinDto itself carries no Ebean annotations at all
public class ContactMixinDto {
public ContactMixinDto(long id, String firstName, boolean active, String secretCode) { ... }
// getters ...
}
@DtoMixin(ContactMixinDto.class)
interface ContactMixinDtoMixin {
@DtoPath("status")
@DtoConvert(value = ContactConversions.class, method = "toActive")
boolean active();
@DtoConvert(value = SecretCipher.class, method = "decode")
String secretCode();
}
```
The processor matches each mixin method to the target's property by name and applies
whichever annotations are present as if they were declared on the target field itself.
The mixin type is never instantiated and carries no runtime footprint — it's purely a
compile-time/codegen-time hint.
---
## Computed / aggregate properties via `@Entity @View`
There's no dedicated "formula on DTO" annotation (a narrower `@Formula2`-on-DTO
variant was explored and rejected — see
[dto-mapping-design.md](../dto-mapping-design.md) for the reasoning). Instead, model
the computed value as its own read-only entity using `@View`, then map that entity to a
plain DTO with the same `@DtoMapping` machinery described above. `@View(name = "...")`
here just points a second entity at an **existing** table — it does not create a new
database view or table.
### Worked example — computed column (`@Formula2`)
```java
@Entity
@View(name = "contact") // reads the existing 'contact' table, no new DDL
public class ContactSummary {
@Id
private Long id;
private String firstName;
private String lastName;
@Formula2("concat(firstName, ' ', lastName)")
private String fullName;
// getters ...
}
```
```java
public class ContactSummaryDto {
private final Long id;
private final String fullName;
// constructor / getters ...
}
```
```java
@DtoMapping(source = ContactSummary.class, target = ContactSummaryDto.class)
```
```java
List<ContactSummaryDto> summaries = DB.find(ContactSummary.class)
.mapTo(ContactSummaryDto.class)
.findList();
```
### Worked example — group-by aggregation (`@Sum`/`@Aggregation`)
The same `@View`-on-base-table pattern applies to Ebean's `@Sum`/`@Aggregation`
group-by formulas — the Blaze-Persistence parallel is an `@EntityView` with
`@Mapping("SIZE(...)")`/`@Mapping("SUM(...)")` correlated mappings:
```java
@Entity
@View(name = "contact")
public class ContactStats {
@Id
private Long id; // required so @Aggregation("count(id)") has something to
// count; deliberately never selected/mapped - selecting it
// would defeat the aggregation (one row per contact
// instead of one row per customer)
@ManyToOne
private Customer customer;
@Aggregation("count(id)")
private Long contactCount;
@Sum
private Integer engagementScore;
// getters ...
}
```
```java
public class ContactStatsDto {
@DtoRef
private final Long customerId; // also the implicit GROUP BY key
private final Long contactCount;
private final Integer engagementScore;
// constructor / getters ...
}
```
Because `customerId` uses `@DtoRef`, the generated fetch spec is
`select("customer,contactCount,engagementScore")` with **no join** — the query groups
by the FK column directly:
```sql
select t0.customer_id, count(t0.id), sum(t0.engagement_score)
from contact t0
group by t0.customer_id
```
---
## Performance notes
### Fail-fast, no accidental lazy loading
`mapTo(...)` forces `query.setUnmodifiable(true)` under the hood. If the mapper ever
needs a property that wasn't fetched, it throws `LazyInitialisationException`
immediately rather than silently issuing an extra query per row or returning `null`.
`InterceptReadOnly` (the unmodifiable-graph bean state) is also cheap — a `boolean[]
loaded` flag array plus a `frozen` flag, not a full second copy of bean state.
### One mapper instance per pair
Generated mappers are constructed once (in dependency order — a mapper with nested
mappers takes them as constructor params) and reused across every `mapTo(...)` call for
that pair, resolved and cached by `DtoMapperManager` keyed on `(sourceType, dtoType)`.
### `DtoMapContext` overhead only where it earns its keep
As shown above, the generator only involves `DtoMapContext` for mappers that can
actually be reached via more than one path in some graph (dedup) or that have nested
mappers of their own (need to thread the context down); a DTO that's only ever a
top-level query result skips it entirely.
### Fetch strategy and pagination carry over unchanged
Existing fetch-strategy control (`+query`/`+lazy`, `fetchQuery()`) and pagination
(including keyset pagination and `findPagedList()`) work the same whether the query
target is an entity graph or a `mapTo(...)` DTO graph — no special-casing needed.
---
## Which should I use?
- **`mapTo(Dto.class)`** — the target is a **nested** shape (has its own ToOne/ToMany
DTO fields) that should mirror part of the entity graph; you want the fetch spec
derived automatically and verified to match the DTO's declared shape.
- **`asDto(Dto.class)`** / `DB.findDto(...)` — the target is a **flat** row (report,
summary, native/vendor SQL); you're comfortable with runtime-checked column-to-bean
matching, or the SQL doesn't map cleanly to entity property paths at all.
- **Plain entity query** — the caller needs a real, persistable, mutable entity — not a
read-only projection.
---
## Reference
### Why `package-info.java`?
`@DtoMapping` is declared on a package (`ElementType.PACKAGE`), not the DTO or the
entity, because:
- the DTO type is often owned/generated elsewhere (e.g. from an OpenAPI spec) and
shouldn't need to be annotated with an internal persistence/entity type;
- one entity may be the source for several different DTOs (e.g. a summary vs. a detail
view), and the same entity/DTO pair may need registering from multiple consuming
modules.
### Annotations at a glance
| Annotation | Target | Purpose |
|---|---|---|
| `@DtoMapping(source=, target=)` | `package-info.java` | Registers an entity → DTO pair, triggers mapper generation |
| `@DtoMapping(..., builder=)` | `package-info.java` | `AUTO` (default, threshold-based) / `ALWAYS` / `NEVER` - builder-chain vs positional constructor |
| `@DtoMapping(..., name=, exclude=)` | `package-info.java` | Registers a named variant sharing the base mapping's generated class, excluding nested paths |
| `@DtoPath("a.b.c")` | DTO field/getter | Renamed and/or flattened multi-hop property mapping |
| `@DtoRef` | DTO field/getter | Id-only back-reference; breaks a cycle; root-selects the FK (no join) |
| `@DtoConvert(value=, method=)` | DTO field/getter | Custom scalar conversion - static (no registration) or instance (via `DtoConverterManager`) dispatch |
| `@DtoMixin(Target.class)` | Companion interface/class | Overlays `@DtoPath`/`@DtoRef`/`@DtoConvert` onto a DTO that can't be annotated directly |
### Parallels with other tools
If you're coming from another mapping library, here's the rough correspondence:
| Ebean | MapStruct | Blaze-Persistence |
|---|---|---|
| Generated `DtoMapper` per (source, DTO) pair | Generated `@Mapper` implementation | `@EntityView` (interface + runtime proxy) |
| `@DtoPath("a.b.c")` | `@Mapping(target = "x", source = "a.b.c")` | `@Mapping("a.b.c")` |
| `@DtoRef` | `@Context`/manual cycle-breaking (no dedicated annotation) | Sub-view referencing an id-only projection |
| `@DtoConvert(value=, method=)` | `@Mapping(qualifiedByName = "...")` / custom mapper methods | Custom converter/`@Mapping` expression |
| `@DtoMixin(Target.class)` | N/A (annotate the `@Mapper` interface's abstract methods instead) | N/A |
| `DtoMapContext` identity de-dup | Not built in (opt-in `@MappingTarget`/manual caching) | Built in (entity-view identity) |
| `@Entity @View` + `@Formula2`/`@Sum`/`@Aggregation` for computed DTO values | N/A (MapStruct doesn't touch SQL) | `@Mapping("SIZE(...)")` / `@Mapping("SUM(...)")` correlated mappings |
See [dto-mapping-design.md](../dto-mapping-design.md) for the full design rationale and
[dto-mapping-requirements.md](../dto-mapping-requirements.md) for the accepted/rejected
requirements this feature was scoped against (issue
[#2540](https://github.com/ebean-orm/ebean/issues/2540)).
+464
View File
@@ -0,0 +1,464 @@
# Guide: Using `RawSql` with Ebean
## Purpose
`RawSql` lets you back an Ebean bean with a **hand-written SQL query** instead of
Ebean generating the SQL from the entity mapping. Ebean still handles object
mapping (result set columns → bean properties), lazy loading of associated beans,
and - depending on how the `RawSql` is built - dynamic `WHERE`/`HAVING` predicates
added through the normal query API.
Use this guide when you need to:
- run vendor-specific SQL, complex aggregation, or reporting queries that don't
map cleanly to an ORM query
- reuse a hand-tuned query but still want typed/dynamic predicates, paging, or
`ORDER BY` added by the caller
- back a query bean (`Q*`) or DTO-like bean with SQL containing a CTE, window
function, or subquery in the `FROM` clause
Prefer ordinary query bean queries first - see
[Write Ebean queries with query beans](writing-ebean-query-beans.md), Step 9,
for the full decision order (query bean → `asDto()` → DTO query → raw SQL).
This guide covers raw SQL once you've decided it's the right tool.
---
## The bean behind a `RawSql` query
A bean queried with `RawSql` is not necessarily backed by a physical table. Annotate
it `@Entity @Sql` to tell Ebean it is mapped via `RawSql` rather than table DDL:
```java
@Entity
@Sql
public class OrderAggregate {
@OneToOne
Order order;
Double totalAmount;
Long totalItems;
// getters/setters
}
```
`@Sql` beans still get a generated query bean (`QOrderAggregate`) if the
querybean-generator annotation processor is configured - see
[Using `RawSql` with query beans](#using-rawsql-with-query-beans) below.
You can also query an ordinary table-backed `@Entity` with `RawSql` - the column
mapping just needs to line up with that entity's properties.
---
## Building a `RawSql` - three factory methods
`RawSqlBuilder` has three ways to construct a `RawSql`, depending on how much of
the SQL Ebean needs to understand:
| Method | SELECT columns parsed? | Dynamic WHERE/HAVING/ORDER BY? | Use for |
|--------|------------------------|------------------------|---------|
| `RawSqlBuilder.parse(sql)` | Yes | Yes | Ordinary `SELECT ... FROM ... WHERE ...` statements |
| `RawSqlBuilder.unparsed(sql)` | No | No | Fixed SQL that never needs additional predicates |
| `RawSqlBuilder.withPlaceholders(sql)` | No (explicit `columnMapping()` required) | Yes, via `${where}` / `${andWhere}` / `${having}` / `${andHaving}` / `${orderBy}` / `${andOrderBy}` | CTEs, window functions, subqueries - SQL that keyword-based parsing can't handle |
### `parse(sql)` - the common case
`parse(sql)` scans the SQL text for the `select` / `from` / `where` / `group by`
/ `having` / `order by` keywords to work out the SELECT column list (so it can
validate your column mappings) and the injection points for dynamic `WHERE`/
`HAVING` expressions.
```java
RawSql rawSql = RawSqlBuilder.parse(
"select c.id, c.name, c.status from customer c")
.columnMapping("c.id", "id")
.columnMapping("c.name", "name")
.columnMapping("c.status", "status")
.create();
List<Customer> customers = DB.find(Customer.class)
.setRawSql(rawSql)
.where().eq("status", Customer.Status.ACTIVE)
.orderBy("name")
.findList();
```
Because the SQL is parsed, mistakes in `columnMapping()` (unknown column, wrong
order for `unparsed`-style mappings) are caught early. **This fails on SQL the
keyword parser can't make sense of** - a `WITH` CTE, a window function, a
subquery in `FROM`, etc. - because the keyword positions found don't correspond
to the outer query's real structure. Use `withPlaceholders(sql)` for that SQL
instead (see below).
### `unparsed(sql)` - fixed queries
`unparsed(sql)` skips all parsing. The SQL is used exactly as written, and **no
further `WHERE`/`HAVING`/`ORDER BY` can be added** by the caller - useful for a
completely fixed reporting query with no caller-supplied filtering.
```java
RawSql rawSql = RawSqlBuilder.unparsed(
"select id, name, status from customer where status = 'ACTIVE'")
.columnMapping("id", "id")
.columnMapping("name", "name")
.columnMapping("status", "status")
.create();
List<Customer> customers = DB.find(Customer.class)
.setRawSql(rawSql)
.findList();
```
Column mappings for `unparsed(sql)` must be supplied **in the same order** as
the columns appear in the SQL, since there's no parsing to match them by name.
### `withPlaceholders(sql)` - complex SQL (CTEs, window functions, subqueries)
`withPlaceholders(sql)` avoids keyword scanning entirely. You mark exactly where
a dynamic `WHERE`/`HAVING`/`ORDER BY` expression should be injected using
placeholder tokens, and column mappings are always explicit (as with `unparsed`).
#### Placeholder reference
| Placeholder | Meaning | Use when |
|-------------|---------|----------|
| `${where}` | Insert a new `WHERE <expr>` clause here | No static `WHERE` clause exists yet at this point in the SQL |
| `${andWhere}` | Insert `AND <expr>` here | A static `WHERE ...` clause already exists in the SQL and you want to append to it |
| `${having}` | Insert a new `HAVING <expr>` clause here | No static `HAVING` clause exists yet at this point in the SQL |
| `${andHaving}` | Insert `AND <expr>` here | A static `HAVING ...` clause already exists in the SQL and you want to append to it |
| `${orderBy}` | Insert a new `ORDER BY <expr>` clause here | No static `ORDER BY` clause exists yet at this point in the SQL, and callers may supply `.orderBy(...)` |
| `${andOrderBy}` | Insert `, <expr>` here | A static `ORDER BY ...` clause already exists in the SQL and you want callers to be able to append extra sort columns to it |
Rules:
- At least one placeholder is required - `withPlaceholders(sql)` throws
`IllegalArgumentException` if none of the six tokens are present.
- Use only the placeholders you need. Omit `${where}`/`${andWhere}` entirely if
the query never needs a dynamic `WHERE` (e.g. only a dynamic `HAVING` on an
aggregate). Omit `${having}`/`${andHaving}` if there's no dynamic `HAVING`.
Omit `${orderBy}`/`${andOrderBy}` if the ordering is always fixed.
- Explicit `columnMapping()` is required for every returned column - there is no
column-list parsing to infer names from.
- **A caller-supplied `.orderBy(...)`/`.order(...)` is only applied if the SQL
contains an `${orderBy}` or `${andOrderBy}` placeholder.** Without one of
those placeholders there is no defined injection point for dynamic ordering,
so any `.orderBy(...)` call on the query is safely ignored rather than risk
producing invalid SQL - even if the template has a static trailing
`ORDER BY ...` of its own. If you need callers to be able to influence
ordering, add `${orderBy}` (no existing static order by) or `${andOrderBy}`
(append after an existing static order by).
- Any other static SQL that follows a `${where}`/`${having}` placeholder (e.g.
a trailing `GROUP BY`) is preserved and correctly positioned **after**
whatever dynamic expression gets injected at that placeholder.
#### Example - CTE with `${where}`
```java
String sql = """
with order_totals as (
select o.id as order_id, sum(d.qty * d.unit_price) as total_amount
from o_order o
join o_order_detail d on d.order_id = o.id
group by o.id
)
select order_id, total_amount
from order_totals
${where}
order by order_id
""";
RawSql rawSql = RawSqlBuilder.withPlaceholders(sql)
.columnMapping("order_id", "order.id")
.columnMapping("total_amount", "totalAmount")
.create();
List<OrderAggregate> list = DB.find(OrderAggregate.class)
.setRawSql(rawSql)
.where().gt("totalAmount", 100)
.findList();
```
`total_amount` is a genuine column of the `order_totals` CTE here, so it's valid
to filter on it in the outer `WHERE` - this only works because the aggregate is
computed inside the CTE rather than as a same-level `SELECT` alias.
#### Example - static `WHERE` already present, append with `${andWhere}`
```java
String sql = "... from order_totals where total_amount > 0 ${andWhere} order by order_id";
RawSql rawSql = RawSqlBuilder.withPlaceholders(sql)
.columnMapping("order_id", "order.id")
.columnMapping("total_amount", "totalAmount")
.create();
// executed SQL: ... where total_amount > 0 and total_amount > ? order by order_id
DB.find(OrderAggregate.class)
.setRawSql(rawSql)
.where().gt("totalAmount", 100)
.findList();
```
#### Example - `${having}` only, filtering on an aggregate directly
No `WHERE` placeholder is needed if you only ever filter on the aggregate value:
```java
String sql =
"select o.id as order_id, sum(d.qty * d.unit_price) as total_amount" +
" from o_order o join o_order_detail d on d.order_id = o.id" +
" group by o.id" +
" ${having}" +
" order by order_id";
RawSql rawSql = RawSqlBuilder.withPlaceholders(sql)
.columnMapping("order_id", "order.id")
.columnMapping("total_amount", "totalAmount")
.create();
List<OrderAggregate> list = DB.find(OrderAggregate.class)
.setRawSql(rawSql)
.having().gt("totalAmount", 100)
.findList();
```
The dynamic `HAVING` clause is injected before the static trailing `ORDER BY`,
even though `${having}` is the only placeholder present. Because there's no
`${orderBy}`/`${andOrderBy}` placeholder here, a caller-supplied `.orderBy(...)`
would be ignored - the ordering stays fixed as `order by order_id`.
#### Example - both `${where}` and `${having}`
```java
String sql =
"select o.id as order_id, sum(d.qty * d.unit_price) as total_amount" +
" from o_order o join o_order_detail d on d.order_id = o.id" +
" ${where}" +
" group by o.id" +
" ${having}" +
" order by order_id";
RawSql rawSql = RawSqlBuilder.withPlaceholders(sql)
.columnMapping("order_id", "order.id")
.columnMapping("total_amount", "totalAmount")
.create();
List<OrderAggregate> list = DB.find(OrderAggregate.class)
.setRawSql(rawSql)
.where().gt("order.id", 0)
.having().gt("totalAmount", 50)
.findList();
```
Both the dynamic `WHERE` and dynamic `HAVING` are injected at their respective
placeholders, and the trailing `order by order_id` is preserved after the
`HAVING` clause.
#### Example - `${orderBy}`, fully dynamic ordering
Use `${orderBy}` when there's no static default ordering and you want the
caller's `.orderBy(...)` to control it entirely:
```java
String sql =
"with order_totals as (" +
" select o.id as order_id, sum(d.qty * d.unit_price) as total_amount" +
" from o_order o join o_order_detail d on d.order_id = o.id" +
" group by o.id" +
")" +
" select order_id, total_amount from order_totals" +
" ${where}" +
" ${orderBy}";
RawSql rawSql = RawSqlBuilder.withPlaceholders(sql)
.columnMapping("order_id", "order.id")
.columnMapping("total_amount", "totalAmount")
.create();
// executed SQL: ... where total_amount > ? order by total_amount desc
List<OrderAggregate> list = DB.find(OrderAggregate.class)
.setRawSql(rawSql)
.where().gt("totalAmount", 0)
.orderBy("totalAmount desc")
.findList();
```
If the caller doesn't call `.orderBy(...)`, nothing is injected at `${orderBy}`
and no `ORDER BY` clause is emitted at all.
#### Example - `${andOrderBy}`, appending to a static default ordering
Use `${andOrderBy}` when there's a sensible static default ordering but you
want callers to be able to add extra tie-breaker sort columns:
```java
String sql =
"... from order_totals" +
" ${where}" +
" order by total_amount desc ${andOrderBy}";
RawSql rawSql = RawSqlBuilder.withPlaceholders(sql)
.columnMapping("order_id", "order.id")
.columnMapping("total_amount", "totalAmount")
.create();
// executed SQL: ... order by total_amount desc , order_id
List<OrderAggregate> list = DB.find(OrderAggregate.class)
.setRawSql(rawSql)
.where().gt("totalAmount", 0)
.orderBy("order.id")
.findList();
```
---
## Using `fetchQuery()` to build out more of the graph
A `RawSql` query can be the **root query** and still use `fetchQuery(path)` the
same way an ordinary ORM query does - Ebean runs the raw SQL for the root rows,
then runs additional secondary ORM queries to populate the requested paths. This
lets you hand-write only the part of the query that needs raw SQL (e.g. an
aggregate/CTE) and let the ORM build out the rest of the object graph normally.
```java
List<OrderAggregate> list = DB.find(OrderAggregate.class)
.setRawSql(rawSql) // root query - runs the CTE/aggregate SQL
.fetchQuery("order") // secondary query - loads the full Order
.fetchQuery("order.details") // secondary query - loads Order.details
.where().gt("totalAmount", 50)
.findList();
```
This executes **three** queries: the raw SQL root query, then one secondary
query per `fetchQuery(path)` call.
**Important**: if the raw SQL's column mapping only populates part of an
association (e.g. only `order.id`, as in the examples above), that association
is a *partial reference*. To load a nested to-many under it (e.g.
`order.details`), you must add an explicit `fetchQuery(...)` (or `fetch(...)`)
for the **intermediate path** (`order`) as well as the nested path
(`order.details`) - `fetchQuery("order.details")` alone will leave `details` as
a deferred/lazy collection, because Ebean doesn't otherwise have a fetch node
for `order` to hang the secondary query off. If the raw SQL already selects the
full set of columns for an association directly (no partial reference), this
extra step isn't needed.
This is the same `fetchQuery()` mechanism used for ordinary query bean queries -
see [Use `fetchQuery()` for to-many paths](writing-ebean-query-beans.md#step-7---use-fetchquery-for-to-many-paths-and-fetchgroup-for-reusable-query-shapes)
for background on why to-many paths are loaded via secondary queries rather than
a single joined query.
---
## Column mapping
Every `RawSqlBuilder` (except a bare `unparsed(sql)` with implicit positional
mapping) uses `columnMapping(dbColumn, propertyName)` to map SQL result columns
to bean properties:
```java
.columnMapping("order_id", "order.id") // maps to the "order" association's "id" property
.columnMapping("total_amount", "totalAmount")
```
- Dotted property paths (e.g. `"order.id"`) map a column into a nested/associated
bean property.
- `columnMappingIgnore(dbColumn)` marks a selected column as intentionally unmapped
(present in the SQL but not needed on the bean).
- `tableAliasMapping(tableAlias, path)` bulk-renames every mapping using a given
SQL table alias to be prefixed with a bean property path - handy when a `parse()`
query selects many columns from a joined table (e.g. alias `c` → path `customer`)
and you don't want to repeat the prefix in every `columnMapping()` call.
---
## Using `RawSql` with query beans
`RawSql` is not limited to the plain `Query<T>` API - it also works with a
generated query bean, giving type-safe `where()`/`having()`-equivalent
expressions (as bean properties) over hand-written SQL. Every generated query
bean exposes `setRawSql(...)`:
```java
RawSql rawSql = RawSqlBuilder.parse("select id, name, status from customer")
.columnMapping("id", "id")
.columnMapping("name", "name")
.columnMapping("status", "status")
.create();
List<Customer> customers = new QCustomer()
.setRawSql(rawSql)
.status.equalTo(Customer.Status.ACTIVE) // typed expression, injected into the parsed WHERE clause
.findList();
```
This also works with `withPlaceholders(sql)` and an `@Sql` query bean:
```java
List<OrderAggregate> list = new QOrderAggregate()
.setRawSql(rawSql) // built with withPlaceholders() as shown above
.totalAmount.gt(100)
.findList();
```
The typed property expression (`.totalAmount.gt(100)`) is translated to a bound
predicate and injected at the `${where}`/`${having}` placeholder position, exactly
as `.where().gt("totalAmount", 100)` would be on the plain `Query<T>` API.
---
## Common anti-patterns
### Anti-pattern 1 - reaching for raw SQL before trying a query bean
Complex-looking joins are often just ordinary association traversal in a query
bean. Don't use raw SQL just because a query touches several tables - see
[Write Ebean queries with query beans](writing-ebean-query-beans.md).
### Anti-pattern 2 - using `parse(sql)` on a CTE or window-function query
`parse(sql)` will throw a parsing exception (or silently mis-locate the WHERE
injection point) on SQL it can't understand structurally. If your SQL starts
with `WITH ...` or has a subquery in `FROM`, use `withPlaceholders(sql)` instead.
### Anti-pattern 3 - filtering on a same-level SELECT alias
You cannot add a dynamic `WHERE` predicate on a `SELECT`-clause alias in the
same query level (e.g. `select sum(x) as total ... ${where}` - `total` isn't a
real column yet at the `WHERE` stage of that query level). Either:
- move the aggregation into a CTE and filter on the CTE's output column in the
outer query (`WHERE` case), or
- use `${having}`/`${andHaving}` to filter on the aggregate at the `HAVING` stage
of the same query level, where the aggregate expression is valid.
### Anti-pattern 4 - forgetting `columnMapping()` with `unparsed()`/`withPlaceholders()`
Both `unparsed(sql)` and `withPlaceholders(sql)` require **every** returned
column to be explicitly mapped (or explicitly ignored via
`columnMappingIgnore(...)`) - there's no column-list parsing to infer them.
---
## Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| `RuntimeException: Error parsing sql, can not find ... keyword` | `parse(sql)` used on SQL with a CTE, window function, or subquery in `FROM` | Use `RawSqlBuilder.withPlaceholders(sql)` instead |
| `IllegalArgumentException: withPlaceholders() requires at least one of ${where}, ${andWhere}, ${having}, ${andHaving}, ${orderBy}, ${andOrderBy}...` | None of the six placeholder tokens were found in the SQL | Add the appropriate placeholder token at the injection point |
| Dynamic `WHERE`/`HAVING` predicate silently has no effect, or query throws | Used `unparsed(sql)` and then tried to add a predicate | `unparsed(sql)` queries cannot be modified - switch to `parse(sql)` or `withPlaceholders(sql)` |
| Generated SQL is invalid / clauses appear in the wrong order | Predicates added via `.where()`/`.having()` don't match the placeholders actually present in the SQL | Make sure `${where}`/`${having}` (or the `and` variants) exist at the point you expect predicates to be injected |
| `.orderBy(...)`/`.order(...)` on the query silently has no effect | The SQL has no `${orderBy}`/`${andOrderBy}` placeholder | This is by design - without one of those placeholders there's no defined injection point, so the ordering is ignored rather than corrupting the SQL. Add `${orderBy}` or `${andOrderBy}` if you need caller-controlled ordering |
| `Unknown column` / unmapped property error | Missing `columnMapping()` for a selected column | Add a `columnMapping(...)` or `columnMappingIgnore(...)` for every SQL column |
| `fetchQuery("a.b")` collection stays deferred/lazy | `a` is a partial reference from the raw SQL column mapping (e.g. only `a.id` mapped), and there's no fetch node for `a` itself | Add `fetchQuery("a")` (or `fetch("a")`) alongside `fetchQuery("a.b")` |
---
## Related documentation
- [Write Ebean queries with query beans](writing-ebean-query-beans.md)
- [Derived / formula properties (`@Formula`, `@Formula2`)](derived-formula-properties.md)
- [Ebean query docs](https://ebean.io/docs/query/)
+30
View File
@@ -462,6 +462,11 @@ List<CustomerSummary> summaries = new QCustomer()
- the result is not going to be updated and saved back as an entity
- the query contains formulas or aggregation intended for a read model
`asDto(...)` maps a **flat**, single-row result. If the target DTO itself needs nested
DTO fields (ToOne/ToMany) mirroring part of the entity graph, use
`mapTo(Dto.class)` instead — see
[Mapping entity graphs to DTOs](mapping-entity-graphs-to-dtos.md).
---
## Step 9 - Only fall back to raw SQL when the ORM query is not a good fit
@@ -483,6 +488,30 @@ Prefer the following order:
Do **not** jump to raw SQL just because the query joins multiple tables. Query
beans already handle ordinary relationship traversal well.
### Using `RawSql` with query beans
`RawSql` is not limited to the plain `Query<T>` API - it also works with a
generated query bean, giving type-safe `where()`/`having()` expressions over
hand-written SQL. Every generated query bean exposes `setRawSql(...)`:
```java
RawSql rawSql = RawSqlBuilder.parse("select id, name, status from customer")
.columnMapping("id", "id")
.columnMapping("name", "name")
.columnMapping("status", "status")
.create();
List<Customer> customers = new QCustomer()
.setRawSql(rawSql)
.status.equalTo(Customer.Status.ACTIVE) // typed expression, injected into the parsed WHERE clause
.findList();
```
For the full guide to building `RawSql` - including `unparsed()`,
`withPlaceholders()` for CTEs/window functions, the `${where}` / `${andWhere}`
/ `${having}` / `${andHaving}` placeholder reference, and column mapping - see
[Using `RawSql` with Ebean](using-rawsql-with-ebean.md).
---
## Common anti-patterns
@@ -570,4 +599,5 @@ When asked to add or modify an Ebean query:
- [Add Ebean Postgres Maven POM](add-ebean-postgres-maven-pom.md)
- [Entity Bean Creation](entity-bean-creation.md)
- [Immutable bean cache for read-only references](immutable-bean-cache.md)
- [Using `RawSql` with Ebean](using-rawsql-with-ebean.md)
- [Ebean query docs](https://ebean.io/docs/query/)
+1 -1
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.1.0</version>
<version>18.3.0</version>
</parent>
<name>ebean api</name>
+4
View File
@@ -457,6 +457,10 @@ public final class DB {
/**
* Same as {@link #checkUniqueness(Object)} but with given transaction.
* <p>
* For control over query cache use and whether to skip the check when the bean's unique
* properties are unchanged, use {@link Database#checkUniqueness(Object, Transaction, boolean, boolean)}
* via {@link #getDefault()} instead.
*/
public static Set<Property> checkUniqueness(Object bean, Transaction transaction) {
return getDefault().checkUniqueness(bean, transaction);
+11 -2
View File
@@ -1078,12 +1078,21 @@ public interface Database {
* @param bean The entity bean to check uniqueness on
* @return a set of Properties if constraint validation was detected or empty list.
*/
Set<Property> checkUniqueness(Object bean);
default Set<Property> checkUniqueness(Object bean) {
return checkUniqueness(bean, null, false, true);
}
/**
* Same as {@link #checkUniqueness(Object)}. but with given transaction.
*/
Set<Property> checkUniqueness(Object bean, Transaction transaction);
default Set<Property> checkUniqueness(Object bean, Transaction transaction) {
return checkUniqueness(bean, transaction, false, true);
}
/**
* Same as {@link #checkUniqueness(Object)}. but with given transaction and extended search options.
*/
Set<Property> checkUniqueness(Object bean, Transaction transaction, boolean useQueryCache, boolean skipClean);
/**
* Marks the entity bean as dirty.
@@ -62,9 +62,10 @@ public interface DatabaseBuilder {
/**
* Build and return the Database instance.
* <p>
* When {@link #setRegister(boolean)} is set to true, and a database with the same
* name is already registered, this may return the existing registered database
* rather than creating a new one.
* When {@link #setRegister(boolean)} is set to true (the default), and a database
* with the same name is already registered, this throws an {@link IllegalStateException}.
* Use a unique name, or use {@link #setRegister(boolean)} with {@code false} if the
* Database instance is not intended to be registered/looked up by name.
*/
Database build();
@@ -887,6 +888,13 @@ public interface DatabaseBuilder {
@Deprecated
DatabaseBuilder setBackgroundExecutorWrapper(BackgroundExecutorWrapper backgroundExecutorWrapper);
/**
* Enable tenant-partitioned caches. When enabled each tenant gets its own cache namespace,
* improving cache-hit ratio by preventing cross-tenant key collisions.
* Use {@link SpiCacheManager#clearTenant(Object)} when a tenant is deactivated.
*/
DatabaseBuilder tenantPartitionedCache(boolean tenantPartitionedCache);
/**
* Set the L2 cache default max size.
*/
@@ -2567,6 +2575,11 @@ public interface DatabaseBuilder {
*/
boolean isAutoPersistUpdates();
/**
* Return true if caches are partitioned by tenant.
*/
boolean isTenantPartitionedCache();
/**
* Return the L2 cache default max size.
*/
@@ -7,8 +7,6 @@ import jakarta.persistence.PersistenceException;
import java.util.concurrent.locks.ReentrantLock;
import static java.lang.System.Logger.Level.WARNING;
/**
* Low-level factory for creating {@link Database} instances.
* <p>
@@ -81,10 +79,10 @@ public final class DatabaseFactory {
// We're explicitly creating a database to be registered, so avoid
// triggering DbContext static initialisation to auto-create a default one.
DbPrimary.setSkip(true);
Database existing = DbContext.getInstance().getRegistered(name);
if (existing != null) {
EbeanVersion.log.log(WARNING, "Using existing database with name:{0}", name);
return existing;
if (DbContext.getInstance().contains(name)) {
throw new IllegalStateException("A Database with name [" + name + "] is already registered."
+ " Use a unique DatabaseConfig name, or set DatabaseConfig.setRegister(false)"
+ " if this Database instance is not intended to be registered/looked up by name.");
}
}
Database server = createInternal(config);
@@ -123,6 +121,24 @@ public final class DatabaseFactory {
}
}
/**
* Remove the registration of this Database.
* <p>
* This is invoked when a Database is shutdown so that its registered name
* becomes available again for a subsequently created Database with the same name.
*/
public static void unregister(Database server) {
lock.lock();
try {
DbContext.getInstance().deregister(server);
if (server.name().equals(defaultServerName)) {
defaultServerName = null;
}
} finally {
lock.unlock();
}
}
/**
* Shutdown gracefully all Database instances cleaning up any resources as required.
* <p>
@@ -4,7 +4,6 @@ import io.ebean.config.BeanNotEnhancedException;
import io.ebean.datasource.DataSourceConfigurationException;
import jakarta.persistence.PersistenceException;
import org.jspecify.annotations.Nullable;
import java.util.HashMap;
import java.util.concurrent.ConcurrentHashMap;
@@ -77,9 +76,8 @@ final class DbContext {
return defaultDatabase;
}
@Nullable
Database getRegistered(String name) {
return concMap.get(name);
boolean contains(String name) {
return concMap.containsKey(name);
}
/**
@@ -122,6 +120,27 @@ final class DbContext {
registerWithName(server.name(), server, isDefault);
}
/**
* Remove the registration for this Database (typically on shutdown) so that
* its name becomes available again for a subsequently created Database.
* <p>
* Only removes the registration if it currently maps to this exact instance
* (avoids removing a different Database subsequently registered with the same name).
*/
void deregister(Database server) {
lock.lock();
try {
String name = server.name();
concMap.remove(name, server);
syncMap.remove(name, server);
if (defaultDatabase == server) {
defaultDatabase = null;
}
} finally {
lock.unlock();
}
}
private void registerWithName(String name, Database server, boolean isDefault) {
lock.lock();
try {
@@ -0,0 +1,65 @@
package io.ebean;
import jakarta.persistence.PersistenceException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Static bridge registering custom {@code @DtoConvert} converter instances so generated DTO
* mappers can reach them.
* <p>
* Generated mappers (see {@code query.mapTo(SomeDto.class)}) are wired via {@code ServiceLoader}
* as plain, no-arg-constructed, compile-time singletons (mirroring how entity/query-bean
* registration already works) - they have no way to reach a dependency-injection container, or
* any particular {@code Database} instance, at construction time. When a
* {@code @DtoConvert(value = ConverterType.class, method = "...")} property's converter is an
* <b>instance</b> method (as opposed to a {@code static} one, which is called directly with no
* registration needed at all), the generated mapper resolves it via {@link #get(Class)} - so the
* application must register an instance here, typically one already built by its own DI
* container, <b>before</b> building the {@code Database}:
* <pre>{@code
* AES256Cipher cipher = ...; // already DI-constructed
* DtoConverterManager.put(DriverConversions.class, new DriverConversionsImpl(cipher));
*
* Database db = DatabaseFactory.create(...); // generated mappers resolve converters from here
* }</pre>
* <p>
* This is a deliberate, narrowly-scoped exception to preferring dependency injection over static
* mutable state - it exists solely to bridge an already-DI-constructed singleton into
* {@code ServiceLoader}-discovered, no-arg-constructed generated code, which cannot otherwise
* reach a DI container or a specific {@code Database} instance. {@link #get(Class)} throws
* immediately if nothing was registered for the given type, so a missing/late registration fails
* fast at {@code Database} build time (a generated mapper's eager field initializer) rather than
* lazily on first use.
*/
public final class DtoConverterManager {
private static final Map<Class<?>, Object> converters = new ConcurrentHashMap<>();
private DtoConverterManager() {
}
/**
* Register a converter instance for the given type - must be called before the
* {@code Database} using it is built.
*/
public static <T> void put(Class<T> type, T instance) {
converters.put(type, instance);
}
/**
* Return the registered converter instance for the given type.
*
* @throws PersistenceException if no instance was registered for {@code type}.
*/
@SuppressWarnings("unchecked")
public static <T> T get(Class<T> type) {
T instance = (T) converters.get(type);
if (instance == null) {
throw new PersistenceException("No " + type.getName() + " registered - call "
+ "DtoConverterManager.put(" + type.getSimpleName() + ".class, ...) before starting the Database");
}
return instance;
}
}
@@ -0,0 +1,51 @@
package io.ebean;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.function.Function;
/**
* Identity-keyed cache of already-mapped source -&gt; target instances, shared across one
* top-level {@link DtoMapper#mapList(java.util.List)} call (or an explicitly shared context).
* <p>
* Keyed by source object <b>identity</b> (an {@link IdentityHashMap}, not {@code equals()}/
* {@code hashCode()}) because the source is an Ebean entity graph, where repeated references to
* the same row within one query already resolve to the same Java object instance.
* <p>
* The identity map is partitioned <b>per target DTO type</b>. This matters because the same
* source instance can legitimately need to be mapped to more than one target type within a
* single graph - e.g. a top-level {@code CustomerDtoMapper} maps a {@code Customer} to a full
* {@code CustomerDto}, while a nested {@code ContactDtoMapper} maps the very same {@code Customer}
* instance (accessed via {@code contact.getCustomer()}) to a shallow {@code CustomerRefDto} to
* avoid a cycle. A single un-partitioned {@code IdentityHashMap<Object,Object>} would have the
* two mappers collide on the same source key and incorrectly hand back the other mapper's
* (wrong-typed) cached result. Partitioning by target type keeps each mapper's cache isolated
* while still sharing one context/instance per top-level mapping call.
* <p>
* Not thread-safe - a context is expected to be created per top-level mapping call and not
* shared across threads.
*/
public final class DtoMapContext {
private final Map<Class<?>, Map<Object, Object>> mappedByType = new HashMap<>();
/**
* Return the already-mapped target for the given source instance if present, otherwise map it
* via {@code mappingFunction}, register it, and return it.
*
* @param targetType the DTO type being produced - used to partition the identity cache so that
* mapping the same source to different target types never collides.
*/
@SuppressWarnings("unchecked")
public <S, T> T computeIfAbsent(Class<T> targetType, S source, Function<S, T> mappingFunction) {
Map<Object, Object> mapped = mappedByType.computeIfAbsent(targetType, t -> new IdentityHashMap<>());
T existing = (T) mapped.get(source);
if (existing != null) {
return existing;
}
T created = mappingFunction.apply(source);
mapped.put(source, created);
return created;
}
}
@@ -0,0 +1,76 @@
package io.ebean;
import java.util.ArrayList;
import java.util.List;
/**
* Mapper interface implemented by generated (or hand-written) entity -&gt; DTO graph mappers.
* <p>
* Used with nested entity-to-DTO graph mapping (see {@code query.mapTo(SomeDto.class)}) as
* distinct from the existing flat, single-row {@link DtoQuery} pipeline. Each entity/DTO type
* pair gets its own small, composable mapper implementation (mirroring MapStruct's per-type
* mapper generation) rather than one large mapper inlining every nested type. Nested mappers are
* wired together via constructor injection, not static singletons - this keeps mappers stateless,
* substitutable (e.g. for tests) and avoids global mutable state.
* <p>
* A {@link DtoMapContext} is threaded through every nested {@code map(...)} call within one
* top-level {@link #mapList(List)} invocation, so that repeated references to the same source
* entity instance (e.g. several {@code Contact}s sharing the same {@code Customer}) map to the
* <b>same</b> target DTO instance rather than creating duplicate-but-equal copies. This mirrors
* the identity semantics Ebean's own entity graph already has, and is what makes the resulting
* DTO graph "graph shaped" rather than "tree of copies shaped".
* <p>
* Implementations contain no reflection or {@code MethodHandles} - only direct getter calls and
* constructor invocation - so generated mappers are safe under GraalVM native-image with zero
* additional reachability metadata.
*
* @param <SOURCE> the source entity (or embeddable) type
* @param <TARGET> the target DTO type
*/
public interface DtoMapper<SOURCE, TARGET> {
/**
* Return the {@link FetchGroup} of exactly the source properties (and nested paths) needed to
* populate the target DTO graph - the select()/fetch() spec is derived from the DTO's declared
* shape rather than maintained separately by hand. Used by {@code query.mapTo(TARGET.class)}
* to automatically apply the correct fetch spec before the query is executed.
*/
FetchGroup<SOURCE> fetchGroup();
/**
* Map a single source instance to its target DTO, reusing/registering the mapping in the
* given context so that repeated references to the same source instance de-duplicate to the
* same target instance. Must return {@code null} when given {@code null}.
*/
TARGET map(SOURCE source, DtoMapContext context);
/**
* Map a single source instance using a fresh, one-off context. Convenience for mapping a
* single object in isolation (no de-duplication opportunity since there's nothing else in
* scope to de-duplicate against).
*/
default TARGET map(SOURCE source) {
return map(source, new DtoMapContext());
}
/**
* Map a list of source instances to a list of target DTOs sharing the given context,
* preserving order.
*/
default List<TARGET> mapList(List<SOURCE> source, DtoMapContext context) {
List<TARGET> result = new ArrayList<>(source.size());
for (SOURCE s : source) {
result.add(map(s, context));
}
return result;
}
/**
* Map a list of source instances to a list of target DTOs using a fresh context shared across
* the whole list - this is the usual top-level entry point, e.g. mapping the result of a
* {@code query.findList()} call.
*/
default List<TARGET> mapList(List<SOURCE> source) {
return mapList(source, new DtoMapContext());
}
}
@@ -0,0 +1,125 @@
package io.ebean;
import io.ebean.config.DtoMapperRegister;
import jakarta.persistence.PersistenceException;
import java.util.ArrayList;
import java.util.List;
import java.util.ServiceLoader;
import java.util.concurrent.ConcurrentHashMap;
/**
* Loads all generated {@link DtoMapperRegister} implementations (via {@code ServiceLoader},
* mirroring how {@code EntityClassRegister} is discovered) once, and resolves the {@link
* DtoMapper} for a given (source, dto) pair, or by the generated mapper's own concrete type, on
* request.
* <p>
* Has no dependency on {@link Database} - it can be constructed independently, before (or
* without) a {@code Database} existing at all, e.g. as a DI-managed singleton constructed
* alongside the rest of an application's dependency graph. If you want the exact same instance
* (and hence the exact same underlying mapper instances) shared between {@code query.mapTo(...)}
* and your own application code, construct it yourself and register it via {@code
* DatabaseBuilder.putServiceObject(DtoMapperManager.class, myManager)} before building the {@code
* Database} - it is then used instead of a Database-internal default instance.
* <p>
* Resolved mappers are cached so that repeated lookups only ever pay the cost of iterating the
* generated registers and constructing the mapper (and its nested mapper/{@code FetchGroup}
* graph) once - after that, every lookup is a single hash-map hit regardless of how many entity/
* DTO pairs are registered.
*/
public final class DtoMapperManager {
private final List<DtoMapperRegister> registers;
private final ConcurrentHashMap<MapperKey, DtoMapper<?, ?>> pairCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Class<?>, Object> typeCache = new ConcurrentHashMap<>();
public DtoMapperManager() {
this.registers = load();
}
private static List<DtoMapperRegister> load() {
List<DtoMapperRegister> result = new ArrayList<>();
for (DtoMapperRegister register : ServiceLoader.load(DtoMapperRegister.class)) {
result.add(register);
}
return result;
}
/**
* Return the {@link DtoMapper} for the given (source, dto) pair.
*
* @throws PersistenceException if no generated mapper is registered for that pair.
*/
@SuppressWarnings("unchecked")
public <S, D> DtoMapper<S, D> mapperFor(Class<S> sourceType, Class<D> dtoType) {
return (DtoMapper<S, D>) pairCache.computeIfAbsent(new MapperKey(sourceType, dtoType), this::resolve);
}
/**
* Return the generated mapper instance of the given concrete mapper type - e.g. {@code
* manager.get(CustomerDtoMapper.class)} - typically used to resolve a mapper instance for
* dependency injection into application code (e.g. an avaje-inject {@code @Factory} bean
* method).
*
* @throws PersistenceException if no generated mapper of that type is registered.
*/
@SuppressWarnings("unchecked")
public <T> T get(Class<T> mapperType) {
return (T) typeCache.computeIfAbsent(mapperType, this::resolveByType);
}
private DtoMapper<?, ?> resolve(MapperKey key) {
for (DtoMapperRegister register : registers) {
DtoMapper<?, ?> mapper = register.mapperFor(key.sourceType, key.dtoType);
if (mapper != null) {
return mapper;
}
}
throw new PersistenceException("No DtoMapper registered mapping " + key.sourceType + " -> " + key.dtoType
+ " - check @DtoMapping(source = " + key.sourceType.getSimpleName() + ".class, target = "
+ key.dtoType.getSimpleName() + ".class) is declared on a package-info.java processed by querybean-generator");
}
private Object resolveByType(Class<?> mapperType) {
for (DtoMapperRegister register : registers) {
Object mapper = register.mapperOfType(mapperType);
if (mapper != null) {
return mapper;
}
}
throw new PersistenceException("No DtoMapper of type " + mapperType.getName() + " registered"
+ " - check a @DtoMapping(...) pair generating " + mapperType.getSimpleName()
+ " is declared on a package-info.java processed by querybean-generator");
}
/**
* Cache key pairing the source entity type and target DTO type.
*/
private static final class MapperKey {
private final Class<?> sourceType;
private final Class<?> dtoType;
MapperKey(Class<?> sourceType, Class<?> dtoType) {
this.sourceType = sourceType;
this.dtoType = dtoType;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof MapperKey)) {
return false;
}
MapperKey other = (MapperKey) o;
return sourceType == other.sourceType && dtoType == other.dtoType;
}
@Override
public int hashCode() {
return 31 * sourceType.hashCode() + dtoType.hashCode();
}
}
}
@@ -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.");
}
}
+34 -90
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>
@@ -215,35 +153,41 @@ 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).
* Return a PagedList for this query using firstRow and maxRows.
* <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.
* 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>
* This is only supported for a DtoQuery that is derived from an ORM query via
* {@link Query#asDto(Class)} / {@link ExpressionList#asDto(Class)}. It is not supported
* for a DtoQuery based on raw SQL (e.g. via {@link Database#findDto(Class, String)}) as
* there is no query structure available from which to derive a matching row count query -
* a PersistenceException is thrown in that case.
* <pre>{@code
*
* @see #usingMaster()
* PagedList<OrderDto> pagedList =
* DB.find(Order.class)
* .where().eq("status", Order.Status.NEW)
* .orderBy().asc("id")
* .setFirstRow(50)
* .setMaxRows(20)
* .asDto(OrderDto.class)
* .findPagedList();
*
* // fetch the total row count in the background
* pagedList.loadCount();
*
* List<OrderDto> orders = pagedList.getList();
* int totalRowCount = pagedList.getTotalCount();
*
* }</pre>
*
* @return The PagedList
*/
DtoQuery<T> usingMaster(boolean useMaster);
@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.
@@ -103,6 +104,29 @@ public interface ExpressionList<T> {
*/
<D> DtoQuery<D> asDto(Class<D> dtoClass);
/**
* Map the query result to a nested DTO graph, automatically deriving the select()/fetch() spec
* from the target DTO's declared shape and forcing {@code setUnmodifiable(true)}.
* <p>
* Distinct from {@link #asDto(Class)} (the flat, single-row SQL pipeline) - this supports
* nested ToOne/ToMany DTO graphs, mapped from the normal ORM entity query result.
*
* @throws jakarta.persistence.PersistenceException if no generated {@link DtoMapper} is
* registered for this (entity, dto) pair.
*/
<D> MappedQuery<D> mapTo(Class<D> dtoType);
/**
* Map the query result to a nested DTO graph using an already-resolved {@link DtoMapper}
* instance, rather than looking one up by (entity, dtoType) - e.g. to select a named variant
* mapper (see {@code @DtoMapping(name = "...", exclude = "...")}), such as
* {@code query.mapTo(User.class, userMapper.noFleets())}.
*
* @param dtoType the DTO type mapped to (must match {@code mapper}'s target type)
* @param mapper the mapper instance to use, e.g. a named variant accessor on a generated mapper
*/
<D> MappedQuery<D> mapTo(Class<D> dtoType, DtoMapper<T, D> mapper);
/**
* Return the underlying query as an UpdateQuery.
* <p>
@@ -205,6 +229,22 @@ public interface ExpressionList<T> {
*/
int delete();
/**
* Execute as a delete query permanently deleting the 'root level' beans that match the
* predicates in the query without soft delete.
* <p>
* This is the same as {@link #delete()} except that when the bean type uses soft delete
* (e.g. {@code @SoftDelete}) the matching rows are permanently (hard) deleted rather than
* being marked as deleted.
* <p>
* Note that if the query includes joins then the generated delete statement may not be
* optimal depending on the database platform.
* </p>
*
* @return the number of rows that were permanently deleted.
*/
int deletePermanent();
/**
* Execute as a update query.
*
@@ -382,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);
}
@@ -0,0 +1,111 @@
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;
/**
* Query that maps an entity graph query result to a nested DTO graph, produced by
* {@code query.mapTo(SomeDto.class)}.
* <p>
* Distinct from the existing flat, single-row {@link DtoQuery} pipeline (see {@link
* QueryBuilder#asDto(Class)}) - this executes the underlying entity ORM query (with the
* select()/fetch() spec automatically derived from the target DTO's declared shape, see
* {@link DtoMapper#fetchGroup()}), forces {@code setUnmodifiable(true)}, and then maps the
* resulting (unmodifiable) entity graph into a DTO graph via the generated {@link DtoMapper},
* supporting nested ToOne/ToMany and identity-aware de-duplication.
*
* @param <D> the target DTO type
*/
@NullMarked
public interface MappedQuery<D> extends StreamableQuery<MappedQuery<D>, D> {
/**
* Execute the query returning the mapped DTO list.
*/
@Override
List<D> findList();
/**
* Execute the query returning a paged list of mapped DTOs.
* <p>
* Mirrors {@code Query#findPagedList()} - the underlying entity graph query is paged (via
* {@code setFirstRow(int)}/{@code setMaxRows(int)}) and executed as normal, then each page's
* result is mapped to the target DTO graph. Row-count/page-index metadata
* ({@link PagedList#getTotalCount()}, {@link PagedList#hasNext()}, etc.) reflects the
* underlying entity query and is unaffected by the DTO mapping.
*/
@Override
PagedList<D> findPagedList();
/**
* Execute the query returning the result as a Stream of mapped DTOs.
* <p>
* Mirrors {@link QueryBuilder#findStream()} - the underlying entity graph query is streamed
* (supporting very large queries iterating any number of results, potentially using multiple
* persistence contexts internally) and each entity is mapped to its target DTO lazily as the
* stream is consumed, sharing one {@link DtoMapContext} across the whole stream so that
* repeated references to the same source entity still de-duplicate to the same DTO instance.
* <pre>{@code
*
* // use try with resources to ensure Stream is closed
*
* try (Stream<CustomerDto> stream = query.mapTo(CustomerDto.class).findStream()) {
* stream
* .map(...)
* .collect(...);
* }
*
* }</pre>
*/
@Override
Stream<D> findStream();
/**
* 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.
*/
@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 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.
*/
@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.
@@ -77,6 +74,32 @@ public interface QueryBuilder<SELF extends QueryBuilder<SELF, T>, T> extends Que
*/
<D> DtoQuery<D> asDto(Class<D> dtoClass);
/**
* Map the query result to a nested DTO graph, automatically deriving the select()/fetch() spec
* from the target DTO's declared shape and forcing {@code setUnmodifiable(true)}.
* <p>
* Distinct from {@link #asDto(Class)} (the flat, single-row SQL pipeline) - this supports
* nested ToOne/ToMany DTO graphs, mapped from the normal ORM entity query result.
*
* @throws jakarta.persistence.PersistenceException if no generated {@link DtoMapper} is
* registered for this (entity, dto) pair.
*/
<D> MappedQuery<D> mapTo(Class<D> dtoType);
/**
* Map the query result to a nested DTO graph using an already-resolved {@link DtoMapper}
* instance, rather than looking one up by (entity, dtoType). Bypasses {@link DtoMapperManager}
* entirely, so it's the way to select a named variant mapper (see {@code @DtoMapping(name =
* "...", exclude = "...")}) - e.g. {@code query.mapTo(User.class, userMapper.noFleets())}.
* <p>
* Also forces {@code setUnmodifiable(true)} and derives the select()/fetch() spec from
* {@code mapper.fetchGroup()}, same as {@link #mapTo(Class)}.
*
* @param dtoType the DTO type mapped to (must match {@code mapper}'s target type)
* @param mapper the mapper instance to use, e.g. a named variant accessor on a generated mapper
*/
<D> MappedQuery<D> mapTo(Class<D> dtoType, DtoMapper<T, D> mapper);
/**
* Convert the query to a UpdateQuery.
* <p>
@@ -120,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>
@@ -607,6 +598,21 @@ public interface QueryBuilder<SELF extends QueryBuilder<SELF, T>, T> extends Que
*/
int delete();
/**
* Execute as a delete query permanently deleting the 'root level' beans that match the
* predicates in the query without soft delete.
* <p>
* This is the same as {@link #delete()} except that when the bean type uses soft delete
* (e.g. {@code @SoftDelete}) the matching rows are permanently (hard) deleted rather than
* being marked as deleted.
* <p>
* Note that if the query includes joins then the generated delete statement may not be
* optimal depending on the database platform.
*
* @return the number of beans/rows that were permanently deleted.
*/
int deletePermanent();
/**
* Execute the query returning true if a row is found.
* <p>
@@ -638,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.
@@ -864,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>
@@ -1007,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();
}
@@ -41,6 +41,49 @@ public interface RawSqlBuilder {
return XBootstrapService.rawSql().unparsed(sql);
}
/**
* Return a RawSqlBuilder for SQL containing {@code ${where}}, {@code ${having}} and/or
* {@code ${orderBy}} placeholder(s). Unlike {@link #parse(String)} this does NOT attempt to parse
* the SELECT columns, so it supports complex SQL such as CTEs, subqueries, and window functions.
* <p>
* Explicit column mappings must be provided (as with {@link #unparsed(String)}), but
* WHERE, HAVING and ORDER BY expressions can be added dynamically via the query API - provided
* the corresponding placeholder is present in the SQL. If a query calls {@code .orderBy(...)}
* on a template with no {@code ${orderBy}}/{@code ${andOrderBy}} placeholder, that ordering is
* ignored (there is no injection point for it) rather than producing invalid SQL.
* </p>
* <p>
* Available placeholders:
* </p>
* <ul>
* <li>{@code ${where}} / {@code ${andWhere}} - inject "where &lt;expr&gt;" / "and &lt;expr&gt;"</li>
* <li>{@code ${having}} / {@code ${andHaving}} - inject "having &lt;expr&gt;" / "and &lt;expr&gt;"</li>
* <li>{@code ${orderBy}} / {@code ${andOrderBy}} - inject "order by &lt;expr&gt;" / ", &lt;expr&gt;"</li>
* </ul>
* <h3>Example:</h3>
* <pre>{@code
*
* String sql = """
* with agg as (
* select company_id, sum(amount) as total
* from orders
* ${where}
* group by company_id
* )
* select company_id, total from agg ${orderBy}
* """;
*
* RawSql rawSql = RawSqlBuilder.withPlaceholders(sql)
* .columnMapping("company_id", "companyId")
* .columnMapping("total", "total")
* .create();
*
* }</pre>
*/
static RawSqlBuilder withPlaceholders(String sql) {
return XBootstrapService.rawSql().withPlaceholders(sql);
}
/**
* Return a RawSqlBuilder parsing the sql.
* <p>
+13 -55
View File
@@ -3,7 +3,6 @@ package io.ebean;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import javax.sql.DataSource;
import java.io.Serializable;
import java.sql.Connection;
import java.util.Collection;
@@ -41,44 +40,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 +61,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 +91,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>
@@ -365,7 +312,7 @@ public interface SqlQuery extends Serializable, CancelableQuery {
*
* @param <T> The type of the scalar values
*/
interface TypeQuery<T> {
interface TypeQuery<T> extends FindableQuery<TypeQuery<T>, T> {
/**
* Ensure the master DataSource is used when useMaster is true. Otherwise, the read only
@@ -373,27 +320,38 @@ public interface SqlQuery extends Serializable, CancelableQuery {
*
* @see SqlQuery#usingMaster(boolean)
*/
@Override
TypeQuery<T> usingMaster(boolean useMaster);
/**
* Execute the query using the given transaction.
*/
@Override
TypeQuery<T> usingTransaction(Transaction transaction);
/**
* Execute the query using the given connection.
*/
@Override
TypeQuery<T> usingConnection(Connection connection);
/**
* Return the single value.
*/
@Nullable
@Override
T findOne();
/**
* Return the single value that is optional.
*/
@Override
Optional<T> findOneOrEmpty();
/**
* Return the list of values.
*/
@Override
List<T> findList();
/**
@@ -158,6 +158,15 @@ public interface SqlUpdate {
*/
int executeNow();
/**
* Set an explicit transaction to use to execute this statement.
* <p>
* When not set, {@link #execute()} and {@link #executeNow()} use whatever transaction
* is currently active on the thread (or auto-commit if none is active) - consistent
* with {@link Database#execute(SqlUpdate, Transaction)}.
*/
SqlUpdate usingTransaction(Transaction transaction);
/**
* Execute when addBatch() has been used to batch multiple bind executions.
*
@@ -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);
}
@@ -84,6 +84,15 @@ public interface Update<T> {
*/
int execute();
/**
* Set an explicit transaction to use to execute this statement.
* <p>
* When not set, {@link #execute()} uses whatever transaction is currently active on
* the thread (or auto-commit if none is active) - consistent with
* {@link Database#execute(Update, Transaction)}.
*/
Update<T> usingTransaction(Transaction transaction);
/**
* Set an ordered bind parameter.
* <p>
@@ -175,7 +175,7 @@ public final class InterceptReadOnly extends InterceptBase {
@Override
public boolean isUpdate() {
return false;
return true;
}
@Override
@@ -432,6 +432,8 @@ public class DatabaseConfig implements DatabaseBuilder.Settings {
private int backgroundExecutorShutdownSecs = 30;
private BackgroundExecutorWrapper backgroundExecutorWrapper = new MdcBackgroundExecutorWrapper();
private boolean tenantPartitionedCache;
// defaults for the L2 bean caching
private int cacheMaxSize = 10000;
@@ -1175,6 +1177,17 @@ public class DatabaseConfig implements DatabaseBuilder.Settings {
return cacheMaxSize;
}
@Override
public boolean isTenantPartitionedCache() {
return tenantPartitionedCache;
}
@Override
public DatabaseConfig tenantPartitionedCache(boolean tenantPartitionedCache) {
this.tenantPartitionedCache = tenantPartitionedCache;
return this;
}
@Override
public DatabaseConfig setCacheMaxSize(int cacheMaxSize) {
this.cacheMaxSize = cacheMaxSize;
@@ -2228,6 +2241,15 @@ public class DatabaseConfig implements DatabaseBuilder.Settings {
ddlPlaceholders = p.get("ddl.placeholders", ddlPlaceholders);
ddlHeader = p.get("ddl.header", ddlHeader);
tenantPartitionedCache = p.getBoolean("tenantPartitionedCache", tenantPartitionedCache);
cacheMaxSize = p.getInt("cacheMaxSize", cacheMaxSize);
cacheMaxIdleTime = p.getInt("cacheMaxIdleTime", cacheMaxIdleTime);
cacheMaxTimeToLive = p.getInt("cacheMaxTimeToLive", cacheMaxTimeToLive);
queryCacheMaxSize = p.getInt("queryCacheMaxSize", queryCacheMaxSize);
queryCacheMaxIdleTime = p.getInt("queryCacheMaxIdleTime", queryCacheMaxIdleTime);
queryCacheMaxTimeToLive = p.getInt("queryCacheMaxTimeToLive", queryCacheMaxTimeToLive);
// read tenant-configuration from config:
// tenant.mode = NONE | DB | SCHEMA | CATALOG | PARTITION
String mode = p.get("tenant.mode");
@@ -0,0 +1,31 @@
package io.ebean.config;
import io.ebean.DtoMapper;
/**
* Loads and returns the {@link DtoMapper} to use for a given DTO type, generated per-module by
* the querybean-generator annotation processor for each {@code io.ebean.annotation.DtoMapping}
* registered pair.
* <p>
* Implementations resolve purely via literal {@code Class} comparisons (no reflection,
* {@code Class.forName}, or {@code MethodHandles}) - safe under GraalVM native-image with zero
* additional reachability metadata - mirroring {@link EntityClassRegister}.
*/
public interface DtoMapperRegister {
/**
* Return the mapper for the given DTO type, or {@code null} if this register has no mapper for
* that type.
*/
<SOURCE,TARGET> DtoMapper<SOURCE, TARGET> mapperFor(Class<SOURCE> sourceType, Class<TARGET> targetType);
/**
* Return the mapper instance of the given concrete generated mapper type, or {@code null} if
* this register has no mapper of that type.
* <p>
* An alternative to {@link #mapperFor(Class, Class)} for looking up a mapper by its own class
* (e.g. {@code CustomerDtoMapper.class}) rather than by its (source, target) pair - typically
* used to resolve a mapper instance for dependency injection into application code.
*/
<T> T mapperOfType(Class<T> mapperType);
}
@@ -162,6 +162,18 @@ public class DatabasePlatform {
protected boolean selectCountWithAlias;
protected boolean selectCountWithColumnAlias;
/**
* Set true for platforms where {@code exists(...)} can only be used as a predicate
* and not as a directly selectable scalar boolean expression (e.g. SQL Server, Oracle).
*/
protected boolean existsWithCaseWhen;
/**
* Clause appended after the {@code case when exists(...) then 1 else 0 end} exists query
* for platforms that require a FROM clause on every select (e.g. {@code from dual} on Oracle).
*/
protected String existsFromClause = "";
/**
* If set then use the FORWARD ONLY hint when creating ResultSets for
* findIterate() and findVisit().
@@ -660,6 +672,21 @@ public class DatabasePlatform {
return selectCountWithColumnAlias;
}
/**
* Return true if a scalar boolean {@code exists(...)} expression is not supported
* as a select expression and needs to be wrapped as {@code case when exists(...) then 1 else 0 end}.
*/
public boolean existsWithCaseWhen() {
return existsWithCaseWhen;
}
/**
* Return the clause to append after the exists case-when wrapping (e.g. {@code from dual} on Oracle).
*/
public String existsFromClause() {
return existsFromClause;
}
public String completeSql(String sql, Query<?> query) {
if (query.isForUpdate()) {
@@ -1,6 +1,7 @@
package io.ebean.event;
import io.ebean.Database;
import io.ebean.DatabaseFactory;
import io.ebean.EbeanVersion;
import io.ebean.service.SpiContainer;
@@ -188,6 +189,7 @@ public final class ShutdownManager {
*/
public static void unregisterDatabase(Database server) {
databases.remove(server);
DatabaseFactory.unregister(server);
}
private static class ShutdownHook extends Thread {
@@ -44,6 +44,11 @@ public interface MetaQueryPlan {
*/
String plan();
/**
* The tenant ID of the plan.
*/
Object tenantId();
/**
* Return the query execution time associated with the bind values capture.
*/
@@ -27,6 +27,13 @@ public interface SpiRawSqlService extends BootstrapService {
*/
RawSqlBuilder unparsed(String sql);
/**
* SQL with ${where}/${having} placeholder(s) but no SELECT column parsing.
* Supports complex SQL (CTEs, window functions) where keyword parsing would fail.
* Explicit column mapping is required (as with unparsed).
*/
RawSqlBuilder withPlaceholders(String sql);
/**
* Create based on a JDBC ResultSet.
*
+1 -1
View File
@@ -6,7 +6,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core-type</artifactId>
<version>18.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-ddl-generator</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -155,37 +155,37 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-querybean</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>querybean-generator</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>kotlin-querybean-generator</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-test</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-redis</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-spring-txn</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<!-- platforms -->
@@ -193,91 +193,91 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-clickhouse</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-db2</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-h2</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-hana</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-mariadb</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-mysql</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-nuodb</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-oracle</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-postgres</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-postgis</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-postgis-types</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-pgvector</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-pgvector-types</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-sqlite</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-sqlserver</artifactId>
<version>18.1.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.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
+3 -3
View File
@@ -4,7 +4,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -29,7 +29,7 @@
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.2</version>
<version>42.7.11</version>
<optional>true</optional>
</dependency>
+8 -8
View File
@@ -3,7 +3,7 @@
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>18.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-core-json</artifactId>
<version>18.1.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.1.0</version>
<version>18.3.0</version>
</dependency>
<dependency>
@@ -149,7 +149,7 @@
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.2</version>
<version>42.7.11</version>
<optional>true</optional>
</dependency>
@@ -157,21 +157,21 @@
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-h2</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-postgres</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-platform-sqlserver</artifactId>
<version>18.1.0</version>
<version>18.3.0</version>
<scope>test</scope>
</dependency>
@@ -100,7 +100,9 @@ public final class LoadBeanRequest extends LoadRequest {
query.setLoadDescription(mode(), description());
if (lazy) {
query.setLazyLoadBatchSize(loadBuffer.batchSize());
if (alreadyLoaded) {
if (alreadyLoaded || !loadCache) {
// alreadyLoaded: bean is being re-loaded, skip the cache to avoid a stale hit
// !loadCache: parent context disabled cache (e.g. CacheMode.OFF or asOf query),
query.setBeanCacheMode(CacheMode.OFF);
}
} else {
@@ -12,6 +12,6 @@ public interface SpiDbQueryPlan extends MetaQueryPlan {
/**
* Extend with queryTimeMicros, captureCount, captureMicros and when the bind values were captured.
*/
SpiDbQueryPlan with(long queryTimeMicros, long captureCount, long captureMicros, Instant whenCaptured);
SpiDbQueryPlan with(long queryTimeMicros, long captureCount, long captureMicros, Instant whenCaptured, Object tenantId);
}
@@ -294,6 +294,16 @@ public interface SpiEbeanServer extends SpiServer, BeanCollectionLoader {
*/
<D> DtoQuery<D> findDto(Class<D> dtoType, SpiQuery<?> ormQuery);
/**
* Return the generated {@link DtoMapper} for mapping the given source entity type to the given
* DTO type, discovered (via {@code ServiceLoader}) from the {@code DtoMapperRegister}s
* generated by {@code querybean-generator} - used by {@code query.mapTo(dtoType)}.
*
* @throws jakarta.persistence.PersistenceException if no mapper is registered for that
* (source, dto) pair.
*/
<S, D> DtoMapper<S, D> dtoMapper(Class<S> sourceType, Class<D> dtoType);
/**
* Execute the underlying ORM query returning as a JDBC ResultSet to map to DTO beans.
*/
@@ -324,6 +334,12 @@ public interface SpiEbeanServer extends SpiServer, BeanCollectionLoader {
*/
int executeNow(SpiSqlUpdate sqlUpdate);
/**
* Execute the sql update regardless of transaction batch mode using the given
* explicit transaction (or the current ambient transaction when null).
*/
int executeNow(SpiSqlUpdate sqlUpdate, @Nullable Transaction transaction);
/**
* Create a query bind capture for the given query plan.
*/
@@ -374,6 +390,8 @@ public interface SpiEbeanServer extends SpiServer, BeanCollectionLoader {
<T> int delete(SpiQuery<T> query);
<T> int deletePermanent(SpiQuery<T> query);
<T> int update(SpiQuery<T> query);
List<SqlRow> findList(SpiSqlQuery query);
@@ -12,6 +12,7 @@ public final class SpiExpressionValidation {
private final BeanType<?> desc;
private final LinkedHashSet<String> unknown = new LinkedHashSet<>();
private final LinkedHashSet<String> all = new LinkedHashSet<>();
public SpiExpressionValidation(BeanType<?> desc) {
this.desc = desc;
@@ -21,6 +22,7 @@ public final class SpiExpressionValidation {
* Validate that the property expression (path) is valid.
*/
public void validate(String propertyName) {
all.add(propertyName);
if (!desc.isValidExpression(propertyName)) {
unknown.add(propertyName);
}
@@ -33,4 +35,14 @@ public final class SpiExpressionValidation {
return unknown;
}
/**
* Return the set of all property names visited during this validation, regardless of
* whether they were considered valid against the bean type. Used to inspect the shape of
* an expression (for example, to check whether it references any associated/joined path)
* without needing a correctly-typed bean descriptor.
*/
public Set<String> allProperties() {
return all;
}
}
@@ -15,4 +15,19 @@ public interface SpiQueryManyJoin {
*/
String fetchOrderBy();
/**
* Return true if this many relationship has an order column stored on the
* ManyToMany intersection table (rather than a target descriptor property).
*/
default boolean hasIntersectionOrderColumn() {
return false;
}
/**
* Return the db column name of the ManyToMany intersection table order column (or null).
*/
default String intersectionOrderColumn() {
return null;
}
}
@@ -60,6 +60,6 @@ public interface SpiTransactionManager {
/**
* Return a connection used for query plan collection.
*/
Connection queryPlanConnection() throws SQLException;
Connection queryPlanConnection(Object tenantId) throws SQLException;
}
@@ -13,8 +13,9 @@ import io.ebeaninternal.server.cluster.ClusterManager;
public final class CacheManagerOptions {
private final ClusterManager clusterManager;
private final DatabaseBuilder.Settings databaseBuilder;
private final String serverName;
private final boolean localL2Caching;
private final boolean tenantPartitionedCache;
private CurrentTenantProvider currentTenantProvider;
private QueryCacheEntryValidate queryCacheEntryValidate;
private ServerCacheFactory cacheFactory = new DefaultServerCacheFactory();
@@ -24,7 +25,8 @@ public final class CacheManagerOptions {
CacheManagerOptions() {
this.localL2Caching = true;
this.clusterManager = null;
this.databaseBuilder = null;
this.serverName = "db";
this.tenantPartitionedCache = false;
this.cacheFactory = new DefaultServerCacheFactory();
this.beanDefault = new ServerCacheOptions();
this.queryDefault = new ServerCacheOptions();
@@ -32,9 +34,10 @@ public final class CacheManagerOptions {
public CacheManagerOptions(ClusterManager clusterManager, DatabaseBuilder.Settings config, boolean localL2Caching) {
this.clusterManager = clusterManager;
this.databaseBuilder = config;
this.serverName = config.getName();
this.localL2Caching = localL2Caching;
this.currentTenantProvider = config.getCurrentTenantProvider();
this.tenantPartitionedCache = config.isTenantPartitionedCache();
}
public CacheManagerOptions with(ServerCacheOptions beanDefault, ServerCacheOptions queryDefault) {
@@ -55,7 +58,7 @@ public final class CacheManagerOptions {
}
public String getServerName() {
return (databaseBuilder == null) ? "db" : databaseBuilder.getName();
return serverName;
}
public boolean isLocalL2Caching() {
@@ -85,4 +88,8 @@ public final class CacheManagerOptions {
public QueryCacheEntryValidate getQueryCacheEntryValidate() {
return queryCacheEntryValidate;
}
public boolean isTenantPartitionedCache() {
return tenantPartitionedCache;
}
}
@@ -9,6 +9,7 @@ import io.ebean.config.CurrentTenantProvider;
import io.ebean.meta.MetricVisitor;
import io.ebean.util.AnnotationUtil;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListSet;
@@ -32,6 +33,7 @@ final class DefaultCacheHolder {
private final ServerCacheOptions queryDefault;
private final CurrentTenantProvider tenantProvider;
private final QueryCacheEntryValidate queryCacheEntryValidate;
private final boolean tenantPartitionedCache;
DefaultCacheHolder(CacheManagerOptions builder) {
this.cacheFactory = builder.getCacheFactory();
@@ -39,6 +41,7 @@ final class DefaultCacheHolder {
this.queryDefault = builder.getQueryDefault();
this.tenantProvider = builder.getCurrentTenantProvider();
this.queryCacheEntryValidate = builder.getQueryCacheEntryValidate();
this.tenantPartitionedCache = builder.isTenantPartitionedCache();
}
void visitMetrics(MetricVisitor visitor) {
@@ -56,16 +59,31 @@ final class DefaultCacheHolder {
return getCacheInternal(beanType, ServerCacheType.COLLECTION_IDS, collectionProperty);
}
private String tenantKey(String beanName) {
if (tenantPartitionedCache) {
return beanName + '.' + tenantProvider.currentId();
}
return beanName;
}
private String key(String beanName, ServerCacheType type) {
if (tenantPartitionedCache) {
return beanName + '.' + tenantProvider.currentId() + type.code();
}
return beanName + type.code();
}
private String key(String beanName, String collectionProperty, ServerCacheType type) {
if (collectionProperty != null) {
return beanName + "." + collectionProperty + type.code();
} else {
return beanName + type.code();
StringBuilder sb = new StringBuilder(beanName.length() + 64);
sb.append(beanName);
if (tenantPartitionedCache) {
sb.append('.').append(tenantProvider.currentId());
}
if (collectionProperty != null) {
sb.append('.').append(collectionProperty);
}
sb.append(type.code());
return sb.toString();
}
/**
@@ -82,12 +100,15 @@ final class DefaultCacheHolder {
if (type == ServerCacheType.COLLECTION_IDS) {
lock.lock();
try {
collectIdCaches.computeIfAbsent(beanType.getName(), s -> new ConcurrentSkipListSet<>()).add(key);
collectIdCaches.computeIfAbsent(tenantKey(beanType.getName()), s -> new ConcurrentSkipListSet<>()).add(key);
} finally {
lock.unlock();
}
}
return cacheFactory.createCache(new ServerCacheConfig(type, key, shortName, options, tenantProvider, queryCacheEntryValidate));
// in partitioned mode each ServerCache instance is already tenant-scoped via its key,
// so the tenantProvider is not needed inside the cache itself
CurrentTenantProvider cacheProvider = tenantPartitionedCache ? null : tenantProvider;
return cacheFactory.createCache(new ServerCacheConfig(type, key, shortName, options, cacheProvider, queryCacheEntryValidate));
}
void clearAll() {
@@ -100,17 +121,75 @@ final class DefaultCacheHolder {
public void clear(String name) {
log.log(DEBUG, "clear {0}", name);
clearIfExists(key(name, ServerCacheType.QUERY));
clearIfExists(key(name, ServerCacheType.BEAN));
clearIfExists(key(name, ServerCacheType.NATURAL_KEY));
Set<String> keys = collectIdCaches.get(name);
if (keys != null) {
for (String collectionIdKey : keys) {
clearIfExists(collectionIdKey);
if (tenantPartitionedCache) {
// In partitioned mode, tenantProvider.currentId() may be null/wrong on a background
// invalidation thread. Scan all cache entries for this entity type across all tenants.
clearAllTenantsFor(name);
} else {
clearIfExists(key(name, ServerCacheType.QUERY));
clearIfExists(key(name, ServerCacheType.BEAN));
clearIfExists(key(name, ServerCacheType.NATURAL_KEY));
Set<String> keys = collectIdCaches.get(name);
if (keys != null) {
for (String collectionIdKey : keys) {
clearIfExists(collectionIdKey);
}
}
}
}
private void clearAllTenantsFor(String name) {
// Keys in partitioned mode: beanName.tenantId_X or beanName.tenantId.prop_X
// collectIdCaches keys: beanName.tenantId
// Both start with beanName + '.' so we can use prefix scan.
String prefix = name + '.';
for (Map.Entry<String, ServerCache> entry : allCaches.entrySet()) {
if (entry.getKey().startsWith(prefix)) {
log.log(TRACE, "clear cache {0}", entry.getKey());
entry.getValue().clear();
}
}
lock.lock();
try {
for (Map.Entry<String, Set<String>> entry : collectIdCaches.entrySet()) {
if (entry.getKey().startsWith(prefix)) {
for (String collectionIdKey : entry.getValue()) {
clearIfExists(collectionIdKey);
}
}
}
} finally {
lock.unlock();
}
}
/**
* Remove all cache entries belonging to the given tenant.
* Call this when a tenant is deactivated to prevent unbounded memory growth.
*/
void clearTenant(Object tenantId) {
String tenantIdStr = String.valueOf(tenantId);
// Keys look like: beanName.tenantId_X or beanName.tenantId.prop_X
String segmentBeforeTypeCode = '.' + tenantIdStr + '_';
String segmentBeforeProperty = '.' + tenantIdStr + '.';
allCaches.entrySet().removeIf(e -> {
String k = e.getKey();
return k.contains(segmentBeforeTypeCode) || k.contains(segmentBeforeProperty);
});
// collectIdCaches keys look like: beanName.tenantId
String collectKeySuffix = '.' + tenantIdStr;
lock.lock();
try {
collectIdCaches.entrySet().removeIf(e -> e.getKey().endsWith(collectKeySuffix));
} finally {
lock.unlock();
}
}
boolean isTenantPartitionedCache() {
return tenantPartitionedCache;
}
private void clearIfExists(String fullKey) {
ServerCache cache = allCaches.get(fullKey);
if (cache != null) {
@@ -154,4 +154,14 @@ public final class DefaultServerCacheManager implements SpiCacheManager {
return cacheHolder.getCache(beanType, ServerCacheType.BEAN);
}
@Override
public boolean isTenantPartitionedCache() {
return cacheHolder.isTenantPartitionedCache();
}
@Override
public void clearTenant(Object tenantId) {
cacheHolder.clearTenant(tenantId);
}
}
@@ -88,4 +88,17 @@ public interface SpiCacheManager {
*/
void clearLocal(Class<?> beanType);
/**
* Returns true if this cache manager runs in tenant-partitioned mode.
* In this mode caches are namespaced per tenant to improve cache-hit ratio.
*/
boolean isTenantPartitionedCache();
/**
* Remove all cache entries belonging to the given tenant.
* Call this when a tenant is deactivated to prevent unbounded memory growth
* in tenant-partitioned mode.
*/
void clearTenant(Object tenantId);
}
@@ -90,6 +90,7 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
private final DtoQueryEngine dtoQueryEngine;
private final ServerCacheManager serverCacheManager;
private final DtoBeanManager dtoBeanManager;
private final DtoMapperManager dtoMapperManager;
private final BeanDescriptorManager descriptorManager;
private final AutoTuneService autoTuneService;
private final ReadAuditPrepare readAuditPrepare;
@@ -122,6 +123,7 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
public DefaultServer(InternalConfiguration config, ServerCacheManager cache) {
this.logManager = config.getLogManager();
this.dtoBeanManager = config.getDtoBeanManager();
this.dtoMapperManager = config.getDtoMapperManager();
this.config = config.getConfig();
this.disableL2Cache = this.config.isDisableL2Cache();
this.serverCacheManager = cache;
@@ -899,6 +901,11 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
return new DefaultDtoQuery<>(this, descriptor, ormQuery);
}
@Override
public <S, D> DtoMapper<S, D> dtoMapper(Class<S> sourceType, Class<D> dtoType) {
return dtoMapperManager.mapperFor(sourceType, dtoType);
}
@Override
public SpiResultSet findResultSet(SpiQuery<?> ormQuery) {
SpiOrmQueryRequest<?> request = createQueryRequest(ormQuery.type(), ormQuery);
@@ -1197,15 +1204,14 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
@Override
public <T> boolean exists(SpiQuery<T> ormQuery) {
SpiQuery<T> ormQueryCopy = ormQuery.copy();
ormQueryCopy.setMaxRows(1);
SpiOrmQueryRequest<?> request = createQueryRequest(Type.EXISTS, ormQueryCopy);
List<Object> ids = request.getFromQueryCache();
if (ids != null) {
return !ids.isEmpty();
Object cached = request.getFromQueryCache();
if (cached != null) {
return (Boolean) cached;
}
try {
request.initTransIfRequired();
return !request.findIds().isEmpty();
return request.findExists();
} finally {
request.endTransIfRequired();
}
@@ -1234,6 +1240,15 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
@Override
public <T> int delete(SpiQuery<T> query) {
return delete(query, false);
}
@Override
public <T> int deletePermanent(SpiQuery<T> query) {
return delete(query, true);
}
private <T> int delete(SpiQuery<T> query, boolean permanent) {
SpiOrmQueryRequest<T> request = createQueryRequest(Type.DELETE, query);
try {
request.initTransIfRequired();
@@ -1247,7 +1262,7 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
if (ids.isEmpty()) {
return 0;
} else {
return persister.deleteByIds(request.descriptor(), ids, request.transaction(), false);
return persister.deleteByIds(request.descriptor(), ids, request.transaction(), permanent);
}
}
} finally {
@@ -1923,7 +1938,12 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
@Override
public int executeNow(SpiSqlUpdate sqlUpdate) {
return persister.executeSqlUpdateNow(sqlUpdate, null);
return executeNow(sqlUpdate, null);
}
@Override
public int executeNow(SpiSqlUpdate sqlUpdate, @Nullable Transaction transaction) {
return persister.executeSqlUpdateNow(sqlUpdate, transaction);
}
@Override
@@ -2188,12 +2208,7 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
}
@Override
public Set<Property> checkUniqueness(Object bean) {
return checkUniqueness(bean, null);
}
@Override
public Set<Property> checkUniqueness(Object bean, @Nullable Transaction transaction) {
public Set<Property> checkUniqueness(Object bean, @Nullable Transaction transaction, boolean useQueryCache, boolean skipClean) {
EntityBean entityBean = checkEntityBean(bean);
BeanDescriptor<?> beanDesc = descriptor(entityBean.getClass());
BeanProperty idProperty = beanDesc.idProperty();
@@ -2205,14 +2220,15 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
if (entityBean._ebean_getIntercept().isNew() && id != null) {
// Primary Key is changeable only on new models - so skip check if we are not new
SpiQuery<?> query = new DefaultOrmQuery<>(beanDesc, this, expressionFactory);
query.setUseQueryCache(useQueryCache);
query.usingTransaction(transaction);
query.setId(id);
if (findCount(query) > 0) {
if (exists(query)) {
return Collections.singleton(idProperty);
}
}
for (BeanProperty[] props : beanDesc.uniqueProps()) {
Set<Property> ret = checkUniqueness(entityBean, beanDesc, props, transaction);
Set<Property> ret = checkUniqueness(entityBean, beanDesc, props, transaction, useQueryCache, skipClean);
if (ret != null) {
return ret;
}
@@ -2220,13 +2236,34 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
return Collections.emptySet();
}
/**
* Checks, if any property is dirty.
*/
private boolean isAnyPropertyDirty(EntityBean entityBean, BeanProperty[] props) {
if (entityBean._ebean_getIntercept().isNew()) {
return true;
}
for (BeanProperty prop : props) {
if (entityBean._ebean_getIntercept().isDirtyProperty(prop.propertyIndex())) {
return true;
}
}
return false;
}
/**
* Returns a set of properties if saving the bean will violate the unique constraints (defined by given properties).
*/
@Nullable
private Set<Property> checkUniqueness(EntityBean entityBean, BeanDescriptor<?> beanDesc, BeanProperty[] props, @Nullable Transaction transaction) {
private Set<Property> checkUniqueness(EntityBean entityBean, BeanDescriptor<?> beanDesc, BeanProperty[] props, @Nullable Transaction transaction,
boolean useQueryCache, boolean skipClean) {
if (skipClean && !isAnyPropertyDirty(entityBean, props)) {
return null;
}
BeanProperty idProperty = beanDesc.idProperty();
SpiQuery<?> query = new DefaultOrmQuery<>(beanDesc, this, expressionFactory);
query.setUseQueryCache(useQueryCache);
query.usingTransaction(transaction);
ExpressionList<?> exprList = query.where();
if (!entityBean._ebean_getIntercept().isNew()) {
@@ -2240,7 +2277,7 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
}
exprList.eq(prop.name(), value);
}
if (findCount(query) > 0) {
if (exists(query)) {
Set<Property> ret = new LinkedHashSet<>();
Collections.addAll(ret, props);
return ret;
@@ -2,6 +2,7 @@ package io.ebeaninternal.server.core;
import io.ebean.DB;
import io.ebean.SqlUpdate;
import io.ebean.Transaction;
import io.ebean.Update;
import io.ebeaninternal.api.BindParams;
import io.ebeaninternal.api.SpiEbeanServer;
@@ -140,7 +141,7 @@ public final class DefaultSqlUpdate implements Serializable, SpiSqlUpdate {
server.executeBatch(this, transaction);
return -1;
}
return server.execute(this);
return server.execute(this, transaction);
} else {
// Hopefully this doesn't catch anyone out...
return DB.getDefault().execute(this);
@@ -150,12 +151,18 @@ public final class DefaultSqlUpdate implements Serializable, SpiSqlUpdate {
@Override
public int executeNow() {
if (server != null) {
return server.executeNow(this);
return server.executeNow(this, transaction);
} else {
throw new IllegalStateException("server is null?");
}
}
@Override
public SqlUpdate usingTransaction(Transaction transaction) {
this.transaction = (SpiTransaction) transaction;
return this;
}
@Override
public int[] executeBatch() {
if (server == null) {
@@ -2,6 +2,7 @@ package io.ebeaninternal.server.core;
import io.avaje.json.stream.JsonStream;
import io.ebean.DatabaseBuilder;
import io.ebean.DtoMapperManager;
import io.ebean.ExpressionFactory;
import io.ebean.annotation.Platform;
import io.ebean.cache.*;
@@ -76,6 +77,7 @@ public final class InternalConfiguration {
private final DeployInherit deployInherit;
private final TypeManager typeManager;
private final DtoBeanManager dtoBeanManager;
private final DtoMapperManager dtoMapperManager;
private final Clock clock;
private final DataTimeZone dataTimeZone;
private final Binder binder;
@@ -125,6 +127,7 @@ public final class InternalConfiguration {
final InternalConfigXmlMap xmlMap = initExternalMapping();
this.dtoBeanManager = new DtoBeanManager(typeManager, xmlMap.readDtoMapping());
this.dtoMapperManager = initDtoMapperManager();
this.dataSourceSupplier = createDataSourceSupplier();
this.beanDescriptorManager = new BeanDescriptorManager(this);
Map<String, String> asOfTableMapping = beanDescriptorManager.deploy(xmlMap.xmlDeployment());
@@ -153,6 +156,17 @@ public final class InternalConfiguration {
}
}
/**
* Use an application-provided {@link DtoMapperManager} (registered via {@code
* config.putServiceObject(DtoMapperManager.class, ...)} before building the Database) if
* present, so the exact same instance (and hence the same underlying mapper instances) can be
* shared between {@code query.mapTo(...)} and application code, otherwise construct a default.
*/
private DtoMapperManager initDtoMapperManager() {
DtoMapperManager manager = config.getServiceObject(DtoMapperManager.class);
return manager != null ? manager : new DtoMapperManager();
}
private List<XmapEbean> readExternalMapping() {
final XmapService xmapService = service(XmapService.class);
if (xmapService == null) {
@@ -520,6 +534,10 @@ public final class InternalConfiguration {
return dtoBeanManager;
}
DtoMapperManager getDtoMapperManager() {
return dtoMapperManager;
}
SpiLogManager getLogManager() {
return logManager;
}
@@ -589,7 +607,8 @@ public final class InternalConfiguration {
return QueryPlanManager.NOOP;
}
long threshold = config.getQueryPlanThresholdMicros();
return new CQueryPlanManager(transactionManager, threshold, queryPlanLogger(databasePlatform.platform(), config), extraMetrics);
return new CQueryPlanManager(transactionManager, config.getCurrentTenantProvider(),
threshold, queryPlanLogger(databasePlatform.platform(), config), extraMetrics);
}
/**
@@ -49,6 +49,11 @@ public interface OrmQueryEngine {
*/
<T> int findCount(OrmQueryRequest<T> request);
/**
* Execute the exists query using SELECT EXISTS(...).
*/
<T> boolean findExists(OrmQueryRequest<T> request);
/**
* Execute the find id's query.
*/
@@ -127,6 +127,13 @@ public final class OrmQueryRequest<T> extends BeanRequest implements SpiOrmQuery
}
}
/**
* Return the secondary queries (fetchQuery and lazy joins) extracted from the query detail.
*/
public SpiQuerySecondary secondaryQueries() {
return secondaryQueries;
}
/**
* For use with QueryIterator and secondary queries this returns the minimum
* batch size that should be loaded before executing the secondary queries.
@@ -355,6 +362,11 @@ public final class OrmQueryRequest<T> extends BeanRequest implements SpiOrmQuery
return queryEngine.findCount(this);
}
@Override
public boolean findExists() {
return queryEngine.findExists(this);
}
@Override
public <A> List<A> findIds() {
return queryEngine.findIds(this);
@@ -56,6 +56,11 @@ public final class PersistRequestBean<T> extends PersistRequest implements BeanP
private Object idValue;
private boolean statelessUpdate;
private boolean notifyCache;
/**
* Snapshot of origValues taken before intercept.setLoaded() clears them.
* Used so that cache update code can access old FK values after setLoaded().
*/
private Object[] capturedOrigValues;
/**
* Flag used to detect when only many properties where updated via a cascade. Used to ensure
* appropriate caches are updated in that case.
@@ -122,6 +127,18 @@ public final class PersistRequestBean<T> extends PersistRequest implements BeanP
*/
private List<SaveMany> saveMany;
private InsertOptions insertOptions;
/**
* Set true when an ON CONFLICT NOTHING insert is skipped (0 rows affected).
* Cascade to children must be suppressed to avoid FK violations.
*/
private boolean insertConflictSkipped;
/**
* Set true once controller.preDelete() has been invoked so that it is
* only ever fired once (as it is fired early, prior to cascading the
* delete to children/many's rather than as part of executing the delete).
*/
private boolean preDeleteCalled;
private boolean preDeleteResult = true;
public PersistRequestBean(SpiEbeanServer server, T bean, Object parentBean, BeanManager<T> mgr, SpiTransaction t,
PersistExecute persistExecute, PersistRequest.Type type, int flags) {
@@ -710,7 +727,11 @@ public final class PersistRequestBean<T> extends PersistRequest implements BeanP
* Return the original / old value for the given property.
*/
public Object origValue(BeanProperty prop) {
return intercept.origValue(prop.propertyIndex());
int idx = prop.propertyIndex();
if (capturedOrigValues != null && idx < capturedOrigValues.length) {
return capturedOrigValues[idx];
}
return intercept.origValue(idx);
}
@Override
@@ -801,6 +822,9 @@ public final class PersistRequestBean<T> extends PersistRequest implements BeanP
throw new OptimisticLockException("Data has changed. updated row count " + rowCount, null, bean);
} else if (rowCount == 0 && type == Type.UPDATE) {
throw new EntityNotFoundException("No rows updated");
} else if (rowCount == 0 && type == Type.INSERT && isConflictNothingInsert()) {
insertConflictSkipped = true;
return;
}
}
switch (type) {
@@ -815,6 +839,14 @@ public final class PersistRequestBean<T> extends PersistRequest implements BeanP
}
}
private boolean isConflictNothingInsert() {
return insertOptions != null && insertOptions.key().charAt(0) == 'N';
}
public boolean isInsertConflictSkipped() {
return insertConflictSkipped;
}
/**
* Clear the bean from the PersistenceContext (L1 cache) for stateless updates.
*/
@@ -867,6 +899,15 @@ public final class PersistRequestBean<T> extends PersistRequest implements BeanP
if (type == Type.UPDATE) {
// get the dirty properties for notify cache & orphanRemoval of vanilla collection detection
dirtyProperties = intercept.dirtyProperties();
// snapshot orig values before setLoaded() clears them - needed by cache FK-eviction logic
if (dirtyProperties != null && notifyCache) {
capturedOrigValues = new Object[dirtyProperties.length];
for (int i = 0; i < dirtyProperties.length; i++) {
if (dirtyProperties[i]) {
capturedOrigValues[i] = intercept.origValue(i);
}
}
}
}
if (isChangeLog) {
changeLog();
@@ -903,6 +944,20 @@ public final class PersistRequestBean<T> extends PersistRequest implements BeanP
}
}
/**
* Ensure the preUpdate event fires (for case where only a ManyToMany collection has changed).
*/
public void preManyToManyUpdate() {
if (controller != null && !dirty) {
// fire preUpdate notification when only ManyToMany intersection updated
controller.preUpdate(this);
pendingPostUpdateNotify = true;
}
if (!dirty) {
setNotifyCache();
}
}
public boolean isNotifyCache() {
return notifyCache;
}
@@ -1228,14 +1283,29 @@ public final class PersistRequestBean<T> extends PersistRequest implements BeanP
}
private int executeDelete() {
setTenantId();
if (controller == null || controller.preDelete(this)) {
if (controllerPreDelete()) {
return beanManager.getBeanPersister().delete(this);
}
// delete handled by the BeanController so return 0
return 0;
}
/**
* Invoke controller.preDelete() if not already invoked.
* <p>
* This is called prior to cascading the delete to children (assoc many's /
* many-to-many intersection rows) so that the persist controller can still
* see those collections/relationships as they were before the cascade delete.
*/
public boolean controllerPreDelete() {
if (!preDeleteCalled) {
preDeleteCalled = true;
setTenantId();
preDeleteResult = controller == null || controller.preDelete(this);
}
return preDeleteResult;
}
/**
* Persist to the document store now (via buffer, not post commit).
*/
@@ -1415,7 +1485,12 @@ public final class PersistRequestBean<T> extends PersistRequest implements BeanP
}
public void setInsertOptions(InsertOptions insertOptions) {
this.insertOptions = insertOptions;
this.insertOptions = insertOptions;
if (insertOptions != null && insertOptions.key().charAt(0) == 'N' && beanDescriptor.hasCascadeChildren()) {
// force immediate (non-batch) execution of this insert so we know whether it
// was actually inserted before attempting cascade saves on children.
skipBatchForTopLevel = true;
}
}
public InsertOptions insertOptions() {
@@ -75,6 +75,11 @@ public interface SpiOrmQueryRequest<T> extends BeanQueryRequest<T>, DocQueryRequ
*/
int findCount();
/**
* Execute the find exists query using select exists(...).
*/
boolean findExists();
/**
* Execute the find ids query.
*/
@@ -10,8 +10,11 @@ import java.sql.SQLException;
*/
final class AssocOneHelpEmbedded extends AssocOneHelp {
AssocOneHelpEmbedded(BeanPropertyAssocOne<?> property) {
private final boolean allowEmpty;
AssocOneHelpEmbedded(BeanPropertyAssocOne<?> property, boolean allowEmpty) {
super(property);
this.allowEmpty = allowEmpty;
}
@Override
@@ -40,7 +43,7 @@ final class AssocOneHelpEmbedded extends AssocOneHelp {
notNull = true;
}
}
if (notNull) {
if (notNull || allowEmpty) {
return embeddedBean;
} else {
return null;
@@ -69,7 +72,7 @@ final class AssocOneHelpEmbedded extends AssocOneHelp {
notNull = true;
}
}
return notNull ? embeddedBean : null;
return (notNull || allowEmpty) ? embeddedBean : null;
}
@Override
@@ -43,6 +43,8 @@ import io.ebeaninternal.server.deploy.meta.DeployBeanDescriptor;
import io.ebeaninternal.server.deploy.meta.DeployBeanPropertyLists;
import io.ebeaninternal.server.el.*;
import io.ebeaninternal.server.persist.DeleteMode;
import io.ebeaninternal.server.persist.MultiValueWrapper;
import io.ebeaninternal.server.type.ScalarTypeArray;
import io.ebeaninternal.server.query.*;
import io.ebeaninternal.server.querydefn.DefaultOrmQuery;
import io.ebeaninternal.server.querydefn.OrmQueryDetail;
@@ -320,7 +322,7 @@ public class BeanDescriptor<T> implements BeanType<T>, STreeType, SpiBeanType {
this.idOnlyReference = isIdOnlyReference(propertiesBaseScalar);
boolean noRelationships = propertiesOne.length + propertiesMany.length == 0;
this.cacheSharableBeans = noRelationships && deploy.getCacheOptions().isReadOnly();
this.cacheHelp = new BeanDescriptorCacheHelp<>(this, owner.cacheManager(), deploy.getCacheOptions(), cacheSharableBeans, propertiesOneImported);
this.cacheHelp = BeanDescriptorCacheHelp.create(this, owner.cacheManager(), deploy.getCacheOptions(), cacheSharableBeans, propertiesOneImported);
this.jsonHelp = initJsonHelp();
this.draftHelp = new BeanDescriptorDraftHelp<>(this);
this.docStoreAdapter = owner.createDocStoreBeanAdapter(this, deploy);
@@ -753,7 +755,16 @@ public class BeanDescriptor<T> implements BeanType<T>, STreeType, SpiBeanType {
public void bindElementValue(SqlUpdate insert, Object value) {
EntityBean bean = (EntityBean) value;
for (BeanProperty property : propertiesBaseScalar) {
insert.setParameter(property.getValue(bean));
Object propertyValue = property.getValue(bean);
if (property.isArrayType() && propertyValue instanceof Collection) {
// Bind with the declared element type rather than relying on MultiValueWrapper's default
// constructor which infers the element type from the first value - this fails with a
// NoSuchElementException for an empty collection. See #2477.
Class<?> elementType = ((ScalarTypeArray) property.scalarType()).elementType();
insert.setParameter(new MultiValueWrapper((Collection<?>) propertyValue, elementType));
} else {
insert.setParameter(propertyValue);
}
}
}
@@ -2402,6 +2413,12 @@ public class BeanDescriptor<T> implements BeanType<T>, STreeType, SpiBeanType {
if (assocProp == null) {
return null;
}
// this method is an entry-point, although it introduces recursive calls via
// buildElPropertyValue -> createElPropertyValue -> buildElGetValue (back to here)
// it seems we can initialize ElPropertyChainBuilder at this point and skip further checks.
if (chain == null) {
chain = new ElPropertyChainBuilder(propName);
}
String remainder = propName.substring(basePos + 1);
return assocProp.buildElPropertyValue(propName, remainder, chain, propertyDeploy);
}
@@ -2413,9 +2430,7 @@ public class BeanDescriptor<T> implements BeanType<T>, STreeType, SpiBeanType {
if (property == null) {
throw new PersistenceException("No property found for [" + propName + "] in expression " + chain.expression());
}
if (property.containsMany()) {
chain.setContainsMany();
}
return chain.add(property).build();
}
@@ -3337,6 +3352,15 @@ public class BeanDescriptor<T> implements BeanType<T>, STreeType, SpiBeanType {
return propertiesManySave;
}
/**
* Return true if this bean has cascade-save children (OneToMany or exported OneToOne)
* that hold a FK back to this bean. Used to decide whether an ON CONFLICT NOTHING
* insert must be executed immediately so the row-count is known before cascading.
*/
public boolean hasCascadeChildren() {
return propertiesManySave.length > 0 || propertiesOneExportedSave.length > 0;
}
/**
* Assoc Many's with delete cascade.
*/
@@ -27,7 +27,7 @@ import static java.lang.System.Logger.Level.*;
*
* @param <T> The entity bean type
*/
final class BeanDescriptorCacheHelp<T> {
abstract class BeanDescriptorCacheHelp<T> {
private static final System.Logger log = CoreLog.internal;
@@ -36,7 +36,7 @@ final class BeanDescriptorCacheHelp<T> {
private static final System.Logger manyLog = AppLog.getLogger("io.ebean.cache.COLL");
private static final System.Logger natLog = AppLog.getLogger("io.ebean.cache.NATKEY");
private final BeanDescriptor<T> desc;
final BeanDescriptor<T> desc;
private final SpiCacheManager cacheManager;
private final CacheOptions cacheOptions;
/**
@@ -44,13 +44,10 @@ final class BeanDescriptorCacheHelp<T> {
*/
private final boolean cacheSharableBeans;
private final boolean invalidateQueryCache;
private final Class<?> beanType;
final Class<?> beanType;
private final String cacheName;
private final BeanPropertyAssocOne<?>[] propertiesOneImported;
private final String[] naturalKey;
private final ServerCache beanCache;
private final ServerCache naturalKeyCache;
private final ServerCache queryCache;
private final boolean noCaching;
private final SpiCacheControl cacheControl;
private final SpiCacheRegion cacheRegion;
@@ -63,6 +60,21 @@ final class BeanDescriptorCacheHelp<T> {
* Set to true if delete changes need to notify cache.
*/
private boolean cacheNotifyOnDelete;
/**
* Set to true if this bean type has owning OneToOne properties pointing to cached targets.
* When true, all persist events must evict the target bean caches regardless of whether
* this bean type itself has a bean cache (bypasses the cacheRegion.isEnabled() gate).
*/
private boolean cacheNotifyOneToOneOwner;
static <T> BeanDescriptorCacheHelp<T> create(BeanDescriptor<T> desc, SpiCacheManager cacheManager, CacheOptions cacheOptions,
boolean cacheSharableBeans, BeanPropertyAssocOne<?>[] propertiesOneImported) {
if ((cacheOptions.isEnableQueryCache() || cacheOptions.isEnableBeanCache()) && cacheManager.isTenantPartitionedCache()) {
return new BeanDescriptorCacheHelpPartitioned<>(desc, cacheManager, cacheOptions, cacheSharableBeans, propertiesOneImported);
} else {
return new BeanDescriptorCacheHelpFixed<>(desc, cacheManager, cacheOptions, cacheSharableBeans, propertiesOneImported);
}
}
BeanDescriptorCacheHelp(BeanDescriptor<T> desc, SpiCacheManager cacheManager, CacheOptions cacheOptions,
boolean cacheSharableBeans, BeanPropertyAssocOne<?>[] propertiesOneImported) {
@@ -75,48 +87,54 @@ final class BeanDescriptorCacheHelp<T> {
this.cacheSharableBeans = cacheSharableBeans;
this.propertiesOneImported = propertiesOneImported;
this.naturalKey = cacheOptions.getNaturalKey();
if (!cacheOptions.isEnableQueryCache()) {
this.queryCache = null;
} else {
this.queryCache = cacheManager.getQueryCache(beanType);
}
if (cacheOptions.isEnableBeanCache()) {
this.beanCache = cacheManager.getBeanCache(beanType);
if (cacheOptions.getNaturalKey() != null) {
this.naturalKeyCache = cacheManager.getNaturalKeyCache(beanType);
} else {
this.naturalKeyCache = null;
}
} else {
this.beanCache = null;
this.naturalKeyCache = null;
}
this.noCaching = (beanCache == null && queryCache == null);
this.noCaching = !cacheOptions.isEnableQueryCache() && !cacheOptions.isEnableBeanCache();
if (noCaching) {
this.cacheControl = DCacheControlNone.INSTANCE;
this.cacheRegion = (invalidateQueryCache) ? cacheManager.getRegion(cacheOptions.getRegion()) : DCacheRegionNone.INSTANCE;
} else {
this.cacheRegion = cacheManager.getRegion(cacheOptions.getRegion());
this.cacheControl = new DCacheControl(cacheRegion, (beanCache != null), (naturalKeyCache != null), (queryCache != null));
this.cacheControl = new DCacheControl(cacheRegion,
cacheOptions.isEnableBeanCache(),
cacheOptions.isEnableBeanCache() && cacheOptions.getNaturalKey() != null,
cacheOptions.isEnableQueryCache());
}
}
abstract boolean hasBeanCache();
abstract boolean hasQueryCache();
abstract ServerCache queryCache();
abstract ServerCache naturalKeyCache();
abstract ServerCache beanCache();
/**
* Derive the cache notify flags.
*/
void deriveNotifyFlags() {
cacheNotifyOnAll = (invalidateQueryCache || beanCache != null || queryCache != null);
cacheNotifyOnAll = (invalidateQueryCache || hasBeanCache() || hasQueryCache());
cacheNotifyOnDelete = !cacheNotifyOnAll && isNotifyOnDeletes();
cacheNotifyOneToOneOwner = hasOwningOneToOneWithCachedTarget();
if (log.isLoggable(DEBUG)) {
if (cacheNotifyOnAll || cacheNotifyOnDelete) {
String notifyMode = cacheNotifyOnAll ? "All" : "Delete";
if (cacheNotifyOnAll || cacheNotifyOnDelete || cacheNotifyOneToOneOwner) {
String notifyMode = cacheNotifyOnAll ? "All" : (cacheNotifyOnDelete ? "Delete" : "OneToOneOwner");
log.log(DEBUG, "l2 caching on {0} - beanCaching:{1} queryCaching:{2} notifyMode:{3} ",
desc.fullName(), isBeanCaching(), isQueryCaching(), notifyMode);
}
}
}
private boolean hasOwningOneToOneWithCachedTarget() {
for (BeanPropertyAssocOne<?> imported : propertiesOneImported) {
if (imported.isCacheNotifyOwningOneToOne()) {
return true;
}
}
return false;
}
/**
* Return true if there is an imported bi-directional relationship to a bea
* that does have bean caching enabled.
@@ -137,6 +155,9 @@ final class BeanDescriptorCacheHelp<T> {
if (hasImmutableCaches()) {
return true;
}
if (cacheNotifyOneToOneOwner) {
return true;
}
return cacheRegion.isEnabled()
&& (cacheNotifyOnAll || cacheNotifyOnDelete && (type == PersistRequest.Type.DELETE || type == PersistRequest.Type.DELETE_PERMANENT));
}
@@ -184,11 +205,11 @@ final class BeanDescriptorCacheHelp<T> {
* Clear the query cache.
*/
void queryCacheClear() {
if (queryCache != null) {
if (hasQueryCache()) {
if (queryLog.isLoggable(DEBUG)) {
queryLog.log(DEBUG, " CLEAR {0}", cacheName);
}
queryCache.clear();
queryCache().clear();
}
}
@@ -196,7 +217,7 @@ final class BeanDescriptorCacheHelp<T> {
* Add query cache clear to the changeSet.
*/
private void queryCacheClear(CacheChangeSet changeSet) {
if (queryCache != null) {
if (hasQueryCache()) {
changeSet.addClearQuery(desc);
}
}
@@ -205,10 +226,10 @@ final class BeanDescriptorCacheHelp<T> {
* Get a query result from the query cache.
*/
Object queryCacheGet(Object id) {
if (queryCache == null) {
if (!hasQueryCache()) {
throw new IllegalStateException("No query cache enabled on " + desc + ". Need explicit @Cache(enableQueryCache=true)");
}
Object queryResult = queryCache.get(id);
Object queryResult = queryCache().get(id);
if (queryLog.isLoggable(DEBUG)) {
if (queryResult == null) {
queryLog.log(DEBUG, " GET {0}({1}) - cache miss", cacheName, id);
@@ -223,13 +244,13 @@ final class BeanDescriptorCacheHelp<T> {
* Put a query result into the query cache.
*/
void queryCachePut(Object id, QueryCacheEntry entry) {
if (queryCache == null) {
if (!hasQueryCache()) {
throw new IllegalStateException("No query cache enabled on " + desc + ". Need explicit @Cache(enableQueryCache=true)");
}
if (queryLog.isLoggable(DEBUG)) {
queryLog.log(DEBUG, " PUT {0}({1})", cacheName, id);
}
queryCache.put(id, entry);
queryCache().put(id, entry);
}
void manyPropRemove(String propertyName, String parentKey) {
@@ -300,7 +321,7 @@ final class BeanDescriptorCacheHelp<T> {
*/
void manyPropPut(BeanPropertyAssocMany<?> many, Object details, String parentKey) {
if (many.isElementCollection()) {
CachedBeanData data = (CachedBeanData) beanCache.get(parentKey);
CachedBeanData data = (CachedBeanData) beanCache().get(parentKey);
if (data != null) {
try {
// add as JSON to bean cache
@@ -312,7 +333,7 @@ final class BeanDescriptorCacheHelp<T> {
if (beanLog.isLoggable(DEBUG)) {
beanLog.log(DEBUG, " UPDATE {0}({1}) changes:{2}", cacheName, parentKey, changes);
}
beanCache.put(parentKey, newData);
beanCache().put(parentKey, newData);
} catch (IOException e) {
log.log(ERROR, "Error updating L2 cache", e);
}
@@ -351,14 +372,15 @@ final class BeanDescriptorCacheHelp<T> {
* Hit the bean cache with the given ids returning the hits.
*/
BeanCacheResult<T> cacheIdLookup(PersistenceContext context, boolean unmodifiable, Collection<?> ids) {
Set<Object> keys = new HashSet<>(ids.size());
for (Object id : ids) {
keys.add(desc.cacheKey(id));
}
if (ids.isEmpty()) {
return new BeanCacheResult<>();
}
Map<Object, Object> beanDataMap = beanCache.getAll(keys);
// map cacheKey -> original id to support type coercion
Map<Object, Object> keyToOriginalId = new HashMap<>(ids.size());
for (Object id : ids) {
keyToOriginalId.put(desc.cacheKey(id), id);
}
Map<Object, Object> beanDataMap = beanCache().getAll(keyToOriginalId.keySet());
if (beanLog.isLoggable(TRACE)) {
beanLog.log(TRACE, " MGET {0}({1}) - hits:{2}", cacheName, ids, beanDataMap.keySet());
}
@@ -366,7 +388,8 @@ final class BeanDescriptorCacheHelp<T> {
for (Map.Entry<Object, Object> entry : beanDataMap.entrySet()) {
CachedBeanData cachedBeanData = (CachedBeanData) entry.getValue();
T bean = convertToBean(entry.getKey(), unmodifiable, context, cachedBeanData);
result.add(bean, desc.id(bean));
Object originalId = keyToOriginalId.get(entry.getKey());
result.add(bean, originalId != null ? originalId : desc.id(bean));
}
return result;
}
@@ -380,7 +403,7 @@ final class BeanDescriptorCacheHelp<T> {
}
// naturalKey -> Id map
Map<Object, Object> naturalKeyMap = naturalKeyCache.getAll(keys);
Map<Object, Object> naturalKeyMap = naturalKeyCache().getAll(keys);
if (natLog.isLoggable(TRACE)) {
natLog.log(TRACE, " MLOOKUP {0}({1}) - hits:{2}", cacheName, keys, naturalKeyMap);
}
@@ -397,7 +420,7 @@ final class BeanDescriptorCacheHelp<T> {
}
Set<Object> ids = new HashSet<>(naturalKeyMap.values());
Map<Object, Object> beanDataMap = beanCache.getAll(ids);
Map<Object, Object> beanDataMap = beanCache().getAll(ids);
if (beanLog.isLoggable(TRACE)) {
beanLog.log(TRACE, " MGET {0}({1}) - hits:{2}", cacheName, ids, beanDataMap.keySet());
}
@@ -428,25 +451,15 @@ final class BeanDescriptorCacheHelp<T> {
desc.contextPut(context, id, bean);
}
/**
* Return the beanCache creating it if necessary.
*/
private ServerCache getBeanCache() {
if (beanCache == null) {
throw new IllegalStateException("No bean cache enabled for " + desc + ". Add the @Cache annotation.");
}
return beanCache;
}
/**
* Clear the bean cache.
*/
void beanCacheClear() {
if (beanCache != null) {
if (hasBeanCache()) {
if (beanLog.isLoggable(DEBUG)) {
beanLog.log(DEBUG, " CLEAR {0}", cacheName);
}
beanCache.clear();
beanCache().clear();
}
}
@@ -516,13 +529,13 @@ final class BeanDescriptorCacheHelp<T> {
if (beanLog.isLoggable(DEBUG)) {
beanLog.log(DEBUG, " MPUT {0}({1})", cacheName, map.keySet());
}
getBeanCache().putAll(map);
beanCache().putAll(map);
if (natKeys != null && !natKeys.isEmpty()) {
if (natLog.isLoggable(DEBUG)) {
natLog.log(DEBUG, " MPUT {0}({1}, {2})", cacheName, Arrays.toString(naturalKey), natKeys.keySet());
}
naturalKeyCache.putAll(natKeys);
naturalKeyCache().putAll(natKeys);
}
}
@@ -535,14 +548,14 @@ final class BeanDescriptorCacheHelp<T> {
if (beanLog.isLoggable(DEBUG)) {
beanLog.log(DEBUG, " PUT {0}({1}) data:{2}", cacheName, key, beanData);
}
getBeanCache().put(key, beanData);
beanCache().put(key, beanData);
if (naturalKey != null) {
String naturalKey = calculateNaturalKey(beanData);
if (naturalKey != null) {
if (natLog.isLoggable(DEBUG)) {
natLog.log(DEBUG, " PUT {0}({1}, {2})", cacheName, naturalKey, key);
}
naturalKeyCache.put(naturalKey, key);
naturalKeyCache().put(naturalKey, key);
}
}
}
@@ -564,7 +577,7 @@ final class BeanDescriptorCacheHelp<T> {
}
CachedBeanData beanCacheGetData(String key) {
return (CachedBeanData) getBeanCache().get(key);
return (CachedBeanData) beanCache().get(key);
}
T beanCacheGet(String key, boolean unmodifiable, PersistenceContext context) {
@@ -579,7 +592,7 @@ final class BeanDescriptorCacheHelp<T> {
* Return a bean from the bean cache.
*/
private T beanCacheGetInternal(String key, boolean unmodifiable, PersistenceContext context) {
CachedBeanData data = (CachedBeanData) getBeanCache().get(key);
CachedBeanData data = (CachedBeanData) beanCache().get(key);
if (data == null) {
if (beanLog.isLoggable(TRACE)) {
beanLog.log(TRACE, " GET {0}({1}) - cache miss", cacheName, key);
@@ -684,11 +697,11 @@ final class BeanDescriptorCacheHelp<T> {
* Remove a bean from the cache given its Id.
*/
void beanCacheApplyInvalidate(Collection<String> keys) {
if (beanCache != null) {
if (hasBeanCache()) {
if (beanLog.isLoggable(DEBUG)) {
beanLog.log(DEBUG, " MREMOVE {0}({1})", cacheName, keys);
}
beanCache.removeAll(new HashSet<>(keys));
beanCache().removeAll(new HashSet<>(keys));
}
for (BeanPropertyAssocOne<?> imported : propertiesOneImported) {
imported.cacheClear();
@@ -704,7 +717,7 @@ final class BeanDescriptorCacheHelp<T> {
ebis.put(desc.cacheKeyForBean(ebi.owner()), ebi);
}
Map<Object, Object> hits = getBeanCache().getAll(ebis.keySet());
Map<Object, Object> hits = beanCache().getAll(ebis.keySet());
if (beanLog.isLoggable(TRACE)) {
beanLog.log(TRACE, " MLOAD {0}({1}) - got hits ({2})", cacheName, ebis.keySet(), hits.size());
}
@@ -737,7 +750,7 @@ final class BeanDescriptorCacheHelp<T> {
* Returns true if it managed to populate/load the single bean from the cache.
*/
boolean beanCacheLoad(EntityBean bean, EntityBeanIntercept ebi, String key, PersistenceContext context) {
CachedBeanData cacheData = (CachedBeanData) getBeanCache().get(key);
CachedBeanData cacheData = (CachedBeanData) beanCache().get(key);
if (cacheData == null) {
if (beanLog.isLoggable(TRACE)) {
beanLog.log(TRACE, " LOAD {0}({1}) - cache miss", cacheName, key);
@@ -759,7 +772,7 @@ final class BeanDescriptorCacheHelp<T> {
}
void cacheUpdateQuery(boolean update, SpiTransaction transaction) {
if (invalidateQueryCache || cacheNotifyOnAll || (!update && cacheNotifyOnDelete)) {
if (invalidateQueryCache || cacheNotifyOnAll || cacheNotifyOneToOneOwner || (!update && cacheNotifyOnDelete)) {
transaction.event().add(desc.baseTable(), false, update, !update);
}
}
@@ -775,10 +788,11 @@ final class BeanDescriptorCacheHelp<T> {
changeSet.addInvalidate(desc);
} else {
queryCacheClear(changeSet);
if (beanCache != null) {
if (hasBeanCache()) {
changeSet.addBeanRemoveMany(desc, ids);
}
cacheDeleteImported(true, null, changeSet);
cacheClearImportedOneToOne(changeSet);
}
}
@@ -793,10 +807,11 @@ final class BeanDescriptorCacheHelp<T> {
changeSet.addInvalidate(desc);
} else {
queryCacheClear(changeSet);
if (beanCache != null) {
if (hasBeanCache()) {
changeSet.addBeanRemove(desc, id);
}
cacheDeleteImported(true, deleteRequest.entityBean(), changeSet);
cacheDeleteImportedOneToOne(deleteRequest.entityBean(), changeSet);
}
}
@@ -809,6 +824,7 @@ final class BeanDescriptorCacheHelp<T> {
} else {
queryCacheClear(changeSet);
cacheDeleteImported(false, insertRequest.entityBean(), changeSet);
cacheDeleteImportedOneToOne(insertRequest.entityBean(), changeSet);
changeSet.addBeanInsert(desc.baseTable());
}
}
@@ -819,6 +835,42 @@ final class BeanDescriptorCacheHelp<T> {
}
}
/**
* Evict the target bean cache for each owning OneToOne property on insert or delete.
*/
private void cacheDeleteImportedOneToOne(EntityBean entityBean, CacheChangeSet changeSet) {
for (BeanPropertyAssocOne<?> imported : propertiesOneImported) {
imported.cacheDeleteOneToOneOwned(entityBean, changeSet);
}
}
/**
* Clear the target bean cache for each owning OneToOne property on bulk delete-by-ids.
*/
private void cacheClearImportedOneToOne(CacheChangeSet changeSet) {
for (BeanPropertyAssocOne<?> imported : propertiesOneImported) {
imported.cacheClearOneToOneOwned(changeSet);
}
}
/**
* Evict inverse caches for any imported FK properties whose value changed in this update.
* Handles collection-ids caches (ManyToOne FK reassignment) and bean caches (owning OneToOne FK change).
*/
private void cacheUpdateImportedFKs(PersistRequestBean<T> updateRequest, CacheChangeSet changeSet) {
boolean[] dirty = updateRequest.dirtyProperties();
if (dirty == null) {
return;
}
EntityBean entityBean = updateRequest.entityBean();
for (BeanPropertyAssocOne<?> imported : propertiesOneImported) {
int propIdx = imported.propertyIndex();
if (propIdx < dirty.length && dirty[propIdx]) {
imported.cacheUpdateFKchange(entityBean, updateRequest.origValue(imported), changeSet);
}
}
}
/**
* Add appropriate changes to support update.
*/
@@ -831,7 +883,8 @@ final class BeanDescriptorCacheHelp<T> {
} else {
queryCacheClear(changeSet);
if (beanCache == null) {
cacheUpdateImportedFKs(updateRequest, changeSet);
if (!hasBeanCache()) {
// query caching only
return;
}
@@ -861,15 +914,17 @@ final class BeanDescriptorCacheHelp<T> {
changeSet.addInvalidate(desc);
return;
}
if (noCaching) {
if (noCaching && !cacheNotifyOneToOneOwner) {
return;
}
changeSet.addClearQuery(desc);
// inserts don't invalidate the bean cache
if (tableIUD.isUpdateOrDelete()) {
changeSet.addClearBean(desc);
if (!noCaching) {
changeSet.addClearQuery(desc);
// inserts don't invalidate the bean cache
if (tableIUD.isUpdateOrDelete()) {
changeSet.addClearBean(desc);
}
}
// any change invalidates the collection IDs cache
// any change invalidates the collection IDs cache and owning OneToOne target bean caches
for (BeanPropertyAssocOne<?> imported : propertiesOneImported) {
imported.cacheClear(changeSet);
}
@@ -877,7 +932,7 @@ final class BeanDescriptorCacheHelp<T> {
void cacheNaturalKeyPut(String key, String newKey) {
if (newKey != null) {
naturalKeyCache.put(newKey, key);
naturalKeyCache().put(newKey, key);
}
}
@@ -885,7 +940,7 @@ final class BeanDescriptorCacheHelp<T> {
* Apply changes to the bean cache entry.
*/
void cacheBeanUpdate(String key, Map<String, Object> changes, boolean updateNaturalKey, long version) {
ServerCache cache = getBeanCache();
ServerCache cache = beanCache();
CachedBeanData existingData = (CachedBeanData) cache.get(key);
if (existingData != null) {
long currentVersion = existingData.getVersion();
@@ -910,7 +965,7 @@ final class BeanDescriptorCacheHelp<T> {
if (natLog.isLoggable(DEBUG)) {
natLog.log(DEBUG, ".. update {0} REMOVE({1}) - old key for ({2})", cacheName, oldKey, key);
}
naturalKeyCache.remove(oldKey);
naturalKeyCache().remove(oldKey);
}
}
}
@@ -0,0 +1,63 @@
package io.ebeaninternal.server.deploy;
import io.ebean.cache.ServerCache;
import io.ebeaninternal.server.cache.SpiCacheManager;
import io.ebeaninternal.server.core.CacheOptions;
/**
* BeanDescriptorCacheHelp implementation for non-tenant-partitioned (fixed) caches.
* Cache instances are obtained once at construction time and reused for all requests.
*
* @param <T> The entity bean type
*/
final class BeanDescriptorCacheHelpFixed<T> extends BeanDescriptorCacheHelp<T> {
private final ServerCache beanCache;
private final ServerCache naturalKeyCache;
private final ServerCache queryCache;
BeanDescriptorCacheHelpFixed(BeanDescriptor<T> desc, SpiCacheManager cacheManager, CacheOptions cacheOptions,
boolean cacheSharableBeans, BeanPropertyAssocOne<?>[] propertiesOneImported) {
super(desc, cacheManager, cacheOptions, cacheSharableBeans, propertiesOneImported);
if (!cacheOptions.isEnableQueryCache()) {
this.queryCache = null;
} else {
this.queryCache = cacheManager.getQueryCache(beanType);
}
if (cacheOptions.isEnableBeanCache()) {
this.beanCache = cacheManager.getBeanCache(beanType);
this.naturalKeyCache = (cacheOptions.getNaturalKey() != null) ? cacheManager.getNaturalKeyCache(beanType) : null;
} else {
this.beanCache = null;
this.naturalKeyCache = null;
}
}
@Override
boolean hasBeanCache() {
return beanCache != null;
}
@Override
boolean hasQueryCache() {
return queryCache != null;
}
@Override
ServerCache queryCache() {
return queryCache;
}
@Override
ServerCache naturalKeyCache() {
return naturalKeyCache;
}
@Override
ServerCache beanCache() {
if (beanCache == null) {
throw new IllegalStateException("No bean cache enabled for " + desc + ". Add the @Cache annotation.");
}
return beanCache;
}
}
@@ -0,0 +1,62 @@
package io.ebeaninternal.server.deploy;
import io.ebean.cache.ServerCache;
import io.ebeaninternal.server.cache.SpiCacheManager;
import io.ebeaninternal.server.core.CacheOptions;
import java.util.function.Supplier;
/**
* BeanDescriptorCacheHelp implementation for tenant-partitioned caches.
* Cache instances are looked up on every access via the cacheManager (which resolves the
* correct tenant-namespaced cache using the current tenant id from the tenant provider).
*
* @param <T> The entity bean type
*/
final class BeanDescriptorCacheHelpPartitioned<T> extends BeanDescriptorCacheHelp<T> {
private final Supplier<ServerCache> beanCacheSupplier;
private final Supplier<ServerCache> naturalKeyCacheSupplier;
private final Supplier<ServerCache> queryCacheSupplier;
BeanDescriptorCacheHelpPartitioned(BeanDescriptor<T> desc, SpiCacheManager cacheManager, CacheOptions cacheOptions,
boolean cacheSharableBeans, BeanPropertyAssocOne<?>[] propertiesOneImported) {
super(desc, cacheManager, cacheOptions, cacheSharableBeans, propertiesOneImported);
this.queryCacheSupplier = cacheOptions.isEnableQueryCache() ? () -> cacheManager.getQueryCache(beanType) : null;
if (cacheOptions.isEnableBeanCache()) {
this.beanCacheSupplier = () -> cacheManager.getBeanCache(beanType);
this.naturalKeyCacheSupplier = (cacheOptions.getNaturalKey() != null) ? () -> cacheManager.getNaturalKeyCache(beanType) : null;
} else {
this.beanCacheSupplier = null;
this.naturalKeyCacheSupplier = null;
}
}
@Override
boolean hasBeanCache() {
return beanCacheSupplier != null;
}
@Override
boolean hasQueryCache() {
return queryCacheSupplier != null;
}
@Override
ServerCache queryCache() {
return queryCacheSupplier.get();
}
@Override
ServerCache naturalKeyCache() {
return naturalKeyCacheSupplier.get();
}
@Override
ServerCache beanCache() {
if (beanCacheSupplier == null) {
throw new IllegalStateException("No bean cache enabled for " + desc + ". Add the @Cache annotation.");
}
return beanCacheSupplier.get();
}
}
@@ -907,8 +907,18 @@ public final class BeanDescriptorManager implements BeanDescriptorMap, SpiBeanTy
}
private void makeOrderColumn(DeployBeanPropertyAssocMany<?> oneToMany) {
DeployBeanDescriptor<?> targetDesc = targetDescriptor(oneToMany);
DeployOrderColumn orderColumn = oneToMany.getOrderColumn();
makeOrderColumn(oneToMany, targetDescriptor(oneToMany));
}
/**
* Create and assign the synthetic order column property onto the given target descriptor.
* <p>
* Used for both {@code @OneToMany} (targetDesc looked up via the target entity type) and
* {@code @ElementCollection} (targetDesc is the synthetic element descriptor which is not
* registered in {@code deployInfoMap} and so must be passed in directly).
*/
public void makeOrderColumn(DeployBeanPropertyAssocMany<?> many, DeployBeanDescriptor<?> targetDesc) {
DeployOrderColumn orderColumn = many.getOrderColumn();
final ScalarType<?> scalarType = typeManager.type(Integer.class);
DeployBeanProperty orderProperty = new DeployBeanProperty(targetDesc, Integer.class, scalarType, null);
orderProperty.setName(DeployOrderColumn.LOGICAL_NAME);
@@ -671,6 +671,35 @@ public class BeanProperty implements ElPropertyValue, Property, STreeProperty {
setValue(entityBean, tenantId);
}
/**
* By default, getIntercept and setIntercept will check if the passed bean is an instance of the descriptor type.
* <p>
* If the property is not part of the type hierarchy (i.e. is not this property from this descriptor) an
* IllegalArgumentException is thrown.
* <p>
* If inheritance is involved, this method returns false instead of throwing an exception, if the property might
* exist on one of the sibling child beans. This is necessary for getIntercept, as it returns <code>null</code>
* in this case.
*
* @return true if the property can be accessed on the given bean, false if it should be treated as unloaded.
*/
private boolean checkPropertyAccess(EntityBean bean) {
if (bean == null || descriptor.type().isInstance(bean)) { // null = fall through - NPE is caught later.
return true;
}
InheritInfo inheritInfo = descriptor.inheritInfo();
if (inheritInfo == null || inheritInfo.isRoot() || !inheritInfo.getRoot().getType().isInstance(bean)) {
throw new IllegalArgumentException(propertyIncompatibleMsg(bean));
} else {
return false;
}
}
private String propertyIncompatibleMsg(EntityBean bean) {
String beanType = bean == null ? "null" : bean.getClass().getName();
return "Property " + name + " on [" + descriptor + "] is incompatible with type[" + beanType + "]";
}
/**
* Set the value of the property without interception or
* PropertyChangeSupport.
@@ -687,6 +716,9 @@ public class BeanProperty implements ElPropertyValue, Property, STreeProperty {
* Set the value of the property.
*/
public void setValueIntercept(EntityBean bean, Object value) {
if (!checkPropertyAccess(bean)) {
throw new IllegalArgumentException(propertyIncompatibleMsg(bean));
}
try {
setter.setIntercept(bean, value);
} catch (Exception ex) {
@@ -798,6 +830,9 @@ public class BeanProperty implements ElPropertyValue, Property, STreeProperty {
}
public Object getValueIntercept(EntityBean bean) {
if (!checkPropertyAccess(bean)) {
return null;
}
try {
return getter.getIntercept(bean);
} catch (Exception ex) {
@@ -161,13 +161,7 @@ public abstract class BeanPropertyAssoc<T> extends BeanProperty implements STree
ElPropertyValue createElPropertyValue(String propName, String remainder, ElPropertyChainBuilder chain, boolean propertyDeploy) {
// associated or embedded bean
BeanDescriptor<?> embDesc = targetDescriptor();
if (chain == null) {
chain = new ElPropertyChainBuilder(isEmbedded(), propName);
}
chain.add(this);
if (containsMany()) {
chain.setContainsMany();
}
return embDesc.buildElGetValue(remainder, chain, propertyDeploy);
}
@@ -217,6 +211,10 @@ public abstract class BeanPropertyAssoc<T> extends BeanProperty implements STree
* Return the BeanDescriptor of the target.
*/
public BeanDescriptor<T> targetDescriptor() {
if (targetDescriptor == null) {
// lazily resolve for association properties inside an @Embeddable used as override copy
targetDescriptor = descriptor.descriptor(targetType);
}
return targetDescriptor;
}
@@ -57,6 +57,12 @@ public class BeanPropertyAssocMany<T> extends BeanPropertyAssoc<T> implements ST
* Flag to indicate that the target has a order column to auto populate.
*/
private final boolean hasOrderColumn;
/**
* For ManyToMany, the db column name of the order column stored on the intersection
* table (null for OneToMany/ElementCollection which use a target descriptor property instead).
*/
private final String intersectionOrderColumn;
private final boolean intersectionOrderColumnNullable;
/**
* Flag to indicate manyToMany relationship.
*/
@@ -95,6 +101,8 @@ public class BeanPropertyAssocMany<T> extends BeanPropertyAssoc<T> implements ST
this.o2mJoinTable = deploy.isO2mJoinTable();
this.hasOrderColumn = deploy.hasOrderColumn();
this.manyToMany = deploy.isManyToMany();
this.intersectionOrderColumn = (manyToMany && hasOrderColumn) ? deploy.getOrderColumn().getName() : null;
this.intersectionOrderColumnNullable = (manyToMany && hasOrderColumn) && deploy.getOrderColumn().isNullable();
this.elementCollection = deploy.isElementCollection();
this.elementDescriptor = deploy.getElementDescriptor();
this.manyType = deploy.getManyType();
@@ -154,6 +162,9 @@ public class BeanPropertyAssocMany<T> extends BeanPropertyAssoc<T> implements ST
embeddedExportedProperties = exportedProperties[0].isEmbedded();
if (fetchOrderBy != null) {
lazyFetchOrderBy = sqlHelp.lazyFetchOrderBy(fetchOrderBy);
} else if (intersectionOrderColumn != null) {
// ManyToMany @OrderColumn - the intersection table is always aliased "int_"
lazyFetchOrderBy = sqlHelp.lazyFetchOrderBy("int_." + intersectionOrderColumn);
}
}
}
@@ -511,6 +522,29 @@ public class BeanPropertyAssocMany<T> extends BeanPropertyAssoc<T> implements ST
return hasOrderColumn;
}
/**
* Return true if this is a ManyToMany with an order column stored on the intersection table.
*/
@Override
public boolean hasIntersectionOrderColumn() {
return intersectionOrderColumn != null;
}
/**
* Return the db column name of the ManyToMany intersection table order column (or null).
*/
@Override
public String intersectionOrderColumn() {
return intersectionOrderColumn;
}
/**
* Return true if the intersection table order column is nullable.
*/
public boolean isIntersectionOrderColumnNullable() {
return intersectionOrderColumnNullable;
}
public boolean isOrphanRemoval() {
return orphanRemoval;
}
@@ -45,6 +45,7 @@ public class BeanPropertyAssocOne<T> extends BeanPropertyAssoc<T> implements STr
private final boolean orphanRemoval;
private final boolean primaryKeyExport;
private final boolean primaryKeyJoin;
private final boolean embeddedAllowEmpty;
private AssocOneHelp localHelp;
final BeanProperty[] embeddedProps;
@@ -54,6 +55,7 @@ public class BeanPropertyAssocOne<T> extends BeanPropertyAssoc<T> implements STr
private String deleteByParentIdInSql;
private BeanPropertyAssocMany<?> relationshipProperty;
private boolean cacheNotifyRelationship;
private boolean cacheNotifyOwningOneToOne;
/**
* Create based on deploy information of an EmbeddedId.
@@ -73,6 +75,7 @@ public class BeanPropertyAssocOne<T> extends BeanPropertyAssoc<T> implements STr
oneToOne = deploy.isOneToOne();
oneToOneExported = deploy.isOneToOneExported();
orphanRemoval = deploy.isOrphanRemoval();
embeddedAllowEmpty = deploy.isEmbeddedAllowEmpty();
if (embedded) {
// Overriding of the columns and use table alias of owning BeanDescriptor
BeanEmbeddedMeta overrideMeta = BeanEmbeddedMetaFactory.create(owner, deploy);
@@ -99,6 +102,7 @@ public class BeanPropertyAssocOne<T> extends BeanPropertyAssoc<T> implements STr
oneToOne = source.oneToOne;
oneToOneExported = source.oneToOneExported;
orphanRemoval = source.orphanRemoval;
embeddedAllowEmpty = source.embeddedAllowEmpty;
embeddedProps = null;
embeddedPropsMap = null;
}
@@ -143,6 +147,7 @@ public class BeanPropertyAssocOne<T> extends BeanPropertyAssoc<T> implements STr
*/
void initialisePostTarget() {
this.cacheNotifyRelationship = isCacheNotifyRelationship();
this.cacheNotifyOwningOneToOne = oneToOne && !oneToOneExported && targetDescriptor.isBeanCaching();
}
/**
@@ -165,18 +170,31 @@ public class BeanPropertyAssocOne<T> extends BeanPropertyAssoc<T> implements STr
}
/**
* Clear the L2 relationship cache for this property.
* Return true if this owning OneToOne property requires target bean cache eviction on change.
*/
boolean isCacheNotifyOwningOneToOne() {
return cacheNotifyOwningOneToOne;
}
/**
* Clear the L2 relationship cache for this property (used from beanCacheApplyInvalidate).
*/
void cacheClear() {
if (cacheNotifyRelationship) {
targetDescriptor.cacheManyPropClear(relationshipProperty.name());
}
if (cacheNotifyOwningOneToOne) {
targetDescriptor.clearBeanCache();
}
}
void cacheClear(CacheChangeSet changeSet) {
if (cacheNotifyRelationship) {
changeSet.addManyClear(targetDescriptor, relationshipProperty.name());
}
if (cacheNotifyOwningOneToOne) {
changeSet.addClearBean(targetDescriptor);
}
}
/**
@@ -199,6 +217,66 @@ public class BeanPropertyAssocOne<T> extends BeanPropertyAssoc<T> implements STr
}
}
/**
* Evict the target's bean cache entry when this owning OneToOne bean is inserted or deleted.
*/
void cacheDeleteOneToOneOwned(EntityBean bean, CacheChangeSet changeSet) {
if (cacheNotifyOwningOneToOne) {
Object assocBean = getValue(bean);
if (assocBean != null) {
Object targetId = targetDescriptor.id(assocBean);
if (targetId != null) {
changeSet.addBeanRemove(targetDescriptor, targetId);
}
}
}
}
/**
* Clear the target's entire bean cache when owning OneToOne beans are deleted by IDs.
*/
void cacheClearOneToOneOwned(CacheChangeSet changeSet) {
if (cacheNotifyOwningOneToOne) {
changeSet.addClearBean(targetDescriptor);
}
}
/**
* Evict the inverse caches for BOTH old and new targets when the FK changes on update.
* Handles collection-ids caches (ManyToOne) and bean caches (owning OneToOne).
*/
void cacheUpdateFKchange(EntityBean bean, Object origAssocBean, CacheChangeSet changeSet) {
Object newAssocBean = getValue(bean);
if (cacheNotifyRelationship) {
if (origAssocBean != null) {
Object oldId = targetDescriptor.id(origAssocBean);
if (oldId != null) {
changeSet.addManyRemove(targetDescriptor, relationshipProperty.name(), targetDescriptor.cacheKey(oldId));
}
}
if (newAssocBean != null) {
Object newId = targetDescriptor.id(newAssocBean);
if (newId != null) {
changeSet.addManyRemove(targetDescriptor, relationshipProperty.name(), targetDescriptor.cacheKey(newId));
}
}
}
if (cacheNotifyOwningOneToOne) {
if (origAssocBean != null) {
Object oldId = targetDescriptor.id(origAssocBean);
if (oldId != null) {
changeSet.addBeanRemove(targetDescriptor, oldId);
}
}
if (newAssocBean != null) {
Object newId = targetDescriptor.id(newAssocBean);
if (newId != null) {
changeSet.addBeanRemove(targetDescriptor, newId);
}
}
}
}
@Override
Object naturalKeyVal(Map<String, Object> values) {
EntityBean bean = (EntityBean) values.get(name);
@@ -211,15 +289,31 @@ public class BeanPropertyAssocOne<T> extends BeanPropertyAssoc<T> implements STr
@Override
public ElPropertyValue buildElPropertyValue(String propName, String remainder, ElPropertyChainBuilder chain, boolean propertyDeploy) {
if (embedded) {
BeanProperty embProp = embeddedPropsMap.get(remainder);
String embName = remainder;
String embRemainder = null;
int basePos = remainder.indexOf('.');
if (basePos > -1) {
embName = remainder.substring(0, basePos);
embRemainder = remainder.substring(basePos + 1);
}
BeanProperty embProp = embeddedPropsMap.get(embName);
if (embProp == null) {
String msg = "Embedded Property " + remainder + " not found in " + fullName();
String msg = "Embedded Property " + embName + " not found in " + fullName();
throw new PersistenceException(msg);
}
if (chain == null) {
chain = new ElPropertyChainBuilder(true, propName);
}
chain.add(this);
if (embRemainder != null) {
if (embProp instanceof BeanPropertyAssocOne) {
// @ManyToOne using the overridden property, e.g. "ma_country_code" instead of "country_code")
return embProp.buildElPropertyValue(propName, embRemainder, chain, propertyDeploy);
}
// scalar (or non-navigable) leaf with a further path segment - invalid path
String msg = "Embedded Property " + embName + "." + embRemainder + " not found in " + fullName();
throw new PersistenceException(msg);
}
// direct scalar/assoc access within embedded — mark as embedded so the prefix
// strips the embedded segment (keeps parent table alias, not the embedded segment)
chain.setEmbedded(true);
return chain.add(embProp).build();
}
@@ -749,7 +843,7 @@ public class BeanPropertyAssocOne<T> extends BeanPropertyAssoc<T> implements STr
private AssocOneHelp createHelp(boolean embedded, boolean oneToOneExported, String embeddedPrefix) {
if (embedded) {
return new AssocOneHelpEmbedded(this);
return new AssocOneHelpEmbedded(this, embeddedAllowEmpty);
} else if (oneToOneExported) {
return new AssocOneHelpRefExported(this);
} else {
@@ -181,4 +181,11 @@ public interface DbSqlContext {
* Include the filter many predicates if specified into the JOIN clause.
*/
void includeFilterMany();
/**
* Return true if the given fetch path (relative to the query root) is the exact join clause
* that the pending filterMany predicate must be attached to - i.e. the deepest path the
* filterMany expression itself references.
*/
boolean isFilterManyAttachPoint(String prefix);
}
@@ -234,6 +234,10 @@ public final class IdBinderSimple implements IdBinder {
@Override
public Object convertId(Object idValue) {
if (!idValue.getClass().equals(expectedType)) {
if (idValue instanceof String) {
// for cacheKey() formatted values
return scalarType.parse((String) idValue);
}
return scalarType.toBeanType(idValue);
}
return idValue;
@@ -13,6 +13,7 @@ public final class DeployBeanPropertyAssocOne<T> extends DeployBeanPropertyAssoc
private boolean oneToOneExported;
private boolean primaryKeyJoin;
private boolean primaryKeyExport;
private boolean embeddedAllowEmpty;
private DeployBeanEmbedded deployEmbedded;
private String columnPrefix;
@@ -103,6 +104,14 @@ public final class DeployBeanPropertyAssocOne<T> extends DeployBeanPropertyAssoc
}
}
public void setEmbeddedAllowEmpty(boolean allowEmpty) {
this.embeddedAllowEmpty = allowEmpty;
}
public boolean isEmbeddedAllowEmpty() {
return embeddedAllowEmpty;
}
public void setColumnPrefix(String columnPrefix) {
this.columnPrefix = columnPrefix;
}
@@ -89,6 +89,11 @@ final class AnnotationAssocManys extends AnnotationAssoc {
ManyToMany manyToMany = get(prop, ManyToMany.class);
if (manyToMany != null) {
readToMany(manyToMany, prop);
OrderColumn orderColumn = get(prop, OrderColumn.class);
if (orderColumn != null) {
// ManyToMany order value is stored on the intersection table (not the target bean)
prop.setOrderColumn(new DeployOrderColumn(orderColumn));
}
}
ElementCollection elementCollection = get(prop, ElementCollection.class);
if (elementCollection != null) {
@@ -177,6 +182,11 @@ final class AnnotationAssocManys extends AnnotationAssoc {
if (!elementCollection.targetClass().equals(void.class)) {
prop.setTargetType(elementCollection.targetClass());
}
OrderColumn orderColumn = get(prop, OrderColumn.class);
if (orderColumn != null) {
prop.setOrderColumn(new DeployOrderColumn(orderColumn));
prop.setFetchOrderBy(DeployOrderColumn.LOGICAL_NAME);
}
Column column = prop.getMetaAnnotation(Column.class);
if (column != null) {
prop.setDbColumn(column.name());
@@ -268,6 +278,11 @@ final class AnnotationAssocManys extends AnnotationAssoc {
elementDescriptor.setName(prop.toString());
factory.createUnidirectional(elementDescriptor, prop.getOwningType(), beanTable, prop.getTableJoin());
if (prop.hasOrderColumn()) {
// create the synthetic order property on the element descriptor - the element descriptor
// is not registered in deployInfoMap so this can't go through the usual OneToMany path
factory.makeOrderColumn(prop, elementDescriptor);
}
prop.setElementDescriptor(factory.createElementDescriptor(elementDescriptor, prop.getManyType(), scalar));
}
@@ -245,6 +245,13 @@ final class AnnotationAssocOnes extends AnnotationAssoc {
} catch (NoSuchMethodError e) {
// using standard JPA API without prefix option, maybe in EE container
}
try {
if (!embedded.nullable()) {
prop.setEmbeddedAllowEmpty(true);
}
} catch (NoSuchMethodError e) {
// older persistence-api without nullable() extension
}
readEmbeddedAttributeOverrides(prop);
}
@@ -42,17 +42,19 @@ public final class ElPropertyChain implements ElPropertyValue {
private final ScalarType<?> scalarType;
private final ElPropertyValue lastElPropertyValue;
public ElPropertyChain(boolean containsMany, boolean embedded, String expression, ElPropertyValue[] chain) {
this.containsMany = containsMany;
public ElPropertyChain(String expression, boolean containsMany, boolean embedded, ElPropertyValue[] chain) {
this.chain = chain;
this.expression = expression;
this.containsMany = containsMany;
int dotPos = expression.lastIndexOf('.');
if (dotPos > -1) {
this.name = expression.substring(dotPos + 1);
if (embedded) {
// embedded segments are transparent (share parent table) — strip the embedded
// segment from the prefix so the alias points to the parent join, not the embedded
int embPos = expression.lastIndexOf('.', dotPos - 1);
this.prefix = embPos == -1 ? null : expression.substring(0, embPos);
} else {
this.prefix = expression.substring(0, dotPos);
}
@@ -61,19 +63,18 @@ public final class ElPropertyChain implements ElPropertyValue {
this.name = expression;
}
this.assocId = chain[chain.length - 1].isAssocId();
this.last = chain.length - 1;
this.lastBeanProperty = chain[chain.length - 1].beanProperty();
this.last = this.chain.length - 1;
this.lastElPropertyValue = this.chain[this.last];
this.assocId = this.lastElPropertyValue.isAssocId();
this.lastBeanProperty = lastElPropertyValue.beanProperty();
if (lastBeanProperty != null) {
this.scalarType = lastBeanProperty.scalarType();
} else {
// case for nested compound type (non-scalar)
this.scalarType = null;
}
this.lastElPropertyValue = chain[chain.length - 1];
this.placeHolder = placeHolder(prefix, lastElPropertyValue, false);
this.placeHolderEncrypted = placeHolder(prefix, lastElPropertyValue, true);
this.placeHolder = placeHolder(this.prefix, lastElPropertyValue, false);
this.placeHolderEncrypted = placeHolder(this.prefix, lastElPropertyValue, true);
}
@Override
@@ -17,14 +17,13 @@ public final class ElPropertyChainBuilder {
private final String expression;
private final List<ElPropertyValue> chain = new ArrayList<>();
private boolean embedded;
private boolean containsMany;
private boolean containsMany = false;
private boolean embedded = false;
/**
* Create with the original expression.
*/
public ElPropertyChainBuilder(boolean embedded, String expression) {
this.embedded = embedded;
public ElPropertyChainBuilder(String expression) {
this.expression = expression;
}
@@ -32,14 +31,18 @@ public final class ElPropertyChainBuilder {
return containsMany;
}
public void setContainsMany() {
this.containsMany = true;
}
public String expression() {
return expression;
}
/**
* Mark the chain as going through an embedded property.
* This affects how the prefix (table alias) is computed for SQL generation.
*/
public void setEmbedded(boolean embedded) {
this.embedded = embedded;
}
/**
* Add a ElGetValue element to the chain.
*/
@@ -48,6 +51,9 @@ public final class ElPropertyChainBuilder {
throw new NullPointerException("element null in expression " + expression);
}
chain.add(element);
if (element.containsMany()) {
containsMany = true;
}
return this;
}
@@ -55,13 +61,6 @@ public final class ElPropertyChainBuilder {
* Build the immutable ElGetChain from the build information.
*/
public ElPropertyChain build() {
return new ElPropertyChain(containsMany, embedded, expression, chain.toArray(new ElPropertyValue[0]));
}
/**
* Permits to set whole chain as embedded when the leaf is embedded
*/
public void setEmbedded(boolean embedded) {
this.embedded = embedded;
return new ElPropertyChain(expression, containsMany, embedded, chain.toArray(new ElPropertyValue[0]));
}
}
@@ -91,4 +91,16 @@ public interface ElPropertyDeploy extends SpiQueryManyJoin {
default String fetchOrderBy() {
return beanProperty().fetchOrderBy();
}
@Override
default boolean hasIntersectionOrderColumn() {
BeanProperty prop = beanProperty();
return prop instanceof io.ebeaninternal.server.deploy.BeanPropertyAssocMany
&& prop.hasIntersectionOrderColumn();
}
@Override
default String intersectionOrderColumn() {
return beanProperty().intersectionOrderColumn();
}
}
@@ -254,17 +254,27 @@ public class DefaultExpressionList<T> implements SpiExpressionList<T> {
@Override
public Query<T> asOf(Timestamp asOf) {
return query.asOf(asOf);
return query().asOf(asOf);
}
@Override
public Query<T> asDraft() {
return query.asDraft();
return query().asDraft();
}
@Override
public <D> DtoQuery<D> asDto(Class<D> dtoClass) {
return query.asDto(dtoClass);
return query().asDto(dtoClass);
}
@Override
public <D> MappedQuery<D> mapTo(Class<D> dtoType) {
return query().mapTo(dtoType);
}
@Override
public <D> MappedQuery<D> mapTo(Class<D> dtoType, DtoMapper<T, D> mapper) {
return query().mapTo(dtoType, mapper);
}
@Override
@@ -328,6 +338,11 @@ public class DefaultExpressionList<T> implements SpiExpressionList<T> {
return query.delete();
}
@Override
public int deletePermanent() {
return query.deletePermanent();
}
@Override
public int update() {
return query.update();
@@ -1320,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();
@@ -342,6 +342,11 @@ final class JunctionExpression<T> implements SpiJunction<T>, SpiExpression, Expr
return exprList.delete();
}
@Override
public int deletePermanent() {
return exprList.deletePermanent();
}
@Override
public int update() {
return exprList.update();
@@ -362,6 +367,16 @@ final class JunctionExpression<T> implements SpiJunction<T>, SpiExpression, Expr
return exprList.asDto(dtoClass);
}
@Override
public <D> MappedQuery<D> mapTo(Class<D> dtoType) {
return exprList.mapTo(dtoType);
}
@Override
public <D> MappedQuery<D> mapTo(Class<D> dtoType, DtoMapper<T, D> mapper) {
return exprList.mapTo(dtoType, mapper);
}
@Override
public UpdateQuery<T> asUpdate() {
return exprList.asUpdate();
@@ -481,7 +481,7 @@ public final class DefaultPersister implements Persister {
saveAssocOne(request);
}
request.executeOrQueue();
if (request.isPersistCascade()) {
if (request.isPersistCascade() && !request.isInsertConflictSkipped()) {
// save any associated List held beans
saveAssocMany(request);
}
@@ -907,6 +907,11 @@ public final class DefaultPersister implements Persister {
* </p>
*/
private int delete(PersistRequestBean<?> request) {
// fire preDelete now, before cascading to children/many's so that the
// BeanPersistController/Adapter still sees the bean's collections and
// relationships as they are prior to the cascade delete
request.controllerPreDelete();
DeleteUnloadedForeignKeys unloadedForeignKeys = null;
if (request.isPersistCascade()) {
// delete children first ... register the
@@ -256,6 +256,12 @@ final class SaveManyBeans extends SaveManyBase {
}
private void saveAssocManyIntersection(boolean queue) {
if (many.hasIntersectionOrderColumn()) {
// With @OrderColumn the position of every row can change on any add/remove/reorder so
// we always delete all intersection rows and reinsert them in the current list order.
saveAssocManyIntersectionOrdered(queue);
return;
}
final boolean vanillaCollection = !(value instanceof BeanCollection<?>);
if (vanillaCollection || forcedUpdate) {
// delete all intersection rows and then treat all
@@ -292,6 +298,17 @@ final class SaveManyBeans extends SaveManyBase {
manyValue.modifyReset();
}
if (!insertedParent) {
// only fire controller when the intersection actually changes:
// vanillaCollection/forcedUpdate always do a full replace,
// tracked BeanCollection fires only when additions or removals exist
boolean intersectionChanged = vanillaCollection || forcedUpdate
|| (additions != null && !additions.isEmpty())
|| (deletions != null && !deletions.isEmpty());
if (intersectionChanged) {
request.preManyToManyUpdate();
}
}
transaction.depth(+1);
if (deletions != null && !deletions.isEmpty()) {
for (Object other : deletions) {
@@ -329,6 +346,53 @@ final class SaveManyBeans extends SaveManyBase {
transaction.depth(-1);
}
/**
* Save the ManyToMany intersection rows for a property with an {@code @OrderColumn}.
* <p>
* Unlike the standard diff based save (additions/removals), this always deletes all existing
* intersection rows for the parent and reinserts every current entry in list order, binding
* the sequential order index. This is required because a pure reorder (no add/remove) would
* not otherwise be detected/persisted, and there is no per-row place (unlike OneToMany/
* ElementCollection) to compare an existing 'loaded' order against - the order value lives on
* the intersection row, not on the target bean.
*/
private void saveAssocManyIntersectionOrdered(boolean queue) {
if (value == null) {
return;
}
Collection<?> current;
if (value instanceof Map<?, ?>) {
current = ((Map<?, ?>) value).values();
} else if (value instanceof Collection<?>) {
current = (Collection<?>) value;
} else {
throw new PersistenceException("Unhandled ManyToMany type " + value.getClass().getName() + " for " + many.fullName());
}
if (value instanceof BeanCollection<?>) {
BeanCollection<?> manyValue = (BeanCollection<?>) value;
setListenMode(manyValue, many);
manyValue.modifyReset();
}
if (!insertedParent) {
request.preManyToManyUpdate();
persister.deleteManyIntersection(parentBean, many, transaction, publish, queue);
}
String orderColumn = many.intersectionOrderColumn();
transaction.depth(+1);
int position = 0;
for (Object other : current) {
EntityBean otherBean = (EntityBean) other;
if (!many.hasImportedId(otherBean)) {
throw new PersistenceException("ManyToMany bean does not have an Id value? " + otherBean);
}
IntersectionRow intRow = many.buildManyToManyMapBean(parentBean, otherBean, publish);
intRow.put(orderColumn, position++);
SpiSqlUpdate sqlInsert = intRow.createInsert(server);
persister.executeOrQueue(sqlInsert, transaction, queue, BatchControl.INSERT_QUEUE);
}
transaction.depth(-1);
}
private boolean isChangedProperty() {
return request.isChangedProperty(many.propertyIndex());
}

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