diff --git a/README.adoc b/README.adoc
index 88d5e59d1..a75990414 100644
--- a/README.adoc
+++ b/README.adoc
@@ -45,13 +45,13 @@ Maven (Java)::
st.ormstorm-java21
- 1.8.0
+ 1.8.1compilest.ormstorm-core
- 1.8.0
+ 1.8.1runtime
----
@@ -59,15 +59,15 @@ Gradle (Java)::
+
[source,groovy]
----
-implementation 'st.orm:storm-java21:1.8.0'
-runtimeOnly 'st.orm:storm-core:1.8.0'
+implementation 'st.orm:storm-java21:1.8.1'
+runtimeOnly 'st.orm:storm-core:1.8.1'
----
Gradle (Kotlin)::
+
[source,groovy]
----
-implementation 'st.orm:storm-kotlin:1.8.0'
-runtimeOnly 'st.orm:storm-core:1.8.0'
+implementation 'st.orm:storm-kotlin:1.8.1'
+runtimeOnly 'st.orm:storm-core:1.8.1'
----
====
@@ -96,7 +96,7 @@ Java::
record City(@PK Integer id,
String name,
long population
-) implements Entity {}
+) implements Entity {}
record User(@PK Integer id,
String email,
@@ -1192,7 +1192,7 @@ Maven::
st.ormstorm-oracle
- 1.8.0
+ 1.8.1runtime
----
@@ -1200,7 +1200,7 @@ Gradle::
+
[source,groovy]
----
-runtimeOnly 'st.orm:storm-oracle:1.8.0'
+runtimeOnly 'st.orm:storm-oracle:1.8.1'
----
====
@@ -1222,7 +1222,7 @@ Maven::
st.ormstorm-metamodel-processor
- 1.8.0
+ 1.8.1provided
----
@@ -1230,7 +1230,7 @@ Gradle::
+
[source,groovy]
----
-annotationProcessor 'st.orm:storm-metamodel-processor:1.8.0'
+annotationProcessor 'st.orm:storm-metamodel-processor:1.8.1'
----
====
@@ -1294,7 +1294,7 @@ Maven (Jackson)::
st.ormstorm-jackson
- 1.8.0
+ 1.8.1compile
----
@@ -1302,13 +1302,13 @@ Gradle (Jackson)::
+
[source,groovy]
----
-implementation 'st.orm:storm-jackson:1.8.0'
+implementation 'st.orm:storm-jackson:1.8.1'
----
Gradle (Kotlinx Serialization)::
+
[source,groovy]
----
-implementation 'st.orm:storm-kotlinx-serialization:1.8.0'
+implementation 'st.orm:storm-kotlinx-serialization:1.8.1'
----
====
@@ -1431,6 +1431,11 @@ EntityRepository, a DSL query, or a SQL template. This observed state is used as
Dirty checking is only applied when updates are executed through an EntityRepository. Manual SQL updates, bulk
statements, or custom queries bypass dirty checking entirely and may leave in-memory entities stale.
+Unless configured otherwise, entity observation is automatically disabled for `READ_UNCOMMITTED` transactions. At this
+isolation level, the application expects to see uncommitted changes from other transactions. Caching observed state
+would mask these changes, contradicting the requested isolation semantics. When observation is disabled, dirty checking
+treats all entities as dirty, resulting in full-row updates.
+
Dirty checking affects both whether an UPDATE is issued and how that UPDATE is constructed. This behavior is
controlled by UpdateMode and by the dirty checking strategy.
@@ -1598,7 +1603,7 @@ Maven (Java)::
st.ormstorm-spring
- 1.8.0
+ 1.8.1compile
----
@@ -1606,7 +1611,7 @@ Gradle (Java)::
+
[source,groovy]
----
-implementation 'st.orm:storm-spring:1.8.0'
+implementation 'st.orm:storm-spring:1.8.1'
----
Maven (Kotlin)::
+
@@ -1615,7 +1620,7 @@ Maven (Kotlin)::
st.ormstorm-kotlin-spring
- 1.8.0
+ 1.8.1compile
----
@@ -1623,7 +1628,7 @@ Gradle (Kotlin)::
+
[source,groovy]
----
-implementation 'st.orm:storm-kotlin-spring:1.8.0'
+implementation 'st.orm:storm-kotlin-spring:1.8.1'
----
====
diff --git a/pom.xml b/pom.xml
index c8c6abad4..b8d2d744e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -16,7 +16,7 @@
st.ormstorm-framework
- 1.8.0
+ 1.8.1pomStorm FrameworkA SQL Template and ORM framework, focusing on modernizing and simplifying database programming.
diff --git a/storm-core/pom.xml b/storm-core/pom.xml
index b137da53e..f098eb3bc 100644
--- a/storm-core/pom.xml
+++ b/storm-core/pom.xml
@@ -6,7 +6,7 @@
st.ormstorm-framework
- 1.8.0
+ 1.8.1../pom.xmlstorm-core
diff --git a/storm-core/src/main/java/st/orm/core/repository/impl/EntityRepositoryImpl.java b/storm-core/src/main/java/st/orm/core/repository/impl/EntityRepositoryImpl.java
index ae868a894..515dbb011 100644
--- a/storm-core/src/main/java/st/orm/core/repository/impl/EntityRepositoryImpl.java
+++ b/storm-core/src/main/java/st/orm/core/repository/impl/EntityRepositoryImpl.java
@@ -290,15 +290,14 @@ public E insertAndFetch(@Nonnull E entity) {
}
/**
- * Returns the entity cache for the current transaction, or null.
+ * Returns the entity cache for the current transaction, if available.
*
- * @return the entity cache for the current transaction, or null.
+ * @return the entity cache for the current transaction, or empty if not available.
* @since 1.7
*/
protected Optional> entityCache() {
//noinspection unchecked
return TRANSACTION_TEMPLATE.currentContext()
- .filter(ctx -> !ctx.isReadOnly())
.map(ctx -> (EntityCache) ctx.entityCache(model().type()));
}
diff --git a/storm-core/src/main/java/st/orm/core/spi/DefaultTransactionTemplateProviderImpl.java b/storm-core/src/main/java/st/orm/core/spi/DefaultTransactionTemplateProviderImpl.java
index 19bc48c4a..77df356a4 100644
--- a/storm-core/src/main/java/st/orm/core/spi/DefaultTransactionTemplateProviderImpl.java
+++ b/storm-core/src/main/java/st/orm/core/spi/DefaultTransactionTemplateProviderImpl.java
@@ -22,6 +22,7 @@
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
+import java.sql.Connection;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
@@ -35,6 +36,42 @@ public class DefaultTransactionTemplateProviderImpl implements TransactionTempla
private static final Object SPRING_CTX_RESOURCE_KEY =
DefaultTransactionTemplateProviderImpl.class.getName() + ".SPRING_TX_CONTEXT";
+ /**
+ * Minimum transaction isolation level required for entity caching to be enabled.
+ *
+ *
Transactions with an isolation level below this threshold will not use entity caching, which means dirty
+ * checking will treat all entities as dirty (resulting in full-row updates). This prevents the entity cache from
+ * masking changes that the application expects to see at lower isolation levels.
+ *
+ *
The default value is {@link Connection#TRANSACTION_READ_COMMITTED}, meaning entity caching is disabled only
+ * for {@code READ_UNCOMMITTED} transactions. This can be overridden using the system property
+ * {@code storm.entityCache.minIsolationLevel}.
+ */
+ private static final int MIN_ISOLATION_LEVEL_FOR_CACHE = parseMinIsolationLevel();
+
+ private static int parseMinIsolationLevel() {
+ String value = System.getProperty("storm.entityCache.minIsolationLevel");
+ if (value == null || value.isBlank()) {
+ return Connection.TRANSACTION_READ_COMMITTED;
+ }
+ value = value.trim().toUpperCase();
+ return switch (value) {
+ case "NONE", "0" -> Connection.TRANSACTION_NONE;
+ case "READ_UNCOMMITTED", "1" -> Connection.TRANSACTION_READ_UNCOMMITTED;
+ case "READ_COMMITTED", "2" -> Connection.TRANSACTION_READ_COMMITTED;
+ case "REPEATABLE_READ", "4" -> Connection.TRANSACTION_REPEATABLE_READ;
+ case "SERIALIZABLE", "8" -> Connection.TRANSACTION_SERIALIZABLE;
+ default -> {
+ try {
+ yield Integer.parseInt(value);
+ } catch (NumberFormatException e) {
+ throw new PersistenceException(
+ "Invalid value for storm.entityCache.minIsolationLevel: '%s'.".formatted(value));
+ }
+ }
+ };
+ }
+
@Override
public TransactionTemplate getTransactionTemplate() {
return new TransactionTemplate() {
@@ -126,6 +163,13 @@ public boolean isReadOnly() {
@Override
public EntityCache extends Entity>, ?> entityCache(@Nonnull Class extends Entity>> entityType) {
+ // Check if entity caching is disabled for this isolation level.
+ Integer isolationLevel = springReflection.getCurrentTransactionIsolationLevel();
+ // Spring returns null when no explicit isolation level is set (database default).
+ // In that case, we assume the database default (typically READ_COMMITTED or higher) and enable caching.
+ if (isolationLevel != null && isolationLevel < MIN_ISOLATION_LEVEL_FOR_CACHE) {
+ return null;
+ }
// We use computeIfAbsent so the "get or create" is a single operation.
//
// Why:
@@ -158,6 +202,7 @@ private static final class SpringReflection {
private final Method isActualTransactionActive;
private final Method isCurrentTransactionReadOnly;
+ private final Method getCurrentTransactionIsolationLevel;
private final Method getResource;
private final Method bindResource;
private final Method registerSynchronization;
@@ -167,6 +212,7 @@ private static final class SpringReflection {
private SpringReflection(
Method isActualTransactionActive,
Method isCurrentTransactionReadOnly,
+ Method getCurrentTransactionIsolationLevel,
Method getResource,
Method bindResource,
Method registerSynchronization,
@@ -175,6 +221,7 @@ private SpringReflection(
) {
this.isActualTransactionActive = isActualTransactionActive;
this.isCurrentTransactionReadOnly = isCurrentTransactionReadOnly;
+ this.getCurrentTransactionIsolationLevel = getCurrentTransactionIsolationLevel;
this.getResource = getResource;
this.bindResource = bindResource;
this.registerSynchronization = registerSynchronization;
@@ -188,6 +235,7 @@ static SpringReflection tryLoad() {
Class> tsm = Class.forName(TSM_FQCN, false, classLoader);
Method isActualTransactionActive = tsm.getMethod("isActualTransactionActive");
Method isCurrentTransactionReadOnly = tsm.getMethod("isCurrentTransactionReadOnly");
+ Method getCurrentTransactionIsolationLevel = tsm.getMethod("getCurrentTransactionIsolationLevel");
Method getResource = tsm.getMethod("getResource", Object.class);
Method bindResource = tsm.getMethod("bindResource", Object.class, Object.class);
// Cleanup hooks (may not exist in very old Spring).
@@ -204,6 +252,7 @@ static SpringReflection tryLoad() {
return new SpringReflection(
isActualTransactionActive,
isCurrentTransactionReadOnly,
+ getCurrentTransactionIsolationLevel,
getResource,
bindResource,
registerSynchronization,
@@ -231,6 +280,14 @@ boolean isCurrentTransactionReadOnly() {
}
}
+ Integer getCurrentTransactionIsolationLevel() {
+ try {
+ return (Integer) getCurrentTransactionIsolationLevel.invoke(null);
+ } catch (Throwable t) {
+ return null;
+ }
+ }
+
Object getResource(Object key) {
try {
return getResource.invoke(null, key);
diff --git a/storm-core/src/main/java/st/orm/core/spi/EntityCacheImpl.java b/storm-core/src/main/java/st/orm/core/spi/EntityCacheImpl.java
index b44d86178..33e76a196 100644
--- a/storm-core/src/main/java/st/orm/core/spi/EntityCacheImpl.java
+++ b/storm-core/src/main/java/st/orm/core/spi/EntityCacheImpl.java
@@ -57,8 +57,11 @@
*/
public final class EntityCacheImpl, ID> implements EntityCache {
+ /** Queue for tracking garbage-collected entities to enable lazy cleanup of {@link #map}. */
private final ReferenceQueue queue = new ReferenceQueue<>();
- private final Map> map = new HashMap<>();
+
+ /** Map from primary key to weakly-referenced entity. Keys are held strongly; values are weak references. */
+ private final Map> map = new HashMap<>();
/**
* Retrieves an entity from the cache by primary key, if available.
@@ -76,7 +79,7 @@ public final class EntityCacheImpl, ID> implements EntityCa
@Override
public Optional get(@Nonnull ID pk) {
drainQueue();
- PkWeakRef ref = map.get(pk);
+ PkWeakReference ref = map.get(pk);
if (ref == null) {
return Optional.empty();
}
@@ -110,14 +113,14 @@ public Optional get(@Nonnull ID pk) {
public E intern(@Nonnull E entity) {
drainQueue();
ID pk = entity.id();
- PkWeakRef existingRef = map.get(pk);
+ PkWeakReference existingRef = map.get(pk);
if (existingRef != null) {
E existing = existingRef.get();
if (existing != null && existing.equals(entity)) {
return existing;
}
}
- map.put(pk, new PkWeakRef<>(pk, entity, queue));
+ map.put(pk, new PkWeakReference<>(pk, entity, queue));
return entity;
}
@@ -135,7 +138,7 @@ public E intern(@Nonnull E entity) {
public void set(@Nonnull E entity) {
drainQueue();
ID pk = entity.id();
- map.put(pk, new PkWeakRef<>(pk, entity, queue));
+ map.put(pk, new PkWeakReference<>(pk, entity, queue));
}
/**
@@ -153,7 +156,7 @@ public void set(@Nonnull Iterable extends E> entities) {
drainQueue();
for (E entity : entities) {
ID pk = entity.id();
- map.put(pk, new PkWeakRef<>(pk, entity, queue));
+ map.put(pk, new PkWeakReference<>(pk, entity, queue));
}
}
@@ -239,18 +242,34 @@ public void clear() {
drainQueue();
}
+ /**
+ * Removes stale entries from {@link #map} by polling the reference queue.
+ *
+ *
When an entity is garbage collected, its {@link PkWeakReference} is enqueued. This method polls the queue
+ * and removes the corresponding entries from the map. Uses a two-argument remove to ensure only the exact
+ * weak reference is removed, preventing removal of a newer entry that may have been added with the same key.
+ */
private void drainQueue() {
- PkWeakRef ref;
+ PkWeakReference weakReference;
//noinspection unchecked
- while ((ref = (PkWeakRef) queue.poll()) != null) {
- map.remove(ref.pk, ref);
+ while ((weakReference = (PkWeakReference) queue.poll()) != null) {
+ map.remove(weakReference.pk, weakReference);
}
}
- private static final class PkWeakRef extends WeakReference {
+ /**
+ * A weak reference to an entity that retains the associated primary key for map cleanup.
+ *
+ *
When the entity is garbage collected, this reference is enqueued in the {@link ReferenceQueue}, allowing
+ * {@link #drainQueue()} to remove the corresponding entry from {@link #map} using the stored primary key.
+ *
+ * @param the primary key type.
+ * @param the entity type.
+ */
+ private static final class PkWeakReference extends WeakReference {
final ID pk;
- PkWeakRef(ID pk, E referent, ReferenceQueue super E> q) {
+ PkWeakReference(ID pk, E referent, ReferenceQueue super E> q) {
super(referent, q);
this.pk = pk;
}
diff --git a/storm-core/src/main/java/st/orm/core/spi/TransactionContext.java b/storm-core/src/main/java/st/orm/core/spi/TransactionContext.java
index acdf0ac67..601c1996b 100644
--- a/storm-core/src/main/java/st/orm/core/spi/TransactionContext.java
+++ b/storm-core/src/main/java/st/orm/core/spi/TransactionContext.java
@@ -16,6 +16,7 @@
package st.orm.core.spi;
import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
import st.orm.Entity;
/**
@@ -33,7 +34,19 @@ public interface TransactionContext {
/**
* Returns a transaction-local cache for entities of the given type, keyed by primary key.
+ *
+ *
Returns {@code null} if entity caching is disabled for this transaction. This can happen when the
+ * transaction's isolation level is below the configured minimum for entity caching. At low isolation levels
+ * (e.g., {@code READ_UNCOMMITTED}), entity caching is disabled to prevent the cache from masking changes
+ * that the application expects to see.
+ *
+ *
When {@code null} is returned, dirty checking will treat all entities as dirty, resulting in full-row
+ * updates.
+ *
+ * @param entityType the entity type for which to retrieve the cache.
+ * @return the entity cache, or {@code null} if caching is disabled for this transaction.
*/
+ @Nullable
EntityCache extends Entity>, ?> entityCache(@Nonnull Class extends Entity>> entityType);
/**
diff --git a/storm-core/src/main/java/st/orm/core/spi/WeakInterner.java b/storm-core/src/main/java/st/orm/core/spi/WeakInterner.java
index 527641a28..d4071ef1a 100644
--- a/storm-core/src/main/java/st/orm/core/spi/WeakInterner.java
+++ b/storm-core/src/main/java/st/orm/core/spi/WeakInterner.java
@@ -16,64 +16,180 @@
package st.orm.core.spi;
import jakarta.annotation.Nonnull;
+import st.orm.Entity;
+import st.orm.Ref;
+import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;
+import java.util.HashMap;
import java.util.Map;
import java.util.WeakHashMap;
import static java.util.Objects.requireNonNull;
/**
- * A weak interner that allows fast lookups and retrieval of existing instances based on equality, while holding
- * elements weakly to permit garbage collection.
+ * A weak interner that ensures canonical instances of objects while holding them weakly to permit garbage collection.
+ *
+ *
This class uses a dual-path interning strategy optimized for different object types:
+ *
+ *
Entities: Uses primary key-based lookup via {@link Ref} for efficient equality checks. Entities are
+ * stored in a separate map with {@link ReferenceQueue}-based cleanup to ensure stale entries are removed when
+ * entities are garbage collected.
+ *
Non-entities: Uses object equality-based lookup via {@link WeakHashMap}, which provides automatic
+ * cleanup when objects are no longer strongly referenced.
+ *
+ *
+ *
The primary key-based lookup for entities avoids potentially expensive deep equality checks on complex entity
+ * objects, while maintaining correct identity semantics (same primary key = same canonical instance).
+ *
+ *
This class is not thread-safe. A new instance is expected to be created for each result set processing call,
+ * ensuring that interning is scoped to a single query execution.
*/
public final class WeakInterner {
+
+ /** Map for non-entity objects, using object equality for lookup. Keys are held weakly. */
private final Map