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..cd69976ba94 --- /dev/null +++ b/core/src/main/java/dev/morphia/mapping/codec/pojo/CodecPlan.java @@ -0,0 +1,136 @@ +package dev.morphia.mapping.codec.pojo; + +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. + * 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; + // 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. + */ + 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) { + this.model = model; + 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) { + 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(); + directBsonTypes[slot] = codecs[slot] != null ? DIRECT_BSON_TYPES.getOrDefault(codecs[slot].getClass(), 0) : 0; + } + 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]; + } + + /** + * @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. + */ + 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..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 @@ -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,35 +99,79 @@ 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); + } - if (model != null) { - final BsonReaderMark mark = reader.getMark(); - try { - if (reader.getCurrentBsonType() == BsonType.NULL) { - reader.readNull(); - } else { - Object value = decoderContext.decodeWithChildContext(model.getCodec(), 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 { + /** + * 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) { 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); } } 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/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 + } +} 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..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 @@ -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; @@ -56,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; @@ -75,9 +78,11 @@ 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; +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 +267,31 @@ 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()); + } + // 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"); + } + } + @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..9d4363415d6 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. @@ -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.