mirror of
https://github.com/ebean-orm/ebean.git
synced 2026-09-20 19:17:55 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
805e309fb0 | ||
|
|
adb2e6a5d2 | ||
|
|
cbd67a6c22 | ||
|
|
30314f163d | ||
|
|
893dc599cd | ||
|
|
b065b030bc | ||
|
|
837ba91126 | ||
|
|
e18c664c98 | ||
|
|
636bb28df3 | ||
|
|
84a311e521 | ||
|
|
791ac13750 | ||
|
|
d54a24b27d | ||
|
|
73c9ff8d80 | ||
|
|
bdbe743f56 | ||
|
|
ed2026b858 | ||
|
|
8d21f91f62 | ||
|
|
6d1d58b8c2 | ||
|
|
a943ee9225 | ||
|
|
94f252f423 | ||
|
|
275f8ad9f9 | ||
|
|
9ac3bd71f6 | ||
|
|
bb46c88db1 | ||
|
|
28e6108315 | ||
|
|
c20d1cdf9b | ||
|
|
007409d21c | ||
|
|
8b61069b3f | ||
|
|
a82dcb2509 | ||
|
|
08bb170cb2 | ||
|
|
a2f954a60e | ||
|
|
1d654e350b | ||
|
|
6dd1763e54 | ||
|
|
b32f3bcad6 |
@@ -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>
|
||||
|
||||
+3
-1
@@ -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) |
|
||||
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
# 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).
|
||||
|
||||
### 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.
|
||||
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,270 @@
|
||||
# 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`.*
|
||||
|
||||
- **`@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.*
|
||||
|
||||
### 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/
|
||||
@@ -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
|
||||
|
||||
@@ -47,6 +47,7 @@ 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 |
|
||||
|
||||
@@ -153,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
|
||||
@@ -182,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
|
||||
|
||||
@@ -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)).
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 -> 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 -> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,4 +246,41 @@ public interface DtoQuery<T> extends CancelableQuery {
|
||||
*/
|
||||
DtoQuery<T> usingMaster(boolean useMaster);
|
||||
|
||||
/**
|
||||
* 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>
|
||||
* 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
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
PagedList<T> findPagedList();
|
||||
|
||||
}
|
||||
|
||||
@@ -103,6 +103,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>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package io.ebean;
|
||||
|
||||
import org.jspecify.annotations.NullMarked;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.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> {
|
||||
|
||||
/**
|
||||
* Execute the query returning the mapped DTO list.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
PagedList<D> findPagedList();
|
||||
|
||||
/**
|
||||
* Execute the query returning the result as a Stream of mapped DTOs.
|
||||
* <p>
|
||||
* Mirrors {@link QueryBuilder#findStream()} - the underlying entity graph query is streamed
|
||||
* (supporting very large queries iterating any number of results, potentially using multiple
|
||||
* persistence contexts internally) and each entity is mapped to its target DTO lazily as the
|
||||
* stream is consumed, sharing one {@link DtoMapContext} across the whole stream so that
|
||||
* repeated references to the same source entity still de-duplicate to the same DTO instance.
|
||||
* <pre>{@code
|
||||
*
|
||||
* // use try with resources to ensure Stream is closed
|
||||
*
|
||||
* try (Stream<CustomerDto> stream = query.mapTo(CustomerDto.class).findStream()) {
|
||||
* stream
|
||||
* .map(...)
|
||||
* .collect(...);
|
||||
* }
|
||||
*
|
||||
* }</pre>
|
||||
*/
|
||||
Stream<D> findStream();
|
||||
|
||||
/**
|
||||
* Execute the query returning a single mapped DTO, or {@code null} if there is no matching row.
|
||||
*/
|
||||
@Nullable
|
||||
D findOne();
|
||||
|
||||
/**
|
||||
* Execute the query returning an optional mapped DTO.
|
||||
*/
|
||||
Optional<D> findOneOrEmpty();
|
||||
|
||||
/**
|
||||
* Ensure the master DataSource is used when useMaster is true. Otherwise, the read only
|
||||
* data source can be used if defined.
|
||||
*/
|
||||
MappedQuery<D> usingMaster(boolean useMaster);
|
||||
|
||||
/**
|
||||
* Use the explicit transaction to execute the query.
|
||||
*/
|
||||
MappedQuery<D> usingTransaction(Transaction transaction);
|
||||
|
||||
/**
|
||||
* Execute the query using the given connection.
|
||||
*/
|
||||
MappedQuery<D> usingConnection(Connection connection);
|
||||
|
||||
}
|
||||
@@ -77,6 +77,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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -2196,12 +2203,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();
|
||||
@@ -2213,14 +2215,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;
|
||||
}
|
||||
@@ -2228,13 +2231,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()) {
|
||||
@@ -2248,7 +2272,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.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;
|
||||
}
|
||||
|
||||
@@ -132,6 +132,13 @@ public final class PersistRequestBean<T> extends PersistRequest implements BeanP
|
||||
* 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) {
|
||||
@@ -1276,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).
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-6
@@ -372,14 +372,15 @@ abstract 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());
|
||||
}
|
||||
@@ -387,7 +388,8 @@ abstract 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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+15
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+13
-3
@@ -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
|
||||
|
||||
@@ -367,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();
|
||||
|
||||
@@ -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
|
||||
@@ -340,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());
|
||||
}
|
||||
|
||||
+5
@@ -44,10 +44,15 @@ final class SaveManyElementCollection extends SaveManyBase {
|
||||
private void saveCollection() {
|
||||
SpiSqlUpdate proto = many.insertElementCollection();
|
||||
Object parentId = request.beanId();
|
||||
boolean hasOrderColumn = many.hasOrderColumn();
|
||||
int position = 0;
|
||||
for (Object value : collection) {
|
||||
final SpiSqlUpdate sqlInsert = proto.copy();
|
||||
sqlInsert.setParameter(parentId);
|
||||
many.bindElementValue(sqlInsert, value);
|
||||
if (hasOrderColumn) {
|
||||
sqlInsert.setParameter(position++);
|
||||
}
|
||||
persister.addToFlushQueue(sqlInsert, transaction, BatchControl.INSERT_QUEUE);
|
||||
}
|
||||
resetModifyState();
|
||||
|
||||
+14
-2
@@ -60,6 +60,10 @@ final class BindableIdEmbedded implements BindableId {
|
||||
@Override
|
||||
public void dmlBind(BindableRequest request, EntityBean bean) throws SQLException {
|
||||
EntityBean idValue = (EntityBean) embId.getValue(bean);
|
||||
if (idValue == null && matches != null) {
|
||||
// The id (e.g. IdClass) hasn't been derived/cached on this bean yet
|
||||
idValue = deriveId(bean);
|
||||
}
|
||||
for (BeanProperty prop : props) {
|
||||
Object value = prop.getValue(idValue);
|
||||
request.bind(value, prop);
|
||||
@@ -83,13 +87,21 @@ final class BindableIdEmbedded implements BindableId {
|
||||
|
||||
@Override
|
||||
public boolean deriveConcatenatedId(PersistRequestBean<?> persist) {
|
||||
deriveId(persist.entityBean());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive/build the concatenated id (e.g. IdClass) from the entity's own matching id
|
||||
* properties, caching it on the bean (via setValueIntercept) for subsequent use.
|
||||
*/
|
||||
private EntityBean deriveId(EntityBean bean) {
|
||||
if (matches == null) {
|
||||
String m = "No matches for " + embId.fullName() + " the concatenated key columns where not found?"
|
||||
+ " I expect that the concatenated key was null, and this bean does"
|
||||
+ " not have ManyToOne assoc beans matching the primary key columns?";
|
||||
throw new PersistenceException(m);
|
||||
}
|
||||
EntityBean bean = persist.entityBean();
|
||||
// create the new id
|
||||
EntityBean newId = (EntityBean) embId.createEmbeddedId();
|
||||
// populate it from the assoc one id values...
|
||||
@@ -97,7 +109,7 @@ final class BindableIdEmbedded implements BindableId {
|
||||
match.populate(bean, newId);
|
||||
}
|
||||
embId.setValueIntercept(bean, newId);
|
||||
return true;
|
||||
return newId;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -321,6 +321,46 @@ final class CQueryBuilder {
|
||||
return lastFound;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of the top-level (non-nested) "select" keyword in sql. Used to detect if sql
|
||||
* starts with a WITH clause (CTE) header - SQL Server does not support a WITH clause nested
|
||||
* inside a subquery/derived table, so it must be hoisted in front of a wrapping SELECT
|
||||
* (count/exists) rather than wrapped along with the rest of the query.
|
||||
* <p>
|
||||
* Returns 0 if there is no leading WITH clause (sql starts directly with SELECT), or -1 if no
|
||||
* top-level SELECT is found at all.
|
||||
*/
|
||||
static int topLevelSelectStart(String sql) {
|
||||
int depth = 0;
|
||||
int len = sql.length();
|
||||
for (int i = 0; i < len; i++) {
|
||||
char c = sql.charAt(i);
|
||||
if (c == '(') {
|
||||
depth++;
|
||||
} else if (c == ')') {
|
||||
depth--;
|
||||
} else if (depth == 0 && sql.regionMatches(true, i, "select", 0, 6)
|
||||
&& (i == 0 || !Character.isLetterOrDigit(sql.charAt(i - 1)))
|
||||
&& (i + 6 == len || !Character.isLetterOrDigit(sql.charAt(i + 6)))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split off a leading WITH clause (CTE header) from sql, returning {@code {header, body}} so the
|
||||
* header can be hoisted in front of a wrapping SELECT. Returns an empty header (unchanged sql as
|
||||
* the body) when there is no leading WITH clause.
|
||||
*/
|
||||
static String[] splitCteHeader(String sql) {
|
||||
int pos = topLevelSelectStart(sql);
|
||||
if (pos <= 0) {
|
||||
return new String[]{"", sql};
|
||||
}
|
||||
return new String[]{sql.substring(0, pos), sql.substring(pos)};
|
||||
}
|
||||
|
||||
static String inlineSqlCommentLabel(String label, ProfileLocation profileLocation, boolean secondary, String simpleName) {
|
||||
if (label != null) {
|
||||
return secondary ? label : CQueryPlan.planLabelWithType(label, simpleName);
|
||||
@@ -329,15 +369,22 @@ final class CQueryBuilder {
|
||||
}
|
||||
|
||||
private String wrapSelectCount(String sql) {
|
||||
sql = "select count(*) from ( " + sql + ")";
|
||||
String[] parts = splitCteHeader(sql);
|
||||
sql = parts[0] + "select count(*) from ( " + parts[1] + ")";
|
||||
if (selectCountWithAlias) {
|
||||
sql += " as c";
|
||||
}
|
||||
return sql;
|
||||
}
|
||||
|
||||
private String wrapSelectExists(String sql) {
|
||||
return "select exists(" + sql + ")";
|
||||
static String wrapSelectExists(String sql, boolean existsWithCaseWhen, String existsFromClause) {
|
||||
String[] parts = splitCteHeader(sql);
|
||||
String header = parts[0];
|
||||
String body = parts[1];
|
||||
if (existsWithCaseWhen) {
|
||||
return header + "select case when exists(" + body + ") then 1 else 0 end" + existsFromClause;
|
||||
}
|
||||
return header + "select exists(" + body + ")";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,7 +413,7 @@ final class CQueryBuilder {
|
||||
}
|
||||
|
||||
SqlLimitResponse s = buildSql("select 1", request, predicates, sqlTree);
|
||||
String sql = wrapSelectExists(s.getSql());
|
||||
String sql = wrapSelectExists(s.getSql(), dbPlatform.existsWithCaseWhen(), dbPlatform.existsFromClause());
|
||||
|
||||
queryPlan = new CQueryPlan(request, sql, sqlTree.plan(), predicates.logWhereSql());
|
||||
request.putQueryPlan(queryPlan);
|
||||
|
||||
@@ -33,6 +33,13 @@ import static java.lang.System.Logger.Level.WARNING;
|
||||
*/
|
||||
public final class CQueryPredicates {
|
||||
|
||||
/**
|
||||
* Default lower bound used for findVersions() (no explicit start/end) on sql2011
|
||||
* standards based platforms that require actual bind values for the root table's
|
||||
* 'for system_time between ? and ?' clause.
|
||||
*/
|
||||
private static final Timestamp EPOCH = Timestamp.valueOf("1970-01-01 00:00:00");
|
||||
|
||||
private final Binder binder;
|
||||
private final OrmQueryRequest<?> request;
|
||||
private final SpiQuery<?> query;
|
||||
@@ -60,11 +67,22 @@ public final class CQueryPredicates {
|
||||
private String dbOrderBy;
|
||||
private String dbDistinctOn;
|
||||
private String dbUpdateClause;
|
||||
/**
|
||||
* Set when the many join is a ManyToMany with an intersection table order column - used to
|
||||
* resolve the literal "${path}dbColumn" marker appended to dbOrderBy in parseTableAlias().
|
||||
*/
|
||||
private String intersectionOrderPath;
|
||||
private String intersectionOrderColumn;
|
||||
/**
|
||||
* Includes from where and order by clauses.
|
||||
*/
|
||||
private Set<String> predicateIncludes;
|
||||
private Set<String> orderByIncludes;
|
||||
/**
|
||||
* The fetch path (relative to the query root) of the many-root whose own join clause the
|
||||
* filterMany-in-JOIN predicate is attached to
|
||||
*/
|
||||
private String filterManyAttachPath;
|
||||
|
||||
CQueryPredicates(Binder binder, OrmQueryRequest<?> request) {
|
||||
this.binder = binder;
|
||||
@@ -84,10 +102,16 @@ public final class CQueryPredicates {
|
||||
// bind the update set clause
|
||||
updateProperties.bind(binder, dataBind);
|
||||
}
|
||||
if (query.isVersionsBetween() && binder.isAsOfStandardsBased()) {
|
||||
if (binder.isAsOfStandardsBased() && query.temporalMode() == SpiQuery.TemporalMode.VERSIONS) {
|
||||
// sql2011 based versions between timestamp syntax
|
||||
Timestamp start = query.versionStart();
|
||||
Timestamp end = query.versionEnd();
|
||||
if (start == null) {
|
||||
start = EPOCH;
|
||||
}
|
||||
if (end == null) {
|
||||
end = new Timestamp(System.currentTimeMillis());
|
||||
}
|
||||
dataBind.append("between ").append(start).append(" and ").append(end);
|
||||
binder.bindObject(dataBind, start);
|
||||
binder.bindObject(dataBind, end);
|
||||
@@ -203,6 +227,8 @@ public final class CQueryPredicates {
|
||||
filterMany = new DefaultExpressionRequest(request, deployParser, binder, filterManyExpr);
|
||||
if (buildSql) {
|
||||
dbFilterMany = filterMany.buildSql();
|
||||
// safe as filterManyJoin only holds when the expression is root-property only -
|
||||
filterManyAttachPath = manyProperty.path();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,6 +265,14 @@ public final class CQueryPredicates {
|
||||
dbHaving = alias.parseWhere(dbHaving);
|
||||
}
|
||||
if (dbOrderBy != null) {
|
||||
if (intersectionOrderColumn != null) {
|
||||
// resolve the ManyToMany intersection table order column BEFORE the generic
|
||||
// alias substitution runs, as it needs the "z_" suffixed intersection alias
|
||||
// rather than the plain target table alias.
|
||||
String marker = "${" + intersectionOrderPath + "}" + intersectionOrderColumn;
|
||||
String targetAlias = alias.tableAlias(intersectionOrderPath);
|
||||
dbOrderBy = dbOrderBy.replace(marker, targetAlias + "z_." + intersectionOrderColumn);
|
||||
}
|
||||
dbOrderBy = alias.parse(dbOrderBy);
|
||||
}
|
||||
if (dbDistinctOn != null) {
|
||||
@@ -268,9 +302,18 @@ public final class CQueryPredicates {
|
||||
}
|
||||
// check for default ordering on the many property...
|
||||
SpiQueryManyJoin manyProp = request.manyJoin();
|
||||
String manyOrderBy = manyProp.fetchOrderBy();
|
||||
if (manyOrderBy != null) {
|
||||
orderBy = orderBy + ", " + parser.parse(CQueryBuilder.prefixOrderByFields(manyProp.path(), manyOrderBy));
|
||||
if (manyProp.hasIntersectionOrderColumn()) {
|
||||
// ManyToMany with @OrderColumn on the intersection table - the column lives on the
|
||||
// intersection table (alias "<targetAlias>z_") rather than a normal property path,
|
||||
// so we append a literal marker that parseTableAlias() resolves directly.
|
||||
intersectionOrderPath = manyProp.path();
|
||||
intersectionOrderColumn = manyProp.intersectionOrderColumn();
|
||||
orderBy = orderBy + ", ${" + intersectionOrderPath + "}" + intersectionOrderColumn;
|
||||
} else {
|
||||
String manyOrderBy = manyProp.fetchOrderBy();
|
||||
if (manyOrderBy != null) {
|
||||
orderBy = orderBy + ", " + parser.parse(CQueryBuilder.prefixOrderByFields(manyProp.path(), manyOrderBy));
|
||||
}
|
||||
}
|
||||
if (request.isFindById()) {
|
||||
// only one master bean so should be fine...
|
||||
@@ -363,6 +406,14 @@ public final class CQueryPredicates {
|
||||
return filterManyJoin ? dbFilterMany : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the fetch path of the filterMany-in-JOIN predicate - the path whose own join clause
|
||||
* the predicate must be appended to (or null if there is no filterMany-in-JOIN predicate at all).
|
||||
*/
|
||||
String filterManyAttachPath() {
|
||||
return filterManyJoin ? filterManyAttachPath : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the db column version of the order by clause.
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,7 @@ final class DefaultDbSqlContext implements DbSqlContext {
|
||||
private final ArrayStack<String> prefixStack = new ArrayStack<>();
|
||||
private final String fromForUpdate;
|
||||
private final String dbFilterManyJoin;
|
||||
private final String filterManyAttachPath;
|
||||
private boolean useColumnAlias;
|
||||
private int columnIndex;
|
||||
private int asOfTableCount;
|
||||
@@ -42,7 +43,8 @@ final class DefaultDbSqlContext implements DbSqlContext {
|
||||
private boolean joinSuppressed;
|
||||
|
||||
DefaultDbSqlContext(SqlTreeAlias alias, String columnAliasPrefix, CQueryHistorySupport historySupport,
|
||||
CQueryDraftSupport draftSupport, String fromForUpdate, String dbFilterManyJoin) {
|
||||
CQueryDraftSupport draftSupport, String fromForUpdate, String dbFilterManyJoin,
|
||||
String filterManyAttachPath) {
|
||||
this.alias = alias;
|
||||
this.columnAliasPrefix = columnAliasPrefix;
|
||||
this.useColumnAlias = columnAliasPrefix != null;
|
||||
@@ -51,6 +53,12 @@ final class DefaultDbSqlContext implements DbSqlContext {
|
||||
this.historyQuery = (historySupport != null);
|
||||
this.fromForUpdate = fromForUpdate;
|
||||
this.dbFilterManyJoin = dbFilterManyJoin;
|
||||
this.filterManyAttachPath = filterManyAttachPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFilterManyAttachPoint(String prefix) {
|
||||
return dbFilterManyJoin != null && filterManyAttachPath != null && filterManyAttachPath.equals(prefix);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -149,6 +149,16 @@ final class DefaultFetchGroupQuery<T> implements SpiFetchGroupQuery<T>, SpiQuery
|
||||
throw new RuntimeException("EB102: Only select() and fetch() clause is allowed on FetchGroup");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <D> MappedQuery<D> mapTo(Class<D> dtoType) {
|
||||
throw new RuntimeException("EB102: Only select() and fetch() clause is allowed on FetchGroup");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <D> MappedQuery<D> mapTo(Class<D> dtoType, DtoMapper<T, D> mapper) {
|
||||
throw new RuntimeException("EB102: Only select() and fetch() clause is allowed on FetchGroup");
|
||||
}
|
||||
|
||||
@Override
|
||||
public UpdateQuery<T> asUpdate() {
|
||||
throw new RuntimeException("EB102: Only select() and fetch() clause is allowed on FetchGroup");
|
||||
|
||||
@@ -43,6 +43,16 @@ public interface STreePropertyAssocMany extends STreePropertyAssoc {
|
||||
*/
|
||||
boolean hasJoinTable();
|
||||
|
||||
/**
|
||||
* Return true if this is a ManyToMany with an order column stored on the intersection table.
|
||||
*/
|
||||
boolean hasIntersectionOrderColumn();
|
||||
|
||||
/**
|
||||
* Return the db column name of the ManyToMany intersection table order column (or null).
|
||||
*/
|
||||
String intersectionOrderColumn();
|
||||
|
||||
/**
|
||||
* Return the intersection table join.
|
||||
*/
|
||||
|
||||
@@ -108,7 +108,7 @@ public final class SqlTreeBuilder {
|
||||
CQueryHistorySupport historySupport = builder.historySupport(query);
|
||||
CQueryDraftSupport draftSupport = builder.draftSupport(query);
|
||||
String colAlias = subQuery || rootNode.isSingleProperty() ? null : columnAliasPrefix;
|
||||
this.ctx = new DefaultDbSqlContext(alias, colAlias, historySupport, draftSupport, fromForUpdate, predicates.dbFilterManyJoin());
|
||||
this.ctx = new DefaultDbSqlContext(alias, colAlias, historySupport, draftSupport, fromForUpdate, predicates.dbFilterManyJoin(), predicates.filterManyAttachPath());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -342,6 +342,10 @@ class SqlTreeNodeBean implements SqlTreeNode {
|
||||
if (desc.isSoftDelete() && temporalMode != SpiQuery.TemporalMode.SOFT_DELETED) {
|
||||
ctx.append(" and ").append(desc.softDeletePredicate(ctx.tableAlias(prefix)));
|
||||
}
|
||||
if (prefix != null && ctx.isFilterManyAttachPoint(prefix)) {
|
||||
// this node is where we inline the filterMany predicate
|
||||
ctx.includeFilterMany();
|
||||
}
|
||||
return sqlJoinType;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,5 @@ final class SqlTreeNodeManyRoot extends SqlTreeNodeBean {
|
||||
@Override
|
||||
public void appendFrom(DbSqlContext ctx, SqlJoinType joinType) {
|
||||
super.appendFrom(ctx, joinType.autoToOuter());
|
||||
ctx.includeFilterMany();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.ebeaninternal.server.querydefn;
|
||||
|
||||
import org.jspecify.annotations.NullMarked;
|
||||
import io.ebean.DtoQuery;
|
||||
import io.ebean.PagedList;
|
||||
import io.ebean.ProfileLocation;
|
||||
import io.ebean.QueryIterator;
|
||||
import io.ebean.Transaction;
|
||||
@@ -11,6 +12,7 @@ import io.ebeaninternal.server.dto.DtoMappingRequest;
|
||||
import io.ebeaninternal.server.dto.DtoQueryPlan;
|
||||
import io.ebeaninternal.server.transaction.ExternalJdbcTransaction;
|
||||
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import javax.annotation.Nullable;
|
||||
import java.sql.Connection;
|
||||
import java.util.Collection;
|
||||
@@ -50,6 +52,8 @@ public final class DefaultDtoQuery<T> extends AbstractQuery implements SpiDtoQue
|
||||
this.useMaster = ormQuery.isUseMaster();
|
||||
this.label = ormQuery.label();
|
||||
this.profileLocation = ormQuery.profileLocation();
|
||||
this.firstRow = ormQuery.getFirstRow();
|
||||
this.maxRows = ormQuery.getMaxRows();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,6 +139,24 @@ public final class DefaultDtoQuery<T> extends AbstractQuery implements SpiDtoQue
|
||||
return server.findDtoList(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PagedList<T> findPagedList() {
|
||||
if (ormQuery == null) {
|
||||
throw new PersistenceException("findPagedList() is only supported for a DtoQuery derived from an ORM query");
|
||||
}
|
||||
if (maxRows == 0) {
|
||||
throw new PersistenceException("maxRows must be specified for findPagedList()");
|
||||
}
|
||||
// Use an independent copy for the row count query. The ormQuery instance is mutated with
|
||||
// a (potentially since ended/inactive) implicit transaction when the DTO list is executed,
|
||||
// so the count query must not share that transaction reference - instead it uses the
|
||||
// transaction explicitly bound to this DtoQuery (if any), consistent with a plain
|
||||
// Query.findPagedList().
|
||||
SpiQuery<?> countQuery = ormQuery.copy();
|
||||
countQuery.usingTransaction(transaction);
|
||||
return new DtoPagedList<>(server, this, countQuery);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public T findOne() {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package io.ebeaninternal.server.querydefn;
|
||||
|
||||
import io.ebean.DtoMapContext;
|
||||
import io.ebean.DtoMapper;
|
||||
import io.ebean.MappedQuery;
|
||||
import io.ebean.PagedList;
|
||||
import io.ebean.Transaction;
|
||||
import io.ebeaninternal.api.SpiEbeanServer;
|
||||
import io.ebeaninternal.api.SpiQuery;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Default implementation of {@link MappedQuery} backing {@code query.mapTo(dtoType)}.
|
||||
* <p>
|
||||
* Resolves the generated {@link DtoMapper} for the query's (entity, dto) pair, applies its
|
||||
* {@link DtoMapper#fetchGroup()} (derived from the target DTO's declared shape) - unless the
|
||||
* caller has already specified their own {@code select()}/{@code fetch()} spec, in which case
|
||||
* that manual spec is left untouched - and forces {@code setUnmodifiable(true)} on the
|
||||
* underlying query before it is executed, then maps the resulting entity graph into the
|
||||
* target DTO graph.
|
||||
*/
|
||||
public final class DefaultMappedQuery<T, D> implements MappedQuery<D> {
|
||||
|
||||
private final SpiEbeanServer server;
|
||||
private final SpiQuery<T> query;
|
||||
private final Class<D> dtoType;
|
||||
private DtoMapper<T, D> mapper;
|
||||
private boolean applied;
|
||||
|
||||
public DefaultMappedQuery(SpiEbeanServer server, SpiQuery<T> query, Class<D> dtoType) {
|
||||
this.server = server;
|
||||
this.query = query;
|
||||
this.dtoType = dtoType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use an already-resolved mapper instance directly - e.g. a named variant accessor on a
|
||||
* generated mapper, such as {@code query.mapTo(User.class, userMapper.noFleets())} - bypassing
|
||||
* {@code server.dtoMapper(...)} lookup entirely.
|
||||
*/
|
||||
public DefaultMappedQuery(SpiEbeanServer server, SpiQuery<T> query, Class<D> dtoType, DtoMapper<T, D> mapper) {
|
||||
this.server = server;
|
||||
this.query = query;
|
||||
this.dtoType = dtoType;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the mapper's fetch spec + unmodifiable to the underlying query (once) - must happen
|
||||
* before the query is executed. Uses an explicit {@code applied} flag rather than a
|
||||
* {@code mapper == null} check since a pre-supplied mapper (see the constructor above) is
|
||||
* already non-null before the fetch spec has been applied.
|
||||
*/
|
||||
private DtoMapper<T, D> mapper() {
|
||||
if (!applied) {
|
||||
if (mapper == null) {
|
||||
mapper = server.dtoMapper(query.getBeanType(), dtoType);
|
||||
}
|
||||
if (query.detail().isEmpty()) {
|
||||
// only apply the mapper's derived fetch spec if the caller hasn't already specified
|
||||
// their own select()/fetch() - allowing manual query tuning/optimisation to take
|
||||
// precedence over the DTO shape's default fetch spec when needed
|
||||
query.select(mapper.fetchGroup());
|
||||
}
|
||||
query.setUnmodifiable(true);
|
||||
applied = true;
|
||||
}
|
||||
return mapper;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public List<D> findList() {
|
||||
DtoMapper<T, D> m = mapper();
|
||||
return m.mapList(query.findList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public D findOne() {
|
||||
DtoMapper<T, D> m = mapper();
|
||||
return m.map(query.findOne());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<D> findOneOrEmpty() {
|
||||
return Optional.ofNullable(findOne());
|
||||
}
|
||||
|
||||
@Override
|
||||
public PagedList<D> findPagedList() {
|
||||
DtoMapper<T, D> m = mapper();
|
||||
return new MappedPagedList<>(query.findPagedList(), m);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<D> findStream() {
|
||||
DtoMapper<T, D> m = mapper();
|
||||
DtoMapContext context = new DtoMapContext();
|
||||
return query.findStream().map(source -> m.map(source, context));
|
||||
}
|
||||
|
||||
@Override
|
||||
public MappedQuery<D> usingMaster(boolean useMaster) {
|
||||
query.usingMaster(useMaster);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MappedQuery<D> usingTransaction(Transaction transaction) {
|
||||
query.usingTransaction(transaction);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MappedQuery<D> usingConnection(Connection connection) {
|
||||
query.usingConnection(connection);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,16 @@ public class DefaultOrmQuery<T> extends AbstractQuery implements SpiQuery<T> {
|
||||
return server.findDto(dtoClass, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public final <D> MappedQuery<D> mapTo(Class<D> dtoType) {
|
||||
return new DefaultMappedQuery<>(server, this, dtoType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public final <D> MappedQuery<D> mapTo(Class<D> dtoType, DtoMapper<T, D> mapper) {
|
||||
return new DefaultMappedQuery<>(server, this, dtoType, mapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public final UpdateQuery<T> asUpdate() {
|
||||
return new DefaultUpdateQuery<>(this);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package io.ebeaninternal.server.querydefn;
|
||||
|
||||
import io.ebean.PagedList;
|
||||
import io.ebeaninternal.api.SpiDtoQuery;
|
||||
import io.ebeaninternal.api.SpiEbeanServer;
|
||||
import io.ebeaninternal.api.SpiQuery;
|
||||
import jakarta.persistence.PersistenceException;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* PagedList implementation for a DtoQuery that is derived from an underlying ORM query
|
||||
* (created via {@code Query.asDto()}).
|
||||
* <p>
|
||||
* The page of DTO beans is fetched via the DtoQuery while the total row count is derived
|
||||
* from the underlying ORM query (which has the matching where clause, joins etc).
|
||||
*/
|
||||
final class DtoPagedList<T> implements PagedList<T> {
|
||||
|
||||
private final transient SpiEbeanServer server;
|
||||
private final transient ReentrantLock lock = new ReentrantLock();
|
||||
private final SpiDtoQuery<T> dtoQuery;
|
||||
private final SpiQuery<?> countQuery;
|
||||
private final int firstRow;
|
||||
private final int maxRows;
|
||||
|
||||
private int totalRowCount = -1;
|
||||
private Future<Integer> futureRowCount;
|
||||
private List<T> list;
|
||||
|
||||
/**
|
||||
* Construct with the dto query and the underlying ORM query used to derive the row count.
|
||||
*/
|
||||
DtoPagedList(SpiEbeanServer server, SpiDtoQuery<T> dtoQuery, SpiQuery<?> countQuery) {
|
||||
this.server = server;
|
||||
this.dtoQuery = dtoQuery;
|
||||
this.countQuery = countQuery;
|
||||
this.maxRows = countQuery.getMaxRows();
|
||||
this.firstRow = countQuery.getFirstRow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadCount() {
|
||||
getFutureCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<Integer> getFutureCount() {
|
||||
lock.lock();
|
||||
try {
|
||||
if (futureRowCount == null) {
|
||||
futureRowCount = server.findFutureCount(countQuery);
|
||||
}
|
||||
return futureRowCount;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<T> getList() {
|
||||
lock.lock();
|
||||
try {
|
||||
if (list == null) {
|
||||
if (totalRowCount == 0) {
|
||||
// already count and no rows
|
||||
list = Collections.emptyList();
|
||||
} else {
|
||||
list = server.findDtoList(dtoQuery);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPageIndex() {
|
||||
if (firstRow == 0) {
|
||||
return 0;
|
||||
}
|
||||
return ((firstRow - 1) / maxRows) + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTotalPageCount() {
|
||||
int rowCount = getTotalCount();
|
||||
if (rowCount == 0) {
|
||||
return 0;
|
||||
} else {
|
||||
return ((rowCount - 1) / maxRows) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTotalCount() {
|
||||
lock.lock();
|
||||
try {
|
||||
if (totalRowCount > -1) {
|
||||
return totalRowCount;
|
||||
}
|
||||
if (futureRowCount != null) {
|
||||
try {
|
||||
// background query already initiated so get it with a wait
|
||||
totalRowCount = futureRowCount.get();
|
||||
return totalRowCount;
|
||||
} catch (Exception e) {
|
||||
throw new PersistenceException(e);
|
||||
}
|
||||
}
|
||||
// just using foreground thread
|
||||
totalRowCount = server.findCount(countQuery);
|
||||
return totalRowCount;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
return (firstRow + maxRows) < getTotalCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasPrev() {
|
||||
return firstRow > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPageSize() {
|
||||
return maxRows;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayXtoYofZ(String to, String of) {
|
||||
int first = firstRow + 1;
|
||||
int last = firstRow + getList().size();
|
||||
int total = getTotalCount();
|
||||
return first + to + last + of + total;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package io.ebeaninternal.server.querydefn;
|
||||
|
||||
import io.ebean.DtoMapper;
|
||||
import io.ebean.PagedList;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* {@link PagedList} adapter backing {@code query.mapTo(dtoType).findPagedList()}.
|
||||
* <p>
|
||||
* Delegates all page metadata (total count, page index, {@code hasNext()}/{@code hasPrev()}, ...)
|
||||
* straight through to the underlying entity-typed {@link PagedList} unchanged - paging is a
|
||||
* property of the query, not of the DTO shape. Only {@link #getList()} differs: it maps the
|
||||
* delegate's entity list to the target DTO list (once, cached) via the given {@link DtoMapper}.
|
||||
*/
|
||||
public final class MappedPagedList<T, D> implements PagedList<D> {
|
||||
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final PagedList<T> delegate;
|
||||
private final DtoMapper<T, D> mapper;
|
||||
private List<D> mappedList;
|
||||
|
||||
public MappedPagedList(PagedList<T> delegate, DtoMapper<T, D> mapper) {
|
||||
this.delegate = delegate;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadCount() {
|
||||
delegate.loadCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<Integer> getFutureCount() {
|
||||
return delegate.getFutureCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<D> getList() {
|
||||
lock.lock();
|
||||
try {
|
||||
if (mappedList == null) {
|
||||
mappedList = mapper.mapList(delegate.getList());
|
||||
}
|
||||
return mappedList;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTotalCount() {
|
||||
return delegate.getTotalCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTotalPageCount() {
|
||||
return delegate.getTotalPageCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPageSize() {
|
||||
return delegate.getPageSize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPageIndex() {
|
||||
return delegate.getPageIndex();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
return delegate.hasNext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasPrev() {
|
||||
return delegate.hasPrev();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayXtoYofZ(String to, String of) {
|
||||
return delegate.getDisplayXtoYofZ(to, of);
|
||||
}
|
||||
}
|
||||
@@ -384,14 +384,18 @@ public final class OrmQueryDetail implements Serializable {
|
||||
OrmQueryProperties chunk = pair.getProperties();
|
||||
if (isQueryJoinCandidate(lazyLoadManyPath, chunk)) {
|
||||
// this is a 'fetch join' (included in main query)
|
||||
if (fetchJoinFirstMany) {
|
||||
BeanDescriptor<?> targetDescriptor = ((BeanPropertyAssoc<?>) elProp.beanProperty()).targetDescriptor();
|
||||
if (fetchJoinFirstMany && !chunk.filterManyHasNestedProperty(targetDescriptor)) {
|
||||
// letting the first one remain a 'fetch join'
|
||||
fetchJoinFirstMany = false;
|
||||
manyFetchProperty = pair.getPath();
|
||||
chunk.filterManyInline();
|
||||
many = elProp;
|
||||
} else {
|
||||
// convert this one over to a 'query join'
|
||||
// convert this one over to a 'query join' - either because another many has already claimed the
|
||||
// 'fetch join' slot, or because its filterMany references a property that requires crossing into
|
||||
// an associated bean and can't safely be included as a JOIN predicate (see
|
||||
// OrmQueryProperties.filterManyHasNestedProperty)
|
||||
chunk.markForQueryJoin();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import io.ebean.util.SplitName;
|
||||
import io.ebeaninternal.api.SpiExpression;
|
||||
import io.ebeaninternal.api.SpiExpressionFactory;
|
||||
import io.ebeaninternal.api.SpiExpressionList;
|
||||
import io.ebeaninternal.api.SpiExpressionValidation;
|
||||
import io.ebeaninternal.api.SpiQuery;
|
||||
import io.ebeaninternal.server.deploy.BeanDescriptor;
|
||||
import io.ebeaninternal.server.el.ElPropertyValue;
|
||||
import io.ebeaninternal.server.expression.FilterExprPath;
|
||||
import io.ebeaninternal.server.expression.FilterExpressionList;
|
||||
|
||||
@@ -234,6 +237,26 @@ public final class OrmQueryProperties implements Serializable {
|
||||
return filterMany != null && !markForQueryJoin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the filterMany expression (if any) references a property that requires
|
||||
* crossing into an associated bean/join - e.g. {@code "group.name"} - rather than only
|
||||
* plain/embedded properties resolving to columns on the many bean's own base table.
|
||||
*/
|
||||
boolean filterManyHasNestedProperty(BeanDescriptor<?> targetDescriptor) {
|
||||
if (filterMany == null) {
|
||||
return false;
|
||||
}
|
||||
SpiExpressionValidation validation = new SpiExpressionValidation(targetDescriptor);
|
||||
filterMany.validate(validation);
|
||||
for (String property : validation.allProperties()) {
|
||||
ElPropertyValue elProp = targetDescriptor.elGetValue(property);
|
||||
if (elProp != null && elProp.isAssocProperty()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust filterMany expressions for inclusion in main query.
|
||||
*/
|
||||
|
||||
@@ -265,7 +265,7 @@ public final class DefaultTypeManager implements TypeManager {
|
||||
|
||||
@Override
|
||||
public ScalarType<?> dbMapType() {
|
||||
return hstoreSupport() ? hstoreType : ScalarTypeJsonMap.typeFor(false, Types.VARCHAR, false);
|
||||
return hstoreSupport() ? hstoreType : ScalarTypeJsonMap.typeFor(false, Types.VARCHAR, MutationDetection.DEFAULT);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -322,17 +322,17 @@ public final class DefaultTypeManager implements TypeManager {
|
||||
}
|
||||
Type genericType = prop.genericType();
|
||||
if (type.equals(List.class) && isValueTypeSimple(genericType)) {
|
||||
return ScalarTypeJsonList.typeFor(postgres, dbType, docType(genericType), prop.isNullable(), keepSource(prop));
|
||||
return ScalarTypeJsonList.typeFor(postgres, dbType, docType(genericType), prop.isNullable(), collectionMutationDetection(prop));
|
||||
}
|
||||
if (type.equals(Set.class) && isValueTypeSimple(genericType)) {
|
||||
return ScalarTypeJsonSet.typeFor(postgres, dbType, docType(genericType), prop.isNullable(), keepSource(prop));
|
||||
return ScalarTypeJsonSet.typeFor(postgres, dbType, docType(genericType), prop.isNullable(), collectionMutationDetection(prop));
|
||||
}
|
||||
if (type.equals(Map.class) && isBuiltinJsonMap(genericType)) {
|
||||
Type keyType = TypeReflectHelper.getMapKeyTypeRaw(genericType);
|
||||
if (isEnumType(keyType)) {
|
||||
return enumJsonMapType(postgres, dbType, keyType, keepSource(prop));
|
||||
return enumJsonMapType(postgres, dbType, keyType, collectionMutationDetection(prop));
|
||||
}
|
||||
return ScalarTypeJsonMap.typeFor(postgres, dbType, keepSource(prop));
|
||||
return ScalarTypeJsonMap.typeFor(postgres, dbType, collectionMutationDetection(prop));
|
||||
}
|
||||
if (objectMapperPresent && prop.mutationDetection() == MutationDetection.DEFAULT) {
|
||||
ScalarTypeSet<?> typeSet = typeSets.get(type);
|
||||
@@ -343,18 +343,25 @@ public final class DefaultTypeManager implements TypeManager {
|
||||
return createJsonObjectMapperType(prop, dbType, DocPropertyType.OBJECT);
|
||||
}
|
||||
|
||||
private boolean keepSource(DeployProperty prop) {
|
||||
if (prop.mutationDetection() == MutationDetection.DEFAULT) {
|
||||
prop.setMutationDetection(jsonManager != null ? jsonManager.mutationDetection() : MutationDetection.NONE);
|
||||
}
|
||||
return prop.mutationDetection() == MutationDetection.SOURCE;
|
||||
/**
|
||||
* Return the mutation detection mode to use for the built-in JSON collection types
|
||||
* (Map, List, Set).
|
||||
* <p>
|
||||
* Unlike {@code @DbJson} properties handled via the Jackson ObjectMapper, {@code DEFAULT}
|
||||
* on these collection types is <em>not</em> resolved against the DatabaseConfig wide
|
||||
* default - it always uses the legacy ModifyAware wrapper based dirty checking. Only an
|
||||
* explicit {@code NONE}, {@code HASH} or {@code SOURCE} on the property itself switches
|
||||
* these types away from ModifyAware based checking.
|
||||
*/
|
||||
private MutationDetection collectionMutationDetection(DeployProperty prop) {
|
||||
return prop.mutationDetection();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private ScalarType<?> enumJsonMapType(boolean postgres, int dbType, Type keyType, boolean keepSource) {
|
||||
private ScalarType<?> enumJsonMapType(boolean postgres, int dbType, Type keyType, MutationDetection mutationDetection) {
|
||||
Class<? extends Enum<?>> enumClass = asEnumClass(keyType);
|
||||
ScalarType<? extends Enum<?>> enumScalarType = (ScalarType<? extends Enum<?>>) enumType(enumClass, null);
|
||||
return ScalarTypeJsonMapEnum.typeFor(postgres, dbType, enumScalarType, keepSource);
|
||||
return ScalarTypeJsonMapEnum.typeFor(postgres, dbType, enumScalarType, mutationDetection);
|
||||
}
|
||||
|
||||
private DocPropertyType docPropertyType(DeployProperty prop, Class<?> type) {
|
||||
|
||||
+4
-4
@@ -1,5 +1,6 @@
|
||||
package io.ebeaninternal.server.type;
|
||||
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.core.type.DocPropertyType;
|
||||
import io.ebean.core.type.ScalarType;
|
||||
|
||||
@@ -27,15 +28,14 @@ class PlatformArrayTypeJsonList implements PlatformArrayTypeFactory {
|
||||
@Override
|
||||
public ScalarType<?> typeFor(Type valueType, boolean nullable) {
|
||||
if (valueType.equals(UUID.class)) {
|
||||
// TODO: keepSource for @DbArray?
|
||||
return new ScalarTypeJsonList.VarcharWithConverter(DocPropertyType.UUID, nullable, false, ArrayElementConverter.UUID);
|
||||
return new ScalarTypeJsonList.VarcharWithConverter(DocPropertyType.UUID, nullable, ArrayElementConverter.UUID);
|
||||
}
|
||||
return new ScalarTypeJsonList(java.sql.Types.VARCHAR, JsonStorage.VARCHAR, docType(valueType), nullable, false);
|
||||
return new ScalarTypeJsonList(java.sql.Types.VARCHAR, JsonStorage.VARCHAR, docType(valueType), nullable, MutationDetection.DEFAULT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ScalarType<?> typeForEnum(ScalarType<?> scalarType, boolean nullable) {
|
||||
final ArrayElementConverter.EnumConverter converter = new ArrayElementConverter.EnumConverter(scalarType);
|
||||
return new ScalarTypeJsonList.VarcharWithConverter(scalarType.docType(), nullable, false, converter);
|
||||
return new ScalarTypeJsonList.VarcharWithConverter(scalarType.docType(), nullable, converter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.ebeaninternal.server.type;
|
||||
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.core.type.DocPropertyType;
|
||||
import io.ebean.core.type.ScalarType;
|
||||
|
||||
@@ -27,15 +28,14 @@ class PlatformArrayTypeJsonSet implements PlatformArrayTypeFactory {
|
||||
@Override
|
||||
public ScalarType<?> typeFor(Type valueType, boolean nullable) {
|
||||
if (valueType.equals(UUID.class)) {
|
||||
// TODO: keepSource for @DbArray?
|
||||
return new ScalarTypeJsonSet.VarcharWithConverter(DocPropertyType.UUID, nullable, false, ArrayElementConverter.UUID);
|
||||
return new ScalarTypeJsonSet.VarcharWithConverter(DocPropertyType.UUID, nullable, ArrayElementConverter.UUID);
|
||||
}
|
||||
return new ScalarTypeJsonSet(java.sql.Types.VARCHAR, JsonStorage.VARCHAR, docType(valueType), nullable, false);
|
||||
return new ScalarTypeJsonSet(java.sql.Types.VARCHAR, JsonStorage.VARCHAR, docType(valueType), nullable, MutationDetection.DEFAULT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ScalarType<?> typeForEnum(ScalarType<?> scalarType, boolean nullable) {
|
||||
final ArrayElementConverter.EnumConverter converter = new ArrayElementConverter.EnumConverter(scalarType);
|
||||
return new ScalarTypeJsonSet.VarcharWithConverter(scalarType.docType(), nullable, false, converter);
|
||||
return new ScalarTypeJsonSet.VarcharWithConverter(scalarType.docType(), nullable, converter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,12 @@ public interface ScalarTypeArray {
|
||||
*/
|
||||
String getDbColumnDefn();
|
||||
|
||||
/**
|
||||
* Return the Java type of the individual array elements.
|
||||
* <p>
|
||||
* Used to bind the array correctly when the collection value is empty
|
||||
* and so the element type can't be determined from the collection content.
|
||||
*/
|
||||
Class<?> elementType();
|
||||
|
||||
}
|
||||
|
||||
@@ -45,31 +45,31 @@ class ScalarTypeArrayList extends ScalarTypeArrayBase<List> implements ScalarTyp
|
||||
try {
|
||||
String key = valueType + ":" + nullable;
|
||||
if (valueType.equals(UUID.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "uuid", DocPropertyType.UUID, ArrayElementConverter.UUID));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "uuid", DocPropertyType.UUID, ArrayElementConverter.UUID, UUID.class));
|
||||
}
|
||||
if (valueType.equals(Long.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "bigint", DocPropertyType.LONG, ArrayElementConverter.LONG));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "bigint", DocPropertyType.LONG, ArrayElementConverter.LONG, Long.class));
|
||||
}
|
||||
if (valueType.equals(Integer.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "integer", DocPropertyType.INTEGER, ArrayElementConverter.INTEGER));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "integer", DocPropertyType.INTEGER, ArrayElementConverter.INTEGER, Integer.class));
|
||||
}
|
||||
if (valueType.equals(Float.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "float4", DocPropertyType.DOUBLE, ArrayElementConverter.FLOAT));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "float4", DocPropertyType.DOUBLE, ArrayElementConverter.FLOAT, Float.class));
|
||||
}
|
||||
if (valueType.equals(Double.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.DOUBLE));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.DOUBLE, Double.class));
|
||||
}
|
||||
if (valueType.equals(BigDecimal.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "decimal", DocPropertyType.DOUBLE, ArrayElementConverter.BIG_DECIMAL));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "decimal", DocPropertyType.DOUBLE, ArrayElementConverter.BIG_DECIMAL, BigDecimal.class));
|
||||
}
|
||||
if (valueType.equals(String.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "varchar", DocPropertyType.TEXT, ArrayElementConverter.STRING));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "varchar", DocPropertyType.TEXT, ArrayElementConverter.STRING, String.class));
|
||||
}
|
||||
if (valueType.equals(Instant.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "timestamptz", DocPropertyType.TEXT, ArrayElementConverter.INSTANT));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "timestamptz", DocPropertyType.TEXT, ArrayElementConverter.INSTANT, Instant.class));
|
||||
}
|
||||
if (valueType.equals(LocalDate.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "date", DocPropertyType.TEXT, ArrayElementConverter.LOCAL_DATE));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "date", DocPropertyType.TEXT, ArrayElementConverter.LOCAL_DATE, LocalDate.class));
|
||||
}
|
||||
throw new IllegalArgumentException("Type [" + valueType + "] not supported for @DbArray mapping");
|
||||
} finally {
|
||||
@@ -79,18 +79,24 @@ class ScalarTypeArrayList extends ScalarTypeArrayBase<List> implements ScalarTyp
|
||||
|
||||
@Override
|
||||
public ScalarTypeArrayList typeForEnum(ScalarType<?> scalarType, boolean nullable) {
|
||||
return new ScalarTypeArrayList(nullable, arrayTypeFor(scalarType), scalarType.docType(), new ArrayElementConverter.EnumConverter(scalarType));
|
||||
return new ScalarTypeArrayList(nullable, arrayTypeFor(scalarType), scalarType.docType(), new ArrayElementConverter.EnumConverter(scalarType), scalarType.type());
|
||||
}
|
||||
}
|
||||
|
||||
private final String arrayType;
|
||||
|
||||
private final ArrayElementConverter converter;
|
||||
private final Class<?> elementType;
|
||||
|
||||
public ScalarTypeArrayList(boolean nullable, String arrayType, DocPropertyType docPropertyType, ArrayElementConverter converter) {
|
||||
public ScalarTypeArrayList(boolean nullable, String arrayType, DocPropertyType docPropertyType, ArrayElementConverter converter, Class<?> elementType) {
|
||||
super(List.class, Types.ARRAY, docPropertyType, nullable);
|
||||
this.arrayType = arrayType;
|
||||
this.converter = converter;
|
||||
this.elementType = elementType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<?> elementType() {
|
||||
return elementType;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -40,31 +40,31 @@ final class ScalarTypeArrayListH2 extends ScalarTypeArrayList {
|
||||
try {
|
||||
String key = valueType + ":" + nullable;
|
||||
if (valueType.equals(UUID.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "uuid", DocPropertyType.UUID, ArrayElementConverter.UUID));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "uuid", DocPropertyType.UUID, ArrayElementConverter.UUID, UUID.class));
|
||||
}
|
||||
if (valueType.equals(Long.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "bigint", DocPropertyType.LONG, ArrayElementConverter.LONG));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "bigint", DocPropertyType.LONG, ArrayElementConverter.LONG, Long.class));
|
||||
}
|
||||
if (valueType.equals(Integer.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "integer", DocPropertyType.INTEGER, ArrayElementConverter.INTEGER));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "integer", DocPropertyType.INTEGER, ArrayElementConverter.INTEGER, Integer.class));
|
||||
}
|
||||
if (valueType.equals(Float.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "real", DocPropertyType.DOUBLE, ArrayElementConverter.FLOAT));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "real", DocPropertyType.DOUBLE, ArrayElementConverter.FLOAT, Float.class));
|
||||
}
|
||||
if (valueType.equals(Double.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.DOUBLE));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.DOUBLE, Double.class));
|
||||
}
|
||||
if (valueType.equals(BigDecimal.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.BIG_DECIMAL));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.BIG_DECIMAL, BigDecimal.class));
|
||||
}
|
||||
if (valueType.equals(String.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "varchar", DocPropertyType.TEXT, ArrayElementConverter.STRING));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "varchar", DocPropertyType.TEXT, ArrayElementConverter.STRING, String.class));
|
||||
}
|
||||
if (valueType.equals(Instant.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "timestamp", DocPropertyType.TEXT, ArrayElementConverter.INSTANT));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "timestamp", DocPropertyType.TEXT, ArrayElementConverter.INSTANT, Instant.class));
|
||||
}
|
||||
if (valueType.equals(LocalDate.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "date", DocPropertyType.TEXT, ArrayElementConverter.LOCAL_DATE));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "date", DocPropertyType.TEXT, ArrayElementConverter.LOCAL_DATE, LocalDate.class));
|
||||
}
|
||||
throw new IllegalArgumentException("Type [" + valueType + "] not supported for @DbArray mapping");
|
||||
} finally {
|
||||
@@ -74,12 +74,12 @@ final class ScalarTypeArrayListH2 extends ScalarTypeArrayList {
|
||||
|
||||
@Override
|
||||
public ScalarType<?> typeForEnum(ScalarType<?> scalarType, boolean nullable) {
|
||||
return new ScalarTypeArrayListH2(nullable, "varchar", DocPropertyType.TEXT, new ArrayElementConverter.EnumConverter(scalarType));
|
||||
return new ScalarTypeArrayListH2(nullable, "varchar", DocPropertyType.TEXT, new ArrayElementConverter.EnumConverter(scalarType), scalarType.type());
|
||||
}
|
||||
}
|
||||
|
||||
private ScalarTypeArrayListH2(boolean nullable, String arrayType, DocPropertyType docPropertyType, ArrayElementConverter converter) {
|
||||
super(nullable, arrayType, docPropertyType, converter);
|
||||
private ScalarTypeArrayListH2(boolean nullable, String arrayType, DocPropertyType docPropertyType, ArrayElementConverter converter, Class<?> elementType) {
|
||||
super(nullable, arrayType, docPropertyType, converter, elementType);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -42,19 +42,19 @@ class ScalarTypeArraySet extends ScalarTypeArrayBase<Set> implements ScalarTypeA
|
||||
try {
|
||||
String key = valueType + ":" + nullable;
|
||||
if (valueType.equals(UUID.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "uuid", DocPropertyType.UUID, ArrayElementConverter.UUID));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "uuid", DocPropertyType.UUID, ArrayElementConverter.UUID, UUID.class));
|
||||
}
|
||||
if (valueType.equals(Long.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "bigint", DocPropertyType.LONG, ArrayElementConverter.LONG));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "bigint", DocPropertyType.LONG, ArrayElementConverter.LONG, Long.class));
|
||||
}
|
||||
if (valueType.equals(Integer.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "integer", DocPropertyType.INTEGER, ArrayElementConverter.INTEGER));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "integer", DocPropertyType.INTEGER, ArrayElementConverter.INTEGER, Integer.class));
|
||||
}
|
||||
if (valueType.equals(Double.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.DOUBLE));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.DOUBLE, Double.class));
|
||||
}
|
||||
if (valueType.equals(String.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "varchar", DocPropertyType.TEXT, ArrayElementConverter.STRING));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "varchar", DocPropertyType.TEXT, ArrayElementConverter.STRING, String.class));
|
||||
}
|
||||
throw new IllegalArgumentException("Type [" + valueType + "] not supported for @DbArray mapping");
|
||||
} finally {
|
||||
@@ -64,18 +64,24 @@ class ScalarTypeArraySet extends ScalarTypeArrayBase<Set> implements ScalarTypeA
|
||||
|
||||
@Override
|
||||
public ScalarType<?> typeForEnum(ScalarType<?> scalarType, boolean nullable) {
|
||||
return new ScalarTypeArraySet(nullable, arrayTypeFor(scalarType), scalarType.docType(), new ArrayElementConverter.EnumConverter(scalarType));
|
||||
return new ScalarTypeArraySet(nullable, arrayTypeFor(scalarType), scalarType.docType(), new ArrayElementConverter.EnumConverter(scalarType), scalarType.type());
|
||||
}
|
||||
}
|
||||
|
||||
private final String arrayType;
|
||||
|
||||
private final ArrayElementConverter converter;
|
||||
private final Class<?> elementType;
|
||||
|
||||
public ScalarTypeArraySet(boolean nullable, String arrayType, DocPropertyType docPropertyType, ArrayElementConverter converter) {
|
||||
public ScalarTypeArraySet(boolean nullable, String arrayType, DocPropertyType docPropertyType, ArrayElementConverter converter, Class<?> elementType) {
|
||||
super(Set.class, Types.ARRAY, docPropertyType, nullable);
|
||||
this.arrayType = arrayType;
|
||||
this.converter = converter;
|
||||
this.elementType = elementType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<?> elementType() {
|
||||
return elementType;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -37,19 +37,19 @@ final class ScalarTypeArraySetH2 extends ScalarTypeArraySet {
|
||||
try {
|
||||
String key = valueType + ":" + nullable;
|
||||
if (valueType.equals(UUID.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "uuid", DocPropertyType.UUID, ArrayElementConverter.UUID));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "uuid", DocPropertyType.UUID, ArrayElementConverter.UUID, UUID.class));
|
||||
}
|
||||
if (valueType.equals(Long.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "bigint", DocPropertyType.LONG, ArrayElementConverter.LONG));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "bigint", DocPropertyType.LONG, ArrayElementConverter.LONG, Long.class));
|
||||
}
|
||||
if (valueType.equals(Integer.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "integer", DocPropertyType.INTEGER, ArrayElementConverter.INTEGER));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "integer", DocPropertyType.INTEGER, ArrayElementConverter.INTEGER, Integer.class));
|
||||
}
|
||||
if (valueType.equals(Double.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.DOUBLE));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.DOUBLE, Double.class));
|
||||
}
|
||||
if (valueType.equals(String.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "varchar", DocPropertyType.TEXT, ArrayElementConverter.STRING));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "varchar", DocPropertyType.TEXT, ArrayElementConverter.STRING, String.class));
|
||||
}
|
||||
throw new IllegalArgumentException("Type [" + valueType + "] not supported for @DbArray mapping");
|
||||
} finally {
|
||||
@@ -59,13 +59,13 @@ final class ScalarTypeArraySetH2 extends ScalarTypeArraySet {
|
||||
|
||||
@Override
|
||||
public ScalarType<?> typeForEnum(ScalarType<?> scalarType, boolean nullable) {
|
||||
return new ScalarTypeArraySetH2(nullable, "varchar", DocPropertyType.TEXT, new ArrayElementConverter.EnumConverter(scalarType));
|
||||
return new ScalarTypeArraySetH2(nullable, "varchar", DocPropertyType.TEXT, new ArrayElementConverter.EnumConverter(scalarType), scalarType.type());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("rawtypes")
|
||||
private ScalarTypeArraySetH2(boolean nullable, String arrayType, DocPropertyType docPropertyType, ArrayElementConverter converter) {
|
||||
super(nullable, arrayType, docPropertyType, converter);
|
||||
private ScalarTypeArraySetH2(boolean nullable, String arrayType, DocPropertyType docPropertyType, ArrayElementConverter converter, Class<?> elementType) {
|
||||
super(nullable, arrayType, docPropertyType, converter, elementType);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+28
-2
@@ -1,7 +1,10 @@
|
||||
package io.ebeaninternal.server.type;
|
||||
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.core.type.DocPropertyType;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Base for the JSON collection value types (List, Set).
|
||||
* <p>
|
||||
@@ -10,9 +13,9 @@ import io.ebean.core.type.DocPropertyType;
|
||||
*/
|
||||
abstract class ScalarTypeJsonCollectionValue<T> extends ScalarTypeJsonValue<T> implements ScalarTypeArray {
|
||||
|
||||
ScalarTypeJsonCollectionValue(Class<T> type, int jdbcType, JsonStorage storage, boolean keepSource,
|
||||
ScalarTypeJsonCollectionValue(Class<T> type, int jdbcType, JsonStorage storage, MutationDetection mutationDetection,
|
||||
boolean nullable, DocPropertyType docType) {
|
||||
super(type, jdbcType, storage, keepSource, nullable, "[]", docType);
|
||||
super(type, jdbcType, storage, mutationDetection, nullable, "[]", docType);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -29,4 +32,27 @@ abstract class ScalarTypeJsonCollectionValue<T> extends ScalarTypeJsonValue<T> i
|
||||
return "varchar[]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the element type from the docType - used only to determine the ScalarType to use
|
||||
* when binding an element (e.g. for an empty collection where the element type can't be
|
||||
* determined from the collection content).
|
||||
*/
|
||||
@Override
|
||||
public Class<?> elementType() {
|
||||
switch (docType()) {
|
||||
case UUID:
|
||||
return UUID.class;
|
||||
case SHORT:
|
||||
case INTEGER:
|
||||
return Integer.class;
|
||||
case LONG:
|
||||
return Long.class;
|
||||
case FLOAT:
|
||||
case DOUBLE:
|
||||
return Double.class;
|
||||
default:
|
||||
return String.class;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.ebeaninternal.server.type;
|
||||
|
||||
import io.avaje.json.JsonReader;
|
||||
import io.avaje.json.JsonWriter;
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.config.dbplatform.DbPlatformType;
|
||||
import io.ebean.core.type.DocPropertyType;
|
||||
import io.ebean.core.type.PostgresHelper;
|
||||
@@ -25,20 +26,20 @@ class ScalarTypeJsonList extends ScalarTypeJsonCollectionValue<List> {
|
||||
/**
|
||||
* Return the appropriate ScalarType for the requested dbType and platform.
|
||||
*/
|
||||
static ScalarType<?> typeFor(boolean postgres, int dbType, DocPropertyType docType, boolean nullable, boolean keepSource) {
|
||||
static ScalarType<?> typeFor(boolean postgres, int dbType, DocPropertyType docType, boolean nullable, MutationDetection mutationDetection) {
|
||||
if (postgres) {
|
||||
switch (dbType) {
|
||||
case DbPlatformType.JSONB:
|
||||
return new ScalarTypeJsonList(DbPlatformType.JSONB, JsonStorage.postgres(PostgresHelper.JSONB_TYPE), docType, nullable, keepSource);
|
||||
return new ScalarTypeJsonList(DbPlatformType.JSONB, JsonStorage.postgres(PostgresHelper.JSONB_TYPE), docType, nullable, mutationDetection);
|
||||
case DbPlatformType.JSON:
|
||||
return new ScalarTypeJsonList(DbPlatformType.JSON, JsonStorage.postgres(PostgresHelper.JSON_TYPE), docType, nullable, keepSource);
|
||||
return new ScalarTypeJsonList(DbPlatformType.JSON, JsonStorage.postgres(PostgresHelper.JSON_TYPE), docType, nullable, mutationDetection);
|
||||
}
|
||||
}
|
||||
return new ScalarTypeJsonList(Types.VARCHAR, JsonStorage.VARCHAR, docType, nullable, keepSource);
|
||||
return new ScalarTypeJsonList(Types.VARCHAR, JsonStorage.VARCHAR, docType, nullable, mutationDetection);
|
||||
}
|
||||
|
||||
ScalarTypeJsonList(int jdbcType, JsonStorage storage, DocPropertyType docType, boolean nullable, boolean keepSource) {
|
||||
super(List.class, jdbcType, storage, keepSource, nullable, docType);
|
||||
ScalarTypeJsonList(int jdbcType, JsonStorage storage, DocPropertyType docType, boolean nullable, MutationDetection mutationDetection) {
|
||||
super(List.class, jdbcType, storage, mutationDetection, nullable, docType);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -87,8 +88,8 @@ class ScalarTypeJsonList extends ScalarTypeJsonCollectionValue<List> {
|
||||
|
||||
private final ArrayElementConverter converter;
|
||||
|
||||
VarcharWithConverter(DocPropertyType docType, boolean nullable, boolean keepSource, ArrayElementConverter converter) {
|
||||
super(Types.VARCHAR, JsonStorage.VARCHAR, docType, nullable, keepSource);
|
||||
VarcharWithConverter(DocPropertyType docType, boolean nullable, ArrayElementConverter converter) {
|
||||
super(Types.VARCHAR, JsonStorage.VARCHAR, docType, nullable, MutationDetection.DEFAULT);
|
||||
this.converter = converter;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.ebeaninternal.server.type;
|
||||
|
||||
import io.avaje.json.JsonReader;
|
||||
import io.avaje.json.JsonWriter;
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.config.dbplatform.DbPlatformType;
|
||||
import io.ebean.core.type.DocPropertyType;
|
||||
import io.ebean.core.type.PostgresHelper;
|
||||
@@ -22,8 +23,8 @@ class ScalarTypeJsonMap extends ScalarTypeJsonValue<Map> {
|
||||
/**
|
||||
* Return the ScalarType for the requested dbType and platform.
|
||||
*/
|
||||
static ScalarTypeJsonMap typeFor(boolean postgres, int dbType, boolean keepSource) {
|
||||
return new ScalarTypeJsonMap(storageFor(postgres, dbType), keepSource);
|
||||
static ScalarTypeJsonMap typeFor(boolean postgres, int dbType, MutationDetection mutationDetection) {
|
||||
return new ScalarTypeJsonMap(storageFor(postgres, dbType), mutationDetection);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,8 +48,8 @@ class ScalarTypeJsonMap extends ScalarTypeJsonValue<Map> {
|
||||
}
|
||||
}
|
||||
|
||||
ScalarTypeJsonMap(JsonStorage storage, boolean keepSource) {
|
||||
super(Map.class, storage.jdbcType(), storage, keepSource, true, null, DocPropertyType.OBJECT);
|
||||
ScalarTypeJsonMap(JsonStorage storage, MutationDetection mutationDetection) {
|
||||
super(Map.class, storage.jdbcType(), storage, mutationDetection, true, null, DocPropertyType.OBJECT);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.ebeaninternal.server.type;
|
||||
|
||||
import io.avaje.json.JsonReader;
|
||||
import io.avaje.json.JsonWriter;
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.core.type.ScalarType;
|
||||
import io.ebean.text.TextException;
|
||||
import io.ebean.text.json.EJson;
|
||||
@@ -23,13 +24,13 @@ final class ScalarTypeJsonMapEnum<T extends Enum<T>> extends ScalarTypeJsonMap {
|
||||
|
||||
private final ScalarType<T> enumType;
|
||||
|
||||
static ScalarType<?> typeFor(boolean postgres, int dbType, ScalarType<? extends Enum<?>> enumType, boolean keepSource) {
|
||||
return new ScalarTypeJsonMapEnum<>(storageFor(postgres, dbType), enumType, keepSource);
|
||||
static ScalarType<?> typeFor(boolean postgres, int dbType, ScalarType<? extends Enum<?>> enumType, MutationDetection mutationDetection) {
|
||||
return new ScalarTypeJsonMapEnum<>(storageFor(postgres, dbType), enumType, mutationDetection);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private ScalarTypeJsonMapEnum(JsonStorage storage, ScalarType<? extends Enum> enumType, boolean keepSource) {
|
||||
super(storage, keepSource);
|
||||
private ScalarTypeJsonMapEnum(JsonStorage storage, ScalarType<? extends Enum> enumType, MutationDetection mutationDetection) {
|
||||
super(storage, mutationDetection);
|
||||
this.enumType = (ScalarType<T>) enumType;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.ebeaninternal.server.type;
|
||||
|
||||
import io.avaje.json.JsonReader;
|
||||
import io.avaje.json.JsonWriter;
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.config.dbplatform.DbPlatformType;
|
||||
import io.ebean.core.type.DocPropertyType;
|
||||
import io.ebean.core.type.PostgresHelper;
|
||||
@@ -25,20 +26,20 @@ class ScalarTypeJsonSet extends ScalarTypeJsonCollectionValue<Set> {
|
||||
/**
|
||||
* Return the appropriate ScalarType for the requested dbType and platform.
|
||||
*/
|
||||
static ScalarType<?> typeFor(boolean postgres, int dbType, DocPropertyType docType, boolean nullable, boolean keepSource) {
|
||||
static ScalarType<?> typeFor(boolean postgres, int dbType, DocPropertyType docType, boolean nullable, MutationDetection mutationDetection) {
|
||||
if (postgres) {
|
||||
switch (dbType) {
|
||||
case DbPlatformType.JSONB:
|
||||
return new ScalarTypeJsonSet(DbPlatformType.JSONB, JsonStorage.postgres(PostgresHelper.JSONB_TYPE), docType, nullable, keepSource);
|
||||
return new ScalarTypeJsonSet(DbPlatformType.JSONB, JsonStorage.postgres(PostgresHelper.JSONB_TYPE), docType, nullable, mutationDetection);
|
||||
case DbPlatformType.JSON:
|
||||
return new ScalarTypeJsonSet(DbPlatformType.JSON, JsonStorage.postgres(PostgresHelper.JSON_TYPE), docType, nullable, keepSource);
|
||||
return new ScalarTypeJsonSet(DbPlatformType.JSON, JsonStorage.postgres(PostgresHelper.JSON_TYPE), docType, nullable, mutationDetection);
|
||||
}
|
||||
}
|
||||
return new ScalarTypeJsonSet(Types.VARCHAR, JsonStorage.VARCHAR, docType, nullable, keepSource);
|
||||
return new ScalarTypeJsonSet(Types.VARCHAR, JsonStorage.VARCHAR, docType, nullable, mutationDetection);
|
||||
}
|
||||
|
||||
ScalarTypeJsonSet(int jdbcType, JsonStorage storage, DocPropertyType docType, boolean nullable, boolean keepSource) {
|
||||
super(Set.class, jdbcType, storage, keepSource, nullable, docType);
|
||||
ScalarTypeJsonSet(int jdbcType, JsonStorage storage, DocPropertyType docType, boolean nullable, MutationDetection mutationDetection) {
|
||||
super(Set.class, jdbcType, storage, mutationDetection, nullable, docType);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -89,8 +90,8 @@ class ScalarTypeJsonSet extends ScalarTypeJsonCollectionValue<Set> {
|
||||
|
||||
private final ArrayElementConverter converter;
|
||||
|
||||
VarcharWithConverter(DocPropertyType docType, boolean nullable, boolean keepSource, ArrayElementConverter converter) {
|
||||
super(Types.VARCHAR, JsonStorage.VARCHAR, docType, nullable, keepSource);
|
||||
VarcharWithConverter(DocPropertyType docType, boolean nullable, ArrayElementConverter converter) {
|
||||
super(Types.VARCHAR, JsonStorage.VARCHAR, docType, nullable, MutationDetection.DEFAULT);
|
||||
this.converter = converter;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.ebeaninternal.server.type;
|
||||
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.core.type.DataBinder;
|
||||
import io.ebean.core.type.DataReader;
|
||||
import io.ebean.core.type.DocPropertyType;
|
||||
@@ -20,11 +21,18 @@ import java.sql.SQLException;
|
||||
* backed {@code EJson} facade)</li>
|
||||
* </ul>
|
||||
* This removes the previous explosion of per-storage and per-platform subclasses.
|
||||
* <p>
|
||||
* Mutation detection: {@code DEFAULT} uses the legacy ModifyAware wrapper based dirty
|
||||
* checking. {@code NONE} disables dirty checking entirely (mutable() is false and the
|
||||
* property is only included in an update when explicitly set). {@code HASH} and
|
||||
* {@code SOURCE} are handled via {@code BeanPropertyJsonMapper} (json content is kept
|
||||
* for the read/bind round trip so that a checksum or the source content can be compared).
|
||||
*/
|
||||
abstract class ScalarTypeJsonValue<T> extends ScalarTypeBase<T> {
|
||||
|
||||
protected final JsonStorage storage;
|
||||
protected final boolean keepSource;
|
||||
private final boolean mutable;
|
||||
private final boolean nullable;
|
||||
private final String emptyJson;
|
||||
private final DocPropertyType docType;
|
||||
@@ -33,11 +41,12 @@ abstract class ScalarTypeJsonValue<T> extends ScalarTypeBase<T> {
|
||||
* @param emptyJson JSON bound when the value is null and the property is not nullable
|
||||
* (e.g. {@code "[]"} for collections), or null to always bind SQL null.
|
||||
*/
|
||||
ScalarTypeJsonValue(Class<T> type, int jdbcType, JsonStorage storage, boolean keepSource,
|
||||
ScalarTypeJsonValue(Class<T> type, int jdbcType, JsonStorage storage, MutationDetection mutationDetection,
|
||||
boolean nullable, String emptyJson, DocPropertyType docType) {
|
||||
super(type, false, jdbcType);
|
||||
this.storage = storage;
|
||||
this.keepSource = keepSource;
|
||||
this.keepSource = mutationDetection == MutationDetection.HASH || mutationDetection == MutationDetection.SOURCE;
|
||||
this.mutable = mutationDetection != MutationDetection.NONE;
|
||||
this.nullable = nullable;
|
||||
this.emptyJson = emptyJson;
|
||||
this.docType = docType;
|
||||
@@ -51,7 +60,7 @@ abstract class ScalarTypeJsonValue<T> extends ScalarTypeBase<T> {
|
||||
|
||||
@Override
|
||||
public final boolean mutable() {
|
||||
return true;
|
||||
return mutable;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -117,6 +117,88 @@ class CQueryBuilderTest {
|
||||
assertThat(countSql).isEqualTo("select count(*) from ( select t0.id from ad t0) as c");
|
||||
}
|
||||
|
||||
@Test
|
||||
void topLevelSelectStart_noLeadingCte_returnsZero() {
|
||||
String sql = "select 1 from o_order t0 where t0.id > ?";
|
||||
assertThat(CQueryBuilder.topLevelSelectStart(sql)).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void topLevelSelectStart_leadingCte_findsOuterSelect() {
|
||||
// The only depth-0 "select" is the outer query - the CTE body's "select" is nested in parens.
|
||||
String sql = "with order_totals as (" +
|
||||
" select o.id as order_id," +
|
||||
" sum(d.order_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 total_amount > ?";
|
||||
|
||||
int pos = CQueryBuilder.topLevelSelectStart(sql);
|
||||
assertThat(sql.substring(pos)).startsWith("select order_id, total_amount");
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL Server does not support a WITH clause (CTE) nested inside a subquery/derived table - see
|
||||
* https://github.com/ebean-orm/ebean/issues/3848 (findCount() wraps raw sql in "select count(*)
|
||||
* from ( ... )" which breaks when the raw sql is a CTE). The CTE header must be hoisted in front
|
||||
* of the wrapping SELECT.
|
||||
*/
|
||||
@Test
|
||||
void splitCteHeader_hoistsLeadingWithClause() {
|
||||
String sql = "with order_totals as (" +
|
||||
" select o.id as order_id," +
|
||||
" sum(d.order_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 total_amount > ?";
|
||||
|
||||
String[] parts = CQueryBuilder.splitCteHeader(sql);
|
||||
assertThat(parts[0] + parts[1]).isEqualTo(sql);
|
||||
assertThat(parts[0]).startsWith("with order_totals as (").endsWith(") ");
|
||||
assertThat(parts[1]).isEqualTo("select order_id, total_amount from order_totals where total_amount > ?");
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitCteHeader_noCte_returnsEmptyHeader() {
|
||||
String sql = "select 1 from o_order t0 where t0.id > ?";
|
||||
String[] parts = CQueryBuilder.splitCteHeader(sql);
|
||||
assertThat(parts[0]).isEmpty();
|
||||
assertThat(parts[1]).isEqualTo(sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrapSelectExists_default_usesScalarExists() {
|
||||
String sql = CQueryBuilder.wrapSelectExists("select 1 from o_order t0 where t0.id > ?", false, "");
|
||||
assertThat(sql).isEqualTo("select exists(select 1 from o_order t0 where t0.id > ?)");
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL Server does not support exists(...) as a directly selectable scalar
|
||||
* boolean expression - see https://github.com/ebean-orm/ebean/issues/3848
|
||||
*/
|
||||
@Test
|
||||
void wrapSelectExists_existsWithCaseWhen_wrapsAsCaseWhen() {
|
||||
String sql = CQueryBuilder.wrapSelectExists("select 1 from o_order t0 where t0.id > ?", true, "");
|
||||
assertThat(sql).isEqualTo("select case when exists(select 1 from o_order t0 where t0.id > ?) then 1 else 0 end");
|
||||
}
|
||||
|
||||
/**
|
||||
* Oracle also requires a FROM clause on every select (from dual) - see https://github.com/ebean-orm/ebean/issues/3848
|
||||
*/
|
||||
@Test
|
||||
void wrapSelectExists_existsWithCaseWhenAndFromClause_appendsFromClause() {
|
||||
String sql = CQueryBuilder.wrapSelectExists("select 1 from o_order t0 where t0.id > ?", true, " from dual");
|
||||
assertThat(sql).isEqualTo("select case when exists(select 1 from o_order t0 where t0.id > ?) then 1 else 0 end from dual");
|
||||
}
|
||||
|
||||
@Test
|
||||
void inlineSqlCommentLabel_rootExplicitLabel_prefixesBeanType() {
|
||||
String label = CQueryBuilder.inlineSqlCommentLabel("fetchMachineFleets", null, false, "COrganisationMachine");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.ebeaninternal.server.type;
|
||||
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.config.dbplatform.ExtraDbTypes;
|
||||
import io.ebean.core.type.DocPropertyType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -11,19 +12,19 @@ public class ScalarTypeJsonListTest extends BasePlatformArrayTypeFactoryTest {
|
||||
@Test
|
||||
public void typeFor_expect_nullToEmpty_when_postgresNonNull() throws SQLException {
|
||||
|
||||
assertBindNullTo_PGObjectEmpty(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSONB, DocPropertyType.OBJECT, false, false));
|
||||
assertBindNullTo_PGObjectEmpty(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSON, DocPropertyType.OBJECT, false, false));
|
||||
assertBindNullTo_PGObjectEmpty(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSONB, DocPropertyType.OBJECT, false, MutationDetection.DEFAULT));
|
||||
assertBindNullTo_PGObjectEmpty(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSON, DocPropertyType.OBJECT, false, MutationDetection.DEFAULT));
|
||||
|
||||
assertBindNullTo_EmptyString(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSONVarchar, DocPropertyType.OBJECT, false, false));
|
||||
assertBindNullTo_EmptyString(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSONVarchar, DocPropertyType.OBJECT, false, MutationDetection.DEFAULT));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void typeFor_expect_nullToNull_when_nullable() throws SQLException {
|
||||
|
||||
assertBindNullTo_PGObjectNull(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSONB, DocPropertyType.OBJECT, true, false));
|
||||
assertBindNullTo_PGObjectNull(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSON, DocPropertyType.OBJECT, true, false));
|
||||
assertBindNullTo_PGObjectNull(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSONB, DocPropertyType.OBJECT, true, MutationDetection.DEFAULT));
|
||||
assertBindNullTo_PGObjectNull(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSON, DocPropertyType.OBJECT, true, MutationDetection.DEFAULT));
|
||||
|
||||
assertBindNullTo_Null(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSONVarchar, DocPropertyType.OBJECT, true, false));
|
||||
assertBindNullTo_Null(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSONVarchar, DocPropertyType.OBJECT, true, MutationDetection.DEFAULT));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+9
-3
@@ -686,7 +686,8 @@ public class BaseTableDdl implements TableDdl {
|
||||
if (hasValue(alterColumn.getUniqueOneToOne())) {
|
||||
alterColumnAddUniqueOneToOneConstraint(writer, alterColumn);
|
||||
}
|
||||
if (hasValue(alterColumn.getComment())) {
|
||||
if (hasValue(alterColumn.getComment()) && !platformDdl.isInlineComments()) {
|
||||
// platform supports comments as a separate statement (e.g. postgres)
|
||||
alterColumnComment(writer, alterColumn);
|
||||
}
|
||||
if (hasValue(alterColumn.getDropCheckConstraint())) {
|
||||
@@ -700,7 +701,10 @@ public class BaseTableDdl implements TableDdl {
|
||||
}
|
||||
if (typeChange(alterColumn)
|
||||
|| hasValue(alterColumn.getDefaultValue())
|
||||
|| alterColumn.isNotnull() != null) {
|
||||
|| alterColumn.isNotnull() != null
|
||||
|| (hasValue(alterColumn.getComment()) && platformDdl.isInlineComments())) {
|
||||
// platforms with inline comments (e.g. mysql) must restate the whole column
|
||||
// definition (including comment) even when only the comment is changing
|
||||
alterColumn(writer, alterColumn);
|
||||
}
|
||||
if (alterCheckConstraint) {
|
||||
@@ -824,7 +828,9 @@ public class BaseTableDdl implements TableDdl {
|
||||
|
||||
platformDdl.alterTableAddColumn(writer, tableName, column, onHistoryTable, help.getDefaultValue());
|
||||
final String comment = column.getComment();
|
||||
if (comment != null && !comment.isEmpty()) {
|
||||
if (comment != null && !comment.isEmpty() && !platformDdl.isInlineComments()) {
|
||||
// platforms with inline comments (e.g. mysql) embed the comment directly into the
|
||||
// "add column" statement itself rather than as a separate statement
|
||||
platformDdl.addColumnComment(writer.applyPostAlter(), tableName, column.getName(), comment);
|
||||
}
|
||||
|
||||
|
||||
+41
-3
@@ -94,9 +94,10 @@ public class MySqlDdl extends PlatformDdl {
|
||||
public void alterColumn(DdlWrite writer, AlterColumn alter) {
|
||||
String tableName = alter.getTableName();
|
||||
String columnName = alter.getColumnName();
|
||||
boolean commentChange = hasValue(alter.getComment());
|
||||
|
||||
if (alter.getType() == null && alter.isNotnull() == null) {
|
||||
// No type change or notNull change -> handle default value change
|
||||
if (alter.getType() == null && alter.isNotnull() == null && !commentChange) {
|
||||
// No type change, notNull change or comment change -> handle default value change
|
||||
if (hasValue(alter.getDefaultValue())) {
|
||||
alterColumnDefault(writer, alter);
|
||||
}
|
||||
@@ -115,13 +116,50 @@ public class MySqlDdl extends PlatformDdl {
|
||||
if (hasValue(defaultValue) && !DdlHelp.isDropDefault(defaultValue)) {
|
||||
buffer.append(" default ").append(convertDefaultValue(defaultValue));
|
||||
}
|
||||
// restate the comment (new, existing, or none) as mysql requires the whole column
|
||||
// definition to be repeated - otherwise a comment could be silently dropped
|
||||
String comment = alter.getComment() != null ? alter.getComment() : alter.getCurrentComment();
|
||||
if (DdlHelp.isDropComment(comment)) {
|
||||
comment = null;
|
||||
}
|
||||
appendColumnComment(buffer, comment);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void alterTableAddColumn(DdlWrite writer, String tableName, Column column, boolean onHistoryTable, String defaultValue) {
|
||||
String convertedType = convert(column.getType());
|
||||
DdlBuffer buffer = alterTable(writer, tableName).append(addColumn, column.getName());
|
||||
buffer.append(convertedType);
|
||||
|
||||
// Add default value also to history table if it is not excluded
|
||||
if (defaultValue != null) {
|
||||
if (!onHistoryTable || !isTrue(column.isHistoryExclude())) {
|
||||
buffer.append(" default ");
|
||||
buffer.append(defaultValue);
|
||||
}
|
||||
}
|
||||
if (!onHistoryTable) {
|
||||
if (isTrue(column.isNotnull())) {
|
||||
buffer.appendWithSpace(columnNotNull);
|
||||
}
|
||||
// check constraints cannot be added in one statement for h2
|
||||
if (!StringHelper.isNull(column.getCheckConstraint())) {
|
||||
String ddl = alterTableAddCheckConstraint(tableName, column.getCheckConstraintName(), column.getCheckConstraint());
|
||||
writer.applyPostAlter().appendStatement(ddl);
|
||||
}
|
||||
// comment must be inline as part of the column definition for mysql
|
||||
appendColumnComment(buffer, column.getComment());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeColumnDefinition(DdlBuffer buffer, Column column, DdlIdentity identity) {
|
||||
super.writeColumnDefinition(buffer, column, identity);
|
||||
String comment = column.getComment();
|
||||
appendColumnComment(buffer, column.getComment());
|
||||
}
|
||||
|
||||
private void appendColumnComment(DdlBuffer buffer, String comment) {
|
||||
if (!StringHelper.isNull(comment)) {
|
||||
// in mysql 5.5 column comment save in information_schema.COLUMNS.COLUMN_COMMENT(VARCHAR 1024)
|
||||
if (comment.length() > 500) {
|
||||
|
||||
+27
@@ -28,6 +28,7 @@ import java.util.List;
|
||||
* <attribute name="notnull" type="{http://www.w3.org/2001/XMLSchema}boolean" />
|
||||
* <attribute name="currentNotnull" type="{http://www.w3.org/2001/XMLSchema}boolean" />
|
||||
* <attribute name="comment" type="{http://www.w3.org/2001/XMLSchema}string" />
|
||||
* <attribute name="currentComment" type="{http://www.w3.org/2001/XMLSchema}string" />
|
||||
* <attribute name="historyExclude" type="{http://www.w3.org/2001/XMLSchema}boolean" />
|
||||
* <attribute name="checkConstraint" type="{http://www.w3.org/2001/XMLSchema}string" />
|
||||
* <attribute name="checkConstraintName" type="{http://www.w3.org/2001/XMLSchema}string" />
|
||||
@@ -77,6 +78,8 @@ public class AlterColumn {
|
||||
protected Boolean currentNotnull;
|
||||
@XmlAttribute(name = "comment")
|
||||
protected String comment;
|
||||
@XmlAttribute(name = "currentComment")
|
||||
protected String currentComment;
|
||||
@XmlAttribute(name = "historyExclude")
|
||||
protected Boolean historyExclude;
|
||||
@XmlAttribute(name = "checkConstraint")
|
||||
@@ -360,6 +363,30 @@ public class AlterColumn {
|
||||
this.comment = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of the currentComment property.
|
||||
* <p>
|
||||
* This is the pre-existing comment value on the column, only populated when a platform
|
||||
* needs to fully restate the column definition (e.g. mysql) so that the existing comment
|
||||
* is not lost when another attribute (type, notnull, or the comment itself) changes.
|
||||
*
|
||||
* @return possible object is
|
||||
* {@link String }
|
||||
*/
|
||||
public String getCurrentComment() {
|
||||
return currentComment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of the currentComment property.
|
||||
*
|
||||
* @param value allowed object is
|
||||
* {@link String }
|
||||
*/
|
||||
public void setCurrentComment(String value) {
|
||||
this.currentComment = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of the historyExclude property.
|
||||
*
|
||||
|
||||
@@ -374,6 +374,7 @@ public class MColumn {
|
||||
this.alterColumn = null;
|
||||
|
||||
boolean changeBaseAttribute = false;
|
||||
boolean changeComment = false;
|
||||
|
||||
if (historyExclude != newColumn.historyExclude) {
|
||||
getAlterColumn(tableName, tableWithHistory).setHistoryExclude(newColumn.historyExclude);
|
||||
@@ -396,6 +397,7 @@ public class MColumn {
|
||||
}
|
||||
}
|
||||
if (different(comment, newColumn.comment)) {
|
||||
changeComment = true;
|
||||
AlterColumn alter = getAlterColumn(tableName, tableWithHistory);
|
||||
if (newColumn.comment == null) {
|
||||
alter.setComment(DdlHelp.DROP_COMMENT);
|
||||
@@ -459,10 +461,14 @@ public class MColumn {
|
||||
|
||||
if (alterColumn != null) {
|
||||
modelDiff.addAlterColumn(alterColumn);
|
||||
if (changeBaseAttribute) {
|
||||
// support reverting these changes
|
||||
if (changeBaseAttribute || changeComment) {
|
||||
// Support reverting these changes and let platforms that must restate the whole
|
||||
// column definition on any change (e.g. mysql) preserve unchanged attributes -
|
||||
// don't lose the existing comment when altering type/notnull, and have the
|
||||
// current type/notnull available to restate when only the comment is changing.
|
||||
alterColumn.setCurrentType(type);
|
||||
alterColumn.setCurrentNotnull(notnull);
|
||||
alterColumn.setCurrentComment(comment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -2,12 +2,15 @@ package io.ebeaninternal.dbmigration.model.build;
|
||||
|
||||
import io.ebeaninternal.dbmigration.model.MColumn;
|
||||
import io.ebeaninternal.dbmigration.model.MTable;
|
||||
import io.ebean.config.dbplatform.DbPlatformType;
|
||||
import io.ebeaninternal.server.deploy.BeanDescriptor;
|
||||
import io.ebeaninternal.server.deploy.BeanProperty;
|
||||
import io.ebeaninternal.server.deploy.BeanPropertyAssocMany;
|
||||
import io.ebeaninternal.server.deploy.TableJoin;
|
||||
import io.ebeaninternal.server.deploy.TableJoinColumn;
|
||||
|
||||
import java.sql.Types;
|
||||
|
||||
/**
|
||||
* Add the intersection table to the model.
|
||||
*/
|
||||
@@ -79,10 +82,23 @@ class ModelBuildIntersectionTable {
|
||||
for (TableJoinColumn otherColumn : otherColumns) {
|
||||
addColumn(table, targetDesc, otherColumn.getLocalDbColumn(), otherColumn.getForeignDbColumn());
|
||||
}
|
||||
|
||||
if (manyProp.hasIntersectionOrderColumn()) {
|
||||
addOrderColumn(table);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the extra (non PK) order column used to persist {@code @OrderColumn} position
|
||||
* for a ManyToMany relationship.
|
||||
*/
|
||||
private void addOrderColumn(MTable table) {
|
||||
DbPlatformType dbType = ctx.getDbTypeMap().get(Types.INTEGER);
|
||||
MColumn col = new MColumn(manyProp.intersectionOrderColumn(), dbType.renderType(0, 0));
|
||||
col.setNotnull(!manyProp.isIntersectionOrderColumnNullable());
|
||||
table.addColumn(col);
|
||||
}
|
||||
|
||||
private void addColumn(MTable table, BeanDescriptor<?> desc, String column, String findPropColumn) {
|
||||
|
||||
BeanProperty p = desc.idBinder().findBeanProperty(findPropColumn);
|
||||
|
||||
@@ -288,6 +288,7 @@
|
||||
<xsd:attribute name="notnull" type="xsd:boolean"/>
|
||||
<xsd:attribute name="currentNotnull" type="xsd:boolean"/>
|
||||
<xsd:attribute name="comment" type="xsd:string"/>
|
||||
<xsd:attribute name="currentComment" type="xsd:string"/>
|
||||
<xsd:attribute name="historyExclude" type="xsd:boolean"/>
|
||||
<xsd:attribute name="checkConstraint" type="xsd:string"/>
|
||||
<xsd:attribute name="checkConstraintName" type="xsd:string"/>
|
||||
|
||||
+54
@@ -180,6 +180,60 @@ public class PlatformDdl_AlterColumnTest {
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mysql_alterColumn_commentOnly_rebuildsWithType() {
|
||||
AlterColumn alter = new AlterColumn();
|
||||
alter.setTableName("mytab");
|
||||
alter.setColumnName("acol");
|
||||
alter.setCurrentType("varchar(50)");
|
||||
alter.setCurrentNotnull(Boolean.TRUE);
|
||||
alter.setComment("new comment");
|
||||
|
||||
String sql = alterColumn(mysqlDdl, alter);
|
||||
softly.assertThat(sql).isEqualTo("-- apply alter tables\n"
|
||||
+ "alter table mytab modify acol varchar(50) not null comment 'new comment';\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mysql_alterColumn_typeChange_preservesExistingComment() {
|
||||
AlterColumn alter = new AlterColumn();
|
||||
alter.setTableName("mytab");
|
||||
alter.setColumnName("acol");
|
||||
alter.setCurrentType("varchar(20)");
|
||||
alter.setType("varchar(50)");
|
||||
alter.setCurrentComment("existing comment");
|
||||
|
||||
String sql = alterColumn(mysqlDdl, alter);
|
||||
softly.assertThat(sql).isEqualTo("-- apply alter tables\n"
|
||||
+ "alter table mytab modify acol varchar(50) comment 'existing comment';\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mysql_alterColumn_dropComment() {
|
||||
AlterColumn alter = new AlterColumn();
|
||||
alter.setTableName("mytab");
|
||||
alter.setColumnName("acol");
|
||||
alter.setCurrentType("varchar(50)");
|
||||
alter.setComment("DROP COMMENT");
|
||||
alter.setCurrentComment("existing comment");
|
||||
|
||||
String sql = alterColumn(mysqlDdl, alter);
|
||||
softly.assertThat(sql).isEqualTo("-- apply alter tables\n"
|
||||
+ "alter table mytab modify acol varchar(50);\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mysql_alterTableAddColumn_withComment() {
|
||||
Column column = simpleColumn();
|
||||
column.setComment("a comment");
|
||||
|
||||
DdlWrite writer = new DdlWrite();
|
||||
mysqlDdl.alterTableAddColumn(writer, "my_table", column, false, "1");
|
||||
softly.assertThat(writer.toString())
|
||||
.isEqualTo("-- apply alter tables\n"
|
||||
+ "alter table my_table add column my_column int default 1 not null comment 'a comment';\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAlterColumnType() {
|
||||
|
||||
|
||||
@@ -174,6 +174,53 @@ class MColumnTest {
|
||||
assertThat(getAlterColumn(diff).getDefaultValue()).isEqualTo("abc");
|
||||
}
|
||||
|
||||
@Test
|
||||
void diffComment_add() {
|
||||
ModelDiff diff = diff();
|
||||
MColumn newCol = basic();
|
||||
newCol.setComment("a comment");
|
||||
basic().compare(diff, table, newCol);
|
||||
|
||||
assertChanges(diff);
|
||||
AlterColumn alterColumn = getAlterColumn(diff);
|
||||
assertThat(alterColumn.getComment()).isEqualTo("a comment");
|
||||
// no pre-existing base attribute changed, current type/notnull/comment carried
|
||||
// through anyway so platforms that must restate the whole column (e.g. mysql)
|
||||
// have what they need to rebuild the statement
|
||||
assertThat(alterColumn.getCurrentType()).isEqualTo("integer");
|
||||
assertThat(alterColumn.getCurrentComment()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void diffComment_remove() {
|
||||
ModelDiff diff = diff();
|
||||
MColumn newCol = basic();
|
||||
MColumn oldCol = basic();
|
||||
oldCol.setComment("a comment");
|
||||
oldCol.compare(diff, table, newCol);
|
||||
|
||||
assertChanges(diff);
|
||||
assertThat(getAlterColumn(diff).getComment()).isEqualTo("DROP COMMENT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void diffType_preservesCurrentComment() {
|
||||
ModelDiff diff = diff();
|
||||
MColumn oldCol = basic();
|
||||
oldCol.setComment("existing comment");
|
||||
MColumn newCol = new MColumn("col", "integer(8)");
|
||||
newCol.setComment("existing comment");
|
||||
oldCol.compare(diff, table, newCol);
|
||||
|
||||
assertChanges(diff);
|
||||
AlterColumn alterColumn = getAlterColumn(diff);
|
||||
assertThat(alterColumn.getType()).isEqualTo("integer(8)");
|
||||
assertThat(alterColumn.getComment()).isNull();
|
||||
// current comment recorded so mysql can restate it when rebuilding the full
|
||||
// column definition for the type change
|
||||
assertThat(alterColumn.getCurrentComment()).isEqualTo("existing comment");
|
||||
}
|
||||
|
||||
@Test
|
||||
void diffReferencesAdd() {
|
||||
ModelDiff diff = diff();
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.2</version>
|
||||
<version>42.7.11</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
|
||||
@@ -435,6 +435,16 @@ public abstract class QueryBean<T, R extends QueryBean<T, R>> implements IQueryB
|
||||
return query.asDto(dtoClass);
|
||||
}
|
||||
|
||||
@Override
|
||||
public final <D> MappedQuery<D> mapTo(Class<D> dtoType) {
|
||||
return query.mapTo(dtoType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public final <D> MappedQuery<D> mapTo(Class<D> dtoType, DtoMapper<T, D> mapper) {
|
||||
return query.mapTo(dtoType, mapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public final R setId(Object id) {
|
||||
query.setId(id);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package io.ebean.redis;
|
||||
|
||||
import org.junit.jupiter.api.extension.BeforeAllCallback;
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
import redis.clients.jedis.Jedis;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Auto-detected JUnit5 extension (see {@code junit-platform.properties}) that flushes the shared
|
||||
* Redis test container exactly once per JVM, before the first test class in this module runs.
|
||||
*
|
||||
* <p>The Redis test container is intentionally long-lived and reused across test runs - and even
|
||||
* shared with the sibling ebean-redisson module (same fixed-name/fixed-port container) - to avoid
|
||||
* restart cost, especially with parallel reactor builds. It is never flushed between runs. Without
|
||||
* this, stale keys/counters left over from an earlier run can leak into hit/miss/TTL assertions
|
||||
* that assume a cold cache, causing hard-to-reproduce flakiness.
|
||||
*/
|
||||
public class RedisFlushExtension implements BeforeAllCallback {
|
||||
|
||||
private static final AtomicBoolean FLUSHED = new AtomicBoolean();
|
||||
|
||||
@Override
|
||||
public void beforeAll(ExtensionContext context) {
|
||||
if (FLUSHED.compareAndSet(false, true)) {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
|
||||
private static void flush() {
|
||||
try (Jedis jedis = new Jedis("localhost", 6379)) {
|
||||
jedis.flushDB();
|
||||
} catch (Exception e) {
|
||||
// best effort - if Redis isn't reachable here, individual tests skip via their own checks
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,23 @@ import io.ebean.Database;
|
||||
import io.ebean.redis.DuelCache;
|
||||
import org.domain.Person;
|
||||
import org.domain.query.QPerson;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ClusterTest {
|
||||
|
||||
private Database createOther(DataSource dataSource) {
|
||||
return Database.builder()
|
||||
.dataSource(dataSource)
|
||||
private static Database db;
|
||||
private static Database other;
|
||||
|
||||
@BeforeAll
|
||||
static void setup() {
|
||||
// ensure the default server exists first
|
||||
db = DB.getDefault();
|
||||
other = Database.builder()
|
||||
.dataSource(db.pluginApi().dataSource())
|
||||
.loadFromProperties()
|
||||
.defaultDatabase(false)
|
||||
.name("other")
|
||||
@@ -24,12 +30,13 @@ class ClusterTest {
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDown() {
|
||||
other.shutdown(false, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBothNear() throws InterruptedException {
|
||||
// ensure the default server exists first
|
||||
final Database db = DB.getDefault();
|
||||
Database other = createOther(db.pluginApi().dataSource());
|
||||
|
||||
new QPerson()
|
||||
.name.eq("Someone")
|
||||
.delete();
|
||||
@@ -59,10 +66,6 @@ class ClusterTest {
|
||||
|
||||
@Test
|
||||
void test() throws InterruptedException {
|
||||
// ensure the default server exists first
|
||||
final Database db = DB.getDefault();
|
||||
Database other = createOther(db.pluginApi().dataSource());
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
Person foo = new Person("name " + i);
|
||||
foo.save();
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
io.ebean.redis.RedisFlushExtension
|
||||
@@ -8,3 +8,10 @@ ebean:
|
||||
platform: h2 # h2, postgres, mysql, oracle, sqlserver, sqlite
|
||||
ddlMode: dropCreate # none | dropCreate | create | migration | createOnly | migrationDropCreate
|
||||
dbName: myapp
|
||||
|
||||
# Keep the shared "ut_redis" test container running rather than removing it on JVM exit.
|
||||
# ebean-redis and ebean-redisson both reuse this fixed-name/fixed-port container - with a
|
||||
# parallel reactor build (mvn -T 1C) the module that finishes first would otherwise remove
|
||||
# the container while the other module's tests are still using it.
|
||||
redis:
|
||||
shutdownMode: none
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
junit.jupiter.extensions.autodetection.enabled=true
|
||||
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<artifactId>ebean-parent</artifactId>
|
||||
<groupId>io.ebean</groupId>
|
||||
<version>18.2.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>ebean-redisson</artifactId>
|
||||
<name>ebean redisson</name>
|
||||
<description>Ebean Redis L2 Cache (Redisson implementation)</description>
|
||||
|
||||
<dependencies>
|
||||
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
<version>1.5.6</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.redisson</groupId>
|
||||
<artifactId>redisson</artifactId>
|
||||
<version>4.3.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ebean</groupId>
|
||||
<artifactId>ebean-api</artifactId>
|
||||
<version>18.2.0</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.ebean</groupId>
|
||||
<artifactId>ebean-core</artifactId>
|
||||
<version>18.2.0</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.ebean</groupId>
|
||||
<artifactId>ebean-querybean</artifactId>
|
||||
<version>18.2.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.ebean</groupId>
|
||||
<artifactId>ebean-test</artifactId>
|
||||
<version>18.2.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.ebean</groupId>
|
||||
<artifactId>querybean-generator</artifactId>
|
||||
<version>18.2.0</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>io.ebean</groupId>
|
||||
<artifactId>ebean-maven-plugin</artifactId>
|
||||
<version>${ebean-maven-plugin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>test</id>
|
||||
<phase>process-test-classes</phase>
|
||||
<configuration>
|
||||
<transformArgs>debug=0</transformArgs>
|
||||
</configuration>
|
||||
<goals>
|
||||
<goal>testEnhance</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-source-plugin</artifactId>
|
||||
<version>3.2.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>attach-sources</id>
|
||||
<goals>
|
||||
<goal>jar</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,145 @@
|
||||
package io.ebean.redisson;
|
||||
|
||||
import io.ebean.cache.ServerCache;
|
||||
import io.ebean.redisson.near.NearCacheInvalidate;
|
||||
import io.ebean.redisson.near.NearCacheNotify;
|
||||
import io.ebean.meta.MetricVisitor;
|
||||
import io.ebeaninternal.server.cache.DefaultServerCache;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public final class DuelCache implements ServerCache, NearCacheInvalidate {
|
||||
|
||||
private final DefaultServerCache near;
|
||||
private final RedissonCache remote;
|
||||
private final NearCacheNotify cacheNotify;
|
||||
private final String cacheKey;
|
||||
|
||||
public DuelCache(DefaultServerCache near, RedissonCache remote, String cacheKey, NearCacheNotify cacheNotify) {
|
||||
this.near = near;
|
||||
this.remote = remote;
|
||||
this.cacheKey = cacheKey;
|
||||
this.cacheNotify = cacheNotify;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(MetricVisitor visitor) {
|
||||
near.visit(visitor);
|
||||
remote.visit(visitor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidateKeys(Set<Object> keySet) {
|
||||
near.removeAll(keySet);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidateKey(Object id) {
|
||||
near.remove(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidateClear() {
|
||||
near.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Object, Object> getAll(Set<Object> keys) {
|
||||
Map<Object, Object> resultMap = near.getAll(keys);
|
||||
Set<Object> localKeys = resultMap.keySet();
|
||||
Set<Object> remainingKeys = new HashSet<>();
|
||||
for (Object key : keys) {
|
||||
if (!localKeys.contains(key)) {
|
||||
remainingKeys.add(key);
|
||||
}
|
||||
}
|
||||
if (!remainingKeys.isEmpty()) {
|
||||
// fetch missing ones from a remote cache and merge results
|
||||
Map<Object, Object> remoteMap = remote.getAll(remainingKeys);
|
||||
if (!remoteMap.isEmpty()) {
|
||||
near.putAll(remoteMap);
|
||||
resultMap.putAll(remoteMap);
|
||||
}
|
||||
}
|
||||
return resultMap;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object get(Object id) {
|
||||
Object val = near.get(id);
|
||||
if (val != null) {
|
||||
return val;
|
||||
}
|
||||
Object remoteVal = remote.get(id);
|
||||
if (remoteVal != null) {
|
||||
near.put(id, remoteVal);
|
||||
}
|
||||
return remoteVal;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putAll(Map<Object, Object> keyValues) {
|
||||
near.putAll(keyValues);
|
||||
remote.putAll(keyValues);
|
||||
cacheNotify.invalidateKeys(cacheKey, keyValues.keySet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(Object id, Object value) {
|
||||
near.put(id, value);
|
||||
remote.put(id, value);
|
||||
cacheNotify.invalidateKey(cacheKey, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAll(Set<Object> keys) {
|
||||
near.removeAll(keys);
|
||||
remote.removeAll(keys);
|
||||
cacheNotify.invalidateKeys(cacheKey, keys);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(Object id) {
|
||||
near.remove(id);
|
||||
remote.remove(id);
|
||||
cacheNotify.invalidateKey(cacheKey, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear() {
|
||||
near.clear();
|
||||
remote.clear();
|
||||
cacheNotify.invalidateClear(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the near cache hit count.
|
||||
*/
|
||||
public long getNearHitCount() {
|
||||
return near.getHitCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the near cache miss count.
|
||||
*/
|
||||
public long getNearMissCount() {
|
||||
return near.getMissCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the redis cache hit count.
|
||||
*/
|
||||
public long getRemoteHitCount() {
|
||||
return remote.getHitCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the redis cache miss count.
|
||||
*/
|
||||
public long getRemoteMissCount() {
|
||||
return remote.getMissCount();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package io.ebean.redisson;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Provides a modified base64 encoded UUID and shorter 12 character random unique value.
|
||||
* <p>
|
||||
* <h3>newId()</h3>
|
||||
* <p>
|
||||
* It produces a 22 character string that is a base64 encoded UUID with the +
|
||||
* and / characters replaced with - and _ so as to be URL safe without requiring
|
||||
* encoding.
|
||||
* </p>
|
||||
* <h3>newShortId()</h3>
|
||||
* <p>
|
||||
* It produces a 12 character string that base64 encoded random number (72 bit).
|
||||
* </p>
|
||||
* <p>
|
||||
* Note that this now internally uses java.util.Base64 to encode the values.
|
||||
* </p>
|
||||
*/
|
||||
public final class ModId {
|
||||
|
||||
private static final SecureRandom shortIdSecureRandom = new SecureRandom();
|
||||
|
||||
private static final Base64.Encoder urlEncoder = Base64.getUrlEncoder();
|
||||
|
||||
/**
|
||||
* Return a 12 character string using a 72 bit randomly generated ID encoded
|
||||
* in modified base64.
|
||||
* <p>
|
||||
* A UUID is 128 bits and this is 72 bits so quite a bit smaller but still
|
||||
* very random with one in 4.7 * 10^21 chance of a collision.
|
||||
* </p>
|
||||
*/
|
||||
public static String id() {
|
||||
// Random 72 bits
|
||||
byte[] randomBytes = new byte[9];
|
||||
shortIdSecureRandom.nextBytes(randomBytes);
|
||||
return encode64(randomBytes);
|
||||
}
|
||||
|
||||
private static String encode64(byte[] bytes) {
|
||||
return urlEncoder.encodeToString(bytes);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package io.ebean.redisson;
|
||||
|
||||
import io.avaje.applog.AppLog;
|
||||
import io.ebean.BackgroundExecutor;
|
||||
import io.ebean.cache.ServerCache;
|
||||
import io.ebean.cache.ServerCacheConfig;
|
||||
import io.ebean.cache.ServerCacheStatistics;
|
||||
import io.ebean.meta.MetricVisitor;
|
||||
import io.ebean.metric.CountMetric;
|
||||
import io.ebean.metric.MetricFactory;
|
||||
import io.ebean.metric.TimedMetric;
|
||||
import io.ebean.metric.TimedMetricStats;
|
||||
import io.ebean.redisson.encode.VersionGatedCodec;
|
||||
import io.ebeaninternal.server.cache.CachedBeanData;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import org.redisson.api.RMapCacheNative;
|
||||
import org.redisson.api.RScript;
|
||||
import org.redisson.api.RedissonClient;
|
||||
import org.redisson.api.map.PutArgs;
|
||||
import org.redisson.client.codec.ByteArrayCodec;
|
||||
import org.redisson.client.codec.Codec;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static java.lang.System.Logger.Level.ERROR;
|
||||
import static java.lang.System.Logger.Level.WARNING;
|
||||
|
||||
/**
|
||||
* Remote (shared) L2 cache region backed by a single Redis hash using
|
||||
* <b>native per-field TTL</b> ({@link RMapCacheNative}). Requires <b>Redis 8.0+</b> / Valkey 9.0+:
|
||||
* writes use {@code HSETEX} (Redis 8.0) and idle refresh uses {@code HEXPIRE}/{@code HGETEX} (Redis 7.4).
|
||||
* <p>
|
||||
* Compared with the scripted {@code RMapCache} this avoids the per-region timeout/idle/last-access
|
||||
* sorted-sets and the client side eviction task: entries are expired by Redis itself. {@code clear()}
|
||||
* remains a single {@code DEL} of the hash.
|
||||
* <p>
|
||||
* Feature handling:
|
||||
* <ul>
|
||||
* <li><b>maxSecsToLive</b> - authoritative native per-field TTL (set on every writing).</li>
|
||||
* <li><b>maxIdleSecs</b> - in the shared remote we only slide the TTL on read when there is no
|
||||
* hard {@code maxSecsToLive} cap. When {@code maxSecsToLive > 0} it is the authoritative bound
|
||||
* (idle eviction is then handled by the in-heap near cache); this deliberately keeps remote
|
||||
* reads as plain {@code HGET} instead of turning every read into a Redis writing. When only
|
||||
* {@code maxIdleSecs} is set it becomes the TTL and is slid forward on each read.</li>
|
||||
* <li><b>maxSize</b> - native hashes are not bounded, so size is enforced by best-effort periodic
|
||||
* trim (see {@link #trimCache()}). Eviction order is approximate (Redis scan order) rather than LFU.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class RedissonCache implements ServerCache {
|
||||
private static final System.Logger log = AppLog.getLogger(RedissonCache.class);
|
||||
private static final String CACHE_KEY_PREFIX = "EBEAN_CACHE";
|
||||
private static final int TRIM_FREQUENCY_SECS = 60;
|
||||
|
||||
/**
|
||||
* Batch compare-and-set: ARGV[1]=ttlMillis, ARGV[2]=marker bytes, then (field, value) pairs. The stored
|
||||
* format is {@code [marker][8-byte big-endian version][bean]} ({@code VersionGatedCodec}).
|
||||
*/
|
||||
private static final String VERSIONED_PUT_LUA =
|
||||
"local ttl = tonumber(ARGV[1]); " +
|
||||
"local marker = ARGV[2]; " +
|
||||
"local mlen = string.len(marker); " +
|
||||
"local i = 3; " +
|
||||
"while i < #ARGV do " +
|
||||
" local field = ARGV[i]; local val = ARGV[i+1]; " +
|
||||
" local cur = redis.call('hget', KEYS[1], field); " +
|
||||
" local skip = false; " +
|
||||
" if cur ~= false and string.len(cur) >= mlen + 8 and string.sub(cur, 1, mlen) == marker then " +
|
||||
" if string.sub(cur, mlen + 1, mlen + 8) > string.sub(val, mlen + 1, mlen + 8) then skip = true; end; " +
|
||||
" end; " +
|
||||
" if not skip then " +
|
||||
" redis.call('hset', KEYS[1], field, val); " +
|
||||
" if ttl > 0 then redis.call('hpexpire', KEYS[1], ttl, 'FIELDS', 1, field); end; " +
|
||||
" end; " +
|
||||
" i = i + 2; " +
|
||||
"end; " +
|
||||
"return 1;";
|
||||
|
||||
private final int maxSize;
|
||||
private final Duration writeTtl;
|
||||
private final boolean slideIdle;
|
||||
private final Duration idleTtl;
|
||||
private final RMapCacheNative<String, Object> cacheMap;
|
||||
private final Codec codec;
|
||||
private final boolean versionGated;
|
||||
private final RScript versionScript;
|
||||
private final String mapName;
|
||||
private final String cacheKey;
|
||||
private final TimedMetric metricGet;
|
||||
private final TimedMetric metricGetAll;
|
||||
private final TimedMetric metricPut;
|
||||
private final TimedMetric metricPutAll;
|
||||
private final TimedMetric metricRemove;
|
||||
private final TimedMetric metricRemoveAll;
|
||||
private final TimedMetric metricClear;
|
||||
private final CountMetric hitCount;
|
||||
private final CountMetric missCount;
|
||||
|
||||
RedissonCache(RedissonClient redissonClient, ServerCacheConfig config, Codec codec, BackgroundExecutor executor, boolean versionGated) {
|
||||
this.cacheKey = config.getCacheKey();
|
||||
this.codec = codec;
|
||||
this.versionGated = versionGated;
|
||||
this.versionScript = versionGated ? redissonClient.getScript(ByteArrayCodec.INSTANCE) : null;
|
||||
|
||||
int maxSecsToLive = Math.max(config.getCacheOptions().getMaxSecsToLive(), 0);
|
||||
int maxIdleSecs = Math.max(config.getCacheOptions().getMaxIdleSecs(), 0);
|
||||
this.maxSize = config.getCacheOptions().getMaxSize();
|
||||
|
||||
if (maxSecsToLive > 0) {
|
||||
this.writeTtl = Duration.ofSeconds(maxSecsToLive);
|
||||
this.slideIdle = false;
|
||||
this.idleTtl = null;
|
||||
} else if (maxIdleSecs > 0) {
|
||||
this.writeTtl = Duration.ofSeconds(maxIdleSecs);
|
||||
this.slideIdle = true;
|
||||
this.idleTtl = Duration.ofSeconds(maxIdleSecs);
|
||||
} else {
|
||||
this.writeTtl = null;
|
||||
this.slideIdle = false;
|
||||
this.idleTtl = null;
|
||||
}
|
||||
|
||||
String namePrefix = "l2r." + config.getShortName();
|
||||
MetricFactory factory = MetricFactory.get();
|
||||
hitCount = factory.createCountMetric(namePrefix + ".hit");
|
||||
missCount = factory.createCountMetric(namePrefix + ".miss");
|
||||
metricGet = factory.createTimedMetric(namePrefix + ".get");
|
||||
metricGetAll = factory.createTimedMetric(namePrefix + ".getMany");
|
||||
metricPut = factory.createTimedMetric(namePrefix + ".put");
|
||||
metricPutAll = factory.createTimedMetric(namePrefix + ".putMany");
|
||||
metricRemove = factory.createTimedMetric(namePrefix + ".remove");
|
||||
metricRemoveAll = factory.createTimedMetric(namePrefix + ".removeMany");
|
||||
metricClear = factory.createTimedMetric(namePrefix + ".clear");
|
||||
this.mapName = CACHE_KEY_PREFIX + ":" + cacheKey;
|
||||
cacheMap = redissonClient.getMapCacheNative(mapName, codec);
|
||||
|
||||
if (maxSize > 0 && executor != null) {
|
||||
executor.scheduleWithFixedDelay(this::trimCache, TRIM_FREQUENCY_SECS, TRIM_FREQUENCY_SECS, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(MetricVisitor visitor) {
|
||||
hitCount.visit(visitor);
|
||||
missCount.visit(visitor);
|
||||
metricGet.visit(visitor);
|
||||
metricGetAll.visit(visitor);
|
||||
metricPut.visit(visitor);
|
||||
metricPutAll.visit(visitor);
|
||||
metricRemove.visit(visitor);
|
||||
metricRemoveAll.visit(visitor);
|
||||
metricClear.visit(visitor);
|
||||
}
|
||||
|
||||
private void errorOnRead(Exception e) {
|
||||
log.log(ERROR, "Error reading redis cache [" + mapName + "] - treating as miss", e);
|
||||
}
|
||||
|
||||
private void errorOnWrite(Exception e) {
|
||||
log.log(ERROR, "Error writing redis cache [" + mapName + "] - treating as miss", e);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Object, Object> getAll(Set<Object> keys) {
|
||||
try {
|
||||
if (keys.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
long start = System.nanoTime();
|
||||
Map<String, Object> strToOrigKey = new LinkedHashMap<>();
|
||||
for (Object key : keys) {
|
||||
strToOrigKey.put(key.toString(), key);
|
||||
}
|
||||
Map<Object, Object> map = new LinkedHashMap<>();
|
||||
Map<String, Object> values = cacheMap.getAll(strToOrigKey.keySet());
|
||||
for (Map.Entry<String, Object> strEntry : strToOrigKey.entrySet()) {
|
||||
Object value = values.get(strEntry.getKey());
|
||||
if (value != null) {
|
||||
map.put(strEntry.getValue(), value);
|
||||
}
|
||||
}
|
||||
if (slideIdle && !values.isEmpty()) {
|
||||
slideIdleAsync(values.keySet());
|
||||
}
|
||||
int hits = map.size();
|
||||
int miss = keys.size() - hits;
|
||||
|
||||
if (hits > 0) {
|
||||
hitCount.add(hits);
|
||||
}
|
||||
if (miss > 0) {
|
||||
missCount.add(miss);
|
||||
}
|
||||
metricGetAll.addSinceNanos(start);
|
||||
return map;
|
||||
} catch (Exception e) {
|
||||
errorOnRead(e);
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object get(Object id) {
|
||||
long start = System.nanoTime();
|
||||
try {
|
||||
String key = id.toString();
|
||||
Object val = cacheMap.get(key);
|
||||
if (val != null) {
|
||||
hitCount.increment();
|
||||
if (slideIdle) {
|
||||
slideIdleAsync(Collections.singleton(key));
|
||||
}
|
||||
} else {
|
||||
missCount.increment();
|
||||
}
|
||||
metricGet.addSinceNanos(start);
|
||||
return val;
|
||||
} catch (Exception e) {
|
||||
errorOnRead(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void slideIdleAsync(Set<String> keys) {
|
||||
try {
|
||||
if (keys.size() == 1) {
|
||||
cacheMap.expireEntryAsync(keys.iterator().next(), idleTtl)
|
||||
.whenComplete((r, e) -> logSlideError(e));
|
||||
} else {
|
||||
cacheMap.expireEntriesAsync(keys, idleTtl)
|
||||
.whenComplete((r, e) -> logSlideError(e));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logSlideError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void logSlideError(Throwable e) {
|
||||
if (e != null) {
|
||||
log.log(WARNING, "Error sliding idle TTL on redis cache [" + mapName + "]", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(Object id, Object value) {
|
||||
long start = System.nanoTime();
|
||||
try {
|
||||
String key = id.toString();
|
||||
if (versionGated && value instanceof CachedBeanData) {
|
||||
versionedPut(Map.of(key, value));
|
||||
} else {
|
||||
writePut(key, value);
|
||||
}
|
||||
metricPut.addSinceNanos(start);
|
||||
} catch (Exception e) {
|
||||
errorOnWrite(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void writePut(String key, Object value) {
|
||||
if (writeTtl == null) {
|
||||
cacheMap.fastPut(key, value);
|
||||
} else {
|
||||
cacheMap.fastPut(key, value, writeTtl);
|
||||
}
|
||||
}
|
||||
|
||||
private void writePutAll(Map<String, Object> map) {
|
||||
if (writeTtl == null) {
|
||||
cacheMap.putAll(map);
|
||||
} else {
|
||||
cacheMap.putAll(PutArgs.entries(map).timeToLive(writeTtl));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Version-gated put: never overwrites a strictly newer cached version
|
||||
*/
|
||||
private void versionedPut(Map<String, Object> data) {
|
||||
long ttlMillis = (writeTtl == null) ? 0L : writeTtl.toMillis();
|
||||
List<Object> argv = new ArrayList<>(2 + data.size() * 2);
|
||||
argv.add(String.valueOf(ttlMillis).getBytes(StandardCharsets.UTF_8));
|
||||
argv.add(VersionGatedCodec.MARKER.clone());
|
||||
for (Map.Entry<String, Object> entry : data.entrySet()) {
|
||||
argv.add(entry.getKey().getBytes(StandardCharsets.UTF_8));
|
||||
argv.add(encodeValue(entry.getValue()));
|
||||
}
|
||||
versionScript.eval(RScript.Mode.READ_WRITE, VERSIONED_PUT_LUA, RScript.ReturnType.BOOLEAN,
|
||||
Collections.singletonList(mapName), argv.toArray());
|
||||
}
|
||||
|
||||
private byte[] encodeValue(Object value) {
|
||||
try {
|
||||
ByteBuf buf = codec.getValueEncoder().encode(value);
|
||||
try {
|
||||
byte[] bytes = new byte[buf.readableBytes()];
|
||||
buf.getBytes(buf.readerIndex(), bytes);
|
||||
return bytes;
|
||||
} finally {
|
||||
buf.release();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putAll(Map<Object, Object> keyValues) {
|
||||
long start = System.nanoTime();
|
||||
try {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
for (Map.Entry<Object, Object> entry : keyValues.entrySet()) {
|
||||
map.put(entry.getKey().toString(), entry.getValue());
|
||||
}
|
||||
if (versionGated && !keyValues.isEmpty() && keyValues.entrySet().iterator().next().getValue() instanceof CachedBeanData) {
|
||||
versionedPut(map);
|
||||
} else {
|
||||
writePutAll(map);
|
||||
}
|
||||
metricPutAll.addSinceNanos(start);
|
||||
} catch (Exception e) {
|
||||
errorOnWrite(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(Object id) {
|
||||
long start = System.nanoTime();
|
||||
try {
|
||||
cacheMap.fastRemove(id.toString());
|
||||
metricRemove.addSinceNanos(start);
|
||||
} catch (Exception e) {
|
||||
errorOnWrite(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAll(Set<Object> keys) {
|
||||
long start = System.nanoTime();
|
||||
try {
|
||||
var keysArray = keys.stream().map(Object::toString).toArray(String[]::new);
|
||||
cacheMap.fastRemove(keysArray);
|
||||
metricRemoveAll.addSinceNanos(start);
|
||||
} catch (Exception e) {
|
||||
errorOnWrite(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear() {
|
||||
long start = System.nanoTime();
|
||||
try {
|
||||
cacheMap.clear();
|
||||
metricClear.addSinceNanos(start);
|
||||
} catch (Exception e) {
|
||||
errorOnWrite(e);
|
||||
}
|
||||
}
|
||||
|
||||
void trimCache() {
|
||||
try {
|
||||
int size = cacheMap.size();
|
||||
int toRemove = size - maxSize;
|
||||
if (toRemove <= 0) {
|
||||
return;
|
||||
}
|
||||
List<String> victims = new ArrayList<>(Math.min(toRemove, 1024));
|
||||
for (String key : cacheMap.keySet()) {
|
||||
victims.add(key);
|
||||
if (victims.size() >= toRemove) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!victims.isEmpty()) {
|
||||
cacheMap.fastRemove(victims.toArray(new String[0]));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.log(WARNING, "Error trimming redis cache [" + mapName + "] to maxSize " + maxSize, e);
|
||||
}
|
||||
}
|
||||
|
||||
public long getHitCount() {
|
||||
return hitCount.get(false);
|
||||
}
|
||||
|
||||
public long getMissCount() {
|
||||
return missCount.get(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerCacheStatistics statistics(boolean reset) {
|
||||
ServerCacheStatistics cacheStats = new ServerCacheStatistics();
|
||||
cacheStats.setCacheName(cacheKey);
|
||||
cacheStats.setHitCount(hitCount.get(reset));
|
||||
cacheStats.setMissCount(missCount.get(reset));
|
||||
cacheStats.setPutCount(count(metricPut.collect(reset)));
|
||||
cacheStats.setRemoveCount(count(metricRemove.collect(reset)));
|
||||
cacheStats.setClearCount(count(metricClear.collect(reset)));
|
||||
return cacheStats;
|
||||
}
|
||||
|
||||
private long count(TimedMetricStats stats) {
|
||||
return stats == null ? 0 : stats.count();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
package io.ebean.redisson;
|
||||
|
||||
|
||||
import io.avaje.applog.AppLog;
|
||||
import io.ebean.BackgroundExecutor;
|
||||
import io.ebean.DatabaseBuilder;
|
||||
import io.ebean.cache.*;
|
||||
import io.ebean.meta.MetricVisitor;
|
||||
import io.ebean.metric.MetricFactory;
|
||||
import io.ebean.metric.TimedMetric;
|
||||
import io.ebean.redisson.dto.*;
|
||||
import io.ebean.redisson.encode.CachedBeanDataCodec;
|
||||
import io.ebean.redisson.encode.CachedManyIdsCodec;
|
||||
import io.ebean.redisson.encode.SerializableCodec;
|
||||
import io.ebean.redisson.encode.VersionGatedCodec;
|
||||
import io.ebean.redisson.near.NearCacheInvalidate;
|
||||
import io.ebean.redisson.near.NearCacheNotify;
|
||||
import io.ebeaninternal.server.cache.DefaultServerCache;
|
||||
import io.ebeaninternal.server.cache.DefaultServerCacheConfig;
|
||||
import io.ebeaninternal.server.cache.DefaultServerQueryCache;
|
||||
import org.redisson.Redisson;
|
||||
import org.redisson.api.RReliableTopic;
|
||||
import org.redisson.api.RedissonClient;
|
||||
import org.redisson.config.Config;
|
||||
|
||||
import java.io.*;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import static java.lang.System.Logger.Level.*;
|
||||
|
||||
public class RedissonCacheFactory implements ServerCacheFactory {
|
||||
|
||||
private static final System.Logger log = AppLog.getLogger(RedissonCacheFactory.class);
|
||||
|
||||
/**
|
||||
* Channel for standard L2 cache messages.
|
||||
*/
|
||||
private static final String CHANNEL_L2 = "ebean.l2cache";
|
||||
/**
|
||||
* Channel specifically for near cache invalidation messages.
|
||||
*/
|
||||
private static final String CHANNEL_NEAR = "ebean.l2near";
|
||||
|
||||
private final ConcurrentHashMap<String, RQueryCache> queryCaches = new ConcurrentHashMap<>();
|
||||
private final Map<String, NearCacheInvalidate> nearCacheMap = new ConcurrentHashMap<>();
|
||||
private final SerializableCodec serializableCodec = new SerializableCodec();
|
||||
private final CachedBeanDataCodec cachedBeanDataCodec = new CachedBeanDataCodec();
|
||||
private final CachedManyIdsCodec cachedManyIdsCodec = new CachedManyIdsCodec();
|
||||
private final BackgroundExecutor executor;
|
||||
private final RedissonClient redissonClient;
|
||||
private final NearCacheNotify nearCacheNotify;
|
||||
private final TimedMetric metricOutNearCache;
|
||||
private final TimedMetric metricOutTableMod;
|
||||
private final TimedMetric metricOutQueryCache;
|
||||
private final TimedMetric metricInNearCache;
|
||||
private final TimedMetric metricInTableMod;
|
||||
private final TimedMetric metricInQueryCache;
|
||||
private final String serverId = ModId.id();
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final RReliableTopic topicL2;
|
||||
private final RReliableTopic topicNear;
|
||||
private ServerCacheNotify listener;
|
||||
|
||||
RedissonCacheFactory(DatabaseBuilder.Settings config, BackgroundExecutor executor) {
|
||||
this.executor = executor;
|
||||
this.nearCacheNotify = new DNearCacheNotify();
|
||||
MetricFactory factory = MetricFactory.get();
|
||||
this.metricOutTableMod = factory.createTimedMetric("l2a.outTableMod");
|
||||
this.metricOutQueryCache = factory.createTimedMetric("l2a.outQueryCache");
|
||||
this.metricOutNearCache = factory.createTimedMetric("l2a.outNearKeys");
|
||||
this.metricInTableMod = factory.createTimedMetric("l2a.inTableMod");
|
||||
this.metricInQueryCache = factory.createTimedMetric("l2a.inQueryCache");
|
||||
this.metricInNearCache = factory.createTimedMetric("l2a.inNearKeys");
|
||||
this.redissonClient = getRedissonClient(config);
|
||||
this.topicL2 = redissonClient.getReliableTopic(CHANNEL_L2);
|
||||
this.topicNear = redissonClient.getReliableTopic(CHANNEL_NEAR);
|
||||
subscribeToMessages();
|
||||
}
|
||||
|
||||
private RedissonClient getRedissonClient(DatabaseBuilder.Settings config) {
|
||||
RedissonClient existingClient = config.getServiceObject(RedissonClient.class);
|
||||
if (existingClient != null) {
|
||||
return existingClient;
|
||||
}
|
||||
|
||||
Config redisConfig = config.getServiceObject(Config.class);
|
||||
if (redisConfig != null) {
|
||||
return Redisson.create(redisConfig);
|
||||
}
|
||||
|
||||
Config loadedConfig = null;
|
||||
try {
|
||||
ClassLoader cl = Thread.currentThread().getContextClassLoader();
|
||||
InputStream is = cl.getResourceAsStream("redisson-config.yaml");
|
||||
|
||||
if (is != null) {
|
||||
loadedConfig = Config.fromYAML(is);
|
||||
log.log(INFO, "Loaded Redisson config from classpath: redisson-config.yaml");
|
||||
} else {
|
||||
log.log(WARNING, "redisson-config.yaml not found in classpath. Falling back to default config.");
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.log(WARNING, "Failed to load redisson-config.yaml from classpath. Falling back to default config.", e);
|
||||
}
|
||||
|
||||
if (loadedConfig == null) {
|
||||
loadedConfig = new Config();
|
||||
loadedConfig.useSingleServer().setAddress("redis://localhost:6379");
|
||||
log.log(WARNING, "Using default Redisson config: redis://localhost:6379");
|
||||
}
|
||||
|
||||
return Redisson.create(loadedConfig);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(MetricVisitor visitor) {
|
||||
metricOutQueryCache.visit(visitor);
|
||||
metricOutTableMod.visit(visitor);
|
||||
metricOutNearCache.visit(visitor);
|
||||
metricInTableMod.visit(visitor);
|
||||
metricInQueryCache.visit(visitor);
|
||||
metricInNearCache.visit(visitor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerCache createCache(ServerCacheConfig config) {
|
||||
if (config.isQueryCache()) {
|
||||
return createQueryCache(config);
|
||||
}
|
||||
return createNormalCache(config);
|
||||
}
|
||||
|
||||
private ServerCache createNormalCache(ServerCacheConfig config) {
|
||||
RedissonCache redissonCache = createRedisCache(config);
|
||||
boolean nearCache = config.getCacheOptions().isNearCache();
|
||||
if (!nearCache) {
|
||||
return config.tenantAware(redissonCache);
|
||||
}
|
||||
|
||||
String cacheKey = config.getCacheKey();
|
||||
DefaultServerCache near = new DefaultServerCache(new DefaultServerCacheConfig(config));
|
||||
near.periodicTrim(executor);
|
||||
DuelCache duelCache = new DuelCache(near, redissonCache, cacheKey, nearCacheNotify);
|
||||
nearCacheMap.put(cacheKey, duelCache);
|
||||
return config.tenantAware(duelCache);
|
||||
}
|
||||
|
||||
private RedissonCache createRedisCache(ServerCacheConfig config) {
|
||||
switch (config.getType()) {
|
||||
case NATURAL_KEY:
|
||||
return new RedissonCache(redissonClient, config, serializableCodec, executor, false);
|
||||
case BEAN: {
|
||||
VersionGatedCodec codec = new VersionGatedCodec(cachedBeanDataCodec);
|
||||
return new RedissonCache(redissonClient, config, codec, executor, true);
|
||||
}
|
||||
case COLLECTION_IDS:
|
||||
return new RedissonCache(redissonClient, config, cachedManyIdsCodec, executor, false);
|
||||
default:
|
||||
throw new IllegalArgumentException("Unexpected cache type? " + config.getType());
|
||||
}
|
||||
}
|
||||
|
||||
private ServerCache createQueryCache(ServerCacheConfig config) {
|
||||
lock.lock();
|
||||
try {
|
||||
RQueryCache cache = queryCaches.get(config.getCacheKey());
|
||||
if (cache == null) {
|
||||
log.log(DEBUG, config.getCacheKey());
|
||||
cache = new RQueryCache(new DefaultServerCacheConfig(config));
|
||||
cache.periodicTrim(executor);
|
||||
queryCaches.put(config.getCacheKey(), cache);
|
||||
}
|
||||
return config.tenantAware(cache);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerCacheNotify createCacheNotify(ServerCacheNotify listener) {
|
||||
this.listener = listener;
|
||||
return new RServerCacheNotify();
|
||||
}
|
||||
|
||||
private void sendQueryCacheInvalidation(String name) {
|
||||
long nanos = System.nanoTime();
|
||||
try {
|
||||
L2QueryInvalidMessage message = new L2QueryInvalidMessage();
|
||||
message.setServerId(serverId);
|
||||
message.setKey(name);
|
||||
|
||||
topicL2.publish(message);
|
||||
} finally {
|
||||
metricOutQueryCache.addSinceNanos(nanos);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendTableMod(Set<String> dependentTables) {
|
||||
long nanos = System.nanoTime();
|
||||
try {
|
||||
|
||||
L2TableModMessage message = new L2TableModMessage();
|
||||
message.setTables(dependentTables);
|
||||
message.setServerId(serverId);
|
||||
|
||||
topicL2.publish(message);
|
||||
} finally {
|
||||
metricOutTableMod.addSinceNanos(nanos);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the query cache if we have it.
|
||||
*/
|
||||
private void queryCacheInvalidate(L2QueryInvalidMessage message) {
|
||||
if (serverId.equals(message.getServerId())) {
|
||||
// ignore this message as we are the server that sent it
|
||||
return;
|
||||
}
|
||||
|
||||
long nanos = System.nanoTime();
|
||||
try {
|
||||
RQueryCache queryCache = queryCaches.get(message.getKey());
|
||||
if (queryCache != null) {
|
||||
queryCache.invalidate();
|
||||
}
|
||||
} finally {
|
||||
metricInQueryCache.addSinceNanos(nanos);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a remote-dependent table modify event.
|
||||
*/
|
||||
private void processTableNotify(L2TableModMessage message) {
|
||||
if (serverId.equals(message.getServerId())) {
|
||||
// ignore this message as we are the server that sent it
|
||||
return;
|
||||
}
|
||||
if (listener == null) {
|
||||
log.log(DEBUG, "Ignoring tableMod, listener not registered yet");
|
||||
return;
|
||||
}
|
||||
long nanos = System.nanoTime();
|
||||
try {
|
||||
listener.notify(new ServerCacheNotification(message.getTables()));
|
||||
} finally {
|
||||
metricInTableMod.addSinceNanos(nanos);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate key for a local near cache.
|
||||
*/
|
||||
private void nearCacheInvalidateKey(NearCacheInvalidateKeyMessage message) {
|
||||
String sourceServerId = message.getServerId();
|
||||
if (sourceServerId.equals(serverId)) {
|
||||
// ignore this message as we are the server that sent it
|
||||
return;
|
||||
}
|
||||
|
||||
String cacheKey = message.getCacheKey();
|
||||
long nanos = System.nanoTime();
|
||||
try (ObjectInputStream oi = new ObjectInputStream(new ByteArrayInputStream(message.getKey()))) {
|
||||
Object key = oi.readObject();
|
||||
NearCacheInvalidate invalidate = nearCacheMap.get(cacheKey);
|
||||
if (invalidate == null) {
|
||||
warnNearCacheNotFound(cacheKey);
|
||||
} else {
|
||||
invalidate.invalidateKey(key);
|
||||
}
|
||||
} catch (IOException | ClassNotFoundException e) {
|
||||
log.log(ERROR, "failed to decode near cache message [" + message + "] for cache:" + cacheKey, e);
|
||||
if (cacheKey != null) {
|
||||
nearCacheInvalidateClear(cacheKey);
|
||||
}
|
||||
} finally {
|
||||
metricInNearCache.addSinceNanos(nanos);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate keys for a local near cache.
|
||||
*/
|
||||
private void nearCacheInvalidateKeys(NearCacheInvalidateKeysMessage message) {
|
||||
String sourceServerId = message.getServerId();
|
||||
if (sourceServerId.equals(serverId)) {
|
||||
// ignore this message as we are the server that sent it
|
||||
return;
|
||||
}
|
||||
|
||||
String cacheKey = message.getCacheKey();
|
||||
long nanos = System.nanoTime();
|
||||
try (ObjectInputStream oi = new ObjectInputStream(new ByteArrayInputStream(message.getKeys()))) {
|
||||
int total = oi.readInt();
|
||||
Set<Object> keys = new LinkedHashSet<>();
|
||||
for (int i = 0; i < total; i++) {
|
||||
keys.add(oi.readObject());
|
||||
}
|
||||
NearCacheInvalidate invalidate = nearCacheMap.get(cacheKey);
|
||||
if (invalidate == null) {
|
||||
warnNearCacheNotFound(cacheKey);
|
||||
} else {
|
||||
invalidate.invalidateKeys(keys);
|
||||
}
|
||||
} catch (IOException | ClassNotFoundException e) {
|
||||
log.log(ERROR, "failed to decode near cache message [" + message + "] for cache:" + cacheKey, e);
|
||||
if (cacheKey != null) {
|
||||
nearCacheInvalidateClear(cacheKey);
|
||||
}
|
||||
} finally {
|
||||
metricInNearCache.addSinceNanos(nanos);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate clear for a local near cache.
|
||||
*/
|
||||
private void nearCacheInvalidateClear(String cacheKey) {
|
||||
NearCacheInvalidate invalidate = nearCacheMap.get(cacheKey);
|
||||
if (invalidate == null) {
|
||||
warnNearCacheNotFound(cacheKey);
|
||||
} else {
|
||||
invalidate.invalidateClear();
|
||||
}
|
||||
}
|
||||
|
||||
private void nearCacheInvalidateClear(NearCacheClearMessage message) {
|
||||
String sourceServerId = message.getServerId();
|
||||
if (sourceServerId.equals(serverId)) {
|
||||
// ignore this message as we are the server that sent it
|
||||
return;
|
||||
}
|
||||
|
||||
String cacheKey = message.getCacheKey();
|
||||
long nanos = System.nanoTime();
|
||||
try {
|
||||
NearCacheInvalidate invalidate = nearCacheMap.get(cacheKey);
|
||||
if (invalidate == null) {
|
||||
warnNearCacheNotFound(cacheKey);
|
||||
} else {
|
||||
invalidate.invalidateClear();
|
||||
}
|
||||
} finally {
|
||||
metricInNearCache.addSinceNanos(nanos);
|
||||
}
|
||||
}
|
||||
|
||||
private void warnNearCacheNotFound(String cacheKey) {
|
||||
log.log(WARNING, "No near cache found for cacheKey [" + cacheKey + "] yet - probably on startup");
|
||||
}
|
||||
|
||||
private void subscribeToMessages() {
|
||||
topicL2.addListener(L2QueryInvalidMessage.class, (channel, message) -> queryCacheInvalidate(message));
|
||||
topicL2.addListener(L2TableModMessage.class, (channel, message) -> processTableNotify(message));
|
||||
topicNear.addListener(NearCacheClearMessage.class, (channel, message) -> nearCacheInvalidateClear(message));
|
||||
topicNear.addListener(NearCacheInvalidateKeyMessage.class, (channel, message) -> nearCacheInvalidateKey(message));
|
||||
topicNear.addListener(NearCacheInvalidateKeysMessage.class, (channel, message) -> nearCacheInvalidateKeys(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Query cache implementation using a Redis channel for message notifications.
|
||||
*/
|
||||
private class RQueryCache extends DefaultServerQueryCache {
|
||||
|
||||
RQueryCache(DefaultServerCacheConfig config) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear() {
|
||||
super.clear();
|
||||
sendQueryCacheInvalidation(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the invalidation message coming from the cluster.
|
||||
*/
|
||||
private void invalidate() {
|
||||
super.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish table modifications using a Redis channel (to other cluster members)
|
||||
*/
|
||||
private class RServerCacheNotify implements ServerCacheNotify {
|
||||
|
||||
@Override
|
||||
public void notify(ServerCacheNotification tableModifications) {
|
||||
Set<String> dependentTables = tableModifications.getDependentTables();
|
||||
if (dependentTables != null && !dependentTables.isEmpty()) {
|
||||
sendTableMod(dependentTables);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DNearCacheNotify implements NearCacheNotify {
|
||||
|
||||
@Override
|
||||
public void invalidateKeys(String cacheKey, Set<Object> keySet) {
|
||||
try {
|
||||
ByteArrayOutputStream ba = new ByteArrayOutputStream(100);
|
||||
ObjectOutputStream os = new ObjectOutputStream(ba);
|
||||
os.writeInt(keySet.size());
|
||||
for (Object key : keySet) {
|
||||
os.writeObject(key);
|
||||
}
|
||||
os.flush();
|
||||
os.close();
|
||||
|
||||
NearCacheInvalidateKeysMessage message = new NearCacheInvalidateKeysMessage();
|
||||
message.setServerId(serverId);
|
||||
message.setCacheKey(cacheKey);
|
||||
message.setKeys(ba.toByteArray());
|
||||
sendMessage(message);
|
||||
} catch (IOException e) {
|
||||
log.log(ERROR, "failed to transmit invalidateKeys() message", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidateKey(String cacheKey, Object id) {
|
||||
try {
|
||||
ByteArrayOutputStream ba = new ByteArrayOutputStream(100);
|
||||
ObjectOutputStream os = new ObjectOutputStream(ba);
|
||||
os.writeObject(id);
|
||||
os.flush();
|
||||
os.close();
|
||||
|
||||
NearCacheInvalidateKeyMessage message = new NearCacheInvalidateKeyMessage();
|
||||
message.setServerId(serverId);
|
||||
message.setCacheKey(cacheKey);
|
||||
message.setKey(ba.toByteArray());
|
||||
sendMessage(message);
|
||||
} catch (IOException e) {
|
||||
log.log(ERROR, "failed to transmit invalidateKeys() message", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidateClear(String cacheKey) {
|
||||
NearCacheClearMessage message = new NearCacheClearMessage();
|
||||
message.setServerId(serverId);
|
||||
message.setCacheKey(cacheKey);
|
||||
sendMessage(message);
|
||||
}
|
||||
|
||||
private void sendMessage(NearMessage message) {
|
||||
long nanos = System.nanoTime();
|
||||
try {
|
||||
topicNear.publish(message);
|
||||
} finally {
|
||||
metricOutNearCache.addSinceNanos(nanos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package io.ebean.redisson;
|
||||
|
||||
import io.ebean.BackgroundExecutor;
|
||||
import io.ebean.DatabaseBuilder;
|
||||
import io.ebean.cache.ServerCacheFactory;
|
||||
import io.ebean.cache.ServerCachePlugin;
|
||||
|
||||
public class RedissonCachePlugin implements ServerCachePlugin {
|
||||
@Override
|
||||
public ServerCacheFactory create(DatabaseBuilder config, BackgroundExecutor executor) {
|
||||
return new RedissonCacheFactory(config.settings(), executor);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user