From f58388bae220357ffce2401164a0175f684a2023 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Mon, 5 Oct 2026 10:17:42 -0400 Subject: [PATCH 1/2] Add round-trip tests for record entities across mapper tiers Covers a record @Entity with an @Id and a renamed component, and a record embedded in a regular entity. The Aot* fixtures sit in a morphia.packages package so critter-maven pre-generates their models; the Runtime* fixtures sit outside it so the critter mapper generates them at runtime. Each test asserts which tier produced the model so a silent fallback can't hide a gap. These fail under every mapper until #4352 lands: ConstructorCreator re-sets every decoded property after construction, and Field.set always throws for record fields. Refs #4354 --- .../test/models/records/AotRecordAddress.java | 10 ++ .../test/models/records/AotRecordHolder.java | 61 +++++++++++ .../test/models/records/AotRecordPerson.java | 16 +++ .../test/records/RuntimeRecordAddress.java | 10 ++ .../test/records/RuntimeRecordHolder.java | 61 +++++++++++ .../test/records/RuntimeRecordPerson.java | 16 +++ .../test/records/TestRecordEntities.java | 102 ++++++++++++++++++ 7 files changed, 276 insertions(+) create mode 100644 core/src/test/java/dev/morphia/test/models/records/AotRecordAddress.java create mode 100644 core/src/test/java/dev/morphia/test/models/records/AotRecordHolder.java create mode 100644 core/src/test/java/dev/morphia/test/models/records/AotRecordPerson.java create mode 100644 core/src/test/java/dev/morphia/test/records/RuntimeRecordAddress.java create mode 100644 core/src/test/java/dev/morphia/test/records/RuntimeRecordHolder.java create mode 100644 core/src/test/java/dev/morphia/test/records/RuntimeRecordPerson.java create mode 100644 core/src/test/java/dev/morphia/test/records/TestRecordEntities.java diff --git a/core/src/test/java/dev/morphia/test/models/records/AotRecordAddress.java b/core/src/test/java/dev/morphia/test/models/records/AotRecordAddress.java new file mode 100644 index 00000000000..0220af51758 --- /dev/null +++ b/core/src/test/java/dev/morphia/test/models/records/AotRecordAddress.java @@ -0,0 +1,10 @@ +package dev.morphia.test.models.records; + +import dev.morphia.annotations.Entity; + +/** + * An embedded record in a package listed in {@code morphia.packages}, so critter-maven generates its model ahead of time. + */ +@Entity +public record AotRecordAddress(String street, String city) { +} diff --git a/core/src/test/java/dev/morphia/test/models/records/AotRecordHolder.java b/core/src/test/java/dev/morphia/test/models/records/AotRecordHolder.java new file mode 100644 index 00000000000..b40557f5651 --- /dev/null +++ b/core/src/test/java/dev/morphia/test/models/records/AotRecordHolder.java @@ -0,0 +1,61 @@ +package dev.morphia.test.models.records; + +import java.util.List; +import java.util.Objects; + +import dev.morphia.annotations.Entity; +import dev.morphia.annotations.Id; + +import org.bson.types.ObjectId; + +/** + * A regular class entity that embeds a record, in a package listed in {@code morphia.packages}. + */ +@Entity("aot_record_holders") +public class AotRecordHolder { + @Id + private ObjectId id; + private AotRecordAddress address; + private List history; + + public AotRecordHolder() { + } + + public AotRecordHolder(AotRecordAddress address, List history) { + this.address = address; + this.history = history; + } + + public ObjectId getId() { + return id; + } + + public AotRecordAddress getAddress() { + return address; + } + + public List getHistory() { + return history; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof AotRecordHolder that)) { + return false; + } + return Objects.equals(id, that.id) && Objects.equals(address, that.address) && Objects.equals(history, that.history); + } + + @Override + public int hashCode() { + return Objects.hash(id, address, history); + } + + @Override + public String toString() { + return "AotRecordHolder{id=" + id + ", address=" + address + ", history=" + history + "}"; + } +} diff --git a/core/src/test/java/dev/morphia/test/models/records/AotRecordPerson.java b/core/src/test/java/dev/morphia/test/models/records/AotRecordPerson.java new file mode 100644 index 00000000000..5ba658fe3d1 --- /dev/null +++ b/core/src/test/java/dev/morphia/test/models/records/AotRecordPerson.java @@ -0,0 +1,16 @@ +package dev.morphia.test.models.records; + +import java.util.List; + +import dev.morphia.annotations.Entity; +import dev.morphia.annotations.Id; +import dev.morphia.annotations.Property; + +import org.bson.types.ObjectId; + +/** + * A record entity in a package listed in {@code morphia.packages}, so critter-maven generates its model ahead of time. + */ +@Entity("aot_record_people") +public record AotRecordPerson(@Id ObjectId id, String name, @Property("years") int age, List nicknames) { +} diff --git a/core/src/test/java/dev/morphia/test/records/RuntimeRecordAddress.java b/core/src/test/java/dev/morphia/test/records/RuntimeRecordAddress.java new file mode 100644 index 00000000000..fa4e3ffedce --- /dev/null +++ b/core/src/test/java/dev/morphia/test/records/RuntimeRecordAddress.java @@ -0,0 +1,10 @@ +package dev.morphia.test.records; + +import dev.morphia.annotations.Entity; + +/** + * An embedded record outside the packages listed in {@code morphia.packages}, so the critter mapper generates its model at runtime. + */ +@Entity +public record RuntimeRecordAddress(String street, String city) { +} diff --git a/core/src/test/java/dev/morphia/test/records/RuntimeRecordHolder.java b/core/src/test/java/dev/morphia/test/records/RuntimeRecordHolder.java new file mode 100644 index 00000000000..03e242cb1f6 --- /dev/null +++ b/core/src/test/java/dev/morphia/test/records/RuntimeRecordHolder.java @@ -0,0 +1,61 @@ +package dev.morphia.test.records; + +import java.util.List; +import java.util.Objects; + +import dev.morphia.annotations.Entity; +import dev.morphia.annotations.Id; + +import org.bson.types.ObjectId; + +/** + * A regular class entity that embeds a record, outside the packages listed in {@code morphia.packages}. + */ +@Entity("runtime_record_holders") +public class RuntimeRecordHolder { + @Id + private ObjectId id; + private RuntimeRecordAddress address; + private List history; + + public RuntimeRecordHolder() { + } + + public RuntimeRecordHolder(RuntimeRecordAddress address, List history) { + this.address = address; + this.history = history; + } + + public ObjectId getId() { + return id; + } + + public RuntimeRecordAddress getAddress() { + return address; + } + + public List getHistory() { + return history; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof RuntimeRecordHolder that)) { + return false; + } + return Objects.equals(id, that.id) && Objects.equals(address, that.address) && Objects.equals(history, that.history); + } + + @Override + public int hashCode() { + return Objects.hash(id, address, history); + } + + @Override + public String toString() { + return "RuntimeRecordHolder{id=" + id + ", address=" + address + ", history=" + history + "}"; + } +} diff --git a/core/src/test/java/dev/morphia/test/records/RuntimeRecordPerson.java b/core/src/test/java/dev/morphia/test/records/RuntimeRecordPerson.java new file mode 100644 index 00000000000..db93a7dc421 --- /dev/null +++ b/core/src/test/java/dev/morphia/test/records/RuntimeRecordPerson.java @@ -0,0 +1,16 @@ +package dev.morphia.test.records; + +import java.util.List; + +import dev.morphia.annotations.Entity; +import dev.morphia.annotations.Id; +import dev.morphia.annotations.Property; + +import org.bson.types.ObjectId; + +/** + * A record entity outside the packages listed in {@code morphia.packages}, so the critter mapper generates its model at runtime. + */ +@Entity("runtime_record_people") +public record RuntimeRecordPerson(@Id ObjectId id, String name, @Property("years") int age, List nicknames) { +} diff --git a/core/src/test/java/dev/morphia/test/records/TestRecordEntities.java b/core/src/test/java/dev/morphia/test/records/TestRecordEntities.java new file mode 100644 index 00000000000..2c352cad06e --- /dev/null +++ b/core/src/test/java/dev/morphia/test/records/TestRecordEntities.java @@ -0,0 +1,102 @@ +package dev.morphia.test.records; + +import java.util.List; + +import dev.morphia.critter.CritterClassLoader; +import dev.morphia.mapping.MapperType; +import dev.morphia.mapping.codec.pojo.EntityModel; +import dev.morphia.mapping.codec.pojo.critter.CritterEntityModel; +import dev.morphia.test.TestBase; +import dev.morphia.test.models.records.AotRecordAddress; +import dev.morphia.test.models.records.AotRecordHolder; +import dev.morphia.test.models.records.AotRecordPerson; + +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static dev.morphia.query.filters.Filters.eq; + +/** + * Round-trips Java records through the datastore. The {@code Aot*} fixtures live in a package listed in + * {@code morphia.packages}, so critter-maven pre-generates their models; the {@code Runtime*} fixtures live here, outside those + * packages, so the critter mapper generates their models at runtime. Under the reflection mapper both sets use reflection. + */ +public class TestRecordEntities extends TestBase { + private enum Tier { + AOT, + RUNTIME + } + + @Test + public void aotRecordEntity() { + AotRecordPerson person = new AotRecordPerson(new ObjectId(), "Ada", 36, List.of("Countess", "Enchantress")); + getDs().save(person); + + assertTier(AotRecordPerson.class, Tier.AOT); + assertStoredShape(getDs().getCollection(AotRecordPerson.class).getNamespace().getCollectionName(), person.id()); + Assertions.assertEquals(person, getDs().find(AotRecordPerson.class).filter(eq("_id", person.id())).first()); + Assertions.assertEquals(person, getDs().find(AotRecordPerson.class).filter(eq("age", 36)).first()); + } + + @Test + public void runtimeRecordEntity() { + RuntimeRecordPerson person = new RuntimeRecordPerson(new ObjectId(), "Grace", 85, List.of("Amazing Grace")); + getDs().save(person); + + assertTier(RuntimeRecordPerson.class, Tier.RUNTIME); + assertStoredShape(getDs().getCollection(RuntimeRecordPerson.class).getNamespace().getCollectionName(), person.id()); + Assertions.assertEquals(person, getDs().find(RuntimeRecordPerson.class).filter(eq("_id", person.id())).first()); + Assertions.assertEquals(person, getDs().find(RuntimeRecordPerson.class).filter(eq("age", 85)).first()); + } + + @Test + public void aotEmbeddedRecord() { + AotRecordHolder holder = new AotRecordHolder(new AotRecordAddress("1 Main St", "Springfield"), + List.of(new AotRecordAddress("2 Elm St", "Shelbyville"), new AotRecordAddress("3 Oak St", "Capital City"))); + getDs().save(holder); + + assertTier(AotRecordHolder.class, Tier.AOT); + assertTier(AotRecordAddress.class, Tier.AOT); + Assertions.assertEquals(holder, getDs().find(AotRecordHolder.class).filter(eq("_id", holder.getId())).first()); + } + + @Test + public void runtimeEmbeddedRecord() { + RuntimeRecordHolder holder = new RuntimeRecordHolder(new RuntimeRecordAddress("1 Main St", "Springfield"), + List.of(new RuntimeRecordAddress("2 Elm St", "Shelbyville"), new RuntimeRecordAddress("3 Oak St", "Capital City"))); + getDs().save(holder); + + assertTier(RuntimeRecordHolder.class, Tier.RUNTIME); + assertTier(RuntimeRecordAddress.class, Tier.RUNTIME); + Assertions.assertEquals(holder, getDs().find(RuntimeRecordHolder.class).filter(eq("_id", holder.getId())).first()); + } + + /** + * Fails if the critter mapper silently fell back to a different tier, which would leave the tier this test is named for + * untested. + */ + private void assertTier(Class type, Tier tier) { + EntityModel model = getMapper().getEntityModel(type); + Assertions.assertNotNull(model, "No model for " + type.getName()); + if (morphiaConfig.mapper() == MapperType.REFLECTION) { + Assertions.assertFalse(model instanceof CritterEntityModel, + "Expected a reflection model for " + type.getName() + " but got " + model.getClass().getName()); + return; + } + Assertions.assertInstanceOf(CritterEntityModel.class, model, + "Expected a critter model for " + type.getName() + " but got " + model.getClass().getName()); + boolean runtimeGenerated = model.getClass().getClassLoader() instanceof CritterClassLoader; + Assertions.assertEquals(tier == Tier.RUNTIME, runtimeGenerated, + "Expected a " + tier + " critter model for " + type.getName() + " but got " + model.getClass().getName() + + " from " + model.getClass().getClassLoader()); + } + + private void assertStoredShape(String collection, ObjectId id) { + Document stored = getDatabase().getCollection(collection).find(new Document("_id", id)).first(); + Assertions.assertNotNull(stored, "Nothing stored in " + collection); + Assertions.assertTrue(stored.containsKey("years"), "Expected the renamed component to be stored as 'years': " + stored); + Assertions.assertFalse(stored.containsKey("age"), "Expected no 'age' key: " + stored); + } +} From 936d41be4da1beb8a12e5198312adf721d185620 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Mon, 5 Oct 2026 13:20:19 -0400 Subject: [PATCH 2/2] Expect AOT record models only when critter-maven generated them Pull-request CI builds and tests without running generate-test-models, so the Aot* record fixtures get runtime-generated models there and the AOT tier check failed. The check now expects AOT exactly when the pre-generated model is on the classpath, and runtime generation otherwise, so every build still verifies which tier it exercised. --- .../morphia/test/records/TestRecordEntities.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/core/src/test/java/dev/morphia/test/records/TestRecordEntities.java b/core/src/test/java/dev/morphia/test/records/TestRecordEntities.java index 2c352cad06e..11bf81308fe 100644 --- a/core/src/test/java/dev/morphia/test/records/TestRecordEntities.java +++ b/core/src/test/java/dev/morphia/test/records/TestRecordEntities.java @@ -2,6 +2,7 @@ import java.util.List; +import dev.morphia.critter.Critter; import dev.morphia.critter.CritterClassLoader; import dev.morphia.mapping.MapperType; import dev.morphia.mapping.codec.pojo.EntityModel; @@ -87,12 +88,23 @@ private void assertTier(Class type, Tier tier) { } Assertions.assertInstanceOf(CritterEntityModel.class, model, "Expected a critter model for " + type.getName() + " but got " + model.getClass().getName()); + // Builds that don't run critter-maven's generate-test-models (e.g. pull-request CI) have no pre-generated + // models, so an AOT fixture is generated at runtime there. Expect AOT exactly when its model is on the classpath. + Tier expected = tier == Tier.AOT && !hasPregeneratedModel(type) ? Tier.RUNTIME : tier; boolean runtimeGenerated = model.getClass().getClassLoader() instanceof CritterClassLoader; - Assertions.assertEquals(tier == Tier.RUNTIME, runtimeGenerated, - "Expected a " + tier + " critter model for " + type.getName() + " but got " + model.getClass().getName() + Assertions.assertEquals(expected == Tier.RUNTIME, runtimeGenerated, + "Expected a " + expected + " critter model for " + type.getName() + " but got " + model.getClass().getName() + " from " + model.getClass().getClassLoader()); } + /** + * @return true if critter-maven pre-generated a model for {@code type}, using the name {@code CritterMapper} looks up + */ + private static boolean hasPregeneratedModel(Class type) { + String modelClass = Critter.critterPackage(type) + "." + type.getSimpleName() + "EntityModel"; + return type.getClassLoader().getResource(modelClass.replace('.', '/') + ".class") != null; + } + private void assertStoredShape(String collection, ObjectId id) { Document stored = getDatabase().getCollection(collection).find(new Document("_id", id)).first(); Assertions.assertNotNull(stored, "Nothing stored in " + collection);