From 5f8fbc93e74732a894ae3f0dba8ca7a45630c83f Mon Sep 17 00:00:00 2001 From: Rob Bygrave Date: Tue, 21 Apr 2026 23:49:29 +1200 Subject: [PATCH 01/10] Change [implicit] Lazy loading to be by TYPE rather than by PATH We have some graph models where a common type (in the tests it is Label) is used in many different *paths* of the graph. When we are loading via *path* then all the loading of the Labels isn't in a single batch / load context but instead split into different load contexts PER PATH. This change, means that lazy loading operates by TYPE instead of by PATH. In the tests, all the Labels are read via a single lazy loading query rather that one lazy loading query per PATH, and this is more efficient. --- .../server/loadcontext/DLoadContext.java | 34 +++++-- .../lazyforeignkeys/TestLazyForeignKeys.java | 3 +- .../TestFetchOneToManySameTypeTwoPaths.java | 33 +++++++ .../tests}/resource/AttributeDescriptor.java | 3 +- .../org/tests}/resource/AttributeValue.java | 4 +- .../tests}/resource/AttributeValueOwner.java | 3 +- .../java/org/tests/resource/BaseModel.java | 34 +++++++ .../test/java/org/tests}/resource/Label.java | 3 +- .../java/org/tests}/resource/LabelText.java | 3 +- .../java/org/tests}/resource/Resource.java | 3 +- .../tests/resource/ResourceEntityTest.java | 92 +++++++++++++++++++ .../example/resource/ResourceEntityTest.java | 61 ------------ 12 files changed, 191 insertions(+), 85 deletions(-) rename {tests/test-java16/src/main/java/org/example => ebean-test/src/test/java/org/tests}/resource/AttributeDescriptor.java (89%) rename {tests/test-java16/src/main/java/org/example => ebean-test/src/test/java/org/tests}/resource/AttributeValue.java (93%) rename {tests/test-java16/src/main/java/org/example => ebean-test/src/test/java/org/tests}/resource/AttributeValueOwner.java (90%) create mode 100644 ebean-test/src/test/java/org/tests/resource/BaseModel.java rename {tests/test-java16/src/main/java/org/example => ebean-test/src/test/java/org/tests}/resource/Label.java (90%) rename {tests/test-java16/src/main/java/org/example => ebean-test/src/test/java/org/tests}/resource/LabelText.java (92%) rename {tests/test-java16/src/main/java/org/example => ebean-test/src/test/java/org/tests}/resource/Resource.java (93%) create mode 100644 ebean-test/src/test/java/org/tests/resource/ResourceEntityTest.java delete mode 100644 tests/test-java16/src/test/java/org/example/resource/ResourceEntityTest.java diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/loadcontext/DLoadContext.java b/ebean-core/src/main/java/io/ebeaninternal/server/loadcontext/DLoadContext.java index feac1ab558..9ba5963c4b 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/loadcontext/DLoadContext.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/loadcontext/DLoadContext.java @@ -20,7 +20,14 @@ public final class DLoadContext implements LoadContext { private final SpiEbeanServer ebeanServer; private final BeanDescriptor rootDescriptor; + /** + * Path based contexts used for +query secondary query execution. + */ private final Map beanMap = new HashMap<>(); + /** + * Type based contexts used for assoc-one lazy loading registration. + */ + private final Map lazyBeanMap = new HashMap<>(); private final Map manyMap = new HashMap<>(); private final DLoadBeanContext rootBeanContext; private final boolean asDraft; @@ -246,12 +253,22 @@ public PersistenceContext persistenceContext() { @Override public void register(String path, EntityBeanIntercept ebi) { - beanContext(path).register(ebi); + DLoadBeanContext context = pathBeanContext(path); + if (context != null) { + context.register(ebi); + } else { + lazyBeanContext(descriptor(ebi)).register(ebi); + } } @Override public void register(String path, EntityBeanIntercept ebi, BeanPropertyAssocOne property) { - beanContextWithInherit(path, property).register(ebi); + DLoadBeanContext context = pathBeanContext(path); + if (context != null) { + context.register(ebi); + } else { + lazyBeanContext(property.targetDescriptor()).register(ebi); + } } @Override @@ -267,16 +284,15 @@ int batchSize(OrmQueryProperties props) { return batchSize == 0 ? defaultBatchSize : batchSize; } - DLoadBeanContext beanContext(String path) { + private DLoadBeanContext pathBeanContext(String path) { if (path == null) { return rootBeanContext; } - return beanMap.computeIfAbsent(path, p -> createBeanContext(p, null)); + return beanMap.get(path); } - DLoadBeanContext beanContextWithInherit(String path, BeanPropertyAssocOne property) { - String key = path + ":" + property.targetDescriptor().name(); - return beanMap.computeIfAbsent(key, p -> createBeanContext(property, path)); + private DLoadBeanContext lazyBeanContext(BeanDescriptor descriptor) { + return lazyBeanMap.computeIfAbsent(descriptor.fullName(), p -> new DLoadBeanContext(this, descriptor, descriptor.name(), null)); } private void registerSecondaryNode(boolean many, OrmQueryProperties props) { @@ -306,8 +322,8 @@ private DLoadBeanContext createBeanContext(String path, OrmQueryProperties query return new DLoadBeanContext(this, p.targetDescriptor(), path, queryProps); } - private DLoadBeanContext createBeanContext(BeanPropertyAssoc property, String path) { - return new DLoadBeanContext(this, property.targetDescriptor(), path, null); + private BeanDescriptor descriptor(EntityBeanIntercept ebi) { + return rootDescriptor.descriptor(ebi.owner().getClass()); } private BeanProperty beanProperty(BeanDescriptor desc, String path) { diff --git a/ebean-test/src/test/java/org/tests/lazyforeignkeys/TestLazyForeignKeys.java b/ebean-test/src/test/java/org/tests/lazyforeignkeys/TestLazyForeignKeys.java index f88f148297..24ae33647f 100644 --- a/ebean-test/src/test/java/org/tests/lazyforeignkeys/TestLazyForeignKeys.java +++ b/ebean-test/src/test/java/org/tests/lazyforeignkeys/TestLazyForeignKeys.java @@ -56,13 +56,12 @@ public void testFindOne() throws Exception { assertTrue(rel1.getEntity2().isDeleted()); List sql = LoggedSql.stop(); - assertThat(sql).hasSize(3); + assertThat(sql).hasSize(2); assertSql(sql.get(0)).contains("select t0.id, t0.attr1, t0.id1, t0.id2, t1.species, t0.cat_id from main_entity_relation t0 left join animal t1 on t1.id = t0.cat_id"); if (isSqlServer() || isOracle()) { assertSql(sql.get(1)).contains("select t0.id, t0.attr1, t0.attr2, CASE WHEN t0.id is null THEN 1 ELSE 0 END from main_entity t0"); } else { assertSql(sql.get(1)).contains("select t0.id, t0.attr1, t0.attr2, t0.id is null from main_entity t0"); - assertSql(sql.get(2)).contains("select t0.id, t0.attr1, t0.attr2, t0.id is null from main_entity t0"); } } diff --git a/ebean-test/src/test/java/org/tests/o2m/recurse/TestFetchOneToManySameTypeTwoPaths.java b/ebean-test/src/test/java/org/tests/o2m/recurse/TestFetchOneToManySameTypeTwoPaths.java index 34df0ac88d..07a6b0cd50 100644 --- a/ebean-test/src/test/java/org/tests/o2m/recurse/TestFetchOneToManySameTypeTwoPaths.java +++ b/ebean-test/src/test/java/org/tests/o2m/recurse/TestFetchOneToManySameTypeTwoPaths.java @@ -3,9 +3,14 @@ import io.ebean.DB; import io.ebean.Database; import io.ebean.FetchConfig; +import io.ebean.test.LoggedSql; import org.junit.jupiter.api.Test; +import java.util.List; + import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; class TestFetchOneToManySameTypeTwoPaths { @@ -130,4 +135,32 @@ void testSubItemListFetch_itemBFirst() { } } + @Test + void testLazyAssocOneSameTypeTwoPaths_singleQuery() { + Database server = DB.getDefault(); + + RMItem itemA = new RMItem("lazy-a"); + server.save(itemA); + RMItem itemB = new RMItem("lazy-b"); + server.save(itemB); + + RMItemHolder holder = new RMItemHolder("holder-lazy"); + holder.setItemA(itemA); + holder.setItemB(itemB); + server.save(holder); + + RMItemHolder found = server.find(RMItemHolder.class) + .select("name,itemA,itemB") + .where().idEq(holder.getId()) + .findOne(); + + LoggedSql.start(); + assertNull(found.getItemA().getItemGroup()); + assertNull(found.getItemB().getItemGroup()); + List sql = LoggedSql.stop(); + + assertEquals(1, sql.size()); + assertTrue(sql.get(0).contains(" from rmitem ")); + } + } diff --git a/tests/test-java16/src/main/java/org/example/resource/AttributeDescriptor.java b/ebean-test/src/test/java/org/tests/resource/AttributeDescriptor.java similarity index 89% rename from tests/test-java16/src/main/java/org/example/resource/AttributeDescriptor.java rename to ebean-test/src/test/java/org/tests/resource/AttributeDescriptor.java index 0d09614b54..b6d30921b9 100644 --- a/tests/test-java16/src/main/java/org/example/resource/AttributeDescriptor.java +++ b/ebean-test/src/test/java/org/tests/resource/AttributeDescriptor.java @@ -1,9 +1,8 @@ -package org.example.resource; +package org.tests.resource; import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.ManyToOne; -import org.example.records.BaseModel; @Entity public class AttributeDescriptor extends BaseModel { diff --git a/tests/test-java16/src/main/java/org/example/resource/AttributeValue.java b/ebean-test/src/test/java/org/tests/resource/AttributeValue.java similarity index 93% rename from tests/test-java16/src/main/java/org/example/resource/AttributeValue.java rename to ebean-test/src/test/java/org/tests/resource/AttributeValue.java index d997ffb95b..9b6b366591 100644 --- a/tests/test-java16/src/main/java/org/example/resource/AttributeValue.java +++ b/ebean-test/src/test/java/org/tests/resource/AttributeValue.java @@ -1,9 +1,7 @@ -package org.example.resource; +package org.tests.resource; import jakarta.persistence.Entity; -import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import org.example.records.BaseModel; @Entity public class AttributeValue extends BaseModel { diff --git a/tests/test-java16/src/main/java/org/example/resource/AttributeValueOwner.java b/ebean-test/src/test/java/org/tests/resource/AttributeValueOwner.java similarity index 90% rename from tests/test-java16/src/main/java/org/example/resource/AttributeValueOwner.java rename to ebean-test/src/test/java/org/tests/resource/AttributeValueOwner.java index ad5036878d..c0ad9402d9 100644 --- a/tests/test-java16/src/main/java/org/example/resource/AttributeValueOwner.java +++ b/ebean-test/src/test/java/org/tests/resource/AttributeValueOwner.java @@ -1,9 +1,8 @@ -package org.example.resource; +package org.tests.resource; import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.OneToMany; -import org.example.records.BaseModel; import java.util.List; diff --git a/ebean-test/src/test/java/org/tests/resource/BaseModel.java b/ebean-test/src/test/java/org/tests/resource/BaseModel.java new file mode 100644 index 0000000000..7258e52f30 --- /dev/null +++ b/ebean-test/src/test/java/org/tests/resource/BaseModel.java @@ -0,0 +1,34 @@ +package org.tests.resource; + +import io.ebean.Model; +import io.ebean.annotation.Identity; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.Version; + +@Identity(start = 1000, cache = 100) +@MappedSuperclass +public class BaseModel extends Model { + + @Id + long id; + + @Version + long version; + + public long id() { + return id; + } + + public void id(long id) { + this.id = id; + } + + public long version() { + return version; + } + + public void version(long version) { + this.version = version; + } +} diff --git a/tests/test-java16/src/main/java/org/example/resource/Label.java b/ebean-test/src/test/java/org/tests/resource/Label.java similarity index 90% rename from tests/test-java16/src/main/java/org/example/resource/Label.java rename to ebean-test/src/test/java/org/tests/resource/Label.java index 9f61681c23..9f860a4d15 100644 --- a/tests/test-java16/src/main/java/org/example/resource/Label.java +++ b/ebean-test/src/test/java/org/tests/resource/Label.java @@ -1,9 +1,8 @@ -package org.example.resource; +package org.tests.resource; import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.OneToMany; -import org.example.records.BaseModel; import java.util.List; import java.util.Locale; diff --git a/tests/test-java16/src/main/java/org/example/resource/LabelText.java b/ebean-test/src/test/java/org/tests/resource/LabelText.java similarity index 92% rename from tests/test-java16/src/main/java/org/example/resource/LabelText.java rename to ebean-test/src/test/java/org/tests/resource/LabelText.java index 468f3e2278..7377829c3d 100644 --- a/tests/test-java16/src/main/java/org/example/resource/LabelText.java +++ b/ebean-test/src/test/java/org/tests/resource/LabelText.java @@ -1,8 +1,7 @@ -package org.example.resource; +package org.tests.resource; import jakarta.persistence.Entity; import jakarta.persistence.ManyToOne; -import org.example.records.BaseModel; import java.util.Locale; diff --git a/tests/test-java16/src/main/java/org/example/resource/Resource.java b/ebean-test/src/test/java/org/tests/resource/Resource.java similarity index 93% rename from tests/test-java16/src/main/java/org/example/resource/Resource.java rename to ebean-test/src/test/java/org/tests/resource/Resource.java index f6f8ab7475..844806cd16 100644 --- a/tests/test-java16/src/main/java/org/example/resource/Resource.java +++ b/ebean-test/src/test/java/org/tests/resource/Resource.java @@ -1,7 +1,6 @@ -package org.example.resource; +package org.tests.resource; import jakarta.persistence.*; -import org.example.records.BaseModel; @Entity public class Resource extends BaseModel { diff --git a/ebean-test/src/test/java/org/tests/resource/ResourceEntityTest.java b/ebean-test/src/test/java/org/tests/resource/ResourceEntityTest.java new file mode 100644 index 0000000000..2d421d812b --- /dev/null +++ b/ebean-test/src/test/java/org/tests/resource/ResourceEntityTest.java @@ -0,0 +1,92 @@ +package org.tests.resource; + +import io.ebean.DB; +import io.ebean.test.LoggedSql; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Locale; + +import static org.assertj.core.api.Assertions.assertThat; + +class ResourceEntityTest { + + static AttributeDescriptor heightDescriptor; + static AttributeDescriptor widthDescriptor; + static Resource resource; + + @BeforeAll + static void setup() { + Locale en = Locale.GERMAN; + Locale de = Locale.ENGLISH; + + heightDescriptor = new AttributeDescriptor(); + heightDescriptor.setName(new Label()); + heightDescriptor.getName().addLabelText(en, "Height"); + heightDescriptor.getName().addLabelText(de, "Höhe"); + heightDescriptor.setDescription(new Label()); + heightDescriptor.getDescription().addLabelText(en, "Height property"); + heightDescriptor.getDescription().addLabelText(de, "Höhe Eigenschaft"); + + DB.save(heightDescriptor); + + widthDescriptor = new AttributeDescriptor(); + widthDescriptor.setName(new Label()); + widthDescriptor.getName().addLabelText(en, "Width"); + widthDescriptor.getName().addLabelText(de, "Breite"); + widthDescriptor.setDescription(new Label()); + widthDescriptor.getDescription().addLabelText(en, "Width property"); + widthDescriptor.getDescription().addLabelText(de, "Breite Eigenschaft"); + + DB.save(widthDescriptor); + + resource = new Resource(); + resource.setResourceId("R1"); + resource.setName(new Label()); + resource.getName().addLabelText(en, "R1_en"); + resource.getName().addLabelText(de, "R1_de"); + + resource.setAttributeValueOwner(new AttributeValueOwner()); + resource.getAttributeValueOwner().addAttributeValue(new AttributeValue(1, heightDescriptor)); + resource.getAttributeValueOwner().addAttributeValue(new AttributeValue(2, widthDescriptor)); + + DB.save(resource); + } + + @Test + void testSimpleResource() { + var resources = DB.find(Resource.class) + .where().eq("resourceId", resource.getResourceId()) + .findList(); + + assertThat(resources).isNotEmpty(); + } + + @Test + void find_then_invokeLazyLoading_expect_singleLazyLoadingQueryForSameType() { + AttributeDescriptor one = DB.find(AttributeDescriptor.class).setId(heightDescriptor.id()) + .findOne(); + + assertThat(one) + .describedAs("setup data exists") + .isNotNull(); + + LoggedSql.start(); + assertThat(one.getName().version()) + .describedAs("lazy loaded name label") + .isGreaterThan(0); + + assertThat(one.getDescription().version()) + .describedAs("lazy loaded description label") + .isGreaterThan(0); + + List sql = LoggedSql.stop(); + + assertThat(sql).hasSize(1); + assertThat(sql.get(0)) + .describedAs("lazy loading query invoked by getName/getDescription") + .contains("select t0.id, t0.version from label t0 where t0.id in "); + } + +} diff --git a/tests/test-java16/src/test/java/org/example/resource/ResourceEntityTest.java b/tests/test-java16/src/test/java/org/example/resource/ResourceEntityTest.java deleted file mode 100644 index de42686df2..0000000000 --- a/tests/test-java16/src/test/java/org/example/resource/ResourceEntityTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package org.example.resource; - -import io.ebean.DB; -import org.example.records.CourseRecordEntity; -import org.example.records.query.QCourseRecordEntity; -import org.example.resource.query.QResource; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.Locale; - -import static org.assertj.core.api.Assertions.assertThat; - -class ResourceEntityTest { - - @Test - void testSimpleResource() { - Locale en = Locale.GERMAN; - Locale de = Locale.ENGLISH; - - AttributeDescriptor heightDescriptor = new AttributeDescriptor(); - heightDescriptor.setName(new Label()); - heightDescriptor.getName().addLabelText(en,"Height"); - heightDescriptor.getName().addLabelText(de,"Höhe"); - heightDescriptor.setDescription(new Label()); - heightDescriptor.getDescription().addLabelText(en, "Height property"); - heightDescriptor.getDescription().addLabelText(de, "Höhe Eigenschaft"); - - DB.save(heightDescriptor); - - AttributeDescriptor widthDescriptor = new AttributeDescriptor(); - widthDescriptor.setName(new Label()); - widthDescriptor.getName().addLabelText(en,"Width"); - widthDescriptor.getName().addLabelText(de,"Breite"); - widthDescriptor.setDescription(new Label()); - widthDescriptor.getDescription().addLabelText(en, "Width property"); - widthDescriptor.getDescription().addLabelText(de, "Breite Eigenschaft"); - - DB.save(widthDescriptor); - - Resource resource = new Resource(); - resource.setResourceId("R1"); - resource.setName(new Label()); - resource.getName().addLabelText(en, "R1_en"); - resource.getName().addLabelText(de, "R1_de"); - - resource.setAttributeValueOwner(new AttributeValueOwner()); - resource.getAttributeValueOwner().addAttributeValue(new AttributeValue(1, heightDescriptor)); - resource.getAttributeValueOwner().addAttributeValue(new AttributeValue(2, widthDescriptor)); - - DB.save(resource); - - QResource qresource = new QResource().resourceId.eq(resource.getResourceId()); - - List resources = qresource.findList(); - - assertThat(resources).isNotEmpty(); - } - - -} From 41bb24eb19be5bcc7b0c1941fc99664a6eacefb2 Mon Sep 17 00:00:00 2001 From: Rob Bygrave Date: Wed, 22 Apr 2026 02:10:52 +1200 Subject: [PATCH 02/10] ImmutableBeanCache part 1 --- .../java/io/ebean/ImmutableBeanCache.java | 46 +++++++++++++++++++ .../src/main/java/io/ebean/QueryBuilder.java | 5 ++ .../io/ebeaninternal/api/LoadContext.java | 5 ++ .../java/io/ebeaninternal/api/SpiQuery.java | 12 +++++ .../server/core/OrmQueryRequest.java | 6 +++ .../server/loadcontext/DLoadBeanContext.java | 15 ++++++ .../server/loadcontext/DLoadContext.java | 31 +++++++++++++ .../server/query/CQueryEngine.java | 2 + .../server/query/DefaultFetchGroupQuery.java | 5 ++ .../server/querydefn/DefaultOrmQuery.java | 23 ++++++++++ .../java/io/ebean/typequery/QueryBean.java | 6 +++ 11 files changed, 156 insertions(+) create mode 100644 ebean-api/src/main/java/io/ebean/ImmutableBeanCache.java diff --git a/ebean-api/src/main/java/io/ebean/ImmutableBeanCache.java b/ebean-api/src/main/java/io/ebean/ImmutableBeanCache.java new file mode 100644 index 0000000000..be9bee9529 --- /dev/null +++ b/ebean-api/src/main/java/io/ebean/ImmutableBeanCache.java @@ -0,0 +1,46 @@ +package io.ebean; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * Query-scoped immutable bean cache. + * + * @param The bean type. + */ +@NullMarked +public interface ImmutableBeanCache { + + /** + * Return the bean type this cache provides values for. + */ + Class type(); + + /** + * Return a cached immutable bean by id or null. + */ + @Nullable + T get(Object id); + + /** + * Return immutable cached beans by id (loading and populating misses as needed). + */ + default Map getAll(Set ids) { + if (ids.isEmpty()) { + return Collections.emptyMap(); + } + Map map = new LinkedHashMap<>(); + for (Object id : ids) { + T bean = get(id); + if (bean != null) { + map.put(id, bean); + } + } + return map; + } +} diff --git a/ebean-api/src/main/java/io/ebean/QueryBuilder.java b/ebean-api/src/main/java/io/ebean/QueryBuilder.java index c50f21e59d..7320a0d2aa 100644 --- a/ebean-api/src/main/java/io/ebean/QueryBuilder.java +++ b/ebean-api/src/main/java/io/ebean/QueryBuilder.java @@ -115,6 +115,11 @@ public interface QueryBuilder, T> extends Que */ SELF usingTransaction(Transaction transaction); + /** + * Execute this query using immutable bean cache values for matching bean types. + */ + SELF using(ImmutableBeanCache beanCache); + /** * Execute the query using the given connection. */ diff --git a/ebean-core/src/main/java/io/ebeaninternal/api/LoadContext.java b/ebean-core/src/main/java/io/ebeaninternal/api/LoadContext.java index c30251a100..9968e8b076 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/api/LoadContext.java +++ b/ebean-core/src/main/java/io/ebeaninternal/api/LoadContext.java @@ -60,4 +60,9 @@ public interface LoadContext { * Return true to include a many as a secondary query for unmodified. */ boolean includeSecondary(BeanPropertyAssocMany many); + + /** + * Populate buffered entity references from immutable bean caches. + */ + void populateFromImmutableCache(); } diff --git a/ebean-core/src/main/java/io/ebeaninternal/api/SpiQuery.java b/ebean-core/src/main/java/io/ebeaninternal/api/SpiQuery.java index 6e32de1541..76cbad6775 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/api/SpiQuery.java +++ b/ebean-core/src/main/java/io/ebeaninternal/api/SpiQuery.java @@ -4,6 +4,7 @@ import io.ebean.CacheMode; import io.ebean.CountDistinctOrder; import io.ebean.ExpressionList; +import io.ebean.ImmutableBeanCache; import io.ebean.OrderBy; import io.ebean.PersistenceContextScope; import io.ebean.ProfileLocation; @@ -27,6 +28,7 @@ import java.sql.Timestamp; import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -759,6 +761,16 @@ public static TemporalMode of(SpiQuery query) { */ boolean isBeanCacheGet(); + /** + * Register immutable bean cache to use for this query execution. + */ + void putImmutableBeanCache(ImmutableBeanCache beanCache); + + /** + * Return immutable bean caches configured for this query by bean type. + */ + Map, ImmutableBeanCache> immutableBeanCaches(); + /** * Return true if the query should PUT against the bean cache. */ diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/core/OrmQueryRequest.java b/ebean-core/src/main/java/io/ebeaninternal/server/core/OrmQueryRequest.java index aac02b6643..55cd38f5f1 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/core/OrmQueryRequest.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/core/OrmQueryRequest.java @@ -122,6 +122,12 @@ public void executeSecondaryQueries(boolean forEach) { } } + public void populateFromImmutableCache() { + if (loadContext != null) { + loadContext.populateFromImmutableCache(); + } + } + /** * For use with QueryIterator and secondary queries this returns the minimum * batch size that should be loaded before executing the secondary queries. diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/loadcontext/DLoadBeanContext.java b/ebean-core/src/main/java/io/ebeaninternal/server/loadcontext/DLoadBeanContext.java index 28d38b6641..4a5f429357 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/loadcontext/DLoadBeanContext.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/loadcontext/DLoadBeanContext.java @@ -79,6 +79,21 @@ void register(EntityBeanIntercept ebi) { currentBuffer.add(ebi); } + BeanDescriptor descriptor() { + return desc; + } + + Set bufferedBeans() { + if (bufferList != null) { + Set beans = new HashSet<>(); + for (LoadBuffer loadBuffer : bufferList) { + beans.addAll(loadBuffer.batch); + } + return beans; + } + return new HashSet<>(currentBuffer.batch); + } + private LoadBuffer createBuffer(int size) { LoadBuffer buffer = new LoadBuffer(this, size); if (bufferList != null) { diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/loadcontext/DLoadContext.java b/ebean-core/src/main/java/io/ebeaninternal/server/loadcontext/DLoadContext.java index 9ba5963c4b..b6bbfc875c 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/loadcontext/DLoadContext.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/loadcontext/DLoadContext.java @@ -1,6 +1,7 @@ package io.ebeaninternal.server.loadcontext; import io.ebean.CacheMode; +import io.ebean.ImmutableBeanCache; import io.ebean.ProfileLocation; import io.ebean.bean.*; import io.ebeaninternal.api.*; @@ -28,6 +29,7 @@ public final class DLoadContext implements LoadContext { * Type based contexts used for assoc-one lazy loading registration. */ private final Map lazyBeanMap = new HashMap<>(); + private final Map, ImmutableBeanCache> immutableCaches; private final Map manyMap = new HashMap<>(); private final DLoadBeanContext rootBeanContext; private final boolean asDraft; @@ -76,6 +78,7 @@ public DLoadContext(BeanDescriptor rootDescriptor, PersistenceContext persist this.planLabel = null; this.profileLocation = null; this.profilingListener = null; + this.immutableCaches = Collections.emptyMap(); this.rootBeanContext = new DLoadBeanContext(this, rootDescriptor, null, null); this.secondaryProperties = null; } @@ -104,6 +107,7 @@ public DLoadContext(OrmQueryRequest request, SpiQuerySecondary secondaryQueri this.profilingListener = query.profilingListener(); this.planLabel = query.planLabel(); this.profileLocation = query.profileLocation(); + this.immutableCaches = query.immutableBeanCaches(); this.secondaryProperties = query.isUnmodifiable() ? new HashSet<>() : null; ObjectGraphNode parentNode = query.parentNode(); @@ -295,6 +299,33 @@ private DLoadBeanContext lazyBeanContext(BeanDescriptor descriptor) { return lazyBeanMap.computeIfAbsent(descriptor.fullName(), p -> new DLoadBeanContext(this, descriptor, descriptor.name(), null)); } + @Override + public void populateFromImmutableCache() { + if (immutableCaches.isEmpty()) { + return; + } + for (Map.Entry, ImmutableBeanCache> entry : immutableCaches.entrySet()) { + BeanDescriptor descriptor = rootDescriptor.descriptor(entry.getKey()); + if (descriptor == null) { + continue; + } + DLoadBeanContext context = lazyBeanMap.get(descriptor.fullName()); + if (context == null) { + continue; + } + Set ids = new HashSet<>(); + for (EntityBeanIntercept ebi : context.bufferedBeans()) { + Object id = descriptor.id(ebi.owner()); + if (id != null) { + ids.add(id); + } + } + if (!ids.isEmpty()) { + entry.getValue().getAll(ids); + } + } + } + private void registerSecondaryNode(boolean many, OrmQueryProperties props) { String path = props.getPath(); if (many) { diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryEngine.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryEngine.java index 02b3d34ded..6ede229ea9 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryEngine.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryEngine.java @@ -354,6 +354,7 @@ BeanCollection findMany(OrmQueryRequest request) { cquery.auditFindMany(); } request.executeSecondaryQueries(false); + request.populateFromImmutableCache(); if (request.isQueryCachePut()) { request.addDependentTables(cquery.dependentTables()); } @@ -390,6 +391,7 @@ public T find(OrmQueryRequest request) { cquery.auditFind(bean); } request.executeSecondaryQueries(false); + request.populateFromImmutableCache(); request.unmodifiableFreeze(bean); return (T) bean; } catch (SQLException e) { diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultFetchGroupQuery.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultFetchGroupQuery.java index 01c60aabc9..5ad377741e 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultFetchGroupQuery.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultFetchGroupQuery.java @@ -224,6 +224,11 @@ public Query usingTransaction(Transaction transaction) { throw new RuntimeException("EB102: Only select() and fetch() clause is allowed on FetchGroup"); } + @Override + public Query using(ImmutableBeanCache beanCache) { + throw new RuntimeException("EB102: Only select() and fetch() clause is allowed on FetchGroup"); + } + @Override public Query usingConnection(Connection connection) { throw new RuntimeException("EB102: Only select() and fetch() clause is allowed on FetchGroup"); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultOrmQuery.java b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultOrmQuery.java index 11601a5c6a..f3d5f100c6 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultOrmQuery.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultOrmQuery.java @@ -163,6 +163,7 @@ public class DefaultOrmQuery extends AbstractQuery implements SpiQuery { private String nativeSql; private boolean orderById; private ProfileLocation profileLocation; + private Map, ImmutableBeanCache> immutableBeanCaches; public DefaultOrmQuery(BeanDescriptor desc, SpiEbeanServer server, ExpressionFactory expressionFactory) { this.beanDescriptor = desc; @@ -768,6 +769,9 @@ public SpiQuery copy(SpiEbeanServer server) { copy.useBeanCache = useBeanCache; copy.useQueryCache = useQueryCache; copy.unmodifiable = unmodifiable; + if (immutableBeanCaches != null) { + copy.immutableBeanCaches = new LinkedHashMap<>(immutableBeanCaches); + } if (detail != null) { copy.detail = detail.copy(null); } @@ -1462,6 +1466,12 @@ public final Query usingTransaction(Transaction transaction) { return this; } + @Override + public Query using(ImmutableBeanCache beanCache) { + putImmutableBeanCache(beanCache); + return this; + } + @Override public final Query usingConnection(Connection connection) { this.transaction = new ExternalJdbcTransaction(connection); @@ -1480,6 +1490,19 @@ public Query usingMaster(boolean useMaster) { return this; } + @Override + public void putImmutableBeanCache(ImmutableBeanCache beanCache) { + if (immutableBeanCaches == null) { + immutableBeanCaches = new LinkedHashMap<>(); + } + immutableBeanCaches.put(beanCache.type(), beanCache); + } + + @Override + public Map, ImmutableBeanCache> immutableBeanCaches() { + return immutableBeanCaches == null ? Collections.emptyMap() : Collections.unmodifiableMap(immutableBeanCaches); + } + @Override public boolean isUseMaster() { return useMaster; diff --git a/ebean-querybean/src/main/java/io/ebean/typequery/QueryBean.java b/ebean-querybean/src/main/java/io/ebean/typequery/QueryBean.java index 536979f828..6cfa28e074 100644 --- a/ebean-querybean/src/main/java/io/ebean/typequery/QueryBean.java +++ b/ebean-querybean/src/main/java/io/ebean/typequery/QueryBean.java @@ -744,6 +744,12 @@ public R usingMaster(boolean useMaster) { return root; } + @Override + public R using(ImmutableBeanCache beanCache) { + query.using(beanCache); + return root; + } + @Override public final boolean exists() { return query.exists(); From a279c04472256cc735d2b34822599549d3bffcce Mon Sep 17 00:00:00 2001 From: Rob Bygrave Date: Thu, 23 Apr 2026 19:12:16 +1200 Subject: [PATCH 03/10] ImmutableBeanCache part 2 --- .../java/io/ebean/ImmutableBeanCache.java | 24 +- .../io/ebeaninternal/api/LoadContext.java | 5 + .../server/deploy/AssocOneHelp.java | 2 + .../server/deploy/AssocOneHelpRefInherit.java | 2 + .../server/deploy/DbReadContext.java | 5 + .../server/loadcontext/DLoadContext.java | 63 +++++- .../io/ebeaninternal/server/query/CQuery.java | 5 + .../tests/resource/ResourceEntityTest.java | 212 ++++++++++++++++++ 8 files changed, 286 insertions(+), 32 deletions(-) diff --git a/ebean-api/src/main/java/io/ebean/ImmutableBeanCache.java b/ebean-api/src/main/java/io/ebean/ImmutableBeanCache.java index be9bee9529..661f611f97 100644 --- a/ebean-api/src/main/java/io/ebean/ImmutableBeanCache.java +++ b/ebean-api/src/main/java/io/ebean/ImmutableBeanCache.java @@ -1,10 +1,6 @@ package io.ebean; import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -import java.util.Collections; -import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; @@ -21,26 +17,8 @@ public interface ImmutableBeanCache { */ Class type(); - /** - * Return a cached immutable bean by id or null. - */ - @Nullable - T get(Object id); - /** * Return immutable cached beans by id (loading and populating misses as needed). */ - default Map getAll(Set ids) { - if (ids.isEmpty()) { - return Collections.emptyMap(); - } - Map map = new LinkedHashMap<>(); - for (Object id : ids) { - T bean = get(id); - if (bean != null) { - map.put(id, bean); - } - } - return map; - } + Map getAll(Set ids); } diff --git a/ebean-core/src/main/java/io/ebeaninternal/api/LoadContext.java b/ebean-core/src/main/java/io/ebeaninternal/api/LoadContext.java index 9968e8b076..c7194dae75 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/api/LoadContext.java +++ b/ebean-core/src/main/java/io/ebeaninternal/api/LoadContext.java @@ -51,6 +51,11 @@ public interface LoadContext { */ void register(String path, BeanPropertyAssocMany many, BeanCollection bc); + /** + * Register a bean as a candidate for immutable cache population. + */ + void registerForImmutable(EntityBeanIntercept ebi); + /** * Use soft-references for streaming queries, so unreachable entries can be garbage collected. */ diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/AssocOneHelp.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/AssocOneHelp.java index 941dece7f0..7525318678 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/AssocOneHelp.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/AssocOneHelp.java @@ -69,6 +69,8 @@ Object read(DbReadContext ctx) throws SQLException { Object ref = target.contextRef(pc, id, ctx.unmodifiable(), ctx.isDisableLazyLoading()); if (!ctx.unmodifiable() && !ctx.isDisableLazyLoading()) { ctx.register(path, ((EntityBean) ref)._ebean_getIntercept()); + } else { + ctx.registerForImmutable(((EntityBean) ref)._ebean_getIntercept()); } return ref; } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/AssocOneHelpRefInherit.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/AssocOneHelpRefInherit.java index dda19513f4..6b9ff95b82 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/AssocOneHelpRefInherit.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/AssocOneHelpRefInherit.java @@ -56,6 +56,8 @@ Object read(DbReadContext ctx) throws SQLException { Object ref = desc.contextRef(pc, id, ctx.unmodifiable(), ctx.isDisableLazyLoading()); if (!ctx.unmodifiable() && !ctx.isDisableLazyLoading()) { ctx.registerBeanInherit(property, ((EntityBean) ref)._ebean_getIntercept()); + } else { + ctx.registerForImmutable(((EntityBean) ref)._ebean_getIntercept()); } return ref; } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/DbReadContext.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/DbReadContext.java index 5751b0793e..beb416ad41 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/DbReadContext.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/DbReadContext.java @@ -59,6 +59,11 @@ public interface DbReadContext { */ void register(BeanPropertyAssocMany many, BeanCollection bc); + /** + * Register a bean as a candidate for immutable cache population. + */ + void registerForImmutable(EntityBeanIntercept ebi); + /** * Set back the bean that has just been loaded with its id. */ diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/loadcontext/DLoadContext.java b/ebean-core/src/main/java/io/ebeaninternal/server/loadcontext/DLoadContext.java index b6bbfc875c..23389ab46b 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/loadcontext/DLoadContext.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/loadcontext/DLoadContext.java @@ -29,6 +29,10 @@ public final class DLoadContext implements LoadContext { * Type based contexts used for assoc-one lazy loading registration. */ private final Map lazyBeanMap = new HashMap<>(); + /** + * Type based sets used when lazy loading is disabled but immutable cache population should still occur. + */ + private final Map> immutableRefMap = new HashMap<>(); private final Map, ImmutableBeanCache> immutableCaches; private final Map manyMap = new HashMap<>(); private final DLoadBeanContext rootBeanContext; @@ -280,6 +284,17 @@ public void register(String path, BeanPropertyAssocMany many, BeanCollection< manyContext(path, many).register(bc); } + @Override + public void registerForImmutable(EntityBeanIntercept ebi) { + if (immutableCaches.isEmpty()) { + return; + } + BeanDescriptor descriptor = descriptor(ebi); + if (immutableCaches.containsKey(descriptor.type())) { + immutableRefMap.computeIfAbsent(descriptor.fullName(), key -> new LinkedHashSet<>()).add(ebi); + } + } + int batchSize(OrmQueryProperties props) { if (props == null) { return defaultBatchSize; @@ -310,22 +325,52 @@ public void populateFromImmutableCache() { continue; } DLoadBeanContext context = lazyBeanMap.get(descriptor.fullName()); - if (context == null) { - continue; + Map interceptById = new LinkedHashMap<>(); + if (context != null) { + for (EntityBeanIntercept ebi : context.bufferedBeans()) { + Object id = descriptor.id(ebi.owner()); + if (id != null) { + interceptById.put(id, ebi); + } + } } - Set ids = new HashSet<>(); - for (EntityBeanIntercept ebi : context.bufferedBeans()) { - Object id = descriptor.id(ebi.owner()); - if (id != null) { - ids.add(id); + Set immutableRefs = immutableRefMap.get(descriptor.fullName()); + if (immutableRefs != null) { + for (EntityBeanIntercept ebi : immutableRefs) { + Object id = descriptor.id(ebi.owner()); + if (id != null) { + interceptById.putIfAbsent(id, ebi); + } } } - if (!ids.isEmpty()) { - entry.getValue().getAll(ids); + if (!interceptById.isEmpty()) { + Map hits = entry.getValue().getAll(interceptById.keySet()); + for (Map.Entry hit : hits.entrySet()) { + EntityBeanIntercept ebi = interceptById.get(hit.getKey()); + Object bean = hit.getValue(); + if (ebi != null && bean instanceof EntityBean) { + EntityBean cachedBean = (EntityBean) bean; + descriptor.merge(cachedBean, ebi.owner()); + // TODO Move loaded-property propagation into BeanDescriptor.merge(). + markLoadedProperties(cachedBean, ebi); + ebi.setLoadedFromCache(true); + ebi.setLoadedLazy(); + } + } } } } + private void markLoadedProperties(EntityBean source, EntityBeanIntercept target) { + Set loadedProperties = source._ebean_getIntercept().loadedPropertyNames(); + if (loadedProperties == null) { + return; + } + for (String property : loadedProperties) { + target.setPropertyLoaded(property, true); + } + } + private void registerSecondaryNode(boolean many, OrmQueryProperties props) { String path = props.getPath(); if (many) { diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQuery.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQuery.java index fd83d7c42d..aea217719d 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQuery.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQuery.java @@ -601,6 +601,11 @@ public void register(BeanPropertyAssocMany many, BeanCollection bc) { request.loadContext().register(path(many.name()), many, bc); } + @Override + public void registerForImmutable(EntityBeanIntercept ebi) { + request.loadContext().registerForImmutable(ebi); + } + @Override public boolean includeSecondary(BeanPropertyAssocMany many) { return request.loadContext().includeSecondary(many); diff --git a/ebean-test/src/test/java/org/tests/resource/ResourceEntityTest.java b/ebean-test/src/test/java/org/tests/resource/ResourceEntityTest.java index 2d421d812b..3016629375 100644 --- a/ebean-test/src/test/java/org/tests/resource/ResourceEntityTest.java +++ b/ebean-test/src/test/java/org/tests/resource/ResourceEntityTest.java @@ -1,12 +1,17 @@ package org.tests.resource; import io.ebean.DB; +import io.ebean.ImmutableBeanCache; import io.ebean.test.LoggedSql; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Map; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -89,4 +94,211 @@ void find_then_invokeLazyLoading_expect_singleLazyLoadingQueryForSameType() { .contains("select t0.id, t0.version from label t0 where t0.id in "); } + @Test + void find_usingImmutableLabelCache_expect_noLazyLoadingQuery() { + + Map cachedLabels = new LinkedHashMap<>(); + cachedLabels.put(heightDescriptor.getName().id(), heightDescriptor.getName()); + cachedLabels.put(heightDescriptor.getDescription().id(), heightDescriptor.getDescription()); + Set cacheLookups = new LinkedHashSet<>(); + + AttributeDescriptor one = DB.find(AttributeDescriptor.class) + .setId(heightDescriptor.id()) + .using(labelCache(cachedLabels, cacheLookups)) + .findOne(); + + assertThat(one).isNotNull(); + + LoggedSql.start(); + assertThat(one.getName().version()).isGreaterThan(0); + assertThat(one.getDescription().version()).isGreaterThan(0); + List sql = LoggedSql.stop(); + + assertThat(cacheLookups) + .containsExactlyInAnyOrder(heightDescriptor.getName().id(), heightDescriptor.getDescription().id()); + assertThat(sql).isEmpty(); + } + + @Test + void find_usingImmutableLabelCacheWithPartialHits_expect_singleLazyLoadingQuery() { + + Map cachedLabels = new LinkedHashMap<>(); + cachedLabels.put(heightDescriptor.getName().id(), heightDescriptor.getName()); + Set cacheLookups = new LinkedHashSet<>(); + + AttributeDescriptor one = DB.find(AttributeDescriptor.class) + .setId(heightDescriptor.id()) + .using(labelCache(cachedLabels, cacheLookups)) + .findOne(); + + assertThat(one).isNotNull(); + + LoggedSql.start(); + assertThat(one.getName().version()).isGreaterThan(0); + assertThat(one.getDescription().version()).isGreaterThan(0); + List sql = LoggedSql.stop(); + + assertThat(cacheLookups) + .containsExactlyInAnyOrder(heightDescriptor.getName().id(), heightDescriptor.getDescription().id()); + assertThat(sql).hasSize(1); + assertThat(sql.get(0)).contains("from label"); + } + + @Test + void find_usingImmutableLabelCacheWithNoHits_expect_singleLazyLoadingQuery() { + + Set cacheLookups = new LinkedHashSet<>(); + + AttributeDescriptor one = DB.find(AttributeDescriptor.class) + .setId(heightDescriptor.id()) + .using(labelCache(new LinkedHashMap<>(), cacheLookups)) + .findOne(); + + assertThat(one).isNotNull(); + + LoggedSql.start(); + assertThat(one.getName().version()).isGreaterThan(0); + assertThat(one.getDescription().version()).isGreaterThan(0); + List sql = LoggedSql.stop(); + + assertThat(cacheLookups) + .containsExactlyInAnyOrder(heightDescriptor.getName().id(), heightDescriptor.getDescription().id()); + assertThat(sql).hasSize(1); + assertThat(sql.get(0)).contains("from label"); + } + + @Test + void find_unmodifiableUsingImmutableLabelCache_expect_readableRefsWithNoLazyLoadSql() { + + Map cachedLabels = new LinkedHashMap<>(); + cachedLabels.put(heightDescriptor.getName().id(), immutableLabel(heightDescriptor.getName().id())); + cachedLabels.put(heightDescriptor.getDescription().id(), immutableLabel(heightDescriptor.getDescription().id())); + Set cacheLookups = new LinkedHashSet<>(); + + AttributeDescriptor one = DB.find(AttributeDescriptor.class) + .setId(heightDescriptor.id()) + .setUnmodifiable(true) + .using(labelCacheBackfill(cachedLabels, cacheLookups)) + .findOne(); + + assertThat(one).isNotNull(); + assertThat(DB.beanState(one.getName()).isUnmodifiable()).isTrue(); + assertThat(DB.beanState(one.getDescription()).isUnmodifiable()).isTrue(); + + LoggedSql.start(); + assertThat(one.getName().version()).isGreaterThan(0); + assertThat(one.getDescription().version()).isGreaterThan(0); + List sql = LoggedSql.stop(); + + assertThat(cacheLookups) + .containsExactlyInAnyOrder(heightDescriptor.getName().id(), heightDescriptor.getDescription().id()); + assertThat(sql).isEmpty(); + } + + @Test + void find_unmodifiableUsingImmutableLabelCacheWithPartialHits_expect_backfillAndNoLazyLoadSql() { + + Map cachedLabels = new LinkedHashMap<>(); + cachedLabels.put(heightDescriptor.getName().id(), immutableLabel(heightDescriptor.getName().id())); + Set cacheLookups = new LinkedHashSet<>(); + + AttributeDescriptor one = DB.find(AttributeDescriptor.class) + .setId(heightDescriptor.id()) + .setUnmodifiable(true) + .using(labelCacheBackfill(cachedLabels, cacheLookups)) + .findOne(); + + assertThat(one).isNotNull(); + assertThat(DB.beanState(one.getName()).isUnmodifiable()).isTrue(); + assertThat(DB.beanState(one.getDescription()).isUnmodifiable()).isTrue(); + + LoggedSql.start(); + assertThat(one.getName().version()).isGreaterThan(0); + assertThat(one.getDescription().version()).isGreaterThan(0); + List sql = LoggedSql.stop(); + + assertThat(cacheLookups) + .containsExactlyInAnyOrder(heightDescriptor.getName().id(), heightDescriptor.getDescription().id()); + assertThat(sql).isEmpty(); + } + + @Test + void find_unmodifiableUsingImmutableLabelCacheWithNoHits_expect_backfillAndNoLazyLoadSql() { + + Set cacheLookups = new LinkedHashSet<>(); + + AttributeDescriptor one = DB.find(AttributeDescriptor.class) + .setId(heightDescriptor.id()) + .setUnmodifiable(true) + .using(labelCacheBackfill(new LinkedHashMap<>(), cacheLookups)) + .findOne(); + + assertThat(one).isNotNull(); + assertThat(DB.beanState(one.getName()).isUnmodifiable()).isTrue(); + assertThat(DB.beanState(one.getDescription()).isUnmodifiable()).isTrue(); + + LoggedSql.start(); + assertThat(one.getName().version()).isGreaterThan(0); + assertThat(one.getDescription().version()).isGreaterThan(0); + List sql = LoggedSql.stop(); + + assertThat(cacheLookups) + .containsExactlyInAnyOrder(heightDescriptor.getName().id(), heightDescriptor.getDescription().id()); + assertThat(sql).isEmpty(); + } + + private ImmutableBeanCache