Compare commits

...
Author SHA1 Message Date
robin.bygrave 99c3d8b07b Fix OrmQueryProperties.filterManyHasNestedProperty() with real nested property check 2026-07-15 23:08:51 +12:00
robin.bygrave 9947a41a9b Tidy up comments 2026-07-15 22:54:30 +12:00
robin.bygrave 055a3bf638 Simplify SpiExpressionValidation with boolean nestedProperty flag 2026-07-15 22:47:32 +12:00
robin.bygrave f6ff485041 Bug: filterMany() with nested-property predicates silently ineffective
filterMany() expressions referencing a nested/dotted property (e.g.
.eq("group.name", "x"), alone or mixed with root properties) were
attached to a LEFT JOIN's ON clause via the "extra join" mechanism.

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

## Fix:

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

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

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

Updated TestQueryFilterMany/TestQueryFilterManySimple assertions to
expect the corrected query-join (2 statement) behaviour.
2026-07-15 22:42:25 +12:00
robin.bygrave b67e28e60a Bug: filterMany() expressions when additional nested fetch has predicates in wrong place
When a filterMany() expression only references the many-root's own properties (e.g. .eq("status", ...)),
but the query also has an additional nested fetch beneath that many-path (e.g. .fetch("orders.customer")
or .fetch("contacts.group")), the filter predicate was incorrectly attached to the last/deepest join's
ON clause instead of the many-root's own join — silently misapplying the filter.

Fix: A hybrid approach:

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

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

 - includeFilterMany() made idempotent so both mechanisms coexist safely.
2026-07-15 20:48:28 +12:00
Rob Bygraveandrobin.bygrave 791ac13750 Fix findCount()/exists() wrapping a raw CTE (WITH clause) for SQL Server (#3850)
SQL Server does not support a WITH clause (CTE) nested inside a subquery
or derived table - only at the start of a statement. buildRowCountQuery()
and buildExistsQuery() wrap raw sql as `select count(*) from (<sql>)` /
`select case when exists(<sql>) then ...`, which breaks when <sql> starts
with a CTE header (e.g. RawSqlBuilder.withPlaceholders() queries), causing
"Incorrect syntax near the keyword 'with'" on SQL Server.

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

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

- Add unit tests in CQueryBuilderTest reproducing the exact failing SQL

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

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

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

* Fix tests TestInsertCheckUnique for Oracle and SQL Server

---------

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

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

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

* Also handle deregistration on shutdown

---------

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

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

## Fix:

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

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

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

## Fix:

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

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

## Fix (in ebean-core):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

* Adjust typos only

---------

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

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

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

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

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

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

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

* 18.2.0 - updated version

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

Tests were skipped whenever they ran before the integration tests triggered
DB/Redis container startup. Each test class now calls
RedissonTestFixtures.startRedis() (RedisContainer.builder("latest").start())
which is idempotent and requires no other test class to run first.
assumeTrue(isReachable()) remains as a fallback for Docker-less environments.
2026-07-06 19:43:40 +12:00
robin.bygrave 6dd1763e54 Fix test TestRawSqlWithPlaceholders for Postgres HAVING clause limitation
Postgres having can't use column alias from select clause
2026-07-06 16:42:51 +12:00
Rob Bygraveandrobin.bygrave b32f3bcad6 Fix test-java16 parent etc (#3831)
Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-06 16:23:56 +12:00
155 changed files with 6044 additions and 233 deletions
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -101,7 +101,7 @@ Inside the `<dependencies>` block, add the PostgreSQL JDBC driver:
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.8</version>
<version>42.7.11</version>
</dependency>
```
+4
View File
@@ -457,6 +457,10 @@ public final class DB {
/**
* Same as {@link #checkUniqueness(Object)} but with given transaction.
* <p>
* For control over query cache use and whether to skip the check when the bean's unique
* properties are unchanged, use {@link Database#checkUniqueness(Object, Transaction, boolean, boolean)}
* via {@link #getDefault()} instead.
*/
public static Set<Property> checkUniqueness(Object bean, Transaction transaction) {
return getDefault().checkUniqueness(bean, transaction);
+11 -2
View File
@@ -1078,12 +1078,21 @@ public interface Database {
* @param bean The entity bean to check uniqueness on
* @return a set of Properties if constraint validation was detected or empty list.
*/
Set<Property> checkUniqueness(Object bean);
default Set<Property> checkUniqueness(Object bean) {
return checkUniqueness(bean, null, false, true);
}
/**
* Same as {@link #checkUniqueness(Object)}. but with given transaction.
*/
Set<Property> checkUniqueness(Object bean, Transaction transaction);
default Set<Property> checkUniqueness(Object bean, Transaction transaction) {
return checkUniqueness(bean, transaction, false, true);
}
/**
* Same as {@link #checkUniqueness(Object)}. but with given transaction and extended search options.
*/
Set<Property> checkUniqueness(Object bean, Transaction transaction, boolean useQueryCache, boolean skipClean);
/**
* Marks the entity bean as dirty.
@@ -62,9 +62,10 @@ public interface DatabaseBuilder {
/**
* Build and return the Database instance.
* <p>
* When {@link #setRegister(boolean)} is set to true, and a database with the same
* name is already registered, this may return the existing registered database
* rather than creating a new one.
* When {@link #setRegister(boolean)} is set to true (the default), and a database
* with the same name is already registered, this throws an {@link IllegalStateException}.
* Use a unique name, or use {@link #setRegister(boolean)} with {@code false} if the
* Database instance is not intended to be registered/looked up by name.
*/
Database build();
@@ -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 {
@@ -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();
}
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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>
@@ -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;
}
}
@@ -2196,12 +2196,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 +2208,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 +2224,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 +2265,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;
@@ -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);
}
}
}
@@ -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;
@@ -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();
}
}
@@ -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());
}
@@ -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();
@@ -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
@@ -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,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;
}
}
@@ -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) {
@@ -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
@@ -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));
}
}
@@ -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);
}
@@ -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) {
@@ -28,6 +28,7 @@ import java.util.List;
* &lt;attribute name="notnull" type="{http://www.w3.org/2001/XMLSchema}boolean" /&gt;
* &lt;attribute name="currentNotnull" type="{http://www.w3.org/2001/XMLSchema}boolean" /&gt;
* &lt;attribute name="comment" type="{http://www.w3.org/2001/XMLSchema}string" /&gt;
* &lt;attribute name="currentComment" type="{http://www.w3.org/2001/XMLSchema}string" /&gt;
* &lt;attribute name="historyExclude" type="{http://www.w3.org/2001/XMLSchema}boolean" /&gt;
* &lt;attribute name="checkConstraint" type="{http://www.w3.org/2001/XMLSchema}string" /&gt;
* &lt;attribute name="checkConstraintName" type="{http://www.w3.org/2001/XMLSchema}string" /&gt;
@@ -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);
}
}
}
@@ -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"/>
@@ -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();
+1 -1
View File
@@ -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>
@@ -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();
@@ -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
+100
View File
@@ -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);
}
}
@@ -0,0 +1,4 @@
package io.ebean.redisson.dto;
public interface L2Message {
}
@@ -0,0 +1,44 @@
package io.ebean.redisson.dto;
import java.util.Objects;
public class L2QueryInvalidMessage implements L2Message {
private String serverId;
private String key;
@Override
public String toString() {
return "L2QueryInvalidMessage{" +
"serverId='" + serverId + '\'' +
", key='" + key + '\'' +
'}';
}
@Override
public boolean equals(Object o) {
if (!(o instanceof L2QueryInvalidMessage)) return false;
L2QueryInvalidMessage that = (L2QueryInvalidMessage) o;
return Objects.equals(getServerId(), that.getServerId()) && Objects.equals(getKey(), that.getKey());
}
@Override
public int hashCode() {
return Objects.hash(getServerId(), getKey());
}
public String getServerId() {
return serverId;
}
public void setServerId(String serverId) {
this.serverId = serverId;
}
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
}
@@ -0,0 +1,45 @@
package io.ebean.redisson.dto;
import java.util.Objects;
import java.util.Set;
public class L2TableModMessage implements L2Message {
private String serverId;
private Set<String> tables;
@Override
public String toString() {
return "L2TableModMessage{" +
"serverId='" + serverId + '\'' +
", tables=" + tables +
'}';
}
@Override
public boolean equals(Object o) {
if (!(o instanceof L2TableModMessage)) return false;
L2TableModMessage that = (L2TableModMessage) o;
return Objects.equals(getServerId(), that.getServerId()) && Objects.equals(getTables(), that.getTables());
}
@Override
public int hashCode() {
return Objects.hash(getServerId(), getTables());
}
public String getServerId() {
return serverId;
}
public void setServerId(String serverId) {
this.serverId = serverId;
}
public Set<String> getTables() {
return tables;
}
public void setTables(Set<String> tables) {
this.tables = tables;
}
}
@@ -0,0 +1,44 @@
package io.ebean.redisson.dto;
import java.util.Objects;
public class NearCacheClearMessage implements NearMessage {
private String serverId;
private String cacheKey;
@Override
public String toString() {
return "NearCacheClearMessage{" +
"serverId='" + serverId + '\'' +
", cacheKey='" + cacheKey + '\'' +
'}';
}
@Override
public boolean equals(Object o) {
if (!(o instanceof NearCacheClearMessage)) return false;
NearCacheClearMessage that = (NearCacheClearMessage) o;
return Objects.equals(getServerId(), that.getServerId()) && Objects.equals(getCacheKey(), that.getCacheKey());
}
@Override
public int hashCode() {
return Objects.hash(getServerId(), getCacheKey());
}
public String getServerId() {
return serverId;
}
public void setServerId(String serverId) {
this.serverId = serverId;
}
public String getCacheKey() {
return cacheKey;
}
public void setCacheKey(String cacheKey) {
this.cacheKey = cacheKey;
}
}
@@ -0,0 +1,55 @@
package io.ebean.redisson.dto;
import java.util.Arrays;
import java.util.Objects;
public class NearCacheInvalidateKeyMessage implements NearMessage {
private String serverId;
private String cacheKey;
private byte[] key;
@Override
public String toString() {
return "NearCacheInvalidateKeyMessage{" +
"serverId='" + serverId + '\'' +
", cacheKey='" + cacheKey + '\'' +
", key=" + Arrays.toString(key) +
'}';
}
@Override
public boolean equals(Object o) {
if (!(o instanceof NearCacheInvalidateKeyMessage)) return false;
NearCacheInvalidateKeyMessage that = (NearCacheInvalidateKeyMessage) o;
return Objects.equals(getServerId(), that.getServerId()) && Objects.equals(getCacheKey(), that.getCacheKey()) && Objects.deepEquals(getKey(), that.getKey());
}
@Override
public int hashCode() {
return Objects.hash(getServerId(), getCacheKey(), Arrays.hashCode(getKey()));
}
public String getServerId() {
return serverId;
}
public void setServerId(String serverId) {
this.serverId = serverId;
}
public String getCacheKey() {
return cacheKey;
}
public void setCacheKey(String cacheKey) {
this.cacheKey = cacheKey;
}
public byte[] getKey() {
return key;
}
public void setKey(byte[] key) {
this.key = key;
}
}
@@ -0,0 +1,55 @@
package io.ebean.redisson.dto;
import java.util.Arrays;
import java.util.Objects;
public class NearCacheInvalidateKeysMessage implements NearMessage {
private String serverId;
private String cacheKey;
private byte[] keys;
@Override
public String toString() {
return "NearCacheInvalidateKeysMessage{" +
"serverId='" + serverId + '\'' +
", cacheKey='" + cacheKey + '\'' +
", keys=" + Arrays.toString(keys) +
'}';
}
@Override
public boolean equals(Object o) {
if (!(o instanceof NearCacheInvalidateKeysMessage)) return false;
NearCacheInvalidateKeysMessage that = (NearCacheInvalidateKeysMessage) o;
return Objects.equals(getServerId(), that.getServerId()) && Objects.equals(getCacheKey(), that.getCacheKey()) && Objects.deepEquals(getKeys(), that.getKeys());
}
@Override
public int hashCode() {
return Objects.hash(getServerId(), getCacheKey(), Arrays.hashCode(getKeys()));
}
public String getServerId() {
return serverId;
}
public void setServerId(String serverId) {
this.serverId = serverId;
}
public String getCacheKey() {
return cacheKey;
}
public void setCacheKey(String cacheKey) {
this.cacheKey = cacheKey;
}
public byte[] getKeys() {
return keys;
}
public void setKeys(byte[] keys) {
this.keys = keys;
}
}
@@ -0,0 +1,4 @@
package io.ebean.redisson.dto;
public interface NearMessage {
}
@@ -0,0 +1,40 @@
package io.ebean.redisson.encode;
import io.ebean.cache.TenantAwareKey;
import io.netty.buffer.Unpooled;
import org.redisson.client.codec.BaseCodec;
import org.redisson.client.protocol.Decoder;
import org.redisson.client.protocol.Encoder;
import java.nio.charset.StandardCharsets;
public abstract class CacheCodec extends BaseCodec {
@Override
public Encoder getMapKeyEncoder() {
return in -> {
try {
if (!(in instanceof String) && !(in instanceof TenantAwareKey.CacheKey)) {
throw new IllegalStateException("Expecting String keys but got type: " + in.getClass());
}
byte[] bytes = in.toString().getBytes(StandardCharsets.UTF_8);
return Unpooled.wrappedBuffer(bytes);
} catch (Exception e) {
throw new RuntimeException("Failed to encode cache key", e);
}
};
}
@Override
public Decoder<Object> getMapKeyDecoder() {
return (buf, state) -> {
try {
byte[] bytes = new byte[buf.readableBytes()];
buf.readBytes(bytes);
return new String(bytes, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("Failed to decode cache key", e);
}
};
}
}
@@ -0,0 +1,52 @@
package io.ebean.redisson.encode;
import io.ebeaninternal.server.cache.CachedBeanData;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.ByteBufInputStream;
import io.netty.buffer.ByteBufOutputStream;
import org.redisson.client.protocol.Decoder;
import org.redisson.client.protocol.Encoder;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
public class CachedBeanDataCodec extends CacheCodec {
private final Encoder encoder = in -> {
ByteBuf out = ByteBufAllocator.DEFAULT.buffer();
try (ByteBufOutputStream os = new ByteBufOutputStream(out);
ObjectOutputStream oos = new ObjectOutputStream(os)) {
((CachedBeanData) in).writeExternal(oos);
return os.buffer();
} catch (IOException e) {
out.release();
throw e;
} catch (Exception e) {
out.release();
throw new IOException(e);
}
};
private final Decoder<Object> decoder = (in, state) -> {
try (ByteBufInputStream is = new ByteBufInputStream(in);
ObjectInputStream ois = new ObjectInputStream(is)) {
CachedBeanData data = new CachedBeanData();
data.readExternal(ois);
return data;
} catch (IOException | ClassNotFoundException e) {
throw new RuntimeException(e);
}
};
@Override
public Encoder getValueEncoder() {
return encoder;
}
@Override
public Decoder<Object> getValueDecoder() {
return decoder;
}
}
@@ -0,0 +1,52 @@
package io.ebean.redisson.encode;
import io.ebeaninternal.server.cache.CachedManyIds;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.ByteBufInputStream;
import io.netty.buffer.ByteBufOutputStream;
import org.redisson.client.protocol.Decoder;
import org.redisson.client.protocol.Encoder;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
public class CachedManyIdsCodec extends CacheCodec {
private final Encoder encoder = in -> {
ByteBuf out = ByteBufAllocator.DEFAULT.buffer();
try (ByteBufOutputStream os = new ByteBufOutputStream(out);
ObjectOutputStream oos = new ObjectOutputStream(os)) {
((CachedManyIds) in).writeExternal(oos);
return os.buffer();
} catch (IOException e) {
out.release();
throw e;
} catch (Exception e) {
out.release();
throw new IOException(e);
}
};
private final Decoder<Object> decoder = (in, state) -> {
try (ByteBufInputStream is = new ByteBufInputStream(in);
ObjectInputStream ois = new ObjectInputStream(is)) {
CachedManyIds data = new CachedManyIds();
data.readExternal(ois);
return data;
} catch (IOException | ClassNotFoundException e) {
throw new RuntimeException(e);
}
};
@Override
public Encoder getValueEncoder() {
return encoder;
}
@Override
public Decoder<Object> getValueDecoder() {
return decoder;
}
}
@@ -0,0 +1,50 @@
package io.ebean.redisson.encode;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.ByteBufInputStream;
import io.netty.buffer.ByteBufOutputStream;
import org.redisson.client.protocol.Decoder;
import org.redisson.client.protocol.Encoder;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
public class SerializableCodec extends CacheCodec {
private final Encoder encoder = in -> {
ByteBuf out = ByteBufAllocator.DEFAULT.buffer();
try (ByteBufOutputStream os = new ByteBufOutputStream(out);
ObjectOutputStream oos = new ObjectOutputStream(os)) {
oos.writeObject(in);
oos.flush();
return os.buffer();
} catch (IOException e) {
out.release();
throw e;
} catch (Exception e) {
out.release();
throw new IOException(e);
}
};
private final Decoder<Object> decoder = (in, state) -> {
try (ByteBufInputStream is = new ByteBufInputStream(in);
ObjectInputStream ois = new ObjectInputStream(is)) {
return ois.readObject();
} catch (IOException | ClassNotFoundException e) {
throw new RuntimeException(e);
}
};
@Override
public Encoder getValueEncoder() {
return encoder;
}
@Override
public Decoder<Object> getValueDecoder() {
return decoder;
}
}
@@ -0,0 +1,82 @@
package io.ebean.redisson.encode;
import io.ebeaninternal.server.cache.CachedBeanData;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import org.redisson.client.codec.BaseCodec;
import org.redisson.client.protocol.Decoder;
import org.redisson.client.protocol.Encoder;
/**
* Wraps a bean codec and stores the entity {@code @Version} as a fixed big-endian prefix in front of the
* encoded value, behind a 2-byte magic {@link #MARKER}.
*/
public class VersionGatedCodec extends BaseCodec {
public static final byte[] MARKER = {(byte) 0xEB, (byte) 0x01};
public static final int VERSION_BYTES = 8;
public static final int PREFIX_BYTES = 2 + VERSION_BYTES;
private final CacheCodec delegate;
private final Encoder valueEncoder;
private final Decoder<Object> valueDecoder;
public VersionGatedCodec(CacheCodec delegate) {
this.delegate = delegate;
Encoder delegateEncoder = delegate.getValueEncoder();
Decoder<Object> delegateDecoder = delegate.getValueDecoder();
this.valueEncoder = in -> {
long version = (in instanceof CachedBeanData) ? ((CachedBeanData) in).getVersion() : 0L;
ByteBuf inner = delegateEncoder.encode(in);
try {
ByteBuf out = ByteBufAllocator.DEFAULT.buffer(PREFIX_BYTES + inner.readableBytes());
out.writeBytes(MARKER);
out.writeLong(version);
out.writeBytes(inner);
return out;
} finally {
inner.release();
}
};
this.valueDecoder = (buf, state) -> {
if (hasMarker(buf)) {
buf.skipBytes(PREFIX_BYTES);
}
return delegateDecoder.decode(buf, state);
};
}
private static boolean hasMarker(ByteBuf buf) {
int ri = buf.readerIndex();
if (buf.readableBytes() < PREFIX_BYTES) {
return false;
}
for (int i = 0; i < MARKER.length; i++) {
if (buf.getByte(ri + i) != MARKER[i]) {
return false;
}
}
return true;
}
@Override
public Encoder getValueEncoder() {
return valueEncoder;
}
@Override
public Decoder<Object> getValueDecoder() {
return valueDecoder;
}
@Override
public Encoder getMapKeyEncoder() {
return delegate.getMapKeyEncoder();
}
@Override
public Decoder<Object> getMapKeyDecoder() {
return delegate.getMapKeyDecoder();
}
}
@@ -0,0 +1,24 @@
package io.ebean.redisson.near;
import java.util.Set;
/**
* Near cache invalidation.
*/
public interface NearCacheInvalidate {
/**
* Invalidate from near cache the given keys.
*/
void invalidateKeys(Set<Object> keySet);
/**
* Invalidate from near cache the given key.
*/
void invalidateKey(Object id);
/**
* Clear the near cache.
*/
void invalidateClear();
}
@@ -0,0 +1,24 @@
package io.ebean.redisson.near;
import java.util.Set;
/**
* Notify other cluster members to invalidate parts of their near cache.
*/
public interface NearCacheNotify {
/**
* Invalidate the given keys.
*/
void invalidateKeys(String cacheKey, Set<Object> keySet);
/**
* Invalidate a single key.
*/
void invalidateKey(String cacheKey, Object id);
/**
* Clear a near cache.
*/
void invalidateClear(String cacheKey);
}
@@ -0,0 +1,13 @@
import io.ebean.cache.ServerCachePlugin;
/**
* Provider of ServerCachePlugin.
*/
open module io.ebean.redisson {
provides ServerCachePlugin with io.ebean.redisson.RedissonCachePlugin;
requires transitive io.ebean.core;
requires transitive redisson;
requires io.netty.buffer;
}
@@ -0,0 +1 @@
io.ebean.redisson.RedissonCachePlugin
@@ -0,0 +1,148 @@
package io.ebean.redisson;
import io.ebean.DatabaseBuilder;
import io.ebean.cache.ServerCache;
import io.ebean.cache.ServerCacheNotification;
import io.ebean.cache.ServerCacheNotify;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import org.redisson.api.RedissonClient;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertNotNull;
class RedissonCacheFactoryTest {
private static RedissonClient client;
private static RedissonCacheFactory factory;
@BeforeAll
static void connect() {
RedissonTestFixtures.startRedis();
assumeTrue(RedissonTestFixtures.isReachable(), "Skip: Redis not reachable");
client = RedissonTestFixtures.createClient();
DatabaseBuilder.Settings settings = RedissonTestFixtures.databaseSettings(client);
factory = new RedissonCacheFactory(settings, RedissonTestFixtures.backgroundExecutor());
}
@AfterAll
static void disconnect() {
if (client != null) client.shutdown();
}
@AfterEach
void clearCaches() {
// Individual tests clear their own caches inline
}
@Test
void createsNaturalKeyCache_roundTrip() {
String key = RedissonTestFixtures.cacheKey("factory-nk");
ServerCache cache = factory.createCache(RedissonTestFixtures.naturalKeyConfig(key));
assertThat(cache).isInstanceOf(RedissonCache.class);
cache.put("1", "one");
assertThat(cache.get("1")).isEqualTo("one");
assertThat(((RedissonCache) cache).getHitCount()).isEqualTo(1);
cache.clear();
}
@Test
void createsBeanCache_withVersionGating() {
String key = RedissonTestFixtures.cacheKey("factory-bean");
ServerCache cache = factory.createCache(RedissonTestFixtures.beanCacheConfig(key));
assertThat(cache).isInstanceOf(RedissonCache.class);
cache.clear();
}
@Test
void createsCollectionIdsCache() {
String key = RedissonTestFixtures.cacheKey("factory-coll");
ServerCache cache = factory.createCache(RedissonTestFixtures.collectionIdsConfig(key));
assertThat(cache).isInstanceOf(RedissonCache.class);
cache.clear();
}
@Test
void createsNearCache_asDuelCache() {
String key = RedissonTestFixtures.cacheKey("factory-near");
ServerCache cache = factory.createCache(RedissonTestFixtures.nearNaturalKeyConfig(key));
assertThat(cache).isInstanceOf(DuelCache.class);
cache.put("1", "near");
assertThat(cache.get("1")).isEqualTo("near");
cache.clear();
}
@Test
void createsNearBeanCache_asDuelCache_typeCheck() {
String key = RedissonTestFixtures.cacheKey("factory-near-bean");
ServerCache cache = factory.createCache(RedissonTestFixtures.nearBeanCacheConfig(key));
assertThat(cache).isInstanceOf(DuelCache.class);
cache.clear();
}
@Test
void queryCache_isSingletonPerKey() {
String key = RedissonTestFixtures.cacheKey("factory-query");
ServerCache first = factory.createCache(RedissonTestFixtures.queryCacheConfig(key));
ServerCache second = factory.createCache(RedissonTestFixtures.queryCacheConfig(key));
assertThat(first).isSameAs(second);
}
@Test
void queryCacheClear_doesNotThrow() {
String key = RedissonTestFixtures.cacheKey("factory-query-clear");
ServerCache cache = factory.createCache(RedissonTestFixtures.queryCacheConfig(key));
assertNotNull(cache);
cache.clear();
}
@Test
void cacheNotify_publishTableMod_doesNotThrow() {
ServerCacheNotify notify = factory.createCacheNotify(n -> {});
assertNotNull(notify);
notify.notify(new ServerCacheNotification(Set.of("tableA", "tableB")));
}
@Test
void cacheNotify_emptyTables_doesNotThrow() {
ServerCacheNotify notify = factory.createCacheNotify(n -> {});
assertNotNull(notify);
notify.notify(new ServerCacheNotification(Set.of()));
}
@Test
void cacheNotify_tableMod_notifiesOtherFactory() throws InterruptedException {
DatabaseBuilder.Settings otherSettings = RedissonTestFixtures.databaseSettings(client);
RedissonCacheFactory otherFactory = new RedissonCacheFactory(otherSettings, RedissonTestFixtures.backgroundExecutor());
CopyOnWriteArrayList<ServerCacheNotification> received = new CopyOnWriteArrayList<>();
otherFactory.createCacheNotify(received::add);
Thread.sleep(300);
ServerCacheNotify notify = factory.createCacheNotify(n -> {});
notify.notify(new ServerCacheNotification(Set.of("orders", "items")));
Thread.sleep(500);
assertThat(received).isNotEmpty();
assertThat(received.get(0).getDependentTables()).contains("orders", "items");
}
@Test
void usesInjectedRedissonClient() {
String key = RedissonTestFixtures.cacheKey("factory-inject");
ServerCache cache = factory.createCache(RedissonTestFixtures.naturalKeyConfig(key));
cache.put("ping", "pong");
assertThat(cache.get("ping")).isEqualTo("pong");
cache.clear();
}
}
@@ -0,0 +1,219 @@
package io.ebean.redisson;
import io.ebean.cache.ServerCacheStatistics;
import io.ebean.cache.ServerCacheType;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import org.redisson.api.RedissonClient;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertNotNull;
class RedissonCacheTest {
private static RedissonClient client;
private String cacheKey;
private RedissonCache cache;
@BeforeAll
static void connect() {
RedissonTestFixtures.startRedis();
assumeTrue(RedissonTestFixtures.isReachable(), "Skip: Redis not reachable");
client = RedissonTestFixtures.createClient();
}
@AfterAll
static void disconnect() {
if (client != null) client.shutdown();
}
void setUp(String suffix) {
cacheKey = RedissonTestFixtures.cacheKey(suffix);
cache = RedissonTestFixtures.naturalKeyCache(client, cacheKey);
}
@AfterEach
void clearCache() {
if (cache != null) cache.clear();
}
@Test
void putAndGet() {
setUp("putAndGet");
cache.put("1", "one");
assertThat(cache.get("1")).isEqualTo("one");
assertThat(cache.getHitCount()).isEqualTo(1);
assertThat(cache.getMissCount()).isZero();
}
@Test
void get_returnsNull_onMiss() {
setUp("getMiss");
assertThat(cache.get("nope")).isNull();
assertThat(cache.getMissCount()).isEqualTo(1);
assertThat(cache.getHitCount()).isZero();
}
@Test
void getAll_partialHit() {
setUp("getAll");
cache.put("1", "one");
cache.put("2", "two");
Map<Object, Object> found = cache.getAll(Set.of("1", "2", "3"));
assertThat(found).containsEntry("1", "one").containsEntry("2", "two").doesNotContainKey("3");
assertThat(cache.getHitCount()).isEqualTo(2);
assertThat(cache.getMissCount()).isEqualTo(1);
}
@Test
void getAll_emptyKeys_returnsEmptyMap() {
setUp("getAllEmpty");
assertThat(cache.getAll(Set.of())).isEmpty();
}
@Test
void getAll_allMiss_returnsEmptyMap() {
setUp("getAllMiss");
assertThat(cache.getAll(Set.of("x", "y"))).isEmpty();
assertThat(cache.getMissCount()).isEqualTo(2);
}
@Test
void getAll_resultKeys_areOriginalKeyObjects() {
setUp("getAllKeys");
cache.put("a", "A");
cache.put("b", "B");
Map<Object, Object> result = cache.getAll(Set.of("a", "b"));
assertThat(result.keySet()).containsExactlyInAnyOrder("a", "b");
}
@Test
void putAll() {
setUp("putAll");
Map<Object, Object> entries = new LinkedHashMap<>();
entries.put("x", "X");
entries.put("y", "Y");
cache.putAll(entries);
assertThat(cache.getAll(Set.of("x", "y")))
.containsEntry("x", "X")
.containsEntry("y", "Y");
}
@Test
void remove() {
setUp("remove");
cache.put("1", "one");
cache.remove("1");
assertThat(cache.get("1")).isNull();
}
@Test
void removeAll() {
setUp("removeAll");
cache.put("1", "one");
cache.put("2", "two");
cache.put("3", "three");
cache.removeAll(Set.of("1", "2"));
assertThat(cache.get("1")).isNull();
assertThat(cache.get("2")).isNull();
assertThat(cache.get("3")).isEqualTo("three");
}
@Test
void clear() {
setUp("clear");
cache.put("1", "one");
cache.put("2", "two");
cache.clear();
assertThat(cache.getAll(Set.of("1", "2"))).isEmpty();
}
@Test
void statistics_countsHitsMissesPutsRemoves() {
setUp("stats");
cache.put("1", "one");
cache.get("1"); // hit
cache.get("missing"); // miss
cache.remove("1");
ServerCacheStatistics stats = cache.statistics(true);
assertNotNull(stats);
assertThat(stats.getCacheName()).isEqualTo(cacheKey);
assertThat(stats.getHitCount()).isEqualTo(1);
assertThat(stats.getMissCount()).isEqualTo(1);
assertThat(stats.getPutCount()).isEqualTo(1);
assertThat(stats.getRemoveCount()).isEqualTo(1);
}
@Test
void statistics_reset_clearsCounters() {
setUp("statsReset");
cache.put("k", "v");
cache.get("k");
cache.statistics(true); // reset
ServerCacheStatistics after = cache.statistics(false);
assertNotNull(after);
assertThat(after.getHitCount()).isZero();
assertThat(after.getMissCount()).isZero();
}
@Test
void ttl_maxSecsToLive_entryStoredWithExpiry() throws InterruptedException {
String ttlKey = RedissonTestFixtures.cacheKey("ttl");
RedissonCache ttlCache = new RedissonCache(
client,
RedissonTestFixtures.cacheConfig(ServerCacheType.NATURAL_KEY, ttlKey,
RedissonTestFixtures.ttlOptions(2)),
new io.ebean.redisson.encode.SerializableCodec(), null, false);
try {
ttlCache.put("k", "v");
assertThat(ttlCache.get("k")).isEqualTo("v");
Thread.sleep(2500);
assertThat(ttlCache.get("k")).isNull(); // expired
} finally {
ttlCache.clear();
}
}
@Test
void trimCache_removesExcessEntries() {
String sizeKey = RedissonTestFixtures.cacheKey("trim");
RedissonCache sizedCache = new RedissonCache(
client,
RedissonTestFixtures.cacheConfig(ServerCacheType.NATURAL_KEY, sizeKey,
RedissonTestFixtures.maxSizeOptions(3)),
new io.ebean.redisson.encode.SerializableCodec(), null, false);
try {
for (int i = 0; i < 10; i++) {
sizedCache.put("k" + i, "v" + i);
}
sizedCache.trimCache();
// After trim the hash should be at or below maxSize
long remaining = 0;
for (int i = 0; i < 10; i++) {
if (sizedCache.get("k" + i) != null) remaining++;
}
assertThat(remaining).isLessThanOrEqualTo(3);
} finally {
sizedCache.clear();
}
}
}
@@ -0,0 +1,163 @@
package io.ebean.redisson;
import io.ebean.BackgroundExecutor;
import io.ebean.Database;
import io.ebean.DatabaseBuilder;
import io.ebean.cache.ServerCacheConfig;
import io.ebean.cache.ServerCacheOptions;
import io.ebean.cache.ServerCacheType;
import io.ebean.redisson.encode.SerializableCodec;
import io.ebean.test.containers.RedisContainer;
import org.jspecify.annotations.NonNull;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import java.io.InputStream;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
final class RedissonTestFixtures {
private RedissonTestFixtures() {}
static String cacheKey(String suffix) {
return "ebean-redisson-it:" + suffix + ":" + System.nanoTime();
}
static ServerCacheOptions defaultOptions() {
return new ServerCacheOptions();
}
static ServerCacheOptions ttlOptions(int secsToLive) {
ServerCacheOptions opts = new ServerCacheOptions();
opts.setMaxSecsToLive(secsToLive);
return opts;
}
static ServerCacheOptions idleOptions(int maxIdleSecs) {
ServerCacheOptions opts = new ServerCacheOptions();
opts.setMaxIdleSecs(maxIdleSecs);
return opts;
}
static ServerCacheOptions maxSizeOptions(int maxSize) {
ServerCacheOptions opts = new ServerCacheOptions();
opts.setMaxSize(maxSize);
return opts;
}
static ServerCacheConfig cacheConfig(ServerCacheType type, String cacheKey, ServerCacheOptions options) {
return new ServerCacheConfig(type, cacheKey, "testCache", options, null, null);
}
static ServerCacheConfig naturalKeyConfig(String cacheKey) {
return cacheConfig(ServerCacheType.NATURAL_KEY, cacheKey, defaultOptions());
}
static ServerCacheConfig beanCacheConfig(String cacheKey) {
return cacheConfig(ServerCacheType.BEAN, cacheKey, defaultOptions());
}
static ServerCacheConfig collectionIdsConfig(String cacheKey) {
return cacheConfig(ServerCacheType.COLLECTION_IDS, cacheKey, defaultOptions());
}
static ServerCacheConfig queryCacheConfig(String cacheKey) {
return cacheConfig(ServerCacheType.QUERY, cacheKey, defaultOptions());
}
static ServerCacheConfig nearNaturalKeyConfig(String cacheKey) {
ServerCacheOptions opts = defaultOptions();
opts.setNearCache(true);
return cacheConfig(ServerCacheType.NATURAL_KEY, cacheKey, opts);
}
static ServerCacheConfig nearBeanCacheConfig(String cacheKey) {
ServerCacheOptions opts = defaultOptions();
opts.setNearCache(true);
return cacheConfig(ServerCacheType.BEAN, cacheKey, opts);
}
static RedissonCache naturalKeyCache(RedissonClient client, String cacheKey) {
return new RedissonCache(client, naturalKeyConfig(cacheKey), new SerializableCodec(), null, false);
}
static RedissonCache beanCache(RedissonClient client, String cacheKey) {
return new RedissonCache(client, beanCacheConfig(cacheKey), new SerializableCodec(), null, false);
}
static DatabaseBuilder.Settings databaseSettings(RedissonClient client) {
return Database.builder()
.name("redisson-factory-test")
.putServiceObject(client)
.settings();
}
/**
* Starts the Redis test container if it is not already running.
* Idempotent: safe to call from multiple test classes; the container
* library detects an already-running instance and skips startup.
*/
static void startRedis() {
RedisContainer.builder("latest").start();
}
/**
* Returns true when Redis is reachable on the configured address.
* Uses a 500ms / zero-retry probe so CI skips fast instead of waiting
* through the full connectTimeout + retryAttempts in redisson-config.yaml.
*/
static boolean isReachable() {
try {
Config probe = loadConfig();
probe.useSingleServer()
.setConnectTimeout(500)
.setTimeout(500)
.setRetryAttempts(0)
.setConnectionMinimumIdleSize(1)
.setConnectionPoolSize(1);
RedissonClient c = Redisson.create(probe);
c.shutdown();
return true;
} catch (Exception e) {
return false;
}
}
static RedissonClient createClient() {
return Redisson.create(loadConfig());
}
private static Config loadConfig() {
InputStream is = RedissonTestFixtures.class.getClassLoader()
.getResourceAsStream("redisson-config.yaml");
if (is != null) {
return Config.fromYAML(is);
}
Config cfg = new Config();
cfg.useSingleServer().setAddress("redis://localhost:6379");
return cfg;
}
static BackgroundExecutor backgroundExecutor() {
ScheduledExecutorService ex = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "ebean-redisson-test-bg");
t.setDaemon(true);
return t;
});
return new BackgroundExecutor() {
@Override public <T> Future<T> submit(Callable<T> task) { return ex.submit(task); }
@Override public Future<?> submit(Runnable task) { return ex.submit(task); }
@Override public void execute(Runnable task) { ex.execute(task); }
@Override public ScheduledFuture<?> scheduleWithFixedDelay(@NonNull Runnable t, long i, long d, @NonNull TimeUnit u) { return ex.scheduleWithFixedDelay(t, i, d, u); }
@Override public ScheduledFuture<?> scheduleAtFixedRate(@NonNull Runnable t, long i, long p, @NonNull TimeUnit u) { return ex.scheduleAtFixedRate(t, i, p, u); }
@Override public ScheduledFuture<?> schedule(@NonNull Runnable t, long d, @NonNull TimeUnit u) { return ex.schedule(t, d, u); }
@Override public <V> ScheduledFuture<V> schedule(@NonNull Callable<V> t, long d, @NonNull TimeUnit u) { return ex.schedule(t, d, u); }
};
}
}
@@ -0,0 +1,92 @@
package io.ebean.redisson.encode;
import io.ebean.cache.TenantAwareKey;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import org.junit.jupiter.api.Test;
import org.redisson.client.protocol.Decoder;
import org.redisson.client.protocol.Encoder;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link CacheCodec} key encoder/decoder.
*
* Regression guard for the decoder bug where "id:tenantId" was decoded to "tenantId"
* (only the part after the first colon), causing every tenant-aware getAll() lookup to miss.
*/
class CacheCodecTest {
// Use SerializableCodec as a concrete CacheCodec (only the key codec matters here)
private final CacheCodec codec = new SerializableCodec();
private final Encoder keyEncoder = codec.getMapKeyEncoder();
private final Decoder<Object> keyDecoder = codec.getMapKeyDecoder();
// ── encoder ──────────────────────────────────────────────────────────────
@Test
void encoder_plainStringKey() throws Exception {
ByteBuf buf = keyEncoder.encode("42");
byte[] bytes = new byte[buf.readableBytes()];
buf.readBytes(bytes);
assertThat(new String(bytes, StandardCharsets.UTF_8)).isEqualTo("42");
buf.release();
}
@Test
void encoder_tenantAwareCacheKey() throws Exception {
TenantAwareKey.CacheKey key = new TenantAwareKey.CacheKey(123L, "tenantA");
ByteBuf buf = keyEncoder.encode(key);
byte[] bytes = new byte[buf.readableBytes()];
buf.readBytes(bytes);
// CacheKey.toString() = "123:tenantA"
assertThat(new String(bytes, StandardCharsets.UTF_8)).isEqualTo("123:tenantA");
buf.release();
}
// ── decoder regression guard ───────────────────────────────────────────
@Test
void decoder_plainKey_returnsFullString() throws Exception {
ByteBuf buf = Unpooled.wrappedBuffer("42".getBytes(StandardCharsets.UTF_8));
Object decoded = keyDecoder.decode(buf, null);
assertThat(decoded).isEqualTo("42");
}
@Test
void decoder_keyContainingColon_returnsFullString() throws Exception {
// REGRESSION: old decoder did substring(pos+1) which turned "123:tenantA" -> "tenantA"
ByteBuf buf = Unpooled.wrappedBuffer("123:tenantA".getBytes(StandardCharsets.UTF_8));
Object decoded = keyDecoder.decode(buf, null);
assertThat(decoded).isEqualTo("123:tenantA"); // must NOT be just "tenantA"
}
@Test
void decoder_keyWithMultipleColons_returnsFullString() throws Exception {
// E.g. UUID-style key with colon in tenantId
ByteBuf buf = Unpooled.wrappedBuffer("key:ten:ant".getBytes(StandardCharsets.UTF_8));
Object decoded = keyDecoder.decode(buf, null);
assertThat(decoded).isEqualTo("key:ten:ant");
}
// ── round-trip ───────────────────────────────────────────────────────────
@Test
void roundTrip_plainString() throws Exception {
String original = "99";
ByteBuf encoded = keyEncoder.encode(original);
Object decoded = keyDecoder.decode(encoded, null);
assertThat(decoded).isEqualTo(original);
}
@Test
void roundTrip_tenantKey() throws Exception {
TenantAwareKey.CacheKey key = new TenantAwareKey.CacheKey(7L, "tenant42");
ByteBuf encoded = keyEncoder.encode(key);
Object decoded = keyDecoder.decode(encoded, null);
// The decoded value is the string representation used as the Redis field name
assertThat(decoded).isEqualTo("7:tenant42");
}
}
@@ -0,0 +1,50 @@
package io.ebean.redisson.encode;
import io.netty.buffer.ByteBuf;
import org.junit.jupiter.api.Test;
import org.redisson.client.protocol.Decoder;
import org.redisson.client.protocol.Encoder;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link SerializableCodec} value encoder/decoder.
* Mirrors ebean-redis {@code EncodeSerializableTest}.
*/
class SerializableCodecTest {
private final SerializableCodec codec = new SerializableCodec();
private final Encoder encoder = codec.getValueEncoder();
private final Decoder<Object> decoder = codec.getValueDecoder();
@Test
void roundTrip_string() throws Exception {
ByteBuf buf = encoder.encode("HelloWorld");
Object result = decoder.decode(buf, null);
assertThat(result).isEqualTo("HelloWorld");
}
@Test
void roundTrip_long() throws Exception {
ByteBuf buf = encoder.encode(42L);
Object result = decoder.decode(buf, null);
assertThat(result).isEqualTo(42L);
}
@Test
void roundTrip_list() throws Exception {
List<String> original = List.of("a", "b", "c");
ByteBuf buf = encoder.encode(original);
Object result = decoder.decode(buf, null);
assertThat(result).isEqualTo(original);
}
@Test
void roundTrip_null() throws Exception {
ByteBuf buf = encoder.encode(null);
Object result = decoder.decode(buf, null);
assertThat(result).isNull();
}
}
@@ -0,0 +1,129 @@
package io.ebean.redisson.encode;
import io.ebeaninternal.server.cache.CachedBeanData;
import io.netty.buffer.ByteBuf;
import org.junit.jupiter.api.Test;
import org.redisson.client.protocol.Decoder;
import org.redisson.client.protocol.Encoder;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link VersionGatedCodec}.
*
* Verifies that:
* <ul>
* <li>The encoder prepends the magic MARKER + 8-byte big-endian version</li>
* <li>The decoder strips the prefix before delegating to the inner codec</li>
* <li>The decoder tolerates absent marker (backward compat)</li>
* <li>The key encoder/decoder are delegated to the inner codec unchanged</li>
* </ul>
*/
class VersionGatedCodecTest {
private final CachedBeanDataCodec inner = new CachedBeanDataCodec();
private final VersionGatedCodec codec = new VersionGatedCodec(inner);
private final Encoder valueEncoder = codec.getValueEncoder();
private final Decoder<Object> valueDecoder = codec.getValueDecoder();
// ── marker structure ─────────────────────────────────────────────────────
@Test
void encoder_prependsMarkerAndVersion() throws Exception {
CachedBeanData data = beanData(5L);
ByteBuf encoded = valueEncoder.encode(data);
// First 2 bytes must be the magic marker
byte b0 = encoded.getByte(0);
byte b1 = encoded.getByte(1);
assertThat(b0).isEqualTo(VersionGatedCodec.MARKER[0]);
assertThat(b1).isEqualTo(VersionGatedCodec.MARKER[1]);
// Next 8 bytes are the version (big-endian long = 5)
long version = encoded.getLong(2);
assertThat(version).isEqualTo(5L);
// Total length > PREFIX_BYTES
assertThat(encoded.readableBytes()).isGreaterThan(VersionGatedCodec.PREFIX_BYTES);
encoded.release();
}
@Test
void encoder_zeroVersion_whenNoCachedBeanData() throws Exception {
// Non-CachedBeanData value → version treated as 0
CachedBeanDataCodec codec2 = new CachedBeanDataCodec();
VersionGatedCodec gated = new VersionGatedCodec(codec2);
CachedBeanData data = beanData(0L);
ByteBuf encoded = gated.getValueEncoder().encode(data);
long version = encoded.getLong(2);
assertThat(version).isEqualTo(0L);
encoded.release();
}
// ── round-trip ───────────────────────────────────────────────────────────
@Test
void roundTrip_versionedBeanData() throws Exception {
CachedBeanData original = beanData(3L);
ByteBuf encoded = valueEncoder.encode(original);
Object decoded = valueDecoder.decode(encoded, null);
assertThat(decoded).isInstanceOf(CachedBeanData.class);
CachedBeanData result = (CachedBeanData) decoded;
assertThat(result.getVersion()).isEqualTo(3L);
}
@Test
void roundTrip_zeroVersion() throws Exception {
CachedBeanData original = beanData(0L);
ByteBuf encoded = valueEncoder.encode(original);
Object decoded = valueDecoder.decode(encoded, null);
assertThat(decoded).isInstanceOf(CachedBeanData.class);
assertThat(((CachedBeanData) decoded).getVersion()).isEqualTo(0L);
}
@Test
void decoder_toleratesAbsentMarker() throws Exception {
// Data without the marker prefix (simulates data stored before VersionGatedCodec was added)
CachedBeanData original = beanData(1L);
ByteBuf rawEncoded = inner.getValueEncoder().encode(original);
// Decoder must NOT throw and must still return a valid CachedBeanData
Object decoded = valueDecoder.decode(rawEncoded, null);
assertThat(decoded).isInstanceOf(CachedBeanData.class);
}
// ── key codec delegation ─────────────────────────────────────────────────
@Test
void keyEncoder_delegatesToInner() throws Exception {
// VersionGatedCodec must delegate key encoding to the inner codec
ByteBuf fromGated = codec.getMapKeyEncoder().encode("myKey");
ByteBuf fromInner = inner.getMapKeyEncoder().encode("myKey");
byte[] gatedBytes = new byte[fromGated.readableBytes()];
fromGated.readBytes(gatedBytes);
byte[] innerBytes = new byte[fromInner.readableBytes()];
fromInner.readBytes(innerBytes);
assertThat(gatedBytes).isEqualTo(innerBytes);
fromGated.release();
fromInner.release();
}
@Test
void keyDecoder_delegatesToInner() throws Exception {
ByteBuf buf = codec.getMapKeyEncoder().encode("someKey");
Object decoded = codec.getMapKeyDecoder().decode(buf, null);
assertThat(decoded).isEqualTo("someKey");
}
// ── helper ───────────────────────────────────────────────────────────────
private static CachedBeanData beanData(long version) {
return new CachedBeanData(null, null, java.util.Collections.emptyMap(), version);
}
}
@@ -0,0 +1,58 @@
package org.domain;
import io.ebean.Model;
import io.ebean.annotation.WhenCreated;
import io.ebean.annotation.WhenModified;
import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.Version;
import java.time.Instant;
@MappedSuperclass
public class EBase extends Model {
@Id
protected long id;
@Version
protected long version;
@WhenCreated
protected Instant whenCreated;
@WhenModified
protected Instant whenModified;
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public long getVersion() {
return version;
}
public void setVersion(long version) {
this.version = version;
}
public Instant getWhenCreated() {
return whenCreated;
}
public void setWhenCreated(Instant whenCreated) {
this.whenCreated = whenCreated;
}
public Instant getWhenModified() {
return whenModified;
}
public void setWhenModified(Instant whenModified) {
this.whenModified = whenModified;
}
}
@@ -0,0 +1,40 @@
package org.domain;
import io.ebean.annotation.Cache;
import jakarta.persistence.Entity;
/**
* Using Natural Key caching but no Near Caching so always hitting Redis.
*/
@SuppressWarnings("unused")
@Cache(naturalKey = {"one", "two"})
@Entity
public class OtherOne extends EBase {
private final String one;
private final String two;
private String notes;
public OtherOne(String one, String two, String notes) {
this.one = one;
this.two = two;
this.notes = notes;
}
public String one() {
return one;
}
public String two() {
return two;
}
public String notes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
}
@@ -0,0 +1,76 @@
package org.domain;
import io.ebean.annotation.Cache;
import io.ebean.annotation.CacheBeanTuning;
import io.ebean.annotation.Index;
import jakarta.persistence.Entity;
import java.time.LocalDate;
@Cache(enableQueryCache = true, nearCache = true, naturalKey = "name")
@CacheBeanTuning(maxSecsToLive = 1)
@Entity
public class Person extends EBase {
public enum Status {
NEW,
ACTIVE,
INACTIVE
}
@Index(unique = true)
String name;
Status status;
LocalDate localDate;
String notes;
/**
* Test that KEY and VALUE are now by default not h2database keywords.
*/
String key;
public Person(String name) {
this.name = name;
this.status = Status.NEW;
}
public String toString() {
return "[id:" + id + " name:" + name + "date:" + localDate + ']';
}
public Status getStatus() {
return status;
}
public void setStatus(Status status) {
this.status = status;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
public LocalDate getLocalDate() {
return localDate;
}
public void setLocalDate(LocalDate localDate) {
this.localDate = localDate;
}
}
@@ -0,0 +1,27 @@
package org.domain;
import io.ebean.annotation.Cache;
import io.ebean.annotation.Index;
import jakarta.persistence.Entity;
@Cache(naturalKey = "name")
@Entity
public class RCust extends EBase {
@Index(unique = true)
String name;
public RCust(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
@@ -0,0 +1,38 @@
package org.domain;
import io.ebean.Model;
import io.ebean.annotation.Cache;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
@Cache
@Entity
public class UChild extends Model {
@Id
long id;
String name;
@ManyToOne
final UParent parent;
public UChild(UParent parent, String name) {
this.parent = parent;
this.name = name;
}
public long id() {
return id;
}
public String name() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

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