mirror of
https://github.com/ebean-orm/ebean.git
synced 2026-09-24 19:30:06 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99c3d8b07b | ||
|
|
9947a41a9b | ||
|
|
055a3bf638 | ||
|
|
f6ff485041 | ||
|
|
b67e28e60a | ||
|
|
791ac13750 | ||
|
|
d54a24b27d | ||
|
|
73c9ff8d80 | ||
|
|
bdbe743f56 | ||
|
|
ed2026b858 | ||
|
|
8d21f91f62 | ||
|
|
6d1d58b8c2 | ||
|
|
a943ee9225 | ||
|
|
94f252f423 | ||
|
|
275f8ad9f9 | ||
|
|
9ac3bd71f6 | ||
|
|
bb46c88db1 | ||
|
|
28e6108315 | ||
|
|
c20d1cdf9b | ||
|
|
007409d21c | ||
|
|
8b61069b3f | ||
|
|
a82dcb2509 | ||
|
|
08bb170cb2 | ||
|
|
a2f954a60e | ||
|
|
1d654e350b | ||
|
|
6dd1763e54 | ||
|
|
b32f3bcad6 |
@@ -14,7 +14,7 @@
|
||||
|
||||
<properties>
|
||||
<postgis.jdbc.version>2023.1.0</postgis.jdbc.version>
|
||||
<postgres.jdbc.version>42.7.2</postgres.jdbc.version>
|
||||
<postgres.jdbc.version>42.7.11</postgres.jdbc.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
|
||||
@@ -457,6 +457,10 @@ public final class DB {
|
||||
|
||||
/**
|
||||
* Same as {@link #checkUniqueness(Object)} but with given transaction.
|
||||
* <p>
|
||||
* For control over query cache use and whether to skip the check when the bean's unique
|
||||
* properties are unchanged, use {@link Database#checkUniqueness(Object, Transaction, boolean, boolean)}
|
||||
* via {@link #getDefault()} instead.
|
||||
*/
|
||||
public static Set<Property> checkUniqueness(Object bean, Transaction transaction) {
|
||||
return getDefault().checkUniqueness(bean, transaction);
|
||||
|
||||
@@ -1078,12 +1078,21 @@ public interface Database {
|
||||
* @param bean The entity bean to check uniqueness on
|
||||
* @return a set of Properties if constraint validation was detected or empty list.
|
||||
*/
|
||||
Set<Property> checkUniqueness(Object bean);
|
||||
default Set<Property> checkUniqueness(Object bean) {
|
||||
return checkUniqueness(bean, null, false, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link #checkUniqueness(Object)}. but with given transaction.
|
||||
*/
|
||||
Set<Property> checkUniqueness(Object bean, Transaction transaction);
|
||||
default Set<Property> checkUniqueness(Object bean, Transaction transaction) {
|
||||
return checkUniqueness(bean, transaction, false, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link #checkUniqueness(Object)}. but with given transaction and extended search options.
|
||||
*/
|
||||
Set<Property> checkUniqueness(Object bean, Transaction transaction, boolean useQueryCache, boolean skipClean);
|
||||
|
||||
/**
|
||||
* Marks the entity bean as dirty.
|
||||
|
||||
@@ -62,9 +62,10 @@ public interface DatabaseBuilder {
|
||||
/**
|
||||
* Build and return the Database instance.
|
||||
* <p>
|
||||
* When {@link #setRegister(boolean)} is set to true, and a database with the same
|
||||
* name is already registered, this may return the existing registered database
|
||||
* rather than creating a new one.
|
||||
* When {@link #setRegister(boolean)} is set to true (the default), and a database
|
||||
* with the same name is already registered, this throws an {@link IllegalStateException}.
|
||||
* Use a unique name, or use {@link #setRegister(boolean)} with {@code false} if the
|
||||
* Database instance is not intended to be registered/looked up by name.
|
||||
*/
|
||||
Database build();
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import jakarta.persistence.PersistenceException;
|
||||
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import static java.lang.System.Logger.Level.WARNING;
|
||||
|
||||
/**
|
||||
* Low-level factory for creating {@link Database} instances.
|
||||
* <p>
|
||||
@@ -81,10 +79,10 @@ public final class DatabaseFactory {
|
||||
// We're explicitly creating a database to be registered, so avoid
|
||||
// triggering DbContext static initialisation to auto-create a default one.
|
||||
DbPrimary.setSkip(true);
|
||||
Database existing = DbContext.getInstance().getRegistered(name);
|
||||
if (existing != null) {
|
||||
EbeanVersion.log.log(WARNING, "Using existing database with name:{0}", name);
|
||||
return existing;
|
||||
if (DbContext.getInstance().contains(name)) {
|
||||
throw new IllegalStateException("A Database with name [" + name + "] is already registered."
|
||||
+ " Use a unique DatabaseConfig name, or set DatabaseConfig.setRegister(false)"
|
||||
+ " if this Database instance is not intended to be registered/looked up by name.");
|
||||
}
|
||||
}
|
||||
Database server = createInternal(config);
|
||||
@@ -123,6 +121,24 @@ public final class DatabaseFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the registration of this Database.
|
||||
* <p>
|
||||
* This is invoked when a Database is shutdown so that its registered name
|
||||
* becomes available again for a subsequently created Database with the same name.
|
||||
*/
|
||||
public static void unregister(Database server) {
|
||||
lock.lock();
|
||||
try {
|
||||
DbContext.getInstance().deregister(server);
|
||||
if (server.name().equals(defaultServerName)) {
|
||||
defaultServerName = null;
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown gracefully all Database instances cleaning up any resources as required.
|
||||
* <p>
|
||||
|
||||
@@ -4,7 +4,6 @@ import io.ebean.config.BeanNotEnhancedException;
|
||||
import io.ebean.datasource.DataSourceConfigurationException;
|
||||
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
@@ -77,9 +76,8 @@ final class DbContext {
|
||||
return defaultDatabase;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
Database getRegistered(String name) {
|
||||
return concMap.get(name);
|
||||
boolean contains(String name) {
|
||||
return concMap.containsKey(name);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,6 +120,27 @@ final class DbContext {
|
||||
registerWithName(server.name(), server, isDefault);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the registration for this Database (typically on shutdown) so that
|
||||
* its name becomes available again for a subsequently created Database.
|
||||
* <p>
|
||||
* Only removes the registration if it currently maps to this exact instance
|
||||
* (avoids removing a different Database subsequently registered with the same name).
|
||||
*/
|
||||
void deregister(Database server) {
|
||||
lock.lock();
|
||||
try {
|
||||
String name = server.name();
|
||||
concMap.remove(name, server);
|
||||
syncMap.remove(name, server);
|
||||
if (defaultDatabase == server) {
|
||||
defaultDatabase = null;
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void registerWithName(String name, Database server, boolean isDefault) {
|
||||
lock.lock();
|
||||
try {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.2</version>
|
||||
<version>42.7.11</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.2</version>
|
||||
<version>42.7.11</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-6
@@ -372,14 +372,15 @@ abstract class BeanDescriptorCacheHelp<T> {
|
||||
* Hit the bean cache with the given ids returning the hits.
|
||||
*/
|
||||
BeanCacheResult<T> cacheIdLookup(PersistenceContext context, boolean unmodifiable, Collection<?> ids) {
|
||||
Set<Object> keys = new HashSet<>(ids.size());
|
||||
for (Object id : ids) {
|
||||
keys.add(desc.cacheKey(id));
|
||||
}
|
||||
if (ids.isEmpty()) {
|
||||
return new BeanCacheResult<>();
|
||||
}
|
||||
Map<Object, Object> beanDataMap = beanCache().getAll(keys);
|
||||
// map cacheKey -> original id to support type coercion
|
||||
Map<Object, Object> keyToOriginalId = new HashMap<>(ids.size());
|
||||
for (Object id : ids) {
|
||||
keyToOriginalId.put(desc.cacheKey(id), id);
|
||||
}
|
||||
Map<Object, Object> beanDataMap = beanCache().getAll(keyToOriginalId.keySet());
|
||||
if (beanLog.isLoggable(TRACE)) {
|
||||
beanLog.log(TRACE, " MGET {0}({1}) - hits:{2}", cacheName, ids, beanDataMap.keySet());
|
||||
}
|
||||
@@ -387,7 +388,8 @@ abstract class BeanDescriptorCacheHelp<T> {
|
||||
for (Map.Entry<Object, Object> entry : beanDataMap.entrySet()) {
|
||||
CachedBeanData cachedBeanData = (CachedBeanData) entry.getValue();
|
||||
T bean = convertToBean(entry.getKey(), unmodifiable, context, cachedBeanData);
|
||||
result.add(bean, desc.id(bean));
|
||||
Object originalId = keyToOriginalId.get(entry.getKey());
|
||||
result.add(bean, originalId != null ? originalId : desc.id(bean));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -907,8 +907,18 @@ public final class BeanDescriptorManager implements BeanDescriptorMap, SpiBeanTy
|
||||
}
|
||||
|
||||
private void makeOrderColumn(DeployBeanPropertyAssocMany<?> oneToMany) {
|
||||
DeployBeanDescriptor<?> targetDesc = targetDescriptor(oneToMany);
|
||||
DeployOrderColumn orderColumn = oneToMany.getOrderColumn();
|
||||
makeOrderColumn(oneToMany, targetDescriptor(oneToMany));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and assign the synthetic order column property onto the given target descriptor.
|
||||
* <p>
|
||||
* Used for both {@code @OneToMany} (targetDesc looked up via the target entity type) and
|
||||
* {@code @ElementCollection} (targetDesc is the synthetic element descriptor which is not
|
||||
* registered in {@code deployInfoMap} and so must be passed in directly).
|
||||
*/
|
||||
public void makeOrderColumn(DeployBeanPropertyAssocMany<?> many, DeployBeanDescriptor<?> targetDesc) {
|
||||
DeployOrderColumn orderColumn = many.getOrderColumn();
|
||||
final ScalarType<?> scalarType = typeManager.type(Integer.class);
|
||||
DeployBeanProperty orderProperty = new DeployBeanProperty(targetDesc, Integer.class, scalarType, null);
|
||||
orderProperty.setName(DeployOrderColumn.LOGICAL_NAME);
|
||||
|
||||
@@ -671,6 +671,35 @@ public class BeanProperty implements ElPropertyValue, Property, STreeProperty {
|
||||
setValue(entityBean, tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* By default, getIntercept and setIntercept will check if the passed bean is an instance of the descriptor type.
|
||||
* <p>
|
||||
* If the property is not part of the type hierarchy (i.e. is not this property from this descriptor) an
|
||||
* IllegalArgumentException is thrown.
|
||||
* <p>
|
||||
* If inheritance is involved, this method returns false instead of throwing an exception, if the property might
|
||||
* exist on one of the sibling child beans. This is necessary for getIntercept, as it returns <code>null</code>
|
||||
* in this case.
|
||||
*
|
||||
* @return true if the property can be accessed on the given bean, false if it should be treated as unloaded.
|
||||
*/
|
||||
private boolean checkPropertyAccess(EntityBean bean) {
|
||||
if (bean == null || descriptor.type().isInstance(bean)) { // null = fall through - NPE is caught later.
|
||||
return true;
|
||||
}
|
||||
InheritInfo inheritInfo = descriptor.inheritInfo();
|
||||
if (inheritInfo == null || inheritInfo.isRoot() || !inheritInfo.getRoot().getType().isInstance(bean)) {
|
||||
throw new IllegalArgumentException(propertyIncompatibleMsg(bean));
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String propertyIncompatibleMsg(EntityBean bean) {
|
||||
String beanType = bean == null ? "null" : bean.getClass().getName();
|
||||
return "Property " + name + " on [" + descriptor + "] is incompatible with type[" + beanType + "]";
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of the property without interception or
|
||||
* PropertyChangeSupport.
|
||||
@@ -687,6 +716,9 @@ public class BeanProperty implements ElPropertyValue, Property, STreeProperty {
|
||||
* Set the value of the property.
|
||||
*/
|
||||
public void setValueIntercept(EntityBean bean, Object value) {
|
||||
if (!checkPropertyAccess(bean)) {
|
||||
throw new IllegalArgumentException(propertyIncompatibleMsg(bean));
|
||||
}
|
||||
try {
|
||||
setter.setIntercept(bean, value);
|
||||
} catch (Exception ex) {
|
||||
@@ -798,6 +830,9 @@ public class BeanProperty implements ElPropertyValue, Property, STreeProperty {
|
||||
}
|
||||
|
||||
public Object getValueIntercept(EntityBean bean) {
|
||||
if (!checkPropertyAccess(bean)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return getter.getIntercept(bean);
|
||||
} catch (Exception ex) {
|
||||
|
||||
@@ -57,6 +57,12 @@ public class BeanPropertyAssocMany<T> extends BeanPropertyAssoc<T> implements ST
|
||||
* Flag to indicate that the target has a order column to auto populate.
|
||||
*/
|
||||
private final boolean hasOrderColumn;
|
||||
/**
|
||||
* For ManyToMany, the db column name of the order column stored on the intersection
|
||||
* table (null for OneToMany/ElementCollection which use a target descriptor property instead).
|
||||
*/
|
||||
private final String intersectionOrderColumn;
|
||||
private final boolean intersectionOrderColumnNullable;
|
||||
/**
|
||||
* Flag to indicate manyToMany relationship.
|
||||
*/
|
||||
@@ -95,6 +101,8 @@ public class BeanPropertyAssocMany<T> extends BeanPropertyAssoc<T> implements ST
|
||||
this.o2mJoinTable = deploy.isO2mJoinTable();
|
||||
this.hasOrderColumn = deploy.hasOrderColumn();
|
||||
this.manyToMany = deploy.isManyToMany();
|
||||
this.intersectionOrderColumn = (manyToMany && hasOrderColumn) ? deploy.getOrderColumn().getName() : null;
|
||||
this.intersectionOrderColumnNullable = (manyToMany && hasOrderColumn) && deploy.getOrderColumn().isNullable();
|
||||
this.elementCollection = deploy.isElementCollection();
|
||||
this.elementDescriptor = deploy.getElementDescriptor();
|
||||
this.manyType = deploy.getManyType();
|
||||
@@ -154,6 +162,9 @@ public class BeanPropertyAssocMany<T> extends BeanPropertyAssoc<T> implements ST
|
||||
embeddedExportedProperties = exportedProperties[0].isEmbedded();
|
||||
if (fetchOrderBy != null) {
|
||||
lazyFetchOrderBy = sqlHelp.lazyFetchOrderBy(fetchOrderBy);
|
||||
} else if (intersectionOrderColumn != null) {
|
||||
// ManyToMany @OrderColumn - the intersection table is always aliased "int_"
|
||||
lazyFetchOrderBy = sqlHelp.lazyFetchOrderBy("int_." + intersectionOrderColumn);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,6 +522,29 @@ public class BeanPropertyAssocMany<T> extends BeanPropertyAssoc<T> implements ST
|
||||
return hasOrderColumn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if this is a ManyToMany with an order column stored on the intersection table.
|
||||
*/
|
||||
@Override
|
||||
public boolean hasIntersectionOrderColumn() {
|
||||
return intersectionOrderColumn != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the db column name of the ManyToMany intersection table order column (or null).
|
||||
*/
|
||||
@Override
|
||||
public String intersectionOrderColumn() {
|
||||
return intersectionOrderColumn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the intersection table order column is nullable.
|
||||
*/
|
||||
public boolean isIntersectionOrderColumnNullable() {
|
||||
return intersectionOrderColumnNullable;
|
||||
}
|
||||
|
||||
public boolean isOrphanRemoval() {
|
||||
return orphanRemoval;
|
||||
}
|
||||
|
||||
@@ -181,4 +181,11 @@ public interface DbSqlContext {
|
||||
* Include the filter many predicates if specified into the JOIN clause.
|
||||
*/
|
||||
void includeFilterMany();
|
||||
|
||||
/**
|
||||
* Return true if the given fetch path (relative to the query root) is the exact join clause
|
||||
* that the pending filterMany predicate must be attached to - i.e. the deepest path the
|
||||
* filterMany expression itself references.
|
||||
*/
|
||||
boolean isFilterManyAttachPoint(String prefix);
|
||||
}
|
||||
|
||||
@@ -234,6 +234,10 @@ public final class IdBinderSimple implements IdBinder {
|
||||
@Override
|
||||
public Object convertId(Object idValue) {
|
||||
if (!idValue.getClass().equals(expectedType)) {
|
||||
if (idValue instanceof String) {
|
||||
// for cacheKey() formatted values
|
||||
return scalarType.parse((String) idValue);
|
||||
}
|
||||
return scalarType.toBeanType(idValue);
|
||||
}
|
||||
return idValue;
|
||||
|
||||
+15
@@ -89,6 +89,11 @@ final class AnnotationAssocManys extends AnnotationAssoc {
|
||||
ManyToMany manyToMany = get(prop, ManyToMany.class);
|
||||
if (manyToMany != null) {
|
||||
readToMany(manyToMany, prop);
|
||||
OrderColumn orderColumn = get(prop, OrderColumn.class);
|
||||
if (orderColumn != null) {
|
||||
// ManyToMany order value is stored on the intersection table (not the target bean)
|
||||
prop.setOrderColumn(new DeployOrderColumn(orderColumn));
|
||||
}
|
||||
}
|
||||
ElementCollection elementCollection = get(prop, ElementCollection.class);
|
||||
if (elementCollection != null) {
|
||||
@@ -177,6 +182,11 @@ final class AnnotationAssocManys extends AnnotationAssoc {
|
||||
if (!elementCollection.targetClass().equals(void.class)) {
|
||||
prop.setTargetType(elementCollection.targetClass());
|
||||
}
|
||||
OrderColumn orderColumn = get(prop, OrderColumn.class);
|
||||
if (orderColumn != null) {
|
||||
prop.setOrderColumn(new DeployOrderColumn(orderColumn));
|
||||
prop.setFetchOrderBy(DeployOrderColumn.LOGICAL_NAME);
|
||||
}
|
||||
Column column = prop.getMetaAnnotation(Column.class);
|
||||
if (column != null) {
|
||||
prop.setDbColumn(column.name());
|
||||
@@ -268,6 +278,11 @@ final class AnnotationAssocManys extends AnnotationAssoc {
|
||||
|
||||
elementDescriptor.setName(prop.toString());
|
||||
factory.createUnidirectional(elementDescriptor, prop.getOwningType(), beanTable, prop.getTableJoin());
|
||||
if (prop.hasOrderColumn()) {
|
||||
// create the synthetic order property on the element descriptor - the element descriptor
|
||||
// is not registered in deployInfoMap so this can't go through the usual OneToMany path
|
||||
factory.makeOrderColumn(prop, elementDescriptor);
|
||||
}
|
||||
prop.setElementDescriptor(factory.createElementDescriptor(elementDescriptor, prop.getManyType(), scalar));
|
||||
}
|
||||
|
||||
|
||||
@@ -91,4 +91,16 @@ public interface ElPropertyDeploy extends SpiQueryManyJoin {
|
||||
default String fetchOrderBy() {
|
||||
return beanProperty().fetchOrderBy();
|
||||
}
|
||||
|
||||
@Override
|
||||
default boolean hasIntersectionOrderColumn() {
|
||||
BeanProperty prop = beanProperty();
|
||||
return prop instanceof io.ebeaninternal.server.deploy.BeanPropertyAssocMany
|
||||
&& prop.hasIntersectionOrderColumn();
|
||||
}
|
||||
|
||||
@Override
|
||||
default String intersectionOrderColumn() {
|
||||
return beanProperty().intersectionOrderColumn();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -907,6 +907,11 @@ public final class DefaultPersister implements Persister {
|
||||
* </p>
|
||||
*/
|
||||
private int delete(PersistRequestBean<?> request) {
|
||||
// fire preDelete now, before cascading to children/many's so that the
|
||||
// BeanPersistController/Adapter still sees the bean's collections and
|
||||
// relationships as they are prior to the cascade delete
|
||||
request.controllerPreDelete();
|
||||
|
||||
DeleteUnloadedForeignKeys unloadedForeignKeys = null;
|
||||
if (request.isPersistCascade()) {
|
||||
// delete children first ... register the
|
||||
|
||||
@@ -256,6 +256,12 @@ final class SaveManyBeans extends SaveManyBase {
|
||||
}
|
||||
|
||||
private void saveAssocManyIntersection(boolean queue) {
|
||||
if (many.hasIntersectionOrderColumn()) {
|
||||
// With @OrderColumn the position of every row can change on any add/remove/reorder so
|
||||
// we always delete all intersection rows and reinsert them in the current list order.
|
||||
saveAssocManyIntersectionOrdered(queue);
|
||||
return;
|
||||
}
|
||||
final boolean vanillaCollection = !(value instanceof BeanCollection<?>);
|
||||
if (vanillaCollection || forcedUpdate) {
|
||||
// delete all intersection rows and then treat all
|
||||
@@ -340,6 +346,53 @@ final class SaveManyBeans extends SaveManyBase {
|
||||
transaction.depth(-1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the ManyToMany intersection rows for a property with an {@code @OrderColumn}.
|
||||
* <p>
|
||||
* Unlike the standard diff based save (additions/removals), this always deletes all existing
|
||||
* intersection rows for the parent and reinserts every current entry in list order, binding
|
||||
* the sequential order index. This is required because a pure reorder (no add/remove) would
|
||||
* not otherwise be detected/persisted, and there is no per-row place (unlike OneToMany/
|
||||
* ElementCollection) to compare an existing 'loaded' order against - the order value lives on
|
||||
* the intersection row, not on the target bean.
|
||||
*/
|
||||
private void saveAssocManyIntersectionOrdered(boolean queue) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
Collection<?> current;
|
||||
if (value instanceof Map<?, ?>) {
|
||||
current = ((Map<?, ?>) value).values();
|
||||
} else if (value instanceof Collection<?>) {
|
||||
current = (Collection<?>) value;
|
||||
} else {
|
||||
throw new PersistenceException("Unhandled ManyToMany type " + value.getClass().getName() + " for " + many.fullName());
|
||||
}
|
||||
if (value instanceof BeanCollection<?>) {
|
||||
BeanCollection<?> manyValue = (BeanCollection<?>) value;
|
||||
setListenMode(manyValue, many);
|
||||
manyValue.modifyReset();
|
||||
}
|
||||
if (!insertedParent) {
|
||||
request.preManyToManyUpdate();
|
||||
persister.deleteManyIntersection(parentBean, many, transaction, publish, queue);
|
||||
}
|
||||
String orderColumn = many.intersectionOrderColumn();
|
||||
transaction.depth(+1);
|
||||
int position = 0;
|
||||
for (Object other : current) {
|
||||
EntityBean otherBean = (EntityBean) other;
|
||||
if (!many.hasImportedId(otherBean)) {
|
||||
throw new PersistenceException("ManyToMany bean does not have an Id value? " + otherBean);
|
||||
}
|
||||
IntersectionRow intRow = many.buildManyToManyMapBean(parentBean, otherBean, publish);
|
||||
intRow.put(orderColumn, position++);
|
||||
SpiSqlUpdate sqlInsert = intRow.createInsert(server);
|
||||
persister.executeOrQueue(sqlInsert, transaction, queue, BatchControl.INSERT_QUEUE);
|
||||
}
|
||||
transaction.depth(-1);
|
||||
}
|
||||
|
||||
private boolean isChangedProperty() {
|
||||
return request.isChangedProperty(many.propertyIndex());
|
||||
}
|
||||
|
||||
+5
@@ -44,10 +44,15 @@ final class SaveManyElementCollection extends SaveManyBase {
|
||||
private void saveCollection() {
|
||||
SpiSqlUpdate proto = many.insertElementCollection();
|
||||
Object parentId = request.beanId();
|
||||
boolean hasOrderColumn = many.hasOrderColumn();
|
||||
int position = 0;
|
||||
for (Object value : collection) {
|
||||
final SpiSqlUpdate sqlInsert = proto.copy();
|
||||
sqlInsert.setParameter(parentId);
|
||||
many.bindElementValue(sqlInsert, value);
|
||||
if (hasOrderColumn) {
|
||||
sqlInsert.setParameter(position++);
|
||||
}
|
||||
persister.addToFlushQueue(sqlInsert, transaction, BatchControl.INSERT_QUEUE);
|
||||
}
|
||||
resetModifyState();
|
||||
|
||||
+14
-2
@@ -60,6 +60,10 @@ final class BindableIdEmbedded implements BindableId {
|
||||
@Override
|
||||
public void dmlBind(BindableRequest request, EntityBean bean) throws SQLException {
|
||||
EntityBean idValue = (EntityBean) embId.getValue(bean);
|
||||
if (idValue == null && matches != null) {
|
||||
// The id (e.g. IdClass) hasn't been derived/cached on this bean yet
|
||||
idValue = deriveId(bean);
|
||||
}
|
||||
for (BeanProperty prop : props) {
|
||||
Object value = prop.getValue(idValue);
|
||||
request.bind(value, prop);
|
||||
@@ -83,13 +87,21 @@ final class BindableIdEmbedded implements BindableId {
|
||||
|
||||
@Override
|
||||
public boolean deriveConcatenatedId(PersistRequestBean<?> persist) {
|
||||
deriveId(persist.entityBean());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive/build the concatenated id (e.g. IdClass) from the entity's own matching id
|
||||
* properties, caching it on the bean (via setValueIntercept) for subsequent use.
|
||||
*/
|
||||
private EntityBean deriveId(EntityBean bean) {
|
||||
if (matches == null) {
|
||||
String m = "No matches for " + embId.fullName() + " the concatenated key columns where not found?"
|
||||
+ " I expect that the concatenated key was null, and this bean does"
|
||||
+ " not have ManyToOne assoc beans matching the primary key columns?";
|
||||
throw new PersistenceException(m);
|
||||
}
|
||||
EntityBean bean = persist.entityBean();
|
||||
// create the new id
|
||||
EntityBean newId = (EntityBean) embId.createEmbeddedId();
|
||||
// populate it from the assoc one id values...
|
||||
@@ -97,7 +109,7 @@ final class BindableIdEmbedded implements BindableId {
|
||||
match.populate(bean, newId);
|
||||
}
|
||||
embId.setValueIntercept(bean, newId);
|
||||
return true;
|
||||
return newId;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -321,6 +321,46 @@ final class CQueryBuilder {
|
||||
return lastFound;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of the top-level (non-nested) "select" keyword in sql. Used to detect if sql
|
||||
* starts with a WITH clause (CTE) header - SQL Server does not support a WITH clause nested
|
||||
* inside a subquery/derived table, so it must be hoisted in front of a wrapping SELECT
|
||||
* (count/exists) rather than wrapped along with the rest of the query.
|
||||
* <p>
|
||||
* Returns 0 if there is no leading WITH clause (sql starts directly with SELECT), or -1 if no
|
||||
* top-level SELECT is found at all.
|
||||
*/
|
||||
static int topLevelSelectStart(String sql) {
|
||||
int depth = 0;
|
||||
int len = sql.length();
|
||||
for (int i = 0; i < len; i++) {
|
||||
char c = sql.charAt(i);
|
||||
if (c == '(') {
|
||||
depth++;
|
||||
} else if (c == ')') {
|
||||
depth--;
|
||||
} else if (depth == 0 && sql.regionMatches(true, i, "select", 0, 6)
|
||||
&& (i == 0 || !Character.isLetterOrDigit(sql.charAt(i - 1)))
|
||||
&& (i + 6 == len || !Character.isLetterOrDigit(sql.charAt(i + 6)))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split off a leading WITH clause (CTE header) from sql, returning {@code {header, body}} so the
|
||||
* header can be hoisted in front of a wrapping SELECT. Returns an empty header (unchanged sql as
|
||||
* the body) when there is no leading WITH clause.
|
||||
*/
|
||||
static String[] splitCteHeader(String sql) {
|
||||
int pos = topLevelSelectStart(sql);
|
||||
if (pos <= 0) {
|
||||
return new String[]{"", sql};
|
||||
}
|
||||
return new String[]{sql.substring(0, pos), sql.substring(pos)};
|
||||
}
|
||||
|
||||
static String inlineSqlCommentLabel(String label, ProfileLocation profileLocation, boolean secondary, String simpleName) {
|
||||
if (label != null) {
|
||||
return secondary ? label : CQueryPlan.planLabelWithType(label, simpleName);
|
||||
@@ -329,15 +369,22 @@ final class CQueryBuilder {
|
||||
}
|
||||
|
||||
private String wrapSelectCount(String sql) {
|
||||
sql = "select count(*) from ( " + sql + ")";
|
||||
String[] parts = splitCteHeader(sql);
|
||||
sql = parts[0] + "select count(*) from ( " + parts[1] + ")";
|
||||
if (selectCountWithAlias) {
|
||||
sql += " as c";
|
||||
}
|
||||
return sql;
|
||||
}
|
||||
|
||||
private String wrapSelectExists(String sql) {
|
||||
return "select exists(" + sql + ")";
|
||||
static String wrapSelectExists(String sql, boolean existsWithCaseWhen, String existsFromClause) {
|
||||
String[] parts = splitCteHeader(sql);
|
||||
String header = parts[0];
|
||||
String body = parts[1];
|
||||
if (existsWithCaseWhen) {
|
||||
return header + "select case when exists(" + body + ") then 1 else 0 end" + existsFromClause;
|
||||
}
|
||||
return header + "select exists(" + body + ")";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,7 +413,7 @@ final class CQueryBuilder {
|
||||
}
|
||||
|
||||
SqlLimitResponse s = buildSql("select 1", request, predicates, sqlTree);
|
||||
String sql = wrapSelectExists(s.getSql());
|
||||
String sql = wrapSelectExists(s.getSql(), dbPlatform.existsWithCaseWhen(), dbPlatform.existsFromClause());
|
||||
|
||||
queryPlan = new CQueryPlan(request, sql, sqlTree.plan(), predicates.logWhereSql());
|
||||
request.putQueryPlan(queryPlan);
|
||||
|
||||
@@ -33,6 +33,13 @@ import static java.lang.System.Logger.Level.WARNING;
|
||||
*/
|
||||
public final class CQueryPredicates {
|
||||
|
||||
/**
|
||||
* Default lower bound used for findVersions() (no explicit start/end) on sql2011
|
||||
* standards based platforms that require actual bind values for the root table's
|
||||
* 'for system_time between ? and ?' clause.
|
||||
*/
|
||||
private static final Timestamp EPOCH = Timestamp.valueOf("1970-01-01 00:00:00");
|
||||
|
||||
private final Binder binder;
|
||||
private final OrmQueryRequest<?> request;
|
||||
private final SpiQuery<?> query;
|
||||
@@ -60,11 +67,22 @@ public final class CQueryPredicates {
|
||||
private String dbOrderBy;
|
||||
private String dbDistinctOn;
|
||||
private String dbUpdateClause;
|
||||
/**
|
||||
* Set when the many join is a ManyToMany with an intersection table order column - used to
|
||||
* resolve the literal "${path}dbColumn" marker appended to dbOrderBy in parseTableAlias().
|
||||
*/
|
||||
private String intersectionOrderPath;
|
||||
private String intersectionOrderColumn;
|
||||
/**
|
||||
* Includes from where and order by clauses.
|
||||
*/
|
||||
private Set<String> predicateIncludes;
|
||||
private Set<String> orderByIncludes;
|
||||
/**
|
||||
* The fetch path (relative to the query root) of the many-root whose own join clause the
|
||||
* filterMany-in-JOIN predicate is attached to
|
||||
*/
|
||||
private String filterManyAttachPath;
|
||||
|
||||
CQueryPredicates(Binder binder, OrmQueryRequest<?> request) {
|
||||
this.binder = binder;
|
||||
@@ -84,10 +102,16 @@ public final class CQueryPredicates {
|
||||
// bind the update set clause
|
||||
updateProperties.bind(binder, dataBind);
|
||||
}
|
||||
if (query.isVersionsBetween() && binder.isAsOfStandardsBased()) {
|
||||
if (binder.isAsOfStandardsBased() && query.temporalMode() == SpiQuery.TemporalMode.VERSIONS) {
|
||||
// sql2011 based versions between timestamp syntax
|
||||
Timestamp start = query.versionStart();
|
||||
Timestamp end = query.versionEnd();
|
||||
if (start == null) {
|
||||
start = EPOCH;
|
||||
}
|
||||
if (end == null) {
|
||||
end = new Timestamp(System.currentTimeMillis());
|
||||
}
|
||||
dataBind.append("between ").append(start).append(" and ").append(end);
|
||||
binder.bindObject(dataBind, start);
|
||||
binder.bindObject(dataBind, end);
|
||||
@@ -203,6 +227,8 @@ public final class CQueryPredicates {
|
||||
filterMany = new DefaultExpressionRequest(request, deployParser, binder, filterManyExpr);
|
||||
if (buildSql) {
|
||||
dbFilterMany = filterMany.buildSql();
|
||||
// safe as filterManyJoin only holds when the expression is root-property only -
|
||||
filterManyAttachPath = manyProperty.path();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,6 +265,14 @@ public final class CQueryPredicates {
|
||||
dbHaving = alias.parseWhere(dbHaving);
|
||||
}
|
||||
if (dbOrderBy != null) {
|
||||
if (intersectionOrderColumn != null) {
|
||||
// resolve the ManyToMany intersection table order column BEFORE the generic
|
||||
// alias substitution runs, as it needs the "z_" suffixed intersection alias
|
||||
// rather than the plain target table alias.
|
||||
String marker = "${" + intersectionOrderPath + "}" + intersectionOrderColumn;
|
||||
String targetAlias = alias.tableAlias(intersectionOrderPath);
|
||||
dbOrderBy = dbOrderBy.replace(marker, targetAlias + "z_." + intersectionOrderColumn);
|
||||
}
|
||||
dbOrderBy = alias.parse(dbOrderBy);
|
||||
}
|
||||
if (dbDistinctOn != null) {
|
||||
@@ -268,9 +302,18 @@ public final class CQueryPredicates {
|
||||
}
|
||||
// check for default ordering on the many property...
|
||||
SpiQueryManyJoin manyProp = request.manyJoin();
|
||||
String manyOrderBy = manyProp.fetchOrderBy();
|
||||
if (manyOrderBy != null) {
|
||||
orderBy = orderBy + ", " + parser.parse(CQueryBuilder.prefixOrderByFields(manyProp.path(), manyOrderBy));
|
||||
if (manyProp.hasIntersectionOrderColumn()) {
|
||||
// ManyToMany with @OrderColumn on the intersection table - the column lives on the
|
||||
// intersection table (alias "<targetAlias>z_") rather than a normal property path,
|
||||
// so we append a literal marker that parseTableAlias() resolves directly.
|
||||
intersectionOrderPath = manyProp.path();
|
||||
intersectionOrderColumn = manyProp.intersectionOrderColumn();
|
||||
orderBy = orderBy + ", ${" + intersectionOrderPath + "}" + intersectionOrderColumn;
|
||||
} else {
|
||||
String manyOrderBy = manyProp.fetchOrderBy();
|
||||
if (manyOrderBy != null) {
|
||||
orderBy = orderBy + ", " + parser.parse(CQueryBuilder.prefixOrderByFields(manyProp.path(), manyOrderBy));
|
||||
}
|
||||
}
|
||||
if (request.isFindById()) {
|
||||
// only one master bean so should be fine...
|
||||
@@ -363,6 +406,14 @@ public final class CQueryPredicates {
|
||||
return filterManyJoin ? dbFilterMany : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the fetch path of the filterMany-in-JOIN predicate - the path whose own join clause
|
||||
* the predicate must be appended to (or null if there is no filterMany-in-JOIN predicate at all).
|
||||
*/
|
||||
String filterManyAttachPath() {
|
||||
return filterManyJoin ? filterManyAttachPath : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the db column version of the order by clause.
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,7 @@ final class DefaultDbSqlContext implements DbSqlContext {
|
||||
private final ArrayStack<String> prefixStack = new ArrayStack<>();
|
||||
private final String fromForUpdate;
|
||||
private final String dbFilterManyJoin;
|
||||
private final String filterManyAttachPath;
|
||||
private boolean useColumnAlias;
|
||||
private int columnIndex;
|
||||
private int asOfTableCount;
|
||||
@@ -42,7 +43,8 @@ final class DefaultDbSqlContext implements DbSqlContext {
|
||||
private boolean joinSuppressed;
|
||||
|
||||
DefaultDbSqlContext(SqlTreeAlias alias, String columnAliasPrefix, CQueryHistorySupport historySupport,
|
||||
CQueryDraftSupport draftSupport, String fromForUpdate, String dbFilterManyJoin) {
|
||||
CQueryDraftSupport draftSupport, String fromForUpdate, String dbFilterManyJoin,
|
||||
String filterManyAttachPath) {
|
||||
this.alias = alias;
|
||||
this.columnAliasPrefix = columnAliasPrefix;
|
||||
this.useColumnAlias = columnAliasPrefix != null;
|
||||
@@ -51,6 +53,12 @@ final class DefaultDbSqlContext implements DbSqlContext {
|
||||
this.historyQuery = (historySupport != null);
|
||||
this.fromForUpdate = fromForUpdate;
|
||||
this.dbFilterManyJoin = dbFilterManyJoin;
|
||||
this.filterManyAttachPath = filterManyAttachPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFilterManyAttachPoint(String prefix) {
|
||||
return dbFilterManyJoin != null && filterManyAttachPath != null && filterManyAttachPath.equals(prefix);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+4
-4
@@ -1,5 +1,6 @@
|
||||
package io.ebeaninternal.server.type;
|
||||
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.core.type.DocPropertyType;
|
||||
import io.ebean.core.type.ScalarType;
|
||||
|
||||
@@ -27,15 +28,14 @@ class PlatformArrayTypeJsonList implements PlatformArrayTypeFactory {
|
||||
@Override
|
||||
public ScalarType<?> typeFor(Type valueType, boolean nullable) {
|
||||
if (valueType.equals(UUID.class)) {
|
||||
// TODO: keepSource for @DbArray?
|
||||
return new ScalarTypeJsonList.VarcharWithConverter(DocPropertyType.UUID, nullable, false, ArrayElementConverter.UUID);
|
||||
return new ScalarTypeJsonList.VarcharWithConverter(DocPropertyType.UUID, nullable, ArrayElementConverter.UUID);
|
||||
}
|
||||
return new ScalarTypeJsonList(java.sql.Types.VARCHAR, JsonStorage.VARCHAR, docType(valueType), nullable, false);
|
||||
return new ScalarTypeJsonList(java.sql.Types.VARCHAR, JsonStorage.VARCHAR, docType(valueType), nullable, MutationDetection.DEFAULT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ScalarType<?> typeForEnum(ScalarType<?> scalarType, boolean nullable) {
|
||||
final ArrayElementConverter.EnumConverter converter = new ArrayElementConverter.EnumConverter(scalarType);
|
||||
return new ScalarTypeJsonList.VarcharWithConverter(scalarType.docType(), nullable, false, converter);
|
||||
return new ScalarTypeJsonList.VarcharWithConverter(scalarType.docType(), nullable, converter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.ebeaninternal.server.type;
|
||||
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.core.type.DocPropertyType;
|
||||
import io.ebean.core.type.ScalarType;
|
||||
|
||||
@@ -27,15 +28,14 @@ class PlatformArrayTypeJsonSet implements PlatformArrayTypeFactory {
|
||||
@Override
|
||||
public ScalarType<?> typeFor(Type valueType, boolean nullable) {
|
||||
if (valueType.equals(UUID.class)) {
|
||||
// TODO: keepSource for @DbArray?
|
||||
return new ScalarTypeJsonSet.VarcharWithConverter(DocPropertyType.UUID, nullable, false, ArrayElementConverter.UUID);
|
||||
return new ScalarTypeJsonSet.VarcharWithConverter(DocPropertyType.UUID, nullable, ArrayElementConverter.UUID);
|
||||
}
|
||||
return new ScalarTypeJsonSet(java.sql.Types.VARCHAR, JsonStorage.VARCHAR, docType(valueType), nullable, false);
|
||||
return new ScalarTypeJsonSet(java.sql.Types.VARCHAR, JsonStorage.VARCHAR, docType(valueType), nullable, MutationDetection.DEFAULT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ScalarType<?> typeForEnum(ScalarType<?> scalarType, boolean nullable) {
|
||||
final ArrayElementConverter.EnumConverter converter = new ArrayElementConverter.EnumConverter(scalarType);
|
||||
return new ScalarTypeJsonSet.VarcharWithConverter(scalarType.docType(), nullable, false, converter);
|
||||
return new ScalarTypeJsonSet.VarcharWithConverter(scalarType.docType(), nullable, converter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,12 @@ public interface ScalarTypeArray {
|
||||
*/
|
||||
String getDbColumnDefn();
|
||||
|
||||
/**
|
||||
* Return the Java type of the individual array elements.
|
||||
* <p>
|
||||
* Used to bind the array correctly when the collection value is empty
|
||||
* and so the element type can't be determined from the collection content.
|
||||
*/
|
||||
Class<?> elementType();
|
||||
|
||||
}
|
||||
|
||||
@@ -45,31 +45,31 @@ class ScalarTypeArrayList extends ScalarTypeArrayBase<List> implements ScalarTyp
|
||||
try {
|
||||
String key = valueType + ":" + nullable;
|
||||
if (valueType.equals(UUID.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "uuid", DocPropertyType.UUID, ArrayElementConverter.UUID));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "uuid", DocPropertyType.UUID, ArrayElementConverter.UUID, UUID.class));
|
||||
}
|
||||
if (valueType.equals(Long.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "bigint", DocPropertyType.LONG, ArrayElementConverter.LONG));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "bigint", DocPropertyType.LONG, ArrayElementConverter.LONG, Long.class));
|
||||
}
|
||||
if (valueType.equals(Integer.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "integer", DocPropertyType.INTEGER, ArrayElementConverter.INTEGER));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "integer", DocPropertyType.INTEGER, ArrayElementConverter.INTEGER, Integer.class));
|
||||
}
|
||||
if (valueType.equals(Float.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "float4", DocPropertyType.DOUBLE, ArrayElementConverter.FLOAT));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "float4", DocPropertyType.DOUBLE, ArrayElementConverter.FLOAT, Float.class));
|
||||
}
|
||||
if (valueType.equals(Double.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.DOUBLE));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.DOUBLE, Double.class));
|
||||
}
|
||||
if (valueType.equals(BigDecimal.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "decimal", DocPropertyType.DOUBLE, ArrayElementConverter.BIG_DECIMAL));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "decimal", DocPropertyType.DOUBLE, ArrayElementConverter.BIG_DECIMAL, BigDecimal.class));
|
||||
}
|
||||
if (valueType.equals(String.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "varchar", DocPropertyType.TEXT, ArrayElementConverter.STRING));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "varchar", DocPropertyType.TEXT, ArrayElementConverter.STRING, String.class));
|
||||
}
|
||||
if (valueType.equals(Instant.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "timestamptz", DocPropertyType.TEXT, ArrayElementConverter.INSTANT));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "timestamptz", DocPropertyType.TEXT, ArrayElementConverter.INSTANT, Instant.class));
|
||||
}
|
||||
if (valueType.equals(LocalDate.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "date", DocPropertyType.TEXT, ArrayElementConverter.LOCAL_DATE));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayList(nullable, "date", DocPropertyType.TEXT, ArrayElementConverter.LOCAL_DATE, LocalDate.class));
|
||||
}
|
||||
throw new IllegalArgumentException("Type [" + valueType + "] not supported for @DbArray mapping");
|
||||
} finally {
|
||||
@@ -79,18 +79,24 @@ class ScalarTypeArrayList extends ScalarTypeArrayBase<List> implements ScalarTyp
|
||||
|
||||
@Override
|
||||
public ScalarTypeArrayList typeForEnum(ScalarType<?> scalarType, boolean nullable) {
|
||||
return new ScalarTypeArrayList(nullable, arrayTypeFor(scalarType), scalarType.docType(), new ArrayElementConverter.EnumConverter(scalarType));
|
||||
return new ScalarTypeArrayList(nullable, arrayTypeFor(scalarType), scalarType.docType(), new ArrayElementConverter.EnumConverter(scalarType), scalarType.type());
|
||||
}
|
||||
}
|
||||
|
||||
private final String arrayType;
|
||||
|
||||
private final ArrayElementConverter converter;
|
||||
private final Class<?> elementType;
|
||||
|
||||
public ScalarTypeArrayList(boolean nullable, String arrayType, DocPropertyType docPropertyType, ArrayElementConverter converter) {
|
||||
public ScalarTypeArrayList(boolean nullable, String arrayType, DocPropertyType docPropertyType, ArrayElementConverter converter, Class<?> elementType) {
|
||||
super(List.class, Types.ARRAY, docPropertyType, nullable);
|
||||
this.arrayType = arrayType;
|
||||
this.converter = converter;
|
||||
this.elementType = elementType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<?> elementType() {
|
||||
return elementType;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -40,31 +40,31 @@ final class ScalarTypeArrayListH2 extends ScalarTypeArrayList {
|
||||
try {
|
||||
String key = valueType + ":" + nullable;
|
||||
if (valueType.equals(UUID.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "uuid", DocPropertyType.UUID, ArrayElementConverter.UUID));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "uuid", DocPropertyType.UUID, ArrayElementConverter.UUID, UUID.class));
|
||||
}
|
||||
if (valueType.equals(Long.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "bigint", DocPropertyType.LONG, ArrayElementConverter.LONG));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "bigint", DocPropertyType.LONG, ArrayElementConverter.LONG, Long.class));
|
||||
}
|
||||
if (valueType.equals(Integer.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "integer", DocPropertyType.INTEGER, ArrayElementConverter.INTEGER));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "integer", DocPropertyType.INTEGER, ArrayElementConverter.INTEGER, Integer.class));
|
||||
}
|
||||
if (valueType.equals(Float.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "real", DocPropertyType.DOUBLE, ArrayElementConverter.FLOAT));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "real", DocPropertyType.DOUBLE, ArrayElementConverter.FLOAT, Float.class));
|
||||
}
|
||||
if (valueType.equals(Double.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.DOUBLE));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.DOUBLE, Double.class));
|
||||
}
|
||||
if (valueType.equals(BigDecimal.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.BIG_DECIMAL));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.BIG_DECIMAL, BigDecimal.class));
|
||||
}
|
||||
if (valueType.equals(String.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "varchar", DocPropertyType.TEXT, ArrayElementConverter.STRING));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "varchar", DocPropertyType.TEXT, ArrayElementConverter.STRING, String.class));
|
||||
}
|
||||
if (valueType.equals(Instant.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "timestamp", DocPropertyType.TEXT, ArrayElementConverter.INSTANT));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "timestamp", DocPropertyType.TEXT, ArrayElementConverter.INSTANT, Instant.class));
|
||||
}
|
||||
if (valueType.equals(LocalDate.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "date", DocPropertyType.TEXT, ArrayElementConverter.LOCAL_DATE));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArrayListH2(nullable, "date", DocPropertyType.TEXT, ArrayElementConverter.LOCAL_DATE, LocalDate.class));
|
||||
}
|
||||
throw new IllegalArgumentException("Type [" + valueType + "] not supported for @DbArray mapping");
|
||||
} finally {
|
||||
@@ -74,12 +74,12 @@ final class ScalarTypeArrayListH2 extends ScalarTypeArrayList {
|
||||
|
||||
@Override
|
||||
public ScalarType<?> typeForEnum(ScalarType<?> scalarType, boolean nullable) {
|
||||
return new ScalarTypeArrayListH2(nullable, "varchar", DocPropertyType.TEXT, new ArrayElementConverter.EnumConverter(scalarType));
|
||||
return new ScalarTypeArrayListH2(nullable, "varchar", DocPropertyType.TEXT, new ArrayElementConverter.EnumConverter(scalarType), scalarType.type());
|
||||
}
|
||||
}
|
||||
|
||||
private ScalarTypeArrayListH2(boolean nullable, String arrayType, DocPropertyType docPropertyType, ArrayElementConverter converter) {
|
||||
super(nullable, arrayType, docPropertyType, converter);
|
||||
private ScalarTypeArrayListH2(boolean nullable, String arrayType, DocPropertyType docPropertyType, ArrayElementConverter converter, Class<?> elementType) {
|
||||
super(nullable, arrayType, docPropertyType, converter, elementType);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -42,19 +42,19 @@ class ScalarTypeArraySet extends ScalarTypeArrayBase<Set> implements ScalarTypeA
|
||||
try {
|
||||
String key = valueType + ":" + nullable;
|
||||
if (valueType.equals(UUID.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "uuid", DocPropertyType.UUID, ArrayElementConverter.UUID));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "uuid", DocPropertyType.UUID, ArrayElementConverter.UUID, UUID.class));
|
||||
}
|
||||
if (valueType.equals(Long.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "bigint", DocPropertyType.LONG, ArrayElementConverter.LONG));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "bigint", DocPropertyType.LONG, ArrayElementConverter.LONG, Long.class));
|
||||
}
|
||||
if (valueType.equals(Integer.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "integer", DocPropertyType.INTEGER, ArrayElementConverter.INTEGER));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "integer", DocPropertyType.INTEGER, ArrayElementConverter.INTEGER, Integer.class));
|
||||
}
|
||||
if (valueType.equals(Double.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.DOUBLE));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.DOUBLE, Double.class));
|
||||
}
|
||||
if (valueType.equals(String.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "varchar", DocPropertyType.TEXT, ArrayElementConverter.STRING));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySet(nullable, "varchar", DocPropertyType.TEXT, ArrayElementConverter.STRING, String.class));
|
||||
}
|
||||
throw new IllegalArgumentException("Type [" + valueType + "] not supported for @DbArray mapping");
|
||||
} finally {
|
||||
@@ -64,18 +64,24 @@ class ScalarTypeArraySet extends ScalarTypeArrayBase<Set> implements ScalarTypeA
|
||||
|
||||
@Override
|
||||
public ScalarType<?> typeForEnum(ScalarType<?> scalarType, boolean nullable) {
|
||||
return new ScalarTypeArraySet(nullable, arrayTypeFor(scalarType), scalarType.docType(), new ArrayElementConverter.EnumConverter(scalarType));
|
||||
return new ScalarTypeArraySet(nullable, arrayTypeFor(scalarType), scalarType.docType(), new ArrayElementConverter.EnumConverter(scalarType), scalarType.type());
|
||||
}
|
||||
}
|
||||
|
||||
private final String arrayType;
|
||||
|
||||
private final ArrayElementConverter converter;
|
||||
private final Class<?> elementType;
|
||||
|
||||
public ScalarTypeArraySet(boolean nullable, String arrayType, DocPropertyType docPropertyType, ArrayElementConverter converter) {
|
||||
public ScalarTypeArraySet(boolean nullable, String arrayType, DocPropertyType docPropertyType, ArrayElementConverter converter, Class<?> elementType) {
|
||||
super(Set.class, Types.ARRAY, docPropertyType, nullable);
|
||||
this.arrayType = arrayType;
|
||||
this.converter = converter;
|
||||
this.elementType = elementType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<?> elementType() {
|
||||
return elementType;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -37,19 +37,19 @@ final class ScalarTypeArraySetH2 extends ScalarTypeArraySet {
|
||||
try {
|
||||
String key = valueType + ":" + nullable;
|
||||
if (valueType.equals(UUID.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "uuid", DocPropertyType.UUID, ArrayElementConverter.UUID));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "uuid", DocPropertyType.UUID, ArrayElementConverter.UUID, UUID.class));
|
||||
}
|
||||
if (valueType.equals(Long.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "bigint", DocPropertyType.LONG, ArrayElementConverter.LONG));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "bigint", DocPropertyType.LONG, ArrayElementConverter.LONG, Long.class));
|
||||
}
|
||||
if (valueType.equals(Integer.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "integer", DocPropertyType.INTEGER, ArrayElementConverter.INTEGER));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "integer", DocPropertyType.INTEGER, ArrayElementConverter.INTEGER, Integer.class));
|
||||
}
|
||||
if (valueType.equals(Double.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.DOUBLE));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "float", DocPropertyType.DOUBLE, ArrayElementConverter.DOUBLE, Double.class));
|
||||
}
|
||||
if (valueType.equals(String.class)) {
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "varchar", DocPropertyType.TEXT, ArrayElementConverter.STRING));
|
||||
return cache.computeIfAbsent(key, s -> new ScalarTypeArraySetH2(nullable, "varchar", DocPropertyType.TEXT, ArrayElementConverter.STRING, String.class));
|
||||
}
|
||||
throw new IllegalArgumentException("Type [" + valueType + "] not supported for @DbArray mapping");
|
||||
} finally {
|
||||
@@ -59,13 +59,13 @@ final class ScalarTypeArraySetH2 extends ScalarTypeArraySet {
|
||||
|
||||
@Override
|
||||
public ScalarType<?> typeForEnum(ScalarType<?> scalarType, boolean nullable) {
|
||||
return new ScalarTypeArraySetH2(nullable, "varchar", DocPropertyType.TEXT, new ArrayElementConverter.EnumConverter(scalarType));
|
||||
return new ScalarTypeArraySetH2(nullable, "varchar", DocPropertyType.TEXT, new ArrayElementConverter.EnumConverter(scalarType), scalarType.type());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("rawtypes")
|
||||
private ScalarTypeArraySetH2(boolean nullable, String arrayType, DocPropertyType docPropertyType, ArrayElementConverter converter) {
|
||||
super(nullable, arrayType, docPropertyType, converter);
|
||||
private ScalarTypeArraySetH2(boolean nullable, String arrayType, DocPropertyType docPropertyType, ArrayElementConverter converter, Class<?> elementType) {
|
||||
super(nullable, arrayType, docPropertyType, converter, elementType);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+28
-2
@@ -1,7 +1,10 @@
|
||||
package io.ebeaninternal.server.type;
|
||||
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.core.type.DocPropertyType;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Base for the JSON collection value types (List, Set).
|
||||
* <p>
|
||||
@@ -10,9 +13,9 @@ import io.ebean.core.type.DocPropertyType;
|
||||
*/
|
||||
abstract class ScalarTypeJsonCollectionValue<T> extends ScalarTypeJsonValue<T> implements ScalarTypeArray {
|
||||
|
||||
ScalarTypeJsonCollectionValue(Class<T> type, int jdbcType, JsonStorage storage, boolean keepSource,
|
||||
ScalarTypeJsonCollectionValue(Class<T> type, int jdbcType, JsonStorage storage, MutationDetection mutationDetection,
|
||||
boolean nullable, DocPropertyType docType) {
|
||||
super(type, jdbcType, storage, keepSource, nullable, "[]", docType);
|
||||
super(type, jdbcType, storage, mutationDetection, nullable, "[]", docType);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -29,4 +32,27 @@ abstract class ScalarTypeJsonCollectionValue<T> extends ScalarTypeJsonValue<T> i
|
||||
return "varchar[]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the element type from the docType - used only to determine the ScalarType to use
|
||||
* when binding an element (e.g. for an empty collection where the element type can't be
|
||||
* determined from the collection content).
|
||||
*/
|
||||
@Override
|
||||
public Class<?> elementType() {
|
||||
switch (docType()) {
|
||||
case UUID:
|
||||
return UUID.class;
|
||||
case SHORT:
|
||||
case INTEGER:
|
||||
return Integer.class;
|
||||
case LONG:
|
||||
return Long.class;
|
||||
case FLOAT:
|
||||
case DOUBLE:
|
||||
return Double.class;
|
||||
default:
|
||||
return String.class;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.ebeaninternal.server.type;
|
||||
|
||||
import io.avaje.json.JsonReader;
|
||||
import io.avaje.json.JsonWriter;
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.config.dbplatform.DbPlatformType;
|
||||
import io.ebean.core.type.DocPropertyType;
|
||||
import io.ebean.core.type.PostgresHelper;
|
||||
@@ -25,20 +26,20 @@ class ScalarTypeJsonList extends ScalarTypeJsonCollectionValue<List> {
|
||||
/**
|
||||
* Return the appropriate ScalarType for the requested dbType and platform.
|
||||
*/
|
||||
static ScalarType<?> typeFor(boolean postgres, int dbType, DocPropertyType docType, boolean nullable, boolean keepSource) {
|
||||
static ScalarType<?> typeFor(boolean postgres, int dbType, DocPropertyType docType, boolean nullable, MutationDetection mutationDetection) {
|
||||
if (postgres) {
|
||||
switch (dbType) {
|
||||
case DbPlatformType.JSONB:
|
||||
return new ScalarTypeJsonList(DbPlatformType.JSONB, JsonStorage.postgres(PostgresHelper.JSONB_TYPE), docType, nullable, keepSource);
|
||||
return new ScalarTypeJsonList(DbPlatformType.JSONB, JsonStorage.postgres(PostgresHelper.JSONB_TYPE), docType, nullable, mutationDetection);
|
||||
case DbPlatformType.JSON:
|
||||
return new ScalarTypeJsonList(DbPlatformType.JSON, JsonStorage.postgres(PostgresHelper.JSON_TYPE), docType, nullable, keepSource);
|
||||
return new ScalarTypeJsonList(DbPlatformType.JSON, JsonStorage.postgres(PostgresHelper.JSON_TYPE), docType, nullable, mutationDetection);
|
||||
}
|
||||
}
|
||||
return new ScalarTypeJsonList(Types.VARCHAR, JsonStorage.VARCHAR, docType, nullable, keepSource);
|
||||
return new ScalarTypeJsonList(Types.VARCHAR, JsonStorage.VARCHAR, docType, nullable, mutationDetection);
|
||||
}
|
||||
|
||||
ScalarTypeJsonList(int jdbcType, JsonStorage storage, DocPropertyType docType, boolean nullable, boolean keepSource) {
|
||||
super(List.class, jdbcType, storage, keepSource, nullable, docType);
|
||||
ScalarTypeJsonList(int jdbcType, JsonStorage storage, DocPropertyType docType, boolean nullable, MutationDetection mutationDetection) {
|
||||
super(List.class, jdbcType, storage, mutationDetection, nullable, docType);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -87,8 +88,8 @@ class ScalarTypeJsonList extends ScalarTypeJsonCollectionValue<List> {
|
||||
|
||||
private final ArrayElementConverter converter;
|
||||
|
||||
VarcharWithConverter(DocPropertyType docType, boolean nullable, boolean keepSource, ArrayElementConverter converter) {
|
||||
super(Types.VARCHAR, JsonStorage.VARCHAR, docType, nullable, keepSource);
|
||||
VarcharWithConverter(DocPropertyType docType, boolean nullable, ArrayElementConverter converter) {
|
||||
super(Types.VARCHAR, JsonStorage.VARCHAR, docType, nullable, MutationDetection.DEFAULT);
|
||||
this.converter = converter;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.ebeaninternal.server.type;
|
||||
|
||||
import io.avaje.json.JsonReader;
|
||||
import io.avaje.json.JsonWriter;
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.config.dbplatform.DbPlatformType;
|
||||
import io.ebean.core.type.DocPropertyType;
|
||||
import io.ebean.core.type.PostgresHelper;
|
||||
@@ -22,8 +23,8 @@ class ScalarTypeJsonMap extends ScalarTypeJsonValue<Map> {
|
||||
/**
|
||||
* Return the ScalarType for the requested dbType and platform.
|
||||
*/
|
||||
static ScalarTypeJsonMap typeFor(boolean postgres, int dbType, boolean keepSource) {
|
||||
return new ScalarTypeJsonMap(storageFor(postgres, dbType), keepSource);
|
||||
static ScalarTypeJsonMap typeFor(boolean postgres, int dbType, MutationDetection mutationDetection) {
|
||||
return new ScalarTypeJsonMap(storageFor(postgres, dbType), mutationDetection);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,8 +48,8 @@ class ScalarTypeJsonMap extends ScalarTypeJsonValue<Map> {
|
||||
}
|
||||
}
|
||||
|
||||
ScalarTypeJsonMap(JsonStorage storage, boolean keepSource) {
|
||||
super(Map.class, storage.jdbcType(), storage, keepSource, true, null, DocPropertyType.OBJECT);
|
||||
ScalarTypeJsonMap(JsonStorage storage, MutationDetection mutationDetection) {
|
||||
super(Map.class, storage.jdbcType(), storage, mutationDetection, true, null, DocPropertyType.OBJECT);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.ebeaninternal.server.type;
|
||||
|
||||
import io.avaje.json.JsonReader;
|
||||
import io.avaje.json.JsonWriter;
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.core.type.ScalarType;
|
||||
import io.ebean.text.TextException;
|
||||
import io.ebean.text.json.EJson;
|
||||
@@ -23,13 +24,13 @@ final class ScalarTypeJsonMapEnum<T extends Enum<T>> extends ScalarTypeJsonMap {
|
||||
|
||||
private final ScalarType<T> enumType;
|
||||
|
||||
static ScalarType<?> typeFor(boolean postgres, int dbType, ScalarType<? extends Enum<?>> enumType, boolean keepSource) {
|
||||
return new ScalarTypeJsonMapEnum<>(storageFor(postgres, dbType), enumType, keepSource);
|
||||
static ScalarType<?> typeFor(boolean postgres, int dbType, ScalarType<? extends Enum<?>> enumType, MutationDetection mutationDetection) {
|
||||
return new ScalarTypeJsonMapEnum<>(storageFor(postgres, dbType), enumType, mutationDetection);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private ScalarTypeJsonMapEnum(JsonStorage storage, ScalarType<? extends Enum> enumType, boolean keepSource) {
|
||||
super(storage, keepSource);
|
||||
private ScalarTypeJsonMapEnum(JsonStorage storage, ScalarType<? extends Enum> enumType, MutationDetection mutationDetection) {
|
||||
super(storage, mutationDetection);
|
||||
this.enumType = (ScalarType<T>) enumType;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.ebeaninternal.server.type;
|
||||
|
||||
import io.avaje.json.JsonReader;
|
||||
import io.avaje.json.JsonWriter;
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.config.dbplatform.DbPlatformType;
|
||||
import io.ebean.core.type.DocPropertyType;
|
||||
import io.ebean.core.type.PostgresHelper;
|
||||
@@ -25,20 +26,20 @@ class ScalarTypeJsonSet extends ScalarTypeJsonCollectionValue<Set> {
|
||||
/**
|
||||
* Return the appropriate ScalarType for the requested dbType and platform.
|
||||
*/
|
||||
static ScalarType<?> typeFor(boolean postgres, int dbType, DocPropertyType docType, boolean nullable, boolean keepSource) {
|
||||
static ScalarType<?> typeFor(boolean postgres, int dbType, DocPropertyType docType, boolean nullable, MutationDetection mutationDetection) {
|
||||
if (postgres) {
|
||||
switch (dbType) {
|
||||
case DbPlatformType.JSONB:
|
||||
return new ScalarTypeJsonSet(DbPlatformType.JSONB, JsonStorage.postgres(PostgresHelper.JSONB_TYPE), docType, nullable, keepSource);
|
||||
return new ScalarTypeJsonSet(DbPlatformType.JSONB, JsonStorage.postgres(PostgresHelper.JSONB_TYPE), docType, nullable, mutationDetection);
|
||||
case DbPlatformType.JSON:
|
||||
return new ScalarTypeJsonSet(DbPlatformType.JSON, JsonStorage.postgres(PostgresHelper.JSON_TYPE), docType, nullable, keepSource);
|
||||
return new ScalarTypeJsonSet(DbPlatformType.JSON, JsonStorage.postgres(PostgresHelper.JSON_TYPE), docType, nullable, mutationDetection);
|
||||
}
|
||||
}
|
||||
return new ScalarTypeJsonSet(Types.VARCHAR, JsonStorage.VARCHAR, docType, nullable, keepSource);
|
||||
return new ScalarTypeJsonSet(Types.VARCHAR, JsonStorage.VARCHAR, docType, nullable, mutationDetection);
|
||||
}
|
||||
|
||||
ScalarTypeJsonSet(int jdbcType, JsonStorage storage, DocPropertyType docType, boolean nullable, boolean keepSource) {
|
||||
super(Set.class, jdbcType, storage, keepSource, nullable, docType);
|
||||
ScalarTypeJsonSet(int jdbcType, JsonStorage storage, DocPropertyType docType, boolean nullable, MutationDetection mutationDetection) {
|
||||
super(Set.class, jdbcType, storage, mutationDetection, nullable, docType);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -89,8 +90,8 @@ class ScalarTypeJsonSet extends ScalarTypeJsonCollectionValue<Set> {
|
||||
|
||||
private final ArrayElementConverter converter;
|
||||
|
||||
VarcharWithConverter(DocPropertyType docType, boolean nullable, boolean keepSource, ArrayElementConverter converter) {
|
||||
super(Types.VARCHAR, JsonStorage.VARCHAR, docType, nullable, keepSource);
|
||||
VarcharWithConverter(DocPropertyType docType, boolean nullable, ArrayElementConverter converter) {
|
||||
super(Types.VARCHAR, JsonStorage.VARCHAR, docType, nullable, MutationDetection.DEFAULT);
|
||||
this.converter = converter;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.ebeaninternal.server.type;
|
||||
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.core.type.DataBinder;
|
||||
import io.ebean.core.type.DataReader;
|
||||
import io.ebean.core.type.DocPropertyType;
|
||||
@@ -20,11 +21,18 @@ import java.sql.SQLException;
|
||||
* backed {@code EJson} facade)</li>
|
||||
* </ul>
|
||||
* This removes the previous explosion of per-storage and per-platform subclasses.
|
||||
* <p>
|
||||
* Mutation detection: {@code DEFAULT} uses the legacy ModifyAware wrapper based dirty
|
||||
* checking. {@code NONE} disables dirty checking entirely (mutable() is false and the
|
||||
* property is only included in an update when explicitly set). {@code HASH} and
|
||||
* {@code SOURCE} are handled via {@code BeanPropertyJsonMapper} (json content is kept
|
||||
* for the read/bind round trip so that a checksum or the source content can be compared).
|
||||
*/
|
||||
abstract class ScalarTypeJsonValue<T> extends ScalarTypeBase<T> {
|
||||
|
||||
protected final JsonStorage storage;
|
||||
protected final boolean keepSource;
|
||||
private final boolean mutable;
|
||||
private final boolean nullable;
|
||||
private final String emptyJson;
|
||||
private final DocPropertyType docType;
|
||||
@@ -33,11 +41,12 @@ abstract class ScalarTypeJsonValue<T> extends ScalarTypeBase<T> {
|
||||
* @param emptyJson JSON bound when the value is null and the property is not nullable
|
||||
* (e.g. {@code "[]"} for collections), or null to always bind SQL null.
|
||||
*/
|
||||
ScalarTypeJsonValue(Class<T> type, int jdbcType, JsonStorage storage, boolean keepSource,
|
||||
ScalarTypeJsonValue(Class<T> type, int jdbcType, JsonStorage storage, MutationDetection mutationDetection,
|
||||
boolean nullable, String emptyJson, DocPropertyType docType) {
|
||||
super(type, false, jdbcType);
|
||||
this.storage = storage;
|
||||
this.keepSource = keepSource;
|
||||
this.keepSource = mutationDetection == MutationDetection.HASH || mutationDetection == MutationDetection.SOURCE;
|
||||
this.mutable = mutationDetection != MutationDetection.NONE;
|
||||
this.nullable = nullable;
|
||||
this.emptyJson = emptyJson;
|
||||
this.docType = docType;
|
||||
@@ -51,7 +60,7 @@ abstract class ScalarTypeJsonValue<T> extends ScalarTypeBase<T> {
|
||||
|
||||
@Override
|
||||
public final boolean mutable() {
|
||||
return true;
|
||||
return mutable;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -117,6 +117,88 @@ class CQueryBuilderTest {
|
||||
assertThat(countSql).isEqualTo("select count(*) from ( select t0.id from ad t0) as c");
|
||||
}
|
||||
|
||||
@Test
|
||||
void topLevelSelectStart_noLeadingCte_returnsZero() {
|
||||
String sql = "select 1 from o_order t0 where t0.id > ?";
|
||||
assertThat(CQueryBuilder.topLevelSelectStart(sql)).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void topLevelSelectStart_leadingCte_findsOuterSelect() {
|
||||
// The only depth-0 "select" is the outer query - the CTE body's "select" is nested in parens.
|
||||
String sql = "with order_totals as (" +
|
||||
" select o.id as order_id," +
|
||||
" sum(d.order_qty * d.unit_price) as total_amount" +
|
||||
" from o_order o" +
|
||||
" join o_order_detail d on d.order_id = o.id" +
|
||||
" group by o.id" +
|
||||
")" +
|
||||
" select order_id, total_amount" +
|
||||
" from order_totals" +
|
||||
" where total_amount > ?";
|
||||
|
||||
int pos = CQueryBuilder.topLevelSelectStart(sql);
|
||||
assertThat(sql.substring(pos)).startsWith("select order_id, total_amount");
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL Server does not support a WITH clause (CTE) nested inside a subquery/derived table - see
|
||||
* https://github.com/ebean-orm/ebean/issues/3848 (findCount() wraps raw sql in "select count(*)
|
||||
* from ( ... )" which breaks when the raw sql is a CTE). The CTE header must be hoisted in front
|
||||
* of the wrapping SELECT.
|
||||
*/
|
||||
@Test
|
||||
void splitCteHeader_hoistsLeadingWithClause() {
|
||||
String sql = "with order_totals as (" +
|
||||
" select o.id as order_id," +
|
||||
" sum(d.order_qty * d.unit_price) as total_amount" +
|
||||
" from o_order o" +
|
||||
" join o_order_detail d on d.order_id = o.id" +
|
||||
" group by o.id" +
|
||||
")" +
|
||||
" select order_id, total_amount" +
|
||||
" from order_totals" +
|
||||
" where total_amount > ?";
|
||||
|
||||
String[] parts = CQueryBuilder.splitCteHeader(sql);
|
||||
assertThat(parts[0] + parts[1]).isEqualTo(sql);
|
||||
assertThat(parts[0]).startsWith("with order_totals as (").endsWith(") ");
|
||||
assertThat(parts[1]).isEqualTo("select order_id, total_amount from order_totals where total_amount > ?");
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitCteHeader_noCte_returnsEmptyHeader() {
|
||||
String sql = "select 1 from o_order t0 where t0.id > ?";
|
||||
String[] parts = CQueryBuilder.splitCteHeader(sql);
|
||||
assertThat(parts[0]).isEmpty();
|
||||
assertThat(parts[1]).isEqualTo(sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrapSelectExists_default_usesScalarExists() {
|
||||
String sql = CQueryBuilder.wrapSelectExists("select 1 from o_order t0 where t0.id > ?", false, "");
|
||||
assertThat(sql).isEqualTo("select exists(select 1 from o_order t0 where t0.id > ?)");
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL Server does not support exists(...) as a directly selectable scalar
|
||||
* boolean expression - see https://github.com/ebean-orm/ebean/issues/3848
|
||||
*/
|
||||
@Test
|
||||
void wrapSelectExists_existsWithCaseWhen_wrapsAsCaseWhen() {
|
||||
String sql = CQueryBuilder.wrapSelectExists("select 1 from o_order t0 where t0.id > ?", true, "");
|
||||
assertThat(sql).isEqualTo("select case when exists(select 1 from o_order t0 where t0.id > ?) then 1 else 0 end");
|
||||
}
|
||||
|
||||
/**
|
||||
* Oracle also requires a FROM clause on every select (from dual) - see https://github.com/ebean-orm/ebean/issues/3848
|
||||
*/
|
||||
@Test
|
||||
void wrapSelectExists_existsWithCaseWhenAndFromClause_appendsFromClause() {
|
||||
String sql = CQueryBuilder.wrapSelectExists("select 1 from o_order t0 where t0.id > ?", true, " from dual");
|
||||
assertThat(sql).isEqualTo("select case when exists(select 1 from o_order t0 where t0.id > ?) then 1 else 0 end from dual");
|
||||
}
|
||||
|
||||
@Test
|
||||
void inlineSqlCommentLabel_rootExplicitLabel_prefixesBeanType() {
|
||||
String label = CQueryBuilder.inlineSqlCommentLabel("fetchMachineFleets", null, false, "COrganisationMachine");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.ebeaninternal.server.type;
|
||||
|
||||
import io.ebean.annotation.MutationDetection;
|
||||
import io.ebean.config.dbplatform.ExtraDbTypes;
|
||||
import io.ebean.core.type.DocPropertyType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -11,19 +12,19 @@ public class ScalarTypeJsonListTest extends BasePlatformArrayTypeFactoryTest {
|
||||
@Test
|
||||
public void typeFor_expect_nullToEmpty_when_postgresNonNull() throws SQLException {
|
||||
|
||||
assertBindNullTo_PGObjectEmpty(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSONB, DocPropertyType.OBJECT, false, false));
|
||||
assertBindNullTo_PGObjectEmpty(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSON, DocPropertyType.OBJECT, false, false));
|
||||
assertBindNullTo_PGObjectEmpty(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSONB, DocPropertyType.OBJECT, false, MutationDetection.DEFAULT));
|
||||
assertBindNullTo_PGObjectEmpty(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSON, DocPropertyType.OBJECT, false, MutationDetection.DEFAULT));
|
||||
|
||||
assertBindNullTo_EmptyString(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSONVarchar, DocPropertyType.OBJECT, false, false));
|
||||
assertBindNullTo_EmptyString(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSONVarchar, DocPropertyType.OBJECT, false, MutationDetection.DEFAULT));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void typeFor_expect_nullToNull_when_nullable() throws SQLException {
|
||||
|
||||
assertBindNullTo_PGObjectNull(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSONB, DocPropertyType.OBJECT, true, false));
|
||||
assertBindNullTo_PGObjectNull(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSON, DocPropertyType.OBJECT, true, false));
|
||||
assertBindNullTo_PGObjectNull(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSONB, DocPropertyType.OBJECT, true, MutationDetection.DEFAULT));
|
||||
assertBindNullTo_PGObjectNull(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSON, DocPropertyType.OBJECT, true, MutationDetection.DEFAULT));
|
||||
|
||||
assertBindNullTo_Null(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSONVarchar, DocPropertyType.OBJECT, true, false));
|
||||
assertBindNullTo_Null(ScalarTypeJsonList.typeFor(true, ExtraDbTypes.JSONVarchar, DocPropertyType.OBJECT, true, MutationDetection.DEFAULT));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+9
-3
@@ -686,7 +686,8 @@ public class BaseTableDdl implements TableDdl {
|
||||
if (hasValue(alterColumn.getUniqueOneToOne())) {
|
||||
alterColumnAddUniqueOneToOneConstraint(writer, alterColumn);
|
||||
}
|
||||
if (hasValue(alterColumn.getComment())) {
|
||||
if (hasValue(alterColumn.getComment()) && !platformDdl.isInlineComments()) {
|
||||
// platform supports comments as a separate statement (e.g. postgres)
|
||||
alterColumnComment(writer, alterColumn);
|
||||
}
|
||||
if (hasValue(alterColumn.getDropCheckConstraint())) {
|
||||
@@ -700,7 +701,10 @@ public class BaseTableDdl implements TableDdl {
|
||||
}
|
||||
if (typeChange(alterColumn)
|
||||
|| hasValue(alterColumn.getDefaultValue())
|
||||
|| alterColumn.isNotnull() != null) {
|
||||
|| alterColumn.isNotnull() != null
|
||||
|| (hasValue(alterColumn.getComment()) && platformDdl.isInlineComments())) {
|
||||
// platforms with inline comments (e.g. mysql) must restate the whole column
|
||||
// definition (including comment) even when only the comment is changing
|
||||
alterColumn(writer, alterColumn);
|
||||
}
|
||||
if (alterCheckConstraint) {
|
||||
@@ -824,7 +828,9 @@ public class BaseTableDdl implements TableDdl {
|
||||
|
||||
platformDdl.alterTableAddColumn(writer, tableName, column, onHistoryTable, help.getDefaultValue());
|
||||
final String comment = column.getComment();
|
||||
if (comment != null && !comment.isEmpty()) {
|
||||
if (comment != null && !comment.isEmpty() && !platformDdl.isInlineComments()) {
|
||||
// platforms with inline comments (e.g. mysql) embed the comment directly into the
|
||||
// "add column" statement itself rather than as a separate statement
|
||||
platformDdl.addColumnComment(writer.applyPostAlter(), tableName, column.getName(), comment);
|
||||
}
|
||||
|
||||
|
||||
+41
-3
@@ -94,9 +94,10 @@ public class MySqlDdl extends PlatformDdl {
|
||||
public void alterColumn(DdlWrite writer, AlterColumn alter) {
|
||||
String tableName = alter.getTableName();
|
||||
String columnName = alter.getColumnName();
|
||||
boolean commentChange = hasValue(alter.getComment());
|
||||
|
||||
if (alter.getType() == null && alter.isNotnull() == null) {
|
||||
// No type change or notNull change -> handle default value change
|
||||
if (alter.getType() == null && alter.isNotnull() == null && !commentChange) {
|
||||
// No type change, notNull change or comment change -> handle default value change
|
||||
if (hasValue(alter.getDefaultValue())) {
|
||||
alterColumnDefault(writer, alter);
|
||||
}
|
||||
@@ -115,13 +116,50 @@ public class MySqlDdl extends PlatformDdl {
|
||||
if (hasValue(defaultValue) && !DdlHelp.isDropDefault(defaultValue)) {
|
||||
buffer.append(" default ").append(convertDefaultValue(defaultValue));
|
||||
}
|
||||
// restate the comment (new, existing, or none) as mysql requires the whole column
|
||||
// definition to be repeated - otherwise a comment could be silently dropped
|
||||
String comment = alter.getComment() != null ? alter.getComment() : alter.getCurrentComment();
|
||||
if (DdlHelp.isDropComment(comment)) {
|
||||
comment = null;
|
||||
}
|
||||
appendColumnComment(buffer, comment);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void alterTableAddColumn(DdlWrite writer, String tableName, Column column, boolean onHistoryTable, String defaultValue) {
|
||||
String convertedType = convert(column.getType());
|
||||
DdlBuffer buffer = alterTable(writer, tableName).append(addColumn, column.getName());
|
||||
buffer.append(convertedType);
|
||||
|
||||
// Add default value also to history table if it is not excluded
|
||||
if (defaultValue != null) {
|
||||
if (!onHistoryTable || !isTrue(column.isHistoryExclude())) {
|
||||
buffer.append(" default ");
|
||||
buffer.append(defaultValue);
|
||||
}
|
||||
}
|
||||
if (!onHistoryTable) {
|
||||
if (isTrue(column.isNotnull())) {
|
||||
buffer.appendWithSpace(columnNotNull);
|
||||
}
|
||||
// check constraints cannot be added in one statement for h2
|
||||
if (!StringHelper.isNull(column.getCheckConstraint())) {
|
||||
String ddl = alterTableAddCheckConstraint(tableName, column.getCheckConstraintName(), column.getCheckConstraint());
|
||||
writer.applyPostAlter().appendStatement(ddl);
|
||||
}
|
||||
// comment must be inline as part of the column definition for mysql
|
||||
appendColumnComment(buffer, column.getComment());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeColumnDefinition(DdlBuffer buffer, Column column, DdlIdentity identity) {
|
||||
super.writeColumnDefinition(buffer, column, identity);
|
||||
String comment = column.getComment();
|
||||
appendColumnComment(buffer, column.getComment());
|
||||
}
|
||||
|
||||
private void appendColumnComment(DdlBuffer buffer, String comment) {
|
||||
if (!StringHelper.isNull(comment)) {
|
||||
// in mysql 5.5 column comment save in information_schema.COLUMNS.COLUMN_COMMENT(VARCHAR 1024)
|
||||
if (comment.length() > 500) {
|
||||
|
||||
+27
@@ -28,6 +28,7 @@ import java.util.List;
|
||||
* <attribute name="notnull" type="{http://www.w3.org/2001/XMLSchema}boolean" />
|
||||
* <attribute name="currentNotnull" type="{http://www.w3.org/2001/XMLSchema}boolean" />
|
||||
* <attribute name="comment" type="{http://www.w3.org/2001/XMLSchema}string" />
|
||||
* <attribute name="currentComment" type="{http://www.w3.org/2001/XMLSchema}string" />
|
||||
* <attribute name="historyExclude" type="{http://www.w3.org/2001/XMLSchema}boolean" />
|
||||
* <attribute name="checkConstraint" type="{http://www.w3.org/2001/XMLSchema}string" />
|
||||
* <attribute name="checkConstraintName" type="{http://www.w3.org/2001/XMLSchema}string" />
|
||||
@@ -77,6 +78,8 @@ public class AlterColumn {
|
||||
protected Boolean currentNotnull;
|
||||
@XmlAttribute(name = "comment")
|
||||
protected String comment;
|
||||
@XmlAttribute(name = "currentComment")
|
||||
protected String currentComment;
|
||||
@XmlAttribute(name = "historyExclude")
|
||||
protected Boolean historyExclude;
|
||||
@XmlAttribute(name = "checkConstraint")
|
||||
@@ -360,6 +363,30 @@ public class AlterColumn {
|
||||
this.comment = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of the currentComment property.
|
||||
* <p>
|
||||
* This is the pre-existing comment value on the column, only populated when a platform
|
||||
* needs to fully restate the column definition (e.g. mysql) so that the existing comment
|
||||
* is not lost when another attribute (type, notnull, or the comment itself) changes.
|
||||
*
|
||||
* @return possible object is
|
||||
* {@link String }
|
||||
*/
|
||||
public String getCurrentComment() {
|
||||
return currentComment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of the currentComment property.
|
||||
*
|
||||
* @param value allowed object is
|
||||
* {@link String }
|
||||
*/
|
||||
public void setCurrentComment(String value) {
|
||||
this.currentComment = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of the historyExclude property.
|
||||
*
|
||||
|
||||
@@ -374,6 +374,7 @@ public class MColumn {
|
||||
this.alterColumn = null;
|
||||
|
||||
boolean changeBaseAttribute = false;
|
||||
boolean changeComment = false;
|
||||
|
||||
if (historyExclude != newColumn.historyExclude) {
|
||||
getAlterColumn(tableName, tableWithHistory).setHistoryExclude(newColumn.historyExclude);
|
||||
@@ -396,6 +397,7 @@ public class MColumn {
|
||||
}
|
||||
}
|
||||
if (different(comment, newColumn.comment)) {
|
||||
changeComment = true;
|
||||
AlterColumn alter = getAlterColumn(tableName, tableWithHistory);
|
||||
if (newColumn.comment == null) {
|
||||
alter.setComment(DdlHelp.DROP_COMMENT);
|
||||
@@ -459,10 +461,14 @@ public class MColumn {
|
||||
|
||||
if (alterColumn != null) {
|
||||
modelDiff.addAlterColumn(alterColumn);
|
||||
if (changeBaseAttribute) {
|
||||
// support reverting these changes
|
||||
if (changeBaseAttribute || changeComment) {
|
||||
// Support reverting these changes and let platforms that must restate the whole
|
||||
// column definition on any change (e.g. mysql) preserve unchanged attributes -
|
||||
// don't lose the existing comment when altering type/notnull, and have the
|
||||
// current type/notnull available to restate when only the comment is changing.
|
||||
alterColumn.setCurrentType(type);
|
||||
alterColumn.setCurrentNotnull(notnull);
|
||||
alterColumn.setCurrentComment(comment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -2,12 +2,15 @@ package io.ebeaninternal.dbmigration.model.build;
|
||||
|
||||
import io.ebeaninternal.dbmigration.model.MColumn;
|
||||
import io.ebeaninternal.dbmigration.model.MTable;
|
||||
import io.ebean.config.dbplatform.DbPlatformType;
|
||||
import io.ebeaninternal.server.deploy.BeanDescriptor;
|
||||
import io.ebeaninternal.server.deploy.BeanProperty;
|
||||
import io.ebeaninternal.server.deploy.BeanPropertyAssocMany;
|
||||
import io.ebeaninternal.server.deploy.TableJoin;
|
||||
import io.ebeaninternal.server.deploy.TableJoinColumn;
|
||||
|
||||
import java.sql.Types;
|
||||
|
||||
/**
|
||||
* Add the intersection table to the model.
|
||||
*/
|
||||
@@ -79,10 +82,23 @@ class ModelBuildIntersectionTable {
|
||||
for (TableJoinColumn otherColumn : otherColumns) {
|
||||
addColumn(table, targetDesc, otherColumn.getLocalDbColumn(), otherColumn.getForeignDbColumn());
|
||||
}
|
||||
|
||||
if (manyProp.hasIntersectionOrderColumn()) {
|
||||
addOrderColumn(table);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the extra (non PK) order column used to persist {@code @OrderColumn} position
|
||||
* for a ManyToMany relationship.
|
||||
*/
|
||||
private void addOrderColumn(MTable table) {
|
||||
DbPlatformType dbType = ctx.getDbTypeMap().get(Types.INTEGER);
|
||||
MColumn col = new MColumn(manyProp.intersectionOrderColumn(), dbType.renderType(0, 0));
|
||||
col.setNotnull(!manyProp.isIntersectionOrderColumnNullable());
|
||||
table.addColumn(col);
|
||||
}
|
||||
|
||||
private void addColumn(MTable table, BeanDescriptor<?> desc, String column, String findPropColumn) {
|
||||
|
||||
BeanProperty p = desc.idBinder().findBeanProperty(findPropColumn);
|
||||
|
||||
@@ -288,6 +288,7 @@
|
||||
<xsd:attribute name="notnull" type="xsd:boolean"/>
|
||||
<xsd:attribute name="currentNotnull" type="xsd:boolean"/>
|
||||
<xsd:attribute name="comment" type="xsd:string"/>
|
||||
<xsd:attribute name="currentComment" type="xsd:string"/>
|
||||
<xsd:attribute name="historyExclude" type="xsd:boolean"/>
|
||||
<xsd:attribute name="checkConstraint" type="xsd:string"/>
|
||||
<xsd:attribute name="checkConstraintName" type="xsd:string"/>
|
||||
|
||||
+54
@@ -180,6 +180,60 @@ public class PlatformDdl_AlterColumnTest {
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mysql_alterColumn_commentOnly_rebuildsWithType() {
|
||||
AlterColumn alter = new AlterColumn();
|
||||
alter.setTableName("mytab");
|
||||
alter.setColumnName("acol");
|
||||
alter.setCurrentType("varchar(50)");
|
||||
alter.setCurrentNotnull(Boolean.TRUE);
|
||||
alter.setComment("new comment");
|
||||
|
||||
String sql = alterColumn(mysqlDdl, alter);
|
||||
softly.assertThat(sql).isEqualTo("-- apply alter tables\n"
|
||||
+ "alter table mytab modify acol varchar(50) not null comment 'new comment';\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mysql_alterColumn_typeChange_preservesExistingComment() {
|
||||
AlterColumn alter = new AlterColumn();
|
||||
alter.setTableName("mytab");
|
||||
alter.setColumnName("acol");
|
||||
alter.setCurrentType("varchar(20)");
|
||||
alter.setType("varchar(50)");
|
||||
alter.setCurrentComment("existing comment");
|
||||
|
||||
String sql = alterColumn(mysqlDdl, alter);
|
||||
softly.assertThat(sql).isEqualTo("-- apply alter tables\n"
|
||||
+ "alter table mytab modify acol varchar(50) comment 'existing comment';\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mysql_alterColumn_dropComment() {
|
||||
AlterColumn alter = new AlterColumn();
|
||||
alter.setTableName("mytab");
|
||||
alter.setColumnName("acol");
|
||||
alter.setCurrentType("varchar(50)");
|
||||
alter.setComment("DROP COMMENT");
|
||||
alter.setCurrentComment("existing comment");
|
||||
|
||||
String sql = alterColumn(mysqlDdl, alter);
|
||||
softly.assertThat(sql).isEqualTo("-- apply alter tables\n"
|
||||
+ "alter table mytab modify acol varchar(50);\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mysql_alterTableAddColumn_withComment() {
|
||||
Column column = simpleColumn();
|
||||
column.setComment("a comment");
|
||||
|
||||
DdlWrite writer = new DdlWrite();
|
||||
mysqlDdl.alterTableAddColumn(writer, "my_table", column, false, "1");
|
||||
softly.assertThat(writer.toString())
|
||||
.isEqualTo("-- apply alter tables\n"
|
||||
+ "alter table my_table add column my_column int default 1 not null comment 'a comment';\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAlterColumnType() {
|
||||
|
||||
|
||||
@@ -174,6 +174,53 @@ class MColumnTest {
|
||||
assertThat(getAlterColumn(diff).getDefaultValue()).isEqualTo("abc");
|
||||
}
|
||||
|
||||
@Test
|
||||
void diffComment_add() {
|
||||
ModelDiff diff = diff();
|
||||
MColumn newCol = basic();
|
||||
newCol.setComment("a comment");
|
||||
basic().compare(diff, table, newCol);
|
||||
|
||||
assertChanges(diff);
|
||||
AlterColumn alterColumn = getAlterColumn(diff);
|
||||
assertThat(alterColumn.getComment()).isEqualTo("a comment");
|
||||
// no pre-existing base attribute changed, current type/notnull/comment carried
|
||||
// through anyway so platforms that must restate the whole column (e.g. mysql)
|
||||
// have what they need to rebuild the statement
|
||||
assertThat(alterColumn.getCurrentType()).isEqualTo("integer");
|
||||
assertThat(alterColumn.getCurrentComment()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void diffComment_remove() {
|
||||
ModelDiff diff = diff();
|
||||
MColumn newCol = basic();
|
||||
MColumn oldCol = basic();
|
||||
oldCol.setComment("a comment");
|
||||
oldCol.compare(diff, table, newCol);
|
||||
|
||||
assertChanges(diff);
|
||||
assertThat(getAlterColumn(diff).getComment()).isEqualTo("DROP COMMENT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void diffType_preservesCurrentComment() {
|
||||
ModelDiff diff = diff();
|
||||
MColumn oldCol = basic();
|
||||
oldCol.setComment("existing comment");
|
||||
MColumn newCol = new MColumn("col", "integer(8)");
|
||||
newCol.setComment("existing comment");
|
||||
oldCol.compare(diff, table, newCol);
|
||||
|
||||
assertChanges(diff);
|
||||
AlterColumn alterColumn = getAlterColumn(diff);
|
||||
assertThat(alterColumn.getType()).isEqualTo("integer(8)");
|
||||
assertThat(alterColumn.getComment()).isNull();
|
||||
// current comment recorded so mysql can restate it when rebuilding the full
|
||||
// column definition for the type change
|
||||
assertThat(alterColumn.getCurrentComment()).isEqualTo("existing comment");
|
||||
}
|
||||
|
||||
@Test
|
||||
void diffReferencesAdd() {
|
||||
ModelDiff diff = diff();
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.2</version>
|
||||
<version>42.7.11</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+55
@@ -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
Reference in New Issue
Block a user