From dcb7e8a0fc67b892bce1ba49a88b6c4e6d2ead07 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Thu, 15 Oct 2026 08:59:32 -0400 Subject: [PATCH 1/3] Add a per-codec CodecPlan (plan item 3.0) MorphiaCodec builds an immutable CodecPlan when it's created: the model's properties, their codecs by slot, and the properties to encode after the id. Each PropertyModel records its slot, its position in its model, when it's added, so a plan lookup is an array index. EntityEncoder, EntityDecoder and LifecycleDecoder read codecs from the plan of the codec for the model being encoded or decoded instead of from PropertyModel. The plan holds the same codecs MorphiaCodec has always set on the property models, so behavior is unchanged; it gives the Phase 3 items a per-codec home for codec-derived state. --- .../morphia/mapping/codec/pojo/CodecPlan.java | 77 +++++++++++++++++++ .../mapping/codec/pojo/EntityDecoder.java | 44 ++++++++++- .../mapping/codec/pojo/EntityEncoder.java | 9 +-- .../mapping/codec/pojo/EntityModel.java | 1 + .../mapping/codec/pojo/LifecycleDecoder.java | 4 +- .../mapping/codec/pojo/MorphiaCodec.java | 12 +++ .../mapping/codec/pojo/PropertyModel.java | 13 ++++ .../codec/harness/CodecHarnessTest.java | 20 +++++ design/mapping-game-plan.md | 2 +- 9 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 core/src/main/java/dev/morphia/mapping/codec/pojo/CodecPlan.java diff --git a/core/src/main/java/dev/morphia/mapping/codec/pojo/CodecPlan.java b/core/src/main/java/dev/morphia/mapping/codec/pojo/CodecPlan.java new file mode 100644 index 00000000000..8ac7ab15480 --- /dev/null +++ b/core/src/main/java/dev/morphia/mapping/codec/pojo/CodecPlan.java @@ -0,0 +1,77 @@ +package dev.morphia.mapping.codec.pojo; + +import java.util.Arrays; +import java.util.List; + +import com.mongodb.lang.Nullable; + +import dev.morphia.annotations.internal.MorphiaInternal; + +import org.bson.codecs.Codec; + +/** + * The per-codec state a {@link MorphiaCodec} reads while encoding and decoding, built once when the codec is created. + * Properties are addressed by their {@link PropertyModel#slot() slot}, their position in their entity model, so a + * lookup is an array index. + *

+ * It holds what belongs to one codec rather than to the shared mapping model: the codecs resolved for this datastore + * and the order properties are encoded in. Later work adds more per-slot data here. + *

+ * + * @hidden + * @morphia.internal + */ +@MorphiaInternal +public final class CodecPlan { + private final EntityModel model; + private final PropertyModel[] properties; + private final Codec[] codecs; + private final PropertyModel[] encodeOrder; + + @SuppressWarnings("unchecked") + CodecPlan(EntityModel model) { + this.model = model; + List list = model.getProperties(); + properties = list.toArray(new PropertyModel[0]); + codecs = new Codec[properties.length]; + for (int slot = 0; slot < properties.length; slot++) { + PropertyModel property = properties[slot]; + if (property.slot() != slot) { + throw new IllegalStateException("%s is in slot %d of %s but records slot %d".formatted(property.getName(), slot, + model.getType().getName(), property.slot())); + } + codecs[slot] = (Codec) property.getCodec(); + } + PropertyModel idProperty = model.getIdProperty(); + encodeOrder = Arrays.stream(properties) + .filter(property -> property != idProperty) + .toArray(PropertyModel[]::new); + } + + /** + * @return the entity model this plan is for + */ + public EntityModel model() { + return model; + } + + /** + * @param property a property of this plan's model + * @return the codec for the property, or null if none was resolved + */ + @Nullable + public Codec codec(PropertyModel property) { + int slot = property.slot(); + if (slot < 0 || slot >= properties.length || properties[slot] != property) { + throw new IllegalArgumentException("%s isn't a property of %s".formatted(property, model.getType().getName())); + } + return codecs[slot]; + } + + /** + * @return the properties to encode after the id, in order. Callers must not modify the array. + */ + PropertyModel[] encodeOrder() { + return encodeOrder; + } +} diff --git a/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityDecoder.java b/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityDecoder.java index 61249f2a9f4..d5154bfa825 100644 --- a/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityDecoder.java +++ b/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityDecoder.java @@ -64,7 +64,7 @@ public T decode(BsonReader reader, DecoderContext decoderContext) { boolean decoded = false; try { - decodeProperties(reader, decoderContext, instanceCreator, classModel); + decodeProperties(reader, decoderContext, instanceCreator, morphiaCodec.getPlan()); decoded = true; } finally { if (prereadId != null) { @@ -99,6 +99,23 @@ public T decode(BsonReader reader, DecoderContext decoderContext) { protected void decodeModel(BsonReader reader, DecoderContext decoderContext, MorphiaInstanceCreator instanceCreator, @Nullable PropertyModel model) { + decodeModel(reader, decoderContext, instanceCreator, morphiaCodec.getPlan(), model); + } + + /** + * Decodes a property with the codec the given plan holds for it. + * + * @param reader the reader + * @param decoderContext the context + * @param instanceCreator the creator to give the value to + * @param plan the plan of the codec for the model the property belongs to + * @param model the property, or null to skip the value + * @hidden + * @morphia.internal + */ + @MorphiaInternal + protected void decodeModel(BsonReader reader, DecoderContext decoderContext, + MorphiaInstanceCreator instanceCreator, CodecPlan plan, @Nullable PropertyModel model) { if (model != null) { final BsonReaderMark mark = reader.getMark(); @@ -106,7 +123,7 @@ protected void decodeModel(BsonReader reader, DecoderContext decoderContext, if (reader.getCurrentBsonType() == BsonType.NULL) { reader.readNull(); } else { - Object value = decoderContext.decodeWithChildContext(model.getCodec(), reader); + Object value = decoderContext.decodeWithChildContext(plan.codec(model), reader); instanceCreator.set(value, model); } } catch (BsonInvalidOperationException e) { @@ -121,13 +138,34 @@ protected void decodeModel(BsonReader reader, DecoderContext decoderContext, protected void decodeProperties(BsonReader reader, DecoderContext decoderContext, MorphiaInstanceCreator instanceCreator, EntityModel classModel) { + CodecPlan plan = morphiaCodec.getPlan(); + if (plan.model() != classModel) { + plan = ((MorphiaCodec) morphiaCodec.getRegistry().get(classModel.getType())).getPlan(); + } + decodeProperties(reader, decoderContext, instanceCreator, plan); + } + + /** + * Decodes a document's properties with the codecs a plan holds. + * + * @param reader the reader + * @param decoderContext the context + * @param instanceCreator the creator to give the values to + * @param plan the plan of the codec for the model being decoded + * @hidden + * @morphia.internal + */ + @MorphiaInternal + protected void decodeProperties(BsonReader reader, DecoderContext decoderContext, + MorphiaInstanceCreator instanceCreator, CodecPlan plan) { + EntityModel classModel = plan.model(); reader.readStartDocument(); while (reader.readBsonType() != BsonType.END_OF_DOCUMENT) { String name = reader.readName(); if (classModel.useDiscriminator() && classModel.discriminatorKey().equals(name)) { reader.readString(); } else { - decodeModel(reader, decoderContext, instanceCreator, classModel.getProperty(name)); + decodeModel(reader, decoderContext, instanceCreator, plan, classModel.getProperty(name)); } } reader.readEndDocument(); diff --git a/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityEncoder.java b/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityEncoder.java index c4175ee7332..2e8176d7fe2 100644 --- a/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityEncoder.java +++ b/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityEncoder.java @@ -8,7 +8,6 @@ import dev.morphia.annotations.internal.MorphiaInternal; import org.bson.BsonWriter; -import org.bson.codecs.Codec; import org.bson.codecs.EncoderContext; import org.bson.codecs.IdGenerator; import org.bson.codecs.ObjectIdGenerator; @@ -52,10 +51,7 @@ public void encode(BsonWriter writer, T value, EncoderContext encoderContext) { encodeDiscriminator(writer, model); } - for (PropertyModel propertyModel : model.properties()) { - if (propertyModel.equals(idModel)) { - continue; - } + for (PropertyModel propertyModel : morphiaCodec.getPlan().encodeOrder()) { encodeProperty(writer, propertyModel, value, encoderContext); } }); @@ -138,8 +134,7 @@ protected void writeValue(BsonWriter writer, EncoderContext encoderContext, Prop if (value == null) { writer.writeNull(); } else { - Codec codec = (Codec) model.getCodec(); - encoderContext.encodeWithChildContext(codec, writer, value); + encoderContext.encodeWithChildContext(morphiaCodec.getPlan().codec(model), writer, value); } } } diff --git a/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityModel.java b/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityModel.java index b913849d031..617d00ca165 100644 --- a/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityModel.java +++ b/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityModel.java @@ -170,6 +170,7 @@ public boolean addProperty(PropertyModel property) { added &= propertyModelsByMappedName.put(property.getMappedName(), property) == null; if (added) { + property.slot(propertyModelsByName.size() - 1); if (property.hasAnnotation(Id.class)) { idProperty = property; } else if (property.hasAnnotation(Version.class)) { diff --git a/core/src/main/java/dev/morphia/mapping/codec/pojo/LifecycleDecoder.java b/core/src/main/java/dev/morphia/mapping/codec/pojo/LifecycleDecoder.java index 4a84e825c0a..f3f967f459e 100644 --- a/core/src/main/java/dev/morphia/mapping/codec/pojo/LifecycleDecoder.java +++ b/core/src/main/java/dev/morphia/mapping/codec/pojo/LifecycleDecoder.java @@ -37,6 +37,7 @@ public LifecycleDecoder(MorphiaCodec codec) { public T decode(BsonReader reader, DecoderContext decoderContext) { Document document = getMorphiaCodec().getRegistry().get(Document.class).decode(reader, decoderContext); EntityModel model = getMorphiaCodec().getEntityModel(); + CodecPlan plan = getMorphiaCodec().getPlan(); if (model.useDiscriminator()) { String discriminator = document.getString(model.discriminatorKey()); if (discriminator != null) { @@ -45,6 +46,7 @@ public T decode(BsonReader reader, DecoderContext decoderContext) { Codec codec = getMorphiaCodec().getRegistry().get(discriminatorClass); if (codec instanceof MorphiaCodec) { model = ((MorphiaCodec) codec).getEntityModel(); + plan = ((MorphiaCodec) codec).getPlan(); } else { throw new CodecConfigurationException(format("Non-entity class used as discriminator: '%s'.", discriminator)); } @@ -70,7 +72,7 @@ public T decode(BsonReader reader, DecoderContext decoderContext) { try { model.callLifecycleMethods(PreLoad.class, entity, document, getMorphiaCodec().getDatastore()); decodeProperties(new DocumentReader(document, getMorphiaCodec().getConversions()), decoderContext, instanceCreator, - model); + plan); model.callLifecycleMethods(PostLoad.class, entity, document, getMorphiaCodec().getDatastore()); decoded = true; } finally { diff --git a/core/src/main/java/dev/morphia/mapping/codec/pojo/MorphiaCodec.java b/core/src/main/java/dev/morphia/mapping/codec/pojo/MorphiaCodec.java index 8dd15c04db6..7ddd16bcae8 100644 --- a/core/src/main/java/dev/morphia/mapping/codec/pojo/MorphiaCodec.java +++ b/core/src/main/java/dev/morphia/mapping/codec/pojo/MorphiaCodec.java @@ -47,6 +47,7 @@ public class MorphiaCodec implements CollectibleCodec { private final PropertyCodecRegistry propertyCodecRegistry; private final DiscriminatorLookup discriminatorLookup; private final Conversions conversions; + private final CodecPlan plan; private EntityEncoder encoder; private EntityDecoder decoder; private MorphiaDatastore datastore; @@ -74,6 +75,7 @@ public MorphiaCodec(MorphiaDatastore datastore, EntityModel model, this.propertyCodecRegistry = new PropertyCodecRegistryImpl(this, registry, propertyCodecProviders); idProperty = model.getIdProperty(); specializePropertyCodecs(); + plan = new CodecPlan(model); } @Override @@ -173,6 +175,16 @@ public EntityModel getEntityModel() { return entityModel; } + /** + * @return the plan this codec encodes and decodes with + * @hidden + * @morphia.internal + */ + @MorphiaInternal + public CodecPlan getPlan() { + return plan; + } + /** * @return the codec registry */ diff --git a/core/src/main/java/dev/morphia/mapping/codec/pojo/PropertyModel.java b/core/src/main/java/dev/morphia/mapping/codec/pojo/PropertyModel.java index 21411ea3bd2..78e389eaed6 100644 --- a/core/src/main/java/dev/morphia/mapping/codec/pojo/PropertyModel.java +++ b/core/src/main/java/dev/morphia/mapping/codec/pojo/PropertyModel.java @@ -67,6 +67,8 @@ public class PropertyModel { private final List loadNames = new ArrayList<>(); private final EntityModel entityModel; private Codec codec; + // this property's position in its entity model, which addresses it in a CodecPlan; assigned when it's added + private int slot = -1; private Class normalizedType; private Conversions conversions; @@ -425,4 +427,15 @@ void codec(Codec codec) { this.codec = codec; } + /** + * @return this property's position in its entity model, or -1 if it hasn't been added to one + */ + int slot() { + return slot; + } + + void slot(int slot) { + this.slot = slot; + } + } diff --git a/core/src/test/java/dev/morphia/test/mapping/codec/harness/CodecHarnessTest.java b/core/src/test/java/dev/morphia/test/mapping/codec/harness/CodecHarnessTest.java index f333cc40299..e931b5f7ff3 100644 --- a/core/src/test/java/dev/morphia/test/mapping/codec/harness/CodecHarnessTest.java +++ b/core/src/test/java/dev/morphia/test/mapping/codec/harness/CodecHarnessTest.java @@ -30,7 +30,9 @@ import dev.morphia.mapping.DiscriminatorFunction; import dev.morphia.mapping.MapperType; import dev.morphia.mapping.codec.MorphiaCodecProvider; +import dev.morphia.mapping.codec.pojo.CodecPlan; import dev.morphia.mapping.codec.pojo.EntityModel; +import dev.morphia.mapping.codec.pojo.MorphiaCodec; import dev.morphia.mapping.codec.pojo.PropertyModel; import dev.morphia.mapping.codec.pojo.critter.CritterEntityModel; import dev.morphia.test.mapping.codec.harness.models.HarnessArrays; @@ -78,6 +80,7 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -262,6 +265,23 @@ void concurrentCodecLookupsShareOneCodec() throws Exception { } } + @Test + void codecPlanResolvesEveryPropertyBySlot() { + for (MapperType mapper : MapperType.values()) { + MorphiaDatastore datastore = datastore(Variant.DEFAULTS, mapper); + CodecPlan plan = ((MorphiaCodec) codec(datastore, HarnessScalars.class)).getPlan(); + EntityModel model = datastore.getMapper().getEntityModel(HarnessScalars.class); + + assertSame(model, plan.model()); + for (PropertyModel property : model.getProperties()) { + assertNotNull(plan.codec(property), mapper + ": no codec for " + property.getName()); + } + PropertyModel foreign = datastore.getMapper().getEntityModel(HarnessRenames.class).getProperties().get(1); + assertThrows(IllegalArgumentException.class, () -> plan.codec(foreign), + mapper + ": a property of another model should be rejected"); + } + } + @Test void readsLoadOnlyProperties() { // @LoadOnly values are never written, so the fixtures can't show that they are read diff --git a/design/mapping-game-plan.md b/design/mapping-game-plan.md index 2781a8ec920..01bd62939bf 100644 --- a/design/mapping-game-plan.md +++ b/design/mapping-game-plan.md @@ -194,7 +194,7 @@ structural item covers are moved to that item (see Phases 4 and 5). **Precondition:** the minimal codec plan (3.0) and the harness (0.1). Each item is its own PR, with before/after JMH numbers on both mappers. -### 3.0 Minimal per-`MorphiaCodec` plan +### ~~3.0 Minimal per-`MorphiaCodec` plan~~ - **What:** an immutable object built once per `MorphiaCodec`. Initially it holds `PropertyModel[] properties`, `Codec[] codecs` by slot, and `PropertyModel[] encodeOrder`. Later items add `directBsonTypes[]`, `boxedTypes[]`, creator positions, lifecycle flags and discriminator/subtype caches. From 521bc5a0e349a49fb11a8cd7c84013a4efaefb6e Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 18 Oct 2026 17:40:56 -0400 Subject: [PATCH 2/3] Skip the reader mark for codecs that can't fail part-way (plan item 3.1) Decoding a property took a reader mark before even checking for null, so a mismatched value could be rewound and decoded through the fallback. The NULL check now comes first, and CodecPlan holds a per-slot bitmask of the BSON types each property's codec reads directly: types it either reads completely or rejects before reading anything. When the current type is in the mask, the property decodes without a mark. The table is keyed on exact codec classes, so subclasses and user codecs keep the mark. --- .../morphia/mapping/codec/pojo/CodecPlan.java | 59 +++++++++++++++++++ .../mapping/codec/pojo/EntityDecoder.java | 36 ++++++----- .../codec/harness/CodecHarnessTest.java | 10 ++++ design/mapping-game-plan.md | 2 +- 4 files changed, 91 insertions(+), 16 deletions(-) diff --git a/core/src/main/java/dev/morphia/mapping/codec/pojo/CodecPlan.java b/core/src/main/java/dev/morphia/mapping/codec/pojo/CodecPlan.java index 8ac7ab15480..de852cbc097 100644 --- a/core/src/main/java/dev/morphia/mapping/codec/pojo/CodecPlan.java +++ b/core/src/main/java/dev/morphia/mapping/codec/pojo/CodecPlan.java @@ -2,12 +2,28 @@ import java.util.Arrays; import java.util.List; +import java.util.Map; import com.mongodb.lang.Nullable; import dev.morphia.annotations.internal.MorphiaInternal; +import dev.morphia.mapping.codec.EnumCodec; +import dev.morphia.mapping.codec.MorphiaDateCodec; +import dev.morphia.mapping.codec.MorphiaLocalDateTimeCodec; +import org.bson.BsonType; +import org.bson.codecs.BigDecimalCodec; +import org.bson.codecs.BooleanCodec; import org.bson.codecs.Codec; +import org.bson.codecs.DateCodec; +import org.bson.codecs.Decimal128Codec; +import org.bson.codecs.DoubleCodec; +import org.bson.codecs.IntegerCodec; +import org.bson.codecs.LongCodec; +import org.bson.codecs.ObjectIdCodec; +import org.bson.codecs.StringCodec; + +import static java.util.Map.entry; /** * The per-codec state a {@link MorphiaCodec} reads while encoding and decoding, built once when the codec is created. @@ -27,6 +43,29 @@ public final class CodecPlan { private final PropertyModel[] properties; private final Codec[] codecs; private final PropertyModel[] encodeOrder; + // per slot, a bitmask of the BSON types (by ordinal) the slot's codec reads without being able to fail part-way + private final int[] directBsonTypes; + + /** + * Codecs that read the listed BSON types directly: they either read the value and return it, or throw before + * reading anything. A property whose codec reads the current type directly can be decoded without a reader mark, + * since there's never a partly consumed value to rewind. Keyed on the exact codec class, so subclasses and user + * codecs keep the mark. Types a codec reads and then may reject, such as an {@code IntegerCodec} reading an INT64 + * that doesn't fit, aren't listed. + */ + private static final Map, Integer> DIRECT_BSON_TYPES = Map.ofEntries( + entry(StringCodec.class, mask(BsonType.STRING, BsonType.SYMBOL)), + entry(ObjectIdCodec.class, mask(BsonType.OBJECT_ID)), + entry(IntegerCodec.class, mask(BsonType.INT32)), + entry(LongCodec.class, mask(BsonType.INT32, BsonType.INT64)), + entry(DoubleCodec.class, mask(BsonType.DOUBLE, BsonType.INT32)), + entry(BooleanCodec.class, mask(BsonType.BOOLEAN)), + entry(Decimal128Codec.class, mask(BsonType.DECIMAL128)), + entry(BigDecimalCodec.class, mask(BsonType.DECIMAL128)), + entry(DateCodec.class, mask(BsonType.DATE_TIME)), + entry(EnumCodec.class, mask(BsonType.STRING)), + entry(MorphiaLocalDateTimeCodec.class, mask(BsonType.DATE_TIME, BsonType.INT64)), + entry(MorphiaDateCodec.class, mask(BsonType.DATE_TIME))); @SuppressWarnings("unchecked") CodecPlan(EntityModel model) { @@ -34,6 +73,7 @@ public final class CodecPlan { List list = model.getProperties(); properties = list.toArray(new PropertyModel[0]); codecs = new Codec[properties.length]; + directBsonTypes = new int[properties.length]; for (int slot = 0; slot < properties.length; slot++) { PropertyModel property = properties[slot]; if (property.slot() != slot) { @@ -41,6 +81,7 @@ public final class CodecPlan { model.getType().getName(), property.slot())); } codecs[slot] = (Codec) property.getCodec(); + directBsonTypes[slot] = codecs[slot] != null ? DIRECT_BSON_TYPES.getOrDefault(codecs[slot].getClass(), 0) : 0; } PropertyModel idProperty = model.getIdProperty(); encodeOrder = Arrays.stream(properties) @@ -68,6 +109,24 @@ public Codec codec(PropertyModel property) { return codecs[slot]; } + /** + * @param property a property of this plan's model + * @param type the BSON type about to be read for it + * @return true if the property's codec reads that type without being able to fail part-way, so decoding it needs no + * reader mark to fall back to + */ + public boolean readsDirectly(PropertyModel property, BsonType type) { + return (directBsonTypes[property.slot()] & (1 << type.ordinal())) != 0; + } + + private static int mask(BsonType... types) { + int mask = 0; + for (BsonType type : types) { + mask |= 1 << type.ordinal(); + } + return mask; + } + /** * @return the properties to encode after the id, in order. Callers must not modify the array. */ diff --git a/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityDecoder.java b/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityDecoder.java index d5154bfa825..186bd6da66a 100644 --- a/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityDecoder.java +++ b/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityDecoder.java @@ -117,22 +117,28 @@ protected void decodeModel(BsonReader reader, DecoderContext decoderContext, protected void decodeModel(BsonReader reader, DecoderContext decoderContext, MorphiaInstanceCreator instanceCreator, CodecPlan plan, @Nullable PropertyModel model) { - if (model != null) { - final BsonReaderMark mark = reader.getMark(); - try { - if (reader.getCurrentBsonType() == BsonType.NULL) { - reader.readNull(); - } else { - Object value = decoderContext.decodeWithChildContext(plan.codec(model), reader); - instanceCreator.set(value, model); - } - } catch (BsonInvalidOperationException e) { - mark.reset(); - final Object value = morphiaCodec.getRegistry().get(Object.class).decode(reader, decoderContext); - instanceCreator.set(morphiaCodec.getConversions().convert(value, model.getTypeData().getType()), model); - } - } else { + if (model == null) { reader.skipValue(); + return; + } + BsonType type = reader.getCurrentBsonType(); + if (type == BsonType.NULL) { + reader.readNull(); + return; + } + if (plan.readsDirectly(model, type)) { + // the codec can't fail part-way on this type, so there's nothing to rewind and no mark to take + instanceCreator.set(decoderContext.decodeWithChildContext(plan.codec(model), reader), model); + return; + } + final BsonReaderMark mark = reader.getMark(); + try { + Object value = decoderContext.decodeWithChildContext(plan.codec(model), reader); + instanceCreator.set(value, model); + } catch (BsonInvalidOperationException e) { + mark.reset(); + final Object value = morphiaCodec.getRegistry().get(Object.class).decode(reader, decoderContext); + instanceCreator.set(morphiaCodec.getConversions().convert(value, model.getTypeData().getType()), model); } } diff --git a/core/src/test/java/dev/morphia/test/mapping/codec/harness/CodecHarnessTest.java b/core/src/test/java/dev/morphia/test/mapping/codec/harness/CodecHarnessTest.java index e931b5f7ff3..6d3f0116004 100644 --- a/core/src/test/java/dev/morphia/test/mapping/codec/harness/CodecHarnessTest.java +++ b/core/src/test/java/dev/morphia/test/mapping/codec/harness/CodecHarnessTest.java @@ -58,6 +58,7 @@ import org.bson.BsonBinaryWriter; import org.bson.BsonDocument; import org.bson.BsonString; +import org.bson.BsonType; import org.bson.RawBsonDocument; import org.bson.codecs.BsonDocumentCodec; import org.bson.codecs.Codec; @@ -77,6 +78,7 @@ import org.junit.jupiter.api.TestInstance.Lifecycle; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; @@ -276,6 +278,14 @@ void codecPlanResolvesEveryPropertyBySlot() { for (PropertyModel property : model.getProperties()) { assertNotNull(plan.codec(property), mapper + ": no codec for " + property.getName()); } + // only types the codec can't fail part-way on skip the reader mark; drifted types keep it for the fallback + PropertyModel name = model.getProperty("name"); + PropertyModel age = model.getProperty("age"); + assertTrue(plan.readsDirectly(name, BsonType.STRING), mapper + ": StringCodec reads STRING directly"); + assertFalse(plan.readsDirectly(name, BsonType.INT32), mapper + ": StringCodec rejects INT32"); + assertTrue(plan.readsDirectly(age, BsonType.INT32), mapper + ": IntegerCodec reads INT32 directly"); + assertFalse(plan.readsDirectly(age, BsonType.INT64), mapper + ": IntegerCodec may reject an INT64 after reading it"); + PropertyModel foreign = datastore.getMapper().getEntityModel(HarnessRenames.class).getProperties().get(1); assertThrows(IllegalArgumentException.class, () -> plan.codec(foreign), mapper + ": a property of another model should be rejected"); diff --git a/design/mapping-game-plan.md b/design/mapping-game-plan.md index 01bd62939bf..9d4363415d6 100644 --- a/design/mapping-game-plan.md +++ b/design/mapping-game-plan.md @@ -202,7 +202,7 @@ JMH numbers on both mappers. the shared `PropertyModel`. - **Scope:** encoders and decoders read codecs from the plan. `PropertyModel.codec` remains for now (removed in 4.1). -### 3.1 Skip the reader mark for codecs that can't fail part-way (#1, B1) +### ~~3.1 Skip the reader mark for codecs that can't fail part-way (#1, B1)~~ - Hoist the NULL check above `getMark()`. - Add a per-slot `directBsonTypes` bitmask from a static table keyed on the exact codec class (the table is in B1). User codecs keep the mark. From ca24509bb8f93bfc132fe4f1670144e58468a118 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 18 Oct 2026 18:38:08 -0400 Subject: [PATCH 3/3] Pin the direct-type table against the real codecs TestDirectBsonTypes walks CodecPlan's table: every listed codec class must be the one the codec registry resolves for its Java type, and must read every listed BSON type (boundary values included) completely without throwing. A driver upgrade that changes one of these codecs fails it. --- .../morphia/mapping/codec/pojo/CodecPlan.java | 2 +- .../codec/pojo/TestDirectBsonTypes.java | 152 ++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 core/src/test/java/dev/morphia/mapping/codec/pojo/TestDirectBsonTypes.java diff --git a/core/src/main/java/dev/morphia/mapping/codec/pojo/CodecPlan.java b/core/src/main/java/dev/morphia/mapping/codec/pojo/CodecPlan.java index de852cbc097..cd69976ba94 100644 --- a/core/src/main/java/dev/morphia/mapping/codec/pojo/CodecPlan.java +++ b/core/src/main/java/dev/morphia/mapping/codec/pojo/CodecPlan.java @@ -53,7 +53,7 @@ public final class CodecPlan { * codecs keep the mark. Types a codec reads and then may reject, such as an {@code IntegerCodec} reading an INT64 * that doesn't fit, aren't listed. */ - private static final Map, Integer> DIRECT_BSON_TYPES = Map.ofEntries( + static final Map, Integer> DIRECT_BSON_TYPES = Map.ofEntries( entry(StringCodec.class, mask(BsonType.STRING, BsonType.SYMBOL)), entry(ObjectIdCodec.class, mask(BsonType.OBJECT_ID)), entry(IntegerCodec.class, mask(BsonType.INT32)), diff --git a/core/src/test/java/dev/morphia/mapping/codec/pojo/TestDirectBsonTypes.java b/core/src/test/java/dev/morphia/mapping/codec/pojo/TestDirectBsonTypes.java new file mode 100644 index 00000000000..741734a4145 --- /dev/null +++ b/core/src/test/java/dev/morphia/mapping/codec/pojo/TestDirectBsonTypes.java @@ -0,0 +1,152 @@ +package dev.morphia.mapping.codec.pojo; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; + +import dev.morphia.MorphiaDatastore; +import dev.morphia.config.ManualMorphiaConfig; +import dev.morphia.mapping.codec.EnumCodec; +import dev.morphia.mapping.codec.MorphiaDateCodec; +import dev.morphia.mapping.codec.MorphiaLocalDateTimeCodec; + +import org.bson.BsonBoolean; +import org.bson.BsonDateTime; +import org.bson.BsonDecimal128; +import org.bson.BsonDocument; +import org.bson.BsonDocumentReader; +import org.bson.BsonDouble; +import org.bson.BsonInt32; +import org.bson.BsonInt64; +import org.bson.BsonObjectId; +import org.bson.BsonString; +import org.bson.BsonSymbol; +import org.bson.BsonType; +import org.bson.BsonValue; +import org.bson.codecs.BigDecimalCodec; +import org.bson.codecs.BooleanCodec; +import org.bson.codecs.Codec; +import org.bson.codecs.DateCodec; +import org.bson.codecs.Decimal128Codec; +import org.bson.codecs.DecoderContext; +import org.bson.codecs.DoubleCodec; +import org.bson.codecs.IntegerCodec; +import org.bson.codecs.LongCodec; +import org.bson.codecs.ObjectIdCodec; +import org.bson.codecs.StringCodec; +import org.bson.types.Decimal128; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static java.util.Map.entry; + +/** + * Pins the assumption behind {@link CodecPlan}'s direct-type table against the codecs the driver and Morphia actually + * provide: for every BSON type a codec is listed as reading directly, it reads the whole value without throwing, so + * decoding that type needs no reader mark. A driver upgrade that changes one of these codecs fails here. + */ +public class TestDirectBsonTypes { + // the Java type whose codec is each table entry + private static final Map, Class> JAVA_TYPES = Map.ofEntries( + entry(StringCodec.class, String.class), + entry(ObjectIdCodec.class, ObjectId.class), + entry(IntegerCodec.class, Integer.class), + entry(LongCodec.class, Long.class), + entry(DoubleCodec.class, Double.class), + entry(BooleanCodec.class, Boolean.class), + entry(Decimal128Codec.class, Decimal128.class), + entry(BigDecimalCodec.class, BigDecimal.class), + entry(DateCodec.class, Date.class), + entry(EnumCodec.class, Color.class), + entry(MorphiaLocalDateTimeCodec.class, LocalDateTime.class), + entry(MorphiaDateCodec.class, LocalDate.class)); + + // values of each BSON type, including the extremes a codec might reject after reading + private static final Map> SAMPLES = Map.ofEntries( + entry(BsonType.STRING, List.of(new BsonString(""), new BsonString("text"))), + entry(BsonType.SYMBOL, List.of(new BsonSymbol("symbol"))), + entry(BsonType.OBJECT_ID, List.of(new BsonObjectId(new ObjectId()))), + entry(BsonType.INT32, List.of(new BsonInt32(Integer.MIN_VALUE), new BsonInt32(0), new BsonInt32(Integer.MAX_VALUE))), + entry(BsonType.INT64, List.of(new BsonInt64(Long.MIN_VALUE), new BsonInt64(1_700_000_000_000L), + new BsonInt64(Long.MAX_VALUE))), + entry(BsonType.DOUBLE, List.of(new BsonDouble(1.5), new BsonDouble(-0.0), new BsonDouble(Double.NaN), + new BsonDouble(Double.MAX_VALUE))), + entry(BsonType.BOOLEAN, List.of(BsonBoolean.TRUE, BsonBoolean.FALSE)), + entry(BsonType.DECIMAL128, List.of(new BsonDecimal128(Decimal128.parse("1.5")), + new BsonDecimal128(Decimal128.parse("-12345678901234567890.123")))), + entry(BsonType.DATE_TIME, List.of(new BsonDateTime(0), new BsonDateTime(1_700_000_000_000L)))); + + private static MongoClient client; + private static MorphiaDatastore datastore; + + @BeforeAll + static void datastore() { + // nothing here talks to a server + client = MongoClients.create("mongodb://localhost:1"); + datastore = new MorphiaDatastore(client, ManualMorphiaConfig.configure() + .database("direct_bson_types") + .applyIndexes(false) + .applyCaps(false) + .applyDocumentValidations(false)); + } + + @AfterAll + static void close() { + client.close(); + } + + @Test + void listedCodecsReadTheirTypesWithoutFailing() { + List checked = new ArrayList<>(); + CodecPlan.DIRECT_BSON_TYPES.forEach((codecClass, mask) -> { + Class javaType = JAVA_TYPES.get(codecClass); + Assertions.assertNotNull(javaType, "no Java type to look up " + codecClass.getName() + " with"); + Codec codec = datastore.getCodecRegistry().get(javaType); + Assertions.assertSame(codecClass, codec.getClass(), + "the table lists " + codecClass.getName() + " but " + javaType.getName() + " resolves to " + codec.getClass()); + + for (BsonType type : BsonType.values()) { + if ((mask & (1 << type.ordinal())) == 0) { + continue; + } + List samples = SAMPLES.get(type); + Assertions.assertNotNull(samples, "no samples of " + type); + for (BsonValue sample : samples) { + BsonValue value = codec instanceof EnumCodec ? new BsonString(Color.RED.name()) : sample; + assertReadsWhole(codec, value); + checked.add(codecClass.getSimpleName() + "/" + type); + } + } + }); + Assertions.assertFalse(checked.isEmpty()); + } + + private static void assertReadsWhole(Codec codec, BsonValue value) { + try (BsonDocumentReader reader = new BsonDocumentReader(new BsonDocument("v", value))) { + reader.readStartDocument(); + reader.readBsonType(); + reader.readName(); + try { + codec.decode(reader, DecoderContext.builder().build()); + } catch (RuntimeException e) { + Assertions.fail(codec.getClass().getSimpleName() + " should read " + value + " directly", e); + } + Assertions.assertEquals(BsonType.END_OF_DOCUMENT, reader.readBsonType(), + codec.getClass().getSimpleName() + " should consume all of " + value); + } + } + + enum Color { + RED + } +}