diff --git a/pom.xml b/pom.xml index 366786fc6d..83212c6c96 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-mongodb-parent - 4.2.0-SNAPSHOT + 4.2.x-4464-SNAPSHOT pom Spring Data MongoDB diff --git a/spring-data-mongodb-benchmarks/pom.xml b/spring-data-mongodb-benchmarks/pom.xml index 2de4b6b635..546736fcbd 100644 --- a/spring-data-mongodb-benchmarks/pom.xml +++ b/spring-data-mongodb-benchmarks/pom.xml @@ -7,7 +7,7 @@ org.springframework.data spring-data-mongodb-parent - 4.2.0-SNAPSHOT + 4.2.x-4464-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb-distribution/pom.xml b/spring-data-mongodb-distribution/pom.xml index 41b81f9aa6..392ee6387b 100644 --- a/spring-data-mongodb-distribution/pom.xml +++ b/spring-data-mongodb-distribution/pom.xml @@ -15,7 +15,7 @@ org.springframework.data spring-data-mongodb-parent - 4.2.0-SNAPSHOT + 4.2.x-4464-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/pom.xml b/spring-data-mongodb/pom.xml index d7a9ddaa63..1d8974112d 100644 --- a/spring-data-mongodb/pom.xml +++ b/spring-data-mongodb/pom.xml @@ -13,7 +13,7 @@ org.springframework.data spring-data-mongodb-parent - 4.2.0-SNAPSHOT + 4.2.x-4464-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/DefaultScriptOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/DefaultScriptOperations.java index 86e89b059a..34fcba989e 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/DefaultScriptOperations.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/DefaultScriptOperations.java @@ -29,6 +29,7 @@ import org.bson.Document; import org.bson.types.ObjectId; import org.springframework.dao.DataAccessException; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.script.ExecutableMongoScript; import org.springframework.data.mongodb.core.script.NamedMongoScript; import org.springframework.util.Assert; @@ -123,7 +124,8 @@ public boolean exists(String scriptName) { Assert.hasText(scriptName, "ScriptName must not be null or empty"); - return mongoOperations.exists(query(where("_id").is(scriptName)), NamedMongoScript.class, SCRIPT_COLLECTION_NAME); + return mongoOperations.exists(query(where(FieldName.ID.name()).is(scriptName)), NamedMongoScript.class, + SCRIPT_COLLECTION_NAME); } @Override diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java index ab870fc4ca..8d95fd9331 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java @@ -39,6 +39,7 @@ import org.springframework.data.mongodb.core.convert.MongoJsonSchemaMapper; import org.springframework.data.mongodb.core.convert.MongoWriter; import org.springframework.data.mongodb.core.convert.QueryMapper; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; import org.springframework.data.mongodb.core.mapping.MongoSimpleTypes; @@ -79,7 +80,7 @@ */ class EntityOperations { - private static final String ID_FIELD = "_id"; + private static final String ID_FIELD = FieldName.ID.name(); private final MappingContext, MongoPersistentProperty> context; private final QueryMapper queryMapper; diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappedDocument.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappedDocument.java index c9165988b0..d25d93fef0 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappedDocument.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappedDocument.java @@ -20,6 +20,7 @@ import org.bson.Document; import org.bson.conversions.Bson; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.query.Update; import org.springframework.data.mongodb.core.query.UpdateDefinition; import org.springframework.data.util.StreamUtils; @@ -33,7 +34,7 @@ */ public class MappedDocument { - private static final String ID_FIELD = "_id"; + private static final String ID_FIELD = FieldName.ID.name(); private static final Document ID_ONLY_PROJECTION = new Document(ID_FIELD, 1); private final Document document; diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/QueryOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/QueryOperations.java index eabbc276dc..050149ce43 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/QueryOperations.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/QueryOperations.java @@ -48,6 +48,7 @@ import org.springframework.data.mongodb.core.aggregation.TypedAggregation; import org.springframework.data.mongodb.core.convert.QueryMapper; import org.springframework.data.mongodb.core.convert.UpdateMapper; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.mapping.MongoId; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; @@ -849,7 +850,7 @@ private boolean shardedById(MongoPersistentEntity domainType) { } String key = shardKey.getPropertyNames().iterator().next(); - if ("_id".equals(key)) { + if (FieldName.ID.name().equals(key)) { return true; } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java index 46e240474d..9b03f08209 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java @@ -17,7 +17,6 @@ import static org.springframework.data.mongodb.core.query.SerializationUtils.*; -import org.springframework.data.mongodb.core.CollectionPreparerSupport.CollectionPreparerDelegate; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.function.Tuple2; @@ -104,6 +103,7 @@ import org.springframework.data.mongodb.core.index.MongoMappingEventPublisher; import org.springframework.data.mongodb.core.index.ReactiveIndexOperations; import org.springframework.data.mongodb.core.index.ReactiveMongoPersistentEntityIndexCreator; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.mapping.MongoMappingContext; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; @@ -133,7 +133,16 @@ import com.mongodb.MongoException; import com.mongodb.ReadPreference; import com.mongodb.WriteConcern; -import com.mongodb.client.model.*; +import com.mongodb.client.model.CountOptions; +import com.mongodb.client.model.CreateCollectionOptions; +import com.mongodb.client.model.CreateViewOptions; +import com.mongodb.client.model.DeleteOptions; +import com.mongodb.client.model.EstimatedDocumentCountOptions; +import com.mongodb.client.model.FindOneAndDeleteOptions; +import com.mongodb.client.model.FindOneAndReplaceOptions; +import com.mongodb.client.model.FindOneAndUpdateOptions; +import com.mongodb.client.model.ReturnDocument; +import com.mongodb.client.model.UpdateOptions; import com.mongodb.client.model.changestream.FullDocument; import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.InsertOneResult; @@ -826,7 +835,7 @@ public Mono exists(Query query, @Nullable Class entityClass, String Document filter = queryContext.getMappedQuery(entityClass, this::getPersistentEntity); FindPublisher findPublisher = collectionPreparer.prepare(collection).find(filter, Document.class) - .projection(new Document("_id", 1)); + .projection(new Document(FieldName.ID.name(), 1)); if (LOGGER.isDebugEnabled()) { LOGGER.debug(String.format("exists: %s in collection: %s", serializeToJsonSafely(filter), collectionName)); diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Aggregation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Aggregation.java index cb9e70dd17..a7a9c89977 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Aggregation.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Aggregation.java @@ -32,6 +32,7 @@ import org.springframework.data.mongodb.core.aggregation.MergeOperation.MergeOperationBuilder; import org.springframework.data.mongodb.core.aggregation.ReplaceRootOperation.ReplaceRootDocumentOperationBuilder; import org.springframework.data.mongodb.core.aggregation.ReplaceRootOperation.ReplaceRootOperationBuilder; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.CriteriaDefinition; import org.springframework.data.mongodb.core.query.NearQuery; @@ -224,7 +225,7 @@ public AggregationOptions getOptions() { * @return */ public static String previousOperation() { - return "_id"; + return FieldName.ID.name(); } /** diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Fields.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Fields.java index 027c27f9cb..e207171029 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Fields.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Fields.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Map; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; @@ -40,7 +41,7 @@ public final class Fields implements Iterable { private static final String AMBIGUOUS_EXCEPTION = "Found two fields both using '%s' as name: %s and %s; Please " + "customize your field definitions to get to unique field names"; - public static final String UNDERSCORE_ID = "_id"; + public static final String UNDERSCORE_ID = FieldName.ID.name(); public static final String UNDERSCORE_ID_REF = "$_id"; private final List fields; diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ObjectOperators.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ObjectOperators.java index 0f81a4af58..90542fa99e 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ObjectOperators.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ObjectOperators.java @@ -18,6 +18,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.Map; import org.bson.Document; import org.springframework.util.Assert; @@ -52,6 +53,41 @@ public static ObjectOperatorFactory valueOf(AggregationExpression expression) { return new ObjectOperatorFactory(expression); } + /** + * Use the value from the given {@link SystemVariable} as input for the target {@link AggregationExpression expression}. + * + * @param variable the {@link SystemVariable} to use (eg. {@link SystemVariable#ROOT}. + * @return new instance of {@link ObjectOperatorFactory}. + * @since 4.2 + */ + public static ObjectOperatorFactory valueOf(SystemVariable variable) { + return new ObjectOperatorFactory(Fields.field(variable.getName(), variable.getTarget())); + } + + /** + * Get the value of the field with given name from the {@literal $$CURRENT} object. + * Short version for {@code ObjectOperators.valueOf("$$CURRENT").getField(fieldName)}. + * + * @param fieldName the field name. + * @return new instance of {@link AggregationExpression}. + * @since 4.2 + */ + public static AggregationExpression getValueOf(String fieldName) { + return new ObjectOperatorFactory(SystemVariable.CURRENT).getField(fieldName); + } + + /** + * Set the value of the field with given name on the {@literal $$CURRENT} object. + * Short version for {@code ObjectOperators.valueOf($$CURRENT).setField(fieldName).toValue(value)}. + * + * @param fieldName the field name. + * @return new instance of {@link AggregationExpression}. + * @since 4.2 + */ + public static AggregationExpression setValueTo(String fieldName, Object value) { + return new ObjectOperatorFactory(SystemVariable.CURRENT).setField(fieldName).toValue(value); + } + /** * @author Christoph Strobl */ @@ -133,7 +169,7 @@ public ObjectToArray toArray() { * @since 4.0 */ public GetField getField(String fieldName) { - return GetField.getField(fieldName).of(value); + return GetField.getField(Fields.field(fieldName)).of(value); } /** @@ -143,7 +179,7 @@ public GetField getField(String fieldName) { * @since 4.0 */ public SetField setField(String fieldName) { - return SetField.field(fieldName).input(value); + return SetField.field(Fields.field(fieldName)).input(value); } /** @@ -340,7 +376,7 @@ public static GetField getField(String fieldName) { * @return new instance of {@link GetField}. */ public static GetField getField(Field field) { - return getField(field.getTarget()); + return new GetField(Collections.singletonMap("field", field)); } /** @@ -369,6 +405,15 @@ private GetField of(Object fieldRef) { return new GetField(append("input", fieldRef)); } + @Override + public Document toDocument(AggregationOperationContext context) { + + if(isArgumentMap() && get("field") instanceof Field field) { + return new GetField(append("field", context.getReference(field).getRaw())).toDocument(context); + } + return super.toDocument(context); + } + @Override protected String getMongoMethod() { return "$getField"; @@ -405,7 +450,7 @@ public static SetField field(String fieldName) { * @return new instance of {@link SetField}. */ public static SetField field(Field field) { - return field(field.getTarget()); + return new SetField(Collections.singletonMap("field", field)); } /** @@ -472,6 +517,14 @@ public SetField toValue(Object value) { return new SetField(append("value", value)); } + @Override + public Document toDocument(AggregationOperationContext context) { + if(get("field") instanceof Field field) { + return new SetField(append("field", context.getReference(field).getRaw())).toDocument(context); + } + return super.toDocument(context); + } + @Override protected String getMongoMethod() { return "$setField"; diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/DefaultDbRefResolver.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/DefaultDbRefResolver.java index 39245093c9..7c1266e9b1 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/DefaultDbRefResolver.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/DefaultDbRefResolver.java @@ -25,12 +25,12 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.bson.Document; - import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.mongodb.MongoDatabaseFactory; import org.springframework.data.mongodb.MongoDatabaseUtils; import org.springframework.data.mongodb.core.convert.ReferenceLoader.DocumentReferenceQuery; import org.springframework.data.mongodb.core.mapping.BasicMongoPersistentProperty; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -87,7 +87,8 @@ public Object resolveDbRef(MongoPersistentProperty property, @Nullable DBRef dbr @Override public Document fetch(DBRef dbRef) { - return getReferenceLoader().fetchOne(DocumentReferenceQuery.forSingleDocument(Filters.eq("_id", dbRef.getId())), + return getReferenceLoader().fetchOne( + DocumentReferenceQuery.forSingleDocument(Filters.eq(FieldName.ID.name(), dbRef.getId())), ReferenceCollection.fromDBRef(dbRef)); } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/DocumentAccessor.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/DocumentAccessor.java index 271551dad7..28e3551e8a 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/DocumentAccessor.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/DocumentAccessor.java @@ -21,7 +21,7 @@ import org.bson.Document; import org.bson.conversions.Bson; - +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; import org.springframework.data.mongodb.util.BsonUtils; @@ -91,14 +91,8 @@ public void putAll(Document source) { public void put(MongoPersistentProperty prop, @Nullable Object value) { Assert.notNull(prop, "MongoPersistentProperty must not be null"); - String fieldName = getFieldName(prop); - - if (!fieldName.contains(".")) { - BsonUtils.addToMap(document, fieldName, value); - return; - } - Iterator parts = Arrays.asList(fieldName.split("\\.")).iterator(); + Iterator parts = Arrays.asList(prop.getMongoField().getName().parts()).iterator(); Bson document = this.document; while (parts.hasNext()) { @@ -135,7 +129,7 @@ public Object get(MongoPersistentProperty property) { */ @Nullable public Object getRawId(MongoPersistentEntity entity) { - return entity.hasIdProperty() ? get(entity.getRequiredIdProperty()) : BsonUtils.get(document, "_id"); + return entity.hasIdProperty() ? get(entity.getRequiredIdProperty()) : BsonUtils.get(document, FieldName.ID.name()); } /** @@ -153,8 +147,8 @@ public boolean hasValue(MongoPersistentProperty property) { return BsonUtils.hasValue(document, getFieldName(property)); } - String getFieldName(MongoPersistentProperty prop) { - return prop.getFieldName(); + FieldName getFieldName(MongoPersistentProperty prop) { + return prop.getMongoField().getName(); } /** diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/DocumentPointerFactory.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/DocumentPointerFactory.java index 3c2e2dc3fe..1b673740c1 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/DocumentPointerFactory.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/DocumentPointerFactory.java @@ -34,6 +34,7 @@ import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.mapping.model.BeanWrapperPropertyAccessorFactory; import org.springframework.data.mongodb.core.mapping.DocumentPointer; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; @@ -232,7 +233,7 @@ Object updatePlaceholders(org.bson.Document source, org.bson.Document target, attribute = attribute.substring(attribute.lastIndexOf('.') + 1); } - String fieldName = entry.getKey().equals("_id") ? "id" : entry.getKey(); + String fieldName = entry.getKey().equals(FieldName.ID.name()) ? "id" : entry.getKey(); if (!fieldName.contains(".")) { Object targetValue = propertyAccessor.getProperty(persistentEntity.getPersistentProperty(fieldName)); diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MappingMongoConverter.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MappingMongoConverter.java index ec5d866461..2949bebba7 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MappingMongoConverter.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MappingMongoConverter.java @@ -72,6 +72,7 @@ import org.springframework.data.mongodb.MongoDatabaseFactory; import org.springframework.data.mongodb.core.mapping.BasicMongoPersistentProperty; import org.springframework.data.mongodb.core.mapping.DocumentPointer; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; import org.springframework.data.mongodb.core.mapping.PersistentPropertyTranslator; @@ -230,8 +231,8 @@ public CustomConversions getCustomConversions() { } /** - * Configure the characters dots potentially contained in a {@link Map} shall be replaced with. By default we don't do - * any translation but rather reject a {@link Map} with keys containing dots causing the conversion for the entire + * Configure the characters dots potentially contained in a {@link Map} shall be replaced with. By default, we don't + * do any translation but rather reject a {@link Map} with keys containing dots causing the conversion for the entire * object to fail. If further customization of the translation is needed, have a look at * {@link #potentiallyEscapeMapKey(String)} as well as {@link #potentiallyUnescapeMapKey(String)}. *

@@ -244,6 +245,16 @@ public void setMapKeyDotReplacement(@Nullable String mapKeyDotReplacement) { this.mapKeyDotReplacement = mapKeyDotReplacement; } + /** + * If {@link #preserveMapKeys(boolean) preserve} is set to {@literal true} the conversion will treat map keys containing {@literal .} (dot) characters as is. + * + * @since 4.2 + * @see #setMapKeyDotReplacement(String) + */ + public void preserveMapKeys(boolean preserve) { + setMapKeyDotReplacement(preserve ? "." : null); + } + /** * Configure a {@link CodecRegistryProvider} that provides native MongoDB {@link org.bson.codecs.Codec codecs} for * reading values. @@ -345,8 +356,8 @@ private R doReadProjection(ConversionContext context, Bson bson, EntityProje Predicates.negate(MongoPersistentProperty::hasExplicitFieldName)); DocumentAccessor documentAccessor = new DocumentAccessor(bson) { @Override - String getFieldName(MongoPersistentProperty prop) { - return propertyTranslator.translate(prop).getFieldName(); + FieldName getFieldName(MongoPersistentProperty prop) { + return propertyTranslator.translate(prop).getMongoField().getName(); } }; diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConverters.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConverters.java index 7344697979..dd6224472b 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConverters.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoConverters.java @@ -51,6 +51,7 @@ import org.springframework.core.convert.converter.ConverterFactory; import org.springframework.data.convert.ReadingConverter; import org.springframework.data.convert.WritingConverter; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.query.Term; import org.springframework.data.mongodb.core.script.NamedMongoScript; import org.springframework.util.Assert; @@ -300,7 +301,7 @@ public NamedMongoScript convert(Document source) { return null; } - String id = source.get("_id").toString(); + String id = source.get(FieldName.ID.name()).toString(); Object rawValue = source.get("value"); return new NamedMongoScript(id, ((Code) rawValue).getCode()); @@ -320,7 +321,7 @@ public Document convert(NamedMongoScript source) { Document document = new Document(); - document.put("_id", source.getName()); + document.put(FieldName.ID.name(), source.getName()); document.put("value", new Code(source.getCode())); return document; diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoExampleMapper.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoExampleMapper.java index 69757b22e9..f93afcee85 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoExampleMapper.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoExampleMapper.java @@ -34,6 +34,7 @@ import org.springframework.data.domain.ExampleMatcher.StringMatcher; import org.springframework.data.mapping.PropertyHandler; import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; import org.springframework.data.mongodb.core.query.MongoRegexCreator; @@ -278,7 +279,8 @@ private Set> getTypesToMatch(Example example) { } private static boolean isEmptyIdProperty(Entry entry) { - return entry.getKey().equals("_id") && (entry.getValue() == null || entry.getValue().equals(Optional.empty())); + return entry.getKey().equals(FieldName.ID.name()) + && (entry.getValue() == null || entry.getValue().equals(Optional.empty())); } private static void applyStringMatcher(Map.Entry entry, StringMatcher stringMatcher, diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java index 06aee31afc..156108c563 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java @@ -47,6 +47,7 @@ import org.springframework.data.mongodb.core.aggregation.AggregationExpression; import org.springframework.data.mongodb.core.aggregation.RelaxedTypeBasedAggregationOperationContext; import org.springframework.data.mongodb.core.convert.MappingMongoConverter.NestedDocument; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty.PropertyToFieldNameConverter; @@ -80,7 +81,7 @@ public class QueryMapper { protected static final Log LOGGER = LogFactory.getLog(QueryMapper.class); - private static final List DEFAULT_ID_NAMES = Arrays.asList("id", "_id"); + private static final List DEFAULT_ID_NAMES = Arrays.asList("id", FieldName.ID.name()); private static final Document META_TEXT_SCORE = new Document("$meta", "textScore"); static final TypeInformation NESTED_DOCUMENT = TypeInformation.of(NestedDocument.class); @@ -168,6 +169,14 @@ public Document getMappedObject(Bson query, @Nullable MongoPersistentEntity e Entry entry = getMappedObjectForField(field, BsonUtils.get(query, key)); + /* + * Note to future self: + * ---- + * This could be the place to plug in a query rewrite mechanism that allows to transform comparison + * against field that has a dot in its name (like 'a.b') into an $expr so that { "a.b" : "some value" } + * eventually becomes { $expr : { $eq : [ { $getField : "a.b" }, "some value" ] } } + * ---- + */ result.put(entry.getKey(), entry.getValue()); } } catch (InvalidPersistentPropertyPath invalidPathException) { @@ -358,7 +367,7 @@ protected Field createPropertyField(@Nullable MongoPersistentEntity entity, S return new Field(key); } - if (Field.ID_KEY.equals(key)) { + if (FieldName.ID.name().equals(key)) { return new MetadataBackedField(key, entity, mappingContext, entity.getIdProperty()); } @@ -378,8 +387,7 @@ protected Document getMappedKeyword(Keyword keyword, @Nullable MongoPersistentEn if (keyword.isOrOrNor() || (keyword.hasIterableValue() && !keyword.isGeometry())) { Iterable conditions = keyword.getValue(); - List newConditions = conditions instanceof Collection collection - ? new ArrayList<>(collection.size()) + List newConditions = conditions instanceof Collection collection ? new ArrayList<>(collection.size()) : new ArrayList<>(); for (Object condition : conditions) { @@ -615,7 +623,7 @@ protected Object convertSimpleOrDocument(Object source, @Nullable MongoPersisten String key = ObjectUtils.nullSafeToString(converter.convertToMongoType(it.getKey())); - if (it.getValue() instanceof Document document) { + if (it.getValue()instanceof Document document) { map.put(key, getMappedObject(document, entity)); } else { map.put(key, delegateConvertToMongoType(it.getValue(), entity)); @@ -968,8 +976,6 @@ protected static class Field { protected static final Pattern POSITIONAL_OPERATOR = Pattern.compile("\\$\\[.*\\]"); - private static final String ID_KEY = "_id"; - protected final String name; /** @@ -999,7 +1005,7 @@ public Field with(String name) { * @return */ public boolean isIdField() { - return ID_KEY.equals(name); + return FieldName.ID.name().equals(name); } /** @@ -1044,7 +1050,7 @@ public boolean isAssociation() { * @return */ public String getMappedKey() { - return isIdField() ? ID_KEY : name; + return isIdField() ? FieldName.ID.name() : name; } /** @@ -1213,6 +1219,11 @@ public Class getFieldType() { @Override public String getMappedKey() { + + if (getProperty() != null && getProperty().getMongoField().getName().isKey()) { + return getProperty().getFieldName(); + } + return path == null ? name : path.toDotPath(isAssociation() ? getAssociationConverter() : getPropertyConverter()); } @@ -1343,7 +1354,7 @@ private static String resolvePath(String source) { /* always start from a property, so we can skip the first segment. from there remove any position placeholder */ - for(int i=1; i < segments.length; i++) { + for (int i = 1; i < segments.length; i++) { String segment = segments[i]; if (segment.startsWith("[") && segment.endsWith("]")) { continue; diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/ReferenceLookupDelegate.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/ReferenceLookupDelegate.java index 045f14f7aa..e4d94d64f5 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/ReferenceLookupDelegate.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/ReferenceLookupDelegate.java @@ -37,6 +37,7 @@ import org.springframework.data.mongodb.core.convert.ReferenceResolver.MongoEntityReader; import org.springframework.data.mongodb.core.convert.ReferenceResolver.ReferenceCollection; import org.springframework.data.mongodb.core.mapping.DocumentReference; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; import org.springframework.data.mongodb.util.BsonUtils; @@ -63,7 +64,8 @@ */ public final class ReferenceLookupDelegate { - private static final Document NO_RESULTS_PREDICATE = new Document("_id", new Document("$exists", false)); + private static final Document NO_RESULTS_PREDICATE = new Document(FieldName.ID.name(), + new Document("$exists", false)); private final MappingContext, MongoPersistentProperty> mappingContext; private final SpELContext spELContext; diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/WildcardIndex.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/WildcardIndex.java index f7b86b3229..8e4186f59e 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/WildcardIndex.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/WildcardIndex.java @@ -21,6 +21,7 @@ import java.util.concurrent.TimeUnit; import org.bson.Document; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.lang.Nullable; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; @@ -77,7 +78,7 @@ public WildcardIndex(@Nullable String path) { */ public WildcardIndex includeId() { - wildcardProjection.put("_id", 1); + wildcardProjection.put(FieldName.ID.name(), 1); return this; } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntity.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntity.java index 9b87e05456..0481049659 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntity.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntity.java @@ -116,7 +116,7 @@ private ShardKey detectShardKey() { String[] keyProperties = sharded.shardKey(); if (ObjectUtils.isEmpty(keyProperties)) { - keyProperties = new String[] { "_id" }; + keyProperties = new String[] { FieldName.ID.name() }; } ShardKey shardKey = ShardingStrategy.HASH.equals(sharded.shardingStrategy()) ? ShardKey.hash(keyProperties) diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentProperty.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentProperty.java index 90f72855de..cfd14672b7 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentProperty.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentProperty.java @@ -26,7 +26,6 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.bson.types.ObjectId; - import org.springframework.data.mapping.Association; import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.model.AnnotationBasedPersistentProperty; @@ -34,6 +33,8 @@ import org.springframework.data.mapping.model.Property; import org.springframework.data.mapping.model.PropertyNameFieldNamingStrategy; import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.mongodb.core.mapping.FieldName.Type; +import org.springframework.data.mongodb.core.mapping.MongoField.MongoFieldBuilder; import org.springframework.data.mongodb.util.encryption.EncryptionUtils; import org.springframework.data.util.Lazy; import org.springframework.expression.EvaluationContext; @@ -57,7 +58,7 @@ public class BasicMongoPersistentProperty extends AnnotationBasedPersistentPrope private static final Log LOG = LogFactory.getLog(BasicMongoPersistentProperty.class); - public static final String ID_FIELD_NAME = "_id"; + public static final String ID_FIELD_NAME = FieldName.ID.name(); private static final String LANGUAGE_FIELD_NAME = "language"; private static final Set> SUPPORTED_ID_TYPES = new HashSet>(); private static final Set SUPPORTED_ID_PROPERTY_NAMES = new HashSet(); @@ -69,7 +70,7 @@ public class BasicMongoPersistentProperty extends AnnotationBasedPersistentPrope SUPPORTED_ID_TYPES.add(BigInteger.class); SUPPORTED_ID_PROPERTY_NAMES.add("id"); - SUPPORTED_ID_PROPERTY_NAMES.add("_id"); + SUPPORTED_ID_PROPERTY_NAMES.add(ID_FIELD_NAME); } private final FieldNamingStrategy fieldNamingStrategy; @@ -130,31 +131,9 @@ public boolean isExplicitIdProperty() { * * @return */ + @Override public String getFieldName() { - - if (isIdProperty()) { - - if (getOwner().getIdProperty() == null) { - return ID_FIELD_NAME; - } - - if (getOwner().isIdProperty(this)) { - return ID_FIELD_NAME; - } - } - - if (hasExplicitFieldName()) { - return getAnnotatedFieldName(); - } - - String fieldName = fieldNamingStrategy.getFieldName(this); - - if (!StringUtils.hasText(fieldName)) { - throw new MappingException(String.format("Invalid (null or empty) field name returned for property %s by %s", - this, fieldNamingStrategy.getClass())); - } - - return fieldName; + return getMongoField().getName().name(); } @Override @@ -175,7 +154,7 @@ public Class getFieldType() { return FieldType.OBJECT_ID.getJavaClass(); } - FieldType fieldType = fieldAnnotation.targetType(); + FieldType fieldType = getMongoField().getFieldType(); if (fieldType == FieldType.IMPLICIT) { if (isEntity()) { @@ -193,6 +172,7 @@ public Class getFieldType() { * {@link org.springframework.data.mongodb.core.mapping.Field#value()} present. * @since 1.7 */ + @Override public boolean hasExplicitFieldName() { return StringUtils.hasText(getAnnotatedFieldName()); } @@ -206,12 +186,9 @@ private String getAnnotatedFieldName() { return annotation != null ? annotation.value() : null; } + @Override public int getFieldOrder() { - - org.springframework.data.mongodb.core.mapping.Field annotation = findAnnotation( - org.springframework.data.mongodb.core.mapping.Field.class); - - return annotation != null ? annotation.order() : Integer.MAX_VALUE; + return getMongoField().getOrder(); } @Override @@ -225,9 +202,10 @@ public boolean writeNullValues() { @Override protected Association createAssociation() { - return new Association(this, null); + return new Association<>(this, null); } + @Override public boolean isDbReference() { return isAnnotationPresent(DBRef.class); } @@ -237,6 +215,7 @@ public boolean isDocumentReference() { return isAnnotationPresent(DocumentReference.class); } + @Override @Nullable public DBRef getDBRef() { return findAnnotation(DBRef.class); @@ -278,6 +257,11 @@ public EvaluationContext getEvaluationContext(@Nullable Object rootObject) { return rootObject != null ? new StandardEvaluationContext(rootObject) : new StandardEvaluationContext(); } + @Override + public MongoField getMongoField() { + return doGetMongoField(); + } + @Override public Collection getEncryptionKeyIds() { @@ -302,4 +286,57 @@ public Collection getEncryptionKeyIds() { } return target; } + + protected MongoField doGetMongoField() { + + MongoFieldBuilder builder = MongoField.builder(); + if (isAnnotationPresent(Field.class) && Type.KEY.equals(findAnnotation(Field.class).nameType())) { + builder.name(doGetFieldName()); + } else { + builder.path(doGetFieldName()); + } + builder.fieldType(doGetFieldType()); + builder.order(doGetFieldOrder()); + return builder.build(); + } + + private String doGetFieldName() { + + if (isIdProperty()) { + + if (getOwner().getIdProperty() == null) { + return ID_FIELD_NAME; + } + + if (getOwner().isIdProperty(this)) { + return ID_FIELD_NAME; + } + } + + if (hasExplicitFieldName()) { + return getAnnotatedFieldName(); + } + + String fieldName = fieldNamingStrategy.getFieldName(this); + + if (!StringUtils.hasText(fieldName)) { + throw new MappingException(String.format("Invalid (null or empty) field name returned for property %s by %s", + this, fieldNamingStrategy.getClass())); + } + + return fieldName; + } + + private FieldType doGetFieldType() { + + Field fieldAnnotation = findAnnotation(Field.class); + return fieldAnnotation != null ? fieldAnnotation.targetType() : FieldType.IMPLICIT; + } + + private int doGetFieldOrder() { + + Field annotation = findAnnotation(Field.class); + return annotation != null ? annotation.order() : Integer.MAX_VALUE; + } + } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/CachingMongoPersistentProperty.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/CachingMongoPersistentProperty.java index 79675ef333..8b4cf6d2e5 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/CachingMongoPersistentProperty.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/CachingMongoPersistentProperty.java @@ -18,6 +18,7 @@ import org.springframework.data.mapping.model.FieldNamingStrategy; import org.springframework.data.mapping.model.Property; import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.util.Lazy; import org.springframework.lang.Nullable; /** @@ -25,6 +26,7 @@ * * @author Oliver Gierke * @author Mark Paluch + * @author Christoph Strobl */ public class CachingMongoPersistentProperty extends BasicMongoPersistentProperty { @@ -37,6 +39,7 @@ public class CachingMongoPersistentProperty extends BasicMongoPersistentProperty private @Nullable Class fieldType; private @Nullable Boolean usePropertyAccess; private @Nullable Boolean isTransient; + private @Nullable Lazy mongoField = Lazy.of(super::getMongoField); /** * Creates a new {@link CachingMongoPersistentProperty}. @@ -134,4 +137,9 @@ public DBRef getDBRef() { return this.dbref; } + + @Override + public MongoField getMongoField() { + return mongoField.get(); + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/Field.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/Field.java index 2f74b0e9f9..f5c38eafa3 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/Field.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/Field.java @@ -22,6 +22,7 @@ import java.lang.annotation.Target; import org.springframework.core.annotation.AliasFor; +import org.springframework.data.mongodb.core.mapping.FieldName.Type; /** * Annotation to define custom metadata for document fields. @@ -39,12 +40,16 @@ * The key to be used to store the field inside the document. Alias for {@link #name()}. * * @return an empty {@link String} by default. + * @see #name() */ @AliasFor("name") String value() default ""; /** - * The key to be used to store the field inside the document. Alias for {@link #value()}. + * The key to be used to store the field inside the document. Alias for {@link #value()}. The name may contain MongoDB + * special characters like {@literal .} (dot). In this case the name is by default treated as a + * {@link Type#PATH path}. To preserve dots within the name set the {@link #nameType()} attribute to + * {@link Type#KEY}. * * @return an empty {@link String} by default. * @since 2.2 @@ -52,6 +57,15 @@ @AliasFor("value") String name() default ""; + /** + * The used {@link Type type} has impact on how a given {@link #name()} is treated if it contains + * {@literal .} (dot) characters. + * + * @return {@link Type#PATH} by default. + * @since 4.2 + */ + Type nameType() default Type.PATH; + /** * The order in which various fields shall be stored. Has to be a positive integer. * @@ -70,8 +84,7 @@ /** * Write rules when to include a property value upon conversion. If set to {@link Write#NON_NULL} (default) * {@literal null} values are not written to the target {@code Document}. Setting the value to {@link Write#ALWAYS} - * explicitly adds an entry for the given field holding {@literal null} as a value {@code 'fieldName' : null }. - *
+ * explicitly adds an entry for the given field holding {@literal null} as a value {@code 'fieldName' : null }.
* NOTE: Setting the value to {@link Write#ALWAYS} may lead to increased document size. * * @return {@link Write#NON_NULL} by default. diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/FieldName.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/FieldName.java new file mode 100644 index 0000000000..b0b1167937 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/FieldName.java @@ -0,0 +1,136 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.core.mapping; + +import org.springframework.util.ObjectUtils; + +/** + * Value Object representing a field name that should be used to read/write fields within the MongoDB document. + * {@link FieldName Field names} field names may contain special characters (such as {@literal .} (dot)) but may be + * treated differently depending on their {@link Type type}. + * + * @author Christoph Strobl + * @since 4.2 + */ +public record FieldName(String name, Type type) { + + private static final String ID_KEY = "_id"; + + public static final FieldName ID = new FieldName(ID_KEY, Type.KEY); + + /** + * Create a new {@link FieldName} that treats the given {@literal value} as is. + * + * @param value must not be {@literal null}. + * @return new instance of {@link FieldName}. + */ + public static FieldName name(String value) { + return new FieldName(value, Type.KEY); + } + + /** + * Create a new {@link FieldName} that treats the given {@literal value} as a path. If the {@literal value} contains + * {@literal .} (dot) characters, they are considered deliminators in a path. + * + * @param value must not be {@literal null}. + * @return new instance of {@link FieldName}. + */ + public static FieldName path(String value) { + return new FieldName(value, Type.PATH); + } + + /** + * Get the parts the field name consists of. If the {@link FieldName} is a {@link Type#KEY} or a {@link Type#PATH} + * that does not contain {@literal .} (dot) characters an array containing a single element is returned. Otherwise the + * {@link #name()} is split into segments using {@literal .} (dot) as a deliminator. + * + * @return never {@literal null}. + */ + public String[] parts() { + + if (isKey()) { + return new String[] { name }; + } + + return name.split("\\."); + } + + /** + * @param type return true if the given {@link Type} is equal to {@link #type()}. + * @return {@literal true} if values are equal. + */ + public boolean isOfType(Type type) { + return ObjectUtils.nullSafeEquals(type(), type); + } + + /** + * @return whether the field name represents a key (i.e. as-is name). + */ + public boolean isKey() { + return isOfType(Type.KEY); + } + + /** + * @return whether the field name represents a path (i.e. dot-path). + */ + public boolean isPath() { + return isOfType(Type.PATH); + } + + @Override + public String toString() { + return "FieldName{%s=%s}".formatted(isKey() ? "key" : "path", name); + } + + @Override + public boolean equals(Object o) { + + if (o == this) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FieldName fieldName = (FieldName) o; + return ObjectUtils.nullSafeEquals(name, fieldName.name) && type == fieldName.type; + } + + @Override + public int hashCode() { + + int hashCode = ObjectUtils.nullSafeHashCode(name); + return 31 * hashCode + ObjectUtils.nullSafeHashCode(type); + } + + /** + * The {@link FieldName.Type type} defines how to treat a {@link FieldName} that contains special characters. + * + * @author Christoph Strobl + * @since 4.2 + */ + public enum Type { + + /** + * {@literal .} (dot) characters are treated as separators for segments in a path. + */ + PATH, + + /** + * Values are used as is. + */ + KEY + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoField.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoField.java new file mode 100644 index 0000000000..29d3676e19 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoField.java @@ -0,0 +1,211 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.core.mapping; + +import org.springframework.data.mongodb.core.mapping.FieldName.Type; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * Value Object for representing a field to read/write within a MongoDB {@link org.bson.Document}. + * + * @author Christoph Strobl + * @since 4.2 + */ +public class MongoField { + + private final FieldName name; + private final FieldType fieldType; + private final int order; + + protected MongoField(FieldName name, Class targetFieldType, int fieldOrder) { + this(name, FieldType.valueOf(targetFieldType.getSimpleName()), fieldOrder); + } + + protected MongoField(FieldName name, FieldType fieldType, int fieldOrder) { + + this.name = name; + this.fieldType = fieldType; + this.order = fieldOrder; + } + + /** + * Create a new {@link MongoField} with given {@literal name}. + * + * @param name the name to be used as is (with all its potentially special characters). + * @return new instance of {@link MongoField}. + */ + public static MongoField fromKey(String name) { + return builder().name(name).build(); + } + + /** + * Create a new {@link MongoField} with given {@literal name}. + * + * @param name the name to be used path expression. + * @return new instance of {@link MongoField}. + */ + public static MongoField fromPath(String name) { + return builder().path(name).build(); + } + + /** + * @return new instance of {@link MongoFieldBuilder}. + */ + public static MongoFieldBuilder builder() { + return new MongoFieldBuilder(); + } + + /** + * @return never {@literal null}. + */ + public FieldName getName() { + return name; + } + + /** + * Get the position of the field within the target document. + * + * @return {@link Integer#MAX_VALUE} if undefined. + */ + public int getOrder() { + return order; + } + + /** + * @param prefix a prefix to the current name. + * @return new instance of {@link MongoField} with prefix appended to current field name. + */ + MongoField withPrefix(String prefix) { + return new MongoField(new FieldName(prefix + name.name(), name.type()), fieldType, order); + } + + /** + * Get the fields target type if defined. + * + * @return never {@literal null}. + */ + public FieldType getFieldType() { + return fieldType; + } + + @Override + public boolean equals(Object o) { + + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + MongoField that = (MongoField) o; + + if (order != that.order) + return false; + if (!ObjectUtils.nullSafeEquals(name, that.name)) { + return false; + } + return fieldType == that.fieldType; + } + + @Override + public int hashCode() { + + int result = ObjectUtils.nullSafeHashCode(name); + result = 31 * result + ObjectUtils.nullSafeHashCode(fieldType); + result = 31 * result + order; + return result; + } + + @Override + public String toString() { + return name.toString(); + } + + /** + * Builder for {@link MongoField}. + */ + public static class MongoFieldBuilder { + + private String name; + private Type nameType = Type.PATH; + private FieldType type = FieldType.IMPLICIT; + private int order = Integer.MAX_VALUE; + + /** + * Configure the field type. + * + * @param fieldType + * @return + */ + public MongoFieldBuilder fieldType(FieldType fieldType) { + + this.type = fieldType; + return this; + } + + /** + * Configure the field name as key. Key field names are used as-is without applying path segmentation splitting + * rules. + * + * @param fieldName + * @return + */ + public MongoFieldBuilder name(String fieldName) { + + Assert.hasText(fieldName, "Field name must not be empty"); + + this.name = fieldName; + this.nameType = Type.KEY; + return this; + } + + /** + * Configure the field name as path. Path field names are applied as paths potentially pointing into subdocuments. + * + * @param path + * @return + */ + public MongoFieldBuilder path(String path) { + + Assert.hasText(path, "Field path (name) must not be empty"); + + this.name = path; + this.nameType = Type.PATH; + return this; + } + + /** + * Configure the field order, defaulting to {@link Integer#MAX_VALUE} (undefined). + * + * @param order + * @return + */ + public MongoFieldBuilder order(int order) { + + this.order = order; + return this; + } + + /** + * Build a new {@link MongoField}. + * + * @return a new {@link MongoField}. + */ + public MongoField build() { + return new MongoField(new FieldName(name, nameType), type, order); + } + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoPersistentProperty.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoPersistentProperty.java index b155d50d50..b0ef8a1d4c 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoPersistentProperty.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoPersistentProperty.java @@ -176,6 +176,12 @@ default boolean isUnwrapped() { */ Collection getEncryptionKeyIds(); + /** + * @return the {@link MongoField} representing the raw field to read/write in a MongoDB document. + * @since 4.2 + */ + MongoField getMongoField(); + /** * Simple {@link Converter} implementation to transform a {@link MongoPersistentProperty} into its field name. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/UnwrappedMongoPersistentProperty.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/UnwrappedMongoPersistentProperty.java index c4f44e974f..da2e39b052 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/UnwrappedMongoPersistentProperty.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/UnwrappedMongoPersistentProperty.java @@ -144,6 +144,16 @@ public Class getType() { return delegate.getType(); } + @Override + public MongoField getMongoField() { + + if (!context.getProperty().isUnwrapped()) { + return delegate.getMongoField(); + } + + return delegate.getMongoField().withPrefix(context.getProperty().findAnnotation(Unwrapped.class).prefix()); + } + @Override public TypeInformation getTypeInformation() { return delegate.getTypeInformation(); diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/Criteria.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/Criteria.java index dd13cb5bcf..276b0c4b89 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/Criteria.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/Criteria.java @@ -122,7 +122,7 @@ public static Criteria byExample(Object example) { * By default the {@link Example} uses typed matching restricting it to probe assignable types. For example, when * sticking with the default type key ({@code _class}), the query has restrictions such as * _class : { $in : [com.acme.Person] } .
- * To avoid the above mentioned type restriction use an {@link UntypedExampleMatcher} with + * To avoid the above-mentioned type restriction use an {@link UntypedExampleMatcher} with * {@link Example#of(Object, org.springframework.data.domain.ExampleMatcher)}. * * @param example must not be {@literal null}. diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AggregationUtils.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AggregationUtils.java index 7b1e709936..e57286f76a 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AggregationUtils.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AggregationUtils.java @@ -27,6 +27,7 @@ import org.springframework.data.mongodb.core.aggregation.AggregationOptions; import org.springframework.data.mongodb.core.aggregation.AggregationPipeline; import org.springframework.data.mongodb.core.convert.MongoConverter; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.query.Collation; import org.springframework.data.mongodb.core.query.Meta; import org.springframework.data.mongodb.core.query.Query; @@ -208,7 +209,7 @@ static T extractSimpleTypeResult(@Nullable Document source, Class targetT } Document intermediate = new Document(source); - intermediate.remove("_id"); + intermediate.remove(FieldName.ID.name()); if (intermediate.size() == 1) { return getPotentiallyConvertedSimpleTypeValue(converter, intermediate.values().iterator().next(), targetType); diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/ReactiveSpringDataMongodbQuery.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/ReactiveSpringDataMongodbQuery.java index e293414255..789dc12650 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/ReactiveSpringDataMongodbQuery.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/ReactiveSpringDataMongodbQuery.java @@ -26,11 +26,12 @@ import org.bson.Document; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Window; import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Window; import org.springframework.data.mongodb.core.MongoOperations; import org.springframework.data.mongodb.core.ReactiveFindOperation; import org.springframework.data.mongodb.core.ReactiveMongoOperations; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.query.BasicQuery; import org.springframework.data.mongodb.core.query.Query; import org.springframework.lang.Nullable; @@ -264,7 +265,7 @@ protected Flux getIds(Class targetType, Mono condition) { protected Flux getJoinIds(Class targetType, @Nullable Predicate condition) { return createQuery(Mono.justOrEmpty(condition), null, QueryModifiers.EMPTY, Collections.emptyList()) - .flatMapMany(query -> mongoOperations.findDistinct(query, "_id", targetType, Object.class)); + .flatMapMany(query -> mongoOperations.findDistinct(query, FieldName.ID.name(), targetType, Object.class)); } @Override diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/SpringDataMongodbQuery.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/SpringDataMongodbQuery.java index 0ecff39583..c2bd4d2482 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/SpringDataMongodbQuery.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/SpringDataMongodbQuery.java @@ -25,10 +25,11 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Window; import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Window; import org.springframework.data.mongodb.core.ExecutableFindOperation; import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.query.BasicQuery; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.support.PageableExecutionUtils; @@ -258,7 +259,7 @@ protected org.springframework.data.mongodb.core.query.Query createQuery(@Nullabl protected List getIds(Class targetType, Predicate condition) { Query query = createQuery(condition, null, QueryModifiers.EMPTY, Collections.emptyList()); - return mongoOperations.findDistinct(query, "_id", targetType, Object.class); + return mongoOperations.findDistinct(query, FieldName.ID.name(), targetType, Object.class); } private static T handleException(RuntimeException e, T defaultValue) { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/SpringDataMongodbSerializer.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/SpringDataMongodbSerializer.java index 7d14ef0b4e..7be427b10e 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/SpringDataMongodbSerializer.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/SpringDataMongodbSerializer.java @@ -25,6 +25,7 @@ import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.mongodb.core.convert.MongoConverter; import org.springframework.data.mongodb.core.convert.QueryMapper; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; import org.springframework.lang.Nullable; @@ -50,7 +51,7 @@ */ class SpringDataMongodbSerializer extends MongodbDocumentSerializer { - private static final String ID_KEY = "_id"; + private static final String ID_KEY = FieldName.ID.name(); private static final Set PATH_TYPES; static { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java index 6858156257..eebd948a14 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java @@ -39,6 +39,8 @@ import org.bson.types.ObjectId; import org.springframework.core.convert.converter.Converter; import org.springframework.data.mongodb.CodecRegistryProvider; +import org.springframework.data.mongodb.core.mapping.FieldName; +import org.springframework.data.mongodb.core.mapping.FieldName.Type; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -244,11 +246,11 @@ public static boolean contains(Bson bson, String key, @Nullable Object value) { */ public static boolean removeNullId(Bson bson) { - if (!contains(bson, "_id", null)) { + if (!contains(bson, FieldName.ID.name(), null)) { return false; } - removeFrom(bson, "_id"); + removeFrom(bson, FieldName.ID.name()); return true; } @@ -518,24 +520,39 @@ public static Object resolveValue(Bson bson, String key) { } /** - * Resolve the value for a given key. If the given {@link Map} value contains the key the value is immediately - * returned. If not and the key contains a path using the dot ({@code .}) notation it will try to resolve the path by - * inspecting the individual parts. If one of the intermediate ones is {@literal null} or cannot be inspected further - * (wrong) type, {@literal null} is returned. + * Resolve the value for a given {@link FieldName field name}. If the given name is a {@link Type#KEY} the value is + * obtained from the target {@link Bson} immediately. If the given fieldName is a {@link Type#PATH} maybe using the + * dot ({@code .}) notation it will try to resolve the path by inspecting the individual parts. If one of the + * intermediate ones is {@literal null} or cannot be inspected further (wrong) type, {@literal null} is returned. + * + * @param bson the source to inspect. Must not be {@literal null}. + * @param fieldName the name to lookup. Must not be {@literal null}. + * @return can be {@literal null}. + * @since 4.2 + */ + public static Object resolveValue(Bson bson, FieldName fieldName) { + return resolveValue(asMap(bson), fieldName); + } + + /** + * Resolve the value for a given {@link FieldName field name}. If the given name is a {@link Type#KEY} the value is + * obtained from the target {@link Bson} immediately. If the given fieldName is a {@link Type#PATH} maybe using the + * dot ({@code .}) notation it will try to resolve the path by inspecting the individual parts. If one of the + * intermediate ones is {@literal null} or cannot be inspected further (wrong) type, {@literal null} is returned. * * @param source the source to inspect. Must not be {@literal null}. - * @param key the key to lookup. Must not be {@literal null}. + * @param fieldName the key to lookup. Must not be {@literal null}. * @return can be {@literal null}. - * @since 4.1 + * @since 4.2 */ @Nullable - public static Object resolveValue(Map source, String key) { + public static Object resolveValue(Map source, FieldName fieldName) { - if (source.containsKey(key) || !key.contains(".")) { - return source.get(key); + if (fieldName.isKey()) { + return source.get(fieldName.name()); } - String[] parts = key.split("\\."); + String[] parts = fieldName.parts(); for (int i = 1; i < parts.length; i++) { @@ -552,28 +569,34 @@ public static Object resolveValue(Map source, String key) { } /** - * Returns whether the underlying {@link Bson bson} has a value ({@literal null} or non-{@literal null}) for the given - * {@code key}. + * Resolve the value for a given key. If the given {@link Map} value contains the key the value is immediately + * returned. If not and the key contains a path using the dot ({@code .}) notation it will try to resolve the path by + * inspecting the individual parts. If one of the intermediate ones is {@literal null} or cannot be inspected further + * (wrong) type, {@literal null} is returned. * - * @param bson the source to inspect. Must not be {@literal null}. + * @param source the source to inspect. Must not be {@literal null}. * @param key the key to lookup. Must not be {@literal null}. - * @return {@literal true} if no non {@literal null} value present. - * @since 3.0.8 + * @return can be {@literal null}. + * @since 4.1 */ - public static boolean hasValue(Bson bson, String key) { - - Map source = asMap(bson); + @Nullable + public static Object resolveValue(Map source, String key) { - if (source.get(key) != null) { - return true; + if (source.containsKey(key)) { + return source.get(key); } - if (!key.contains(".")) { - return false; - } + return resolveValue(source, FieldName.path(key)); + } - String[] parts = key.split("\\."); + public static boolean hasValue(Bson bson, FieldName fieldName) { + + Map source = asMap(bson); + if (fieldName.isKey()) { + return source.get(fieldName.name()) != null; + } + String[] parts = fieldName.parts(); Object result; for (int i = 1; i < parts.length; i++) { @@ -587,6 +610,20 @@ public static boolean hasValue(Bson bson, String key) { } return source.containsKey(parts[parts.length - 1]); + + } + + /** + * Returns whether the underlying {@link Bson bson} has a value ({@literal null} or non-{@literal null}) for the given + * {@code key}. + * + * @param bson the source to inspect. Must not be {@literal null}. + * @param key the key to lookup. Must not be {@literal null}. + * @return {@literal true} if no non {@literal null} value present. + * @since 3.0.8 + */ + public static boolean hasValue(Bson bson, String key) { + return hasValue(bson, FieldName.path(key)); } /** diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingDocumentCodec.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingDocumentCodec.java index 9fac4c7d05..fb85255b24 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingDocumentCodec.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingDocumentCodec.java @@ -41,6 +41,7 @@ import org.bson.codecs.configuration.CodecRegistry; import org.bson.json.JsonParseException; import org.springframework.data.mapping.model.SpELExpressionEvaluator; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.spel.EvaluationContextProvider; import org.springframework.data.spel.ExpressionDependencies; import org.springframework.expression.ExpressionParser; @@ -66,7 +67,7 @@ */ public class ParameterBindingDocumentCodec implements CollectibleCodec { - private static final String ID_FIELD_NAME = "_id"; + private static final String ID_FIELD_NAME = FieldName.ID.name(); private static final CodecRegistry DEFAULT_REGISTRY = fromProviders( asList(new ValueCodecProvider(), new BsonValueCodecProvider(), new DocumentCodecProvider())); private static final BsonTypeClassMap DEFAULT_BSON_TYPE_CLASS_MAP = new BsonTypeClassMap(); diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateTests.java index 76b4d25d87..9a5788a394 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateTests.java @@ -59,15 +59,22 @@ import org.springframework.data.mongodb.InvalidMongoDbApiUsageException; import org.springframework.data.mongodb.MongoDatabaseFactory; import org.springframework.data.mongodb.core.BulkOperations.BulkMode; +import org.springframework.data.mongodb.core.aggregation.AggregationUpdate; +import org.springframework.data.mongodb.core.aggregation.ComparisonOperators; +import org.springframework.data.mongodb.core.aggregation.ObjectOperators; +import org.springframework.data.mongodb.core.aggregation.ReplaceWithOperation; import org.springframework.data.mongodb.core.aggregation.StringOperators; import org.springframework.data.mongodb.core.convert.LazyLoadingProxy; +import org.springframework.data.mongodb.core.convert.MappingMongoConverter; import org.springframework.data.mongodb.core.convert.MongoCustomConversions; import org.springframework.data.mongodb.core.convert.MongoCustomConversions.MongoConverterConfigurationAdapter; +import org.springframework.data.mongodb.core.convert.NoOpDbRefResolver; import org.springframework.data.mongodb.core.geo.GeoJsonPoint; import org.springframework.data.mongodb.core.index.Index; import org.springframework.data.mongodb.core.index.IndexField; import org.springframework.data.mongodb.core.index.IndexInfo; import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.core.mapping.FieldName.Type; import org.springframework.data.mongodb.core.mapping.MongoId; import org.springframework.data.mongodb.core.mapping.event.AbstractMongoEventListener; import org.springframework.data.mongodb.core.mapping.event.AfterSaveEvent; @@ -78,8 +85,10 @@ import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.Update; import org.springframework.data.mongodb.test.util.Client; +import org.springframework.data.mongodb.test.util.EnableIfMongoServerVersion; import org.springframework.data.mongodb.test.util.MongoClientExtension; import org.springframework.data.mongodb.test.util.MongoTestTemplate; +import org.springframework.data.mongodb.test.util.MongoTestUtils; import org.springframework.data.mongodb.test.util.MongoVersion; import org.springframework.lang.Nullable; import org.springframework.test.annotation.DirtiesContext; @@ -3686,8 +3695,7 @@ void insertHonorsExistingRawId() { template.insert(source); org.bson.Document result = template - .execute(db -> db.getCollection(template.getCollectionName(RawStringId.class)) - .find().limit(1).cursor().next()); + .execute(db -> db.getCollection(template.getCollectionName(RawStringId.class)).find().limit(1).cursor().next()); assertThat(result).isNotNull(); assertThat(result.get("_id")).isEqualTo("abc"); @@ -3881,13 +3889,132 @@ public void replaceShouldReplaceDocument() { template.save(doc, collectionName); org.bson.Document replacement = new org.bson.Document("foo", "baz"); - UpdateResult updateResult = template.replace(query(where("foo").is("bar")), replacement, ReplaceOptions.replaceOptions(), - collectionName); + UpdateResult updateResult = template.replace(query(where("foo").is("bar")), replacement, + ReplaceOptions.replaceOptions(), collectionName); assertThat(updateResult.wasAcknowledged()).isTrue(); assertThat(template.findOne(query(where("foo").is("baz")), org.bson.Document.class, collectionName)).isNotNull(); } + @Test // GH-4464 + void saveEntityWithDotInFieldName() { + + WithFieldNameContainingDots source = new WithFieldNameContainingDots(); + source.id = "id-1"; + source.value = "v1"; + + template.save(source); + + org.bson.Document raw = template.execute(WithFieldNameContainingDots.class, collection -> collection.find(new org.bson.Document("_id", source.id)).first()); + assertThat(raw).containsEntry("field.name.with.dots", "v1"); + } + + @Test // GH-4464 + @EnableIfMongoServerVersion(isGreaterThanEqual = "5.0") + void queryEntityWithDotInFieldNameUsingExpr() { + + WithFieldNameContainingDots source = new WithFieldNameContainingDots(); + source.id = "id-1"; + source.value = "v1"; + + WithFieldNameContainingDots source2 = new WithFieldNameContainingDots(); + source2.id = "id-2"; + source2.value = "v2"; + + template.save(source); + template.save(source2); + + WithFieldNameContainingDots loaded = template.query(WithFieldNameContainingDots.class) // with property -> fieldname mapping + .matching(expr(ComparisonOperators.valueOf(ObjectOperators.getValueOf("value")).equalToValue("v1"))).firstValue(); + + assertThat(loaded).isEqualTo(source); + + loaded = template.query(WithFieldNameContainingDots.class) // using raw fieldname + .matching(expr(ComparisonOperators.valueOf(ObjectOperators.getValueOf("field.name.with.dots")).equalToValue("v1"))).firstValue(); + + assertThat(loaded).isEqualTo(source); + } + + @Test // GH-4464 + @EnableIfMongoServerVersion(isGreaterThanEqual = "5.0") + void updateEntityWithDotInFieldNameUsingAggregations() { + + WithFieldNameContainingDots source = new WithFieldNameContainingDots(); + source.id = "id-1"; + source.value = "v1"; + + template.save(source); + + template.update(WithFieldNameContainingDots.class) + .matching(where("id").is(source.id)) + .apply(AggregationUpdate.newUpdate(ReplaceWithOperation.replaceWithValue(ObjectOperators.setValueTo("value", "changed")))) + .first(); + + org.bson.Document raw = template.execute(WithFieldNameContainingDots.class, collection -> collection.find(new org.bson.Document("_id", source.id)).first()); + assertThat(raw).containsEntry("field.name.with.dots", "changed"); + + template.update(WithFieldNameContainingDots.class) + .matching(where("id").is(source.id)) + .apply(AggregationUpdate.newUpdate(ReplaceWithOperation.replaceWithValue(ObjectOperators.setValueTo("field.name.with.dots", "changed-again")))) + .first(); + + raw = template.execute(WithFieldNameContainingDots.class, collection -> collection.find(new org.bson.Document("_id", source.id)).first()); + assertThat(raw).containsEntry("field.name.with.dots", "changed-again"); + } + + @Test // GH-4464 + void savesMapWithDotInKey() { + + MongoTestUtils.flushCollection(DB_NAME, template.getCollectionName(WithFieldNameContainingDots.class), client); + + MappingMongoConverter converter = new MappingMongoConverter(NoOpDbRefResolver.INSTANCE, + template.getConverter().getMappingContext()); + converter.preserveMapKeys(true); + converter.afterPropertiesSet(); + + MongoTemplate template = new MongoTemplate(new SimpleMongoClientDatabaseFactory(client, DB_NAME), converter); + + WithFieldNameContainingDots source = new WithFieldNameContainingDots(); + source.id = "id-1"; + source.mapValue = Map.of("k1", "v1", "map.key.with.dot", "v2"); + + template.save(source); + + org.bson.Document raw = template.execute(WithFieldNameContainingDots.class, + collection -> collection.find(new org.bson.Document("_id", source.id)).first()); + + assertThat(raw.get("mapValue", org.bson.Document.class)) + .containsEntry("k1", "v1") + .containsEntry("map.key.with.dot", "v2"); + } + + @Test // GH-4464 + void readsMapWithDotInKey() { + + MongoTestUtils.flushCollection(DB_NAME, template.getCollectionName(WithFieldNameContainingDots.class), client); + + MappingMongoConverter converter = new MappingMongoConverter(NoOpDbRefResolver.INSTANCE, + template.getConverter().getMappingContext()); + converter.preserveMapKeys(true); + converter.afterPropertiesSet(); + + MongoTemplate template = new MongoTemplate(new SimpleMongoClientDatabaseFactory(client, DB_NAME), converter); + + Map sourceMap = Map.of("k1", "v1", "sourceMap.key.with.dot", "v2"); + template.execute(WithFieldNameContainingDots.class, + collection -> { + collection.insertOne(new org.bson.Document("_id", "id-1").append("mapValue", sourceMap)); + return null; + } + ); + + WithFieldNameContainingDots loaded = template.query(WithFieldNameContainingDots.class) + .matching(where("id").is("id-1")) + .firstValue(); + + assertThat(loaded.mapValue).isEqualTo(sourceMap); + } + private AtomicReference createAfterSaveReference() { AtomicReference saved = new AtomicReference<>(); @@ -4053,11 +4180,12 @@ static class DocumentWithLazyDBRefsAndConstructorCreation { @org.springframework.data.mongodb.core.mapping.DBRef(lazy = true) // public Sample lazyDbRefProperty; - @Field("lazy_db_ref_list") @org.springframework.data.mongodb.core.mapping.DBRef(lazy = true) // + @Field("lazy_db_ref_list") + @org.springframework.data.mongodb.core.mapping.DBRef(lazy = true) // public List lazyDbRefAnnotatedList; - @Field("lazy_db_ref_map") @org.springframework.data.mongodb.core.mapping.DBRef( - lazy = true) public Map lazyDbRefAnnotatedMap; + @Field("lazy_db_ref_map") + @org.springframework.data.mongodb.core.mapping.DBRef(lazy = true) public Map lazyDbRefAnnotatedMap; public DocumentWithLazyDBRefsAndConstructorCreation(String id, Sample lazyDbRefProperty, List lazyDbRefAnnotatedList, Map lazyDbRefAnnotatedMap) { @@ -4848,4 +4976,37 @@ public void setNickname(String nickname) { this.nickname = nickname; } } + + static class WithFieldNameContainingDots { + + String id; + + @Field(value = "field.name.with.dots", nameType = Type.KEY) + String value; + + Map mapValue; + + @Override + public String toString() { + return "WithMap{" + "id='" + id + '\'' + ", value='" + value + '\'' + ", mapValue=" + mapValue + '}'; + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + WithFieldNameContainingDots withFieldNameContainingDots = (WithFieldNameContainingDots) o; + return Objects.equals(id, withFieldNameContainingDots.id) && Objects.equals(value, withFieldNameContainingDots.value) + && Objects.equals(mapValue, withFieldNameContainingDots.mapValue); + } + + @Override + public int hashCode() { + return Objects.hash(id, value, mapValue); + } + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/ObjectOperatorsUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/ObjectOperatorsUnitTests.java index a49cdd2c07..fd31f9ca99 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/ObjectOperatorsUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/ObjectOperatorsUnitTests.java @@ -20,6 +20,10 @@ import org.bson.Document; import org.junit.jupiter.api.Test; import org.springframework.data.mongodb.core.aggregation.ObjectOperators.MergeObjects; +import org.springframework.data.mongodb.core.convert.MappingMongoConverter; +import org.springframework.data.mongodb.core.convert.NoOpDbRefResolver; +import org.springframework.data.mongodb.core.convert.QueryMapper; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; /** * Unit tests for {@link ObjectOperators}. @@ -109,6 +113,23 @@ public void getField() { .isEqualTo(Document.parse("{ $getField : { field : \"robin\", input : \"$batman\" }}")); } + @Test // GH-4464 + public void getFieldOfCurrent() { + + assertThat(ObjectOperators.valueOf(Aggregation.CURRENT).getField("robin").toDocument(Aggregation.DEFAULT_CONTEXT)) + .isEqualTo(Document.parse("{ $getField : { field : \"robin\", input : \"$$CURRENT\" }}")); + } + + @Test // GH-4464 + public void getFieldOfMappedKey() { + + MappingMongoConverter converter = new MappingMongoConverter(NoOpDbRefResolver.INSTANCE, new MongoMappingContext()); + converter.afterPropertiesSet(); + + assertThat(ObjectOperators.getValueOf("population").toDocument(new RelaxedTypeBasedAggregationOperationContext(ZipInfo.class, converter.getMappingContext(), new QueryMapper(converter)))) + .isEqualTo(Document.parse("{ $getField : { field : \"pop\", input : \"$$CURRENT\" } }")); + } + @Test // GH-4139 public void setField() { @@ -116,6 +137,16 @@ public void setField() { .isEqualTo(Document.parse("{ $setField : { field : \"friend\", value : \"robin\", input : \"$batman\" }}")); } + @Test // GH-4464 + public void setFieldOfMappedKey() { + + MappingMongoConverter converter = new MappingMongoConverter(NoOpDbRefResolver.INSTANCE, new MongoMappingContext()); + converter.afterPropertiesSet(); + + assertThat(ObjectOperators.setValueTo("population", "robin").toDocument(new RelaxedTypeBasedAggregationOperationContext(ZipInfo.class, converter.getMappingContext(), new QueryMapper(converter)))) + .isEqualTo(Document.parse("{ $setField : { field : \"pop\", value : \"robin\", input : \"$$CURRENT\" }}")); + } + @Test // GH-4139 public void removeField() { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java index 3d5b415f3a..0e3e6416b5 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java @@ -77,7 +77,9 @@ import org.springframework.data.mongodb.core.geo.Sphere; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.core.mapping.FieldName.Type; import org.springframework.data.mongodb.core.mapping.FieldType; +import org.springframework.data.mongodb.core.mapping.MongoField; import org.springframework.data.mongodb.core.mapping.MongoMappingContext; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; import org.springframework.data.mongodb.core.mapping.PersonPojoStringId; @@ -87,6 +89,7 @@ import org.springframework.data.projection.EntityProjection; import org.springframework.data.projection.EntityProjectionIntrospector; import org.springframework.data.util.ClassTypeInformation; +import org.springframework.data.util.TypeInformation; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import org.springframework.test.util.ReflectionTestUtils; @@ -2637,8 +2640,8 @@ void readsMapThatDoesNotComeAsDocument() { DocumentAccessor accessor = new DocumentAccessor(new org.bson.Document()); MongoPersistentProperty persistentProperty = mock(MongoPersistentProperty.class); when(persistentProperty.isAssociation()).thenReturn(true); - when(persistentProperty.getFieldName()).thenReturn("pName"); - doReturn(ClassTypeInformation.from(Person.class)).when(persistentProperty).getTypeInformation(); + when(persistentProperty.getMongoField()).thenReturn(MongoField.fromKey("pName")); + doReturn(TypeInformation.of(Person.class)).when(persistentProperty).getTypeInformation(); doReturn(Person.class).when(persistentProperty).getType(); doReturn(Person.class).when(persistentProperty).getRawType(); @@ -2879,6 +2882,86 @@ void shouldConvertBsonUndefinedToNull() { assertThat(converter.read(Address.class, source).city).isNull(); } + @Test // GH-4464 + void shouldNotSplitKeyNamesWithDotOnWriteIfFieldTypeIsKey() { + + WithPropertyHavingDotsInFieldName source = new WithPropertyHavingDotsInFieldName(); + source.value = "A"; + + assertThat(write(source)).containsEntry("field.name.with.dots", "A"); + } + + @Test // GH-4464 + void shouldNotSplitKeyNamesWithDotOnReadIfFieldTypeIsKey() { + + org.bson.Document source = new org.bson.Document("field.name.with.dots", "A"); + + WithPropertyHavingDotsInFieldName target = converter.read(WithPropertyHavingDotsInFieldName.class, source); + assertThat(target.value).isEqualTo("A"); + } + + @Test // GH-4464 + void shouldNotSplitKeyNamesWithDotOnWriteOfNestedPropertyIfFieldTypeIsKey() { + + WrapperForTypeWithPropertyHavingDotsInFieldName source = new WrapperForTypeWithPropertyHavingDotsInFieldName(); + source.nested = new WithPropertyHavingDotsInFieldName(); + source.nested.value = "A"; + + assertThat(write(source).get("nested", org.bson.Document.class)).containsEntry("field.name.with.dots", "A"); + } + + @Test // GH-4464 + void shouldNotSplitKeyNamesWithDotOnReadOfNestedIfFieldTypeIsKey() { + + org.bson.Document source = new org.bson.Document("nested", new org.bson.Document("field.name.with.dots", "A")); + + WrapperForTypeWithPropertyHavingDotsInFieldName target = converter.read(WrapperForTypeWithPropertyHavingDotsInFieldName.class, source); + assertThat(target.nested).isNotNull(); + assertThat(target.nested.value).isEqualTo("A"); + } + + @Test // GH-4464 + void writeShouldAllowDotsInMapKeyNameIfConfigured() { + + converter = new MappingMongoConverter(resolver, mappingContext); + converter.preserveMapKeys(true); + converter.afterPropertiesSet(); + + Person person = new Person(); + person.firstname = "bart"; + person.lastname = "simpson"; + + ClassWithMapProperty source = new ClassWithMapProperty(); + source.mapOfPersons = Map.of("map.key.with.dots", person); + + assertThat(write(source).get("mapOfPersons", org.bson.Document.class)).containsKey("map.key.with.dots"); + } + + @Test // GH-4464 + void readShouldAllowDotsInMapKeyNameIfConfigured() { + + converter = new MappingMongoConverter(resolver, mappingContext); + converter.preserveMapKeys(true); + converter.afterPropertiesSet(); + + Person person = new Person(); + person.firstname = "bart"; + person.lastname = "simpson"; + + org.bson.Document source = new org.bson.Document("mapOfPersons", new org.bson.Document("map.key.with.dots", write(person))); + + ClassWithMapProperty target = converter.read(ClassWithMapProperty.class, source); + + assertThat(target.mapOfPersons).containsEntry("map.key.with.dots", person); + } + + org.bson.Document write(Object source) { + + org.bson.Document target = new org.bson.Document(); + converter.write(source, target); + return target; + } + static class GenericType { T content; } @@ -2962,6 +3045,23 @@ static class Person implements Contact { public Person(Set
addresses) { this.addresses = addresses; } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Person person = (Person) o; + return Objects.equals(id, person.id) && Objects.equals(birthDate, person.birthDate) && Objects.equals(firstname, person.firstname) && Objects.equals(lastname, person.lastname) && Objects.equals(addresses, person.addresses); + } + + @Override + public int hashCode() { + return Objects.hash(id, birthDate, firstname, lastname, addresses); + } } interface PersonProjection { @@ -3922,4 +4022,16 @@ public int hashCode() { } } + static class WrapperForTypeWithPropertyHavingDotsInFieldName { + + WithPropertyHavingDotsInFieldName nested; + } + + static class WithPropertyHavingDotsInFieldName { + + @Field(name = "field.name.with.dots", nameType = Type.KEY) + String value; + + } + } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/QueryMapperUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/QueryMapperUnitTests.java index fc0345b3d4..88984baa1f 100755 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/QueryMapperUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/QueryMapperUnitTests.java @@ -52,6 +52,7 @@ import org.springframework.data.mongodb.core.geo.GeoJsonPoint; import org.springframework.data.mongodb.core.geo.GeoJsonPolygon; import org.springframework.data.mongodb.core.mapping.*; +import org.springframework.data.mongodb.core.mapping.FieldName.Type; import org.springframework.data.mongodb.core.query.BasicQuery; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; @@ -1509,6 +1510,13 @@ void convertsListOfValuesForPropertyThatHasValueConverterButIsNotCollectionLikeO assertThat(mappedObject).isEqualTo("{ 'text' : { $in : ['gnirps', 'atad'] } }"); } + @Test // GH-4464 + void usesKeyNameWithDotsIfFieldNameTypeIsKey() { + + org.bson.Document mappedObject = mapper.getMappedObject(query(where("value").is("A")).getQueryObject(), context.getPersistentEntity(WithPropertyHavingDotsInFieldName.class)); + assertThat(mappedObject).isEqualTo("{ 'field.name.with.dots' : 'A' }"); + } + class WithDeepArrayNesting { List level0; @@ -1804,4 +1812,11 @@ public org.bson.Document convert(MyAddress address) { return doc; } } + + static class WithPropertyHavingDotsInFieldName { + + @Field(name = "field.name.with.dots", nameType = Type.KEY) + String value; + + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/support/MongoRepositoryFactoryUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/support/MongoRepositoryFactoryUnitTests.java index 1a95379fea..efd9cfc171 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/support/MongoRepositoryFactoryUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/support/MongoRepositoryFactoryUnitTests.java @@ -29,6 +29,7 @@ import org.mockito.quality.Strictness; import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mongodb.core.MongoOperations; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.convert.MongoConverter; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; @@ -46,7 +47,7 @@ @MockitoSettings(strictness = Strictness.LENIENT) public class MongoRepositoryFactoryUnitTests { - @Mock MongoTemplate template; + @Mock MongoOperations template; @Mock MongoConverter converter; diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/util/json/BsonUtilsTest.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/util/json/BsonUtilsTest.java index e9cc628155..96a2dd92c7 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/util/json/BsonUtilsTest.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/util/json/BsonUtilsTest.java @@ -26,7 +26,9 @@ import java.util.Collection; import java.util.Collections; import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.stream.Stream; import org.bson.BsonArray; @@ -41,6 +43,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.util.BsonUtils; import com.mongodb.BasicDBList; @@ -161,6 +164,33 @@ void convertsPrimitiveArrayToBsonArray() { .isEqualTo(new BsonArray(List.of(new BsonInt32(1), new BsonInt32(2), new BsonInt32(3)))); } + @ParameterizedTest + @MethodSource("fieldNames") + void resolveValueForField(FieldName fieldName, boolean exists) { + + Map source = new LinkedHashMap<>(); + source.put("a", "a-value"); // top level + source.put("b", new Document("a", "b.a-value")); // path + source.put("c.a", "c.a-value"); // key + + if(exists) { + assertThat(BsonUtils.resolveValue(source, fieldName)).isEqualTo(fieldName.name() + "-value"); + } else { + assertThat(BsonUtils.resolveValue(source, fieldName)).isNull(); + } + } + + static Stream fieldNames() { + return Stream.of(// + Arguments.of(FieldName.path("a"), true), // + Arguments.of(FieldName.path("b.a"), true), // + Arguments.of(FieldName.path("c.a"), false), // + Arguments.of(FieldName.name("d"), false), // + Arguments.of(FieldName.name("b.a"), false), // + Arguments.of(FieldName.name("c.a"), true) // + ); + } + static Stream javaTimeInstances() { return Stream.of(Arguments.of(Instant.now()), Arguments.of(LocalDate.now()), Arguments.of(LocalDateTime.now()), diff --git a/src/main/antora/modules/ROOT/pages/mongodb/mapping/mapping.adoc b/src/main/antora/modules/ROOT/pages/mongodb/mapping/mapping.adoc index 7d6db21b6d..9283ad9be8 100644 --- a/src/main/antora/modules/ROOT/pages/mongodb/mapping/mapping.adoc +++ b/src/main/antora/modules/ROOT/pages/mongodb/mapping/mapping.adoc @@ -36,7 +36,7 @@ The following outlines what field will be mapped to the `_id` document field: * A field without an annotation but named `id` will be mapped to the `_id` field. * The default field name for identifiers is `_id` and can be customized via the `@Field` annotation. -[cols="1,2", options="header"] +[cols="1,2",options="header"] .Examples for the translation of `_id` field definitions |=== | Field definition @@ -60,9 +60,16 @@ The following outlines what field will be mapped to the `_id` document field: The following outlines what type conversion, if any, will be done on the property mapped to the _id document field. -* If a field named `id` is declared as a String or BigInteger in the Java class it will be converted to and stored as an ObjectId if possible. ObjectId as a field type is also valid. If you specify a value for `id` in your application, the conversion to an ObjectId is detected to the MongoDB driver. If the specified `id` value cannot be converted to an ObjectId, then the value will be stored as is in the document's _id field. This also applies if the field is annotated with `@Id`. -* If a field is annotated with `@MongoId` in the Java class it will be converted to and stored as using its actual type. No further conversion happens unless `@MongoId` declares a desired field type. If no value is provided for the `id` field, a new `ObjectId` will be created and converted to the properties type. -* If a field is annotated with `@MongoId(FieldType.…)` in the Java class it will be attempted to convert the value to the declared `FieldType`. If no value is provided for the `id` field, a new `ObjectId` will be created and converted to the declared type. +* If a field named `id` is declared as a String or BigInteger in the Java class it will be converted to and stored as an ObjectId if possible. +ObjectId as a field type is also valid. +If you specify a value for `id` in your application, the conversion to an ObjectId is detected to the MongoDB driver. +If the specified `id` value cannot be converted to an ObjectId, then the value will be stored as is in the document's _id field. +This also applies if the field is annotated with `@Id`. +* If a field is annotated with `@MongoId` in the Java class it will be converted to and stored as using its actual type. +No further conversion happens unless `@MongoId` declares a desired field type. +If no value is provided for the `id` field, a new `ObjectId` will be created and converted to the properties type. +* If a field is annotated with `@MongoId(FieldType.…)` in the Java class it will be attempted to convert the value to the declared `FieldType`. +If no value is provided for the `id` field, a new `ObjectId` will be created and converted to the declared type. * If a field named `id` id field is not declared as a String, BigInteger, or ObjectID in the Java class then you should assign it a value in your application so it can be stored 'as-is' in the document's _id field. * If no field named `id` is present in the Java class then an implicit `_id` file will be generated by the driver but not mapped to a property or field of the Java class. @@ -79,7 +86,7 @@ See xref:mongodb/mapping/custom-conversions.adoc[Custom Conversions - Overriding .Built in Type conversions: [%collapsible] ==== -[cols="3,1,6", options="header"] +[cols="3,1,6",options="header"] .Type |=== | Type @@ -250,9 +257,9 @@ calling `get()` before the actual conversion | `GeoJsonMultiPolygon` | converter | `{"geoJsonMultiPolygon" : { "type" : "MultiPolygon", "coordinates" : [ - [ [ [ -73.958 , 40.8003 ] , [ -73.9498 , 40.7968 ] ] ], - [ [ [ -73.973 , 40.7648 ] , [ -73.9588 , 40.8003 ] ] ] - ] }}` +[ [ [ -73.958 , 40.8003 ] , [ -73.9498 , 40.7968 ] ] ], +[ [ [ -73.973 , 40.7648 ] , [ -73.9588 , 40.8003 ] ] ] +] }}` | `GeoJsonLineString` | converter @@ -261,19 +268,22 @@ calling `get()` before the actual conversion | `GeoJsonMultiLineString` | converter | `{"geoJsonLineString" : { "type" : "MultiLineString", coordinates: [ - [ [ -73.97162 , 40.78205 ], [ -73.96374 , 40.77715 ] ], - [ [ -73.97880 , 40.77247 ], [ -73.97036 , 40.76811 ] ] - ] }}` +[ [ -73.97162 , 40.78205 ], [ -73.96374 , 40.77715 ] ], +[ [ -73.97880 , 40.77247 ], [ -73.97036 , 40.76811 ] ] +] }}` |=== ==== [[mapping-configuration]] == Mapping Configuration -Unless explicitly configured, an instance of `MappingMongoConverter` is created by default when you create a `MongoTemplate`. You can create your own instance of the `MappingMongoConverter`. Doing so lets you dictate where in the classpath your domain classes can be found, so that Spring Data MongoDB can extract metadata and construct indexes. Also, by creating your own instance, you can register Spring converters to map specific classes to and from the database. - -You can configure the `MappingMongoConverter` as well as `com.mongodb.client.MongoClient` and MongoTemplate by using either Java-based or XML-based metadata. The following example shows the configuration: +Unless explicitly configured, an instance of `MappingMongoConverter` is created by default when you create a `MongoTemplate`. +You can create your own instance of the `MappingMongoConverter`. +Doing so lets you dictate where in the classpath your domain classes can be found, so that Spring Data MongoDB can extract metadata and construct indexes. +Also, by creating your own instance, you can register Spring converters to map specific classes to and from the database. +You can configure the `MappingMongoConverter` as well as `com.mongodb.client.MongoClient` and MongoTemplate by using either Java-based or XML-based metadata. +The following example shows the configuration: [tabs] ====== @@ -309,7 +319,9 @@ public class MongoConfig extends AbstractMongoClientConfiguration { } } ---- -<1> The mapping base package defines the root path used to scan for entities used to pre initialize the `MappingContext`. By default the configuration classes package is used. + +<1> The mapping base package defines the root path used to scan for entities used to pre initialize the `MappingContext`. +By default the configuration classes package is used. <2> Configure additional custom converters for specific domain types that replace the default mapping procedure for those types with your custom implementation. XML:: @@ -363,7 +375,6 @@ Also shown in the preceding example is a `LoggingEventListener`, which logs `Mon [TIP] ==== .Java Time Types - We recommend using MongoDB's native JSR-310 support via `MongoConverterConfigurationAdapter.useNativeDriverJavaTimeCodecs()` as described above as it is using an `UTC` based approach. The default JSR-310 support for `java.time` types inherited from Spring Data Commons uses the local machine timezone as reference and should only be used for backwards compatibility. ==== @@ -418,26 +429,38 @@ WARNING: Auto index creation is **disabled** by default and needs to be enabled [[mapping-usage-annotations]] === Mapping Annotation Overview -The MappingMongoConverter can use metadata to drive the mapping of objects to documents. The following annotations are available: +The MappingMongoConverter can use metadata to drive the mapping of objects to documents. +The following annotations are available: * `@Id`: Applied at the field level to mark the field used for identity purpose. -* `@MongoId`: Applied at the field level to mark the field used for identity purpose. Accepts an optional `FieldType` to customize id conversion. -* `@Document`: Applied at the class level to indicate this class is a candidate for mapping to the database. You can specify the name of the collection where the data will be stored. +* `@MongoId`: Applied at the field level to mark the field used for identity purpose. +Accepts an optional `FieldType` to customize id conversion. +* `@Document`: Applied at the class level to indicate this class is a candidate for mapping to the database. +You can specify the name of the collection where the data will be stored. * `@DBRef`: Applied at the field to indicate it is to be stored using a com.mongodb.DBRef. -* `@DocumentReference`: Applied at the field to indicate it is to be stored as a pointer to another document. This can be a single value (the _id_ by default), or a `Document` provided via a converter. +* `@DocumentReference`: Applied at the field to indicate it is to be stored as a pointer to another document. +This can be a single value (the _id_ by default), or a `Document` provided via a converter. * `@Indexed`: Applied at the field level to describe how to index the field. * `@CompoundIndex` (repeatable): Applied at the type level to declare Compound Indexes. * `@GeoSpatialIndexed`: Applied at the field level to describe how to geoindex the field. * `@TextIndexed`: Applied at the field level to mark the field to be included in the text index. * `@HashIndexed`: Applied at the field level for usage within a hashed index to partition data across a sharded cluster. * `@Language`: Applied at the field level to set the language override property for text index. -* `@Transient`: By default, all fields are mapped to the document. This annotation excludes the field where it is applied from being stored in the database. Transient properties cannot be used within a persistence constructor as the converter cannot materialize a value for the constructor argument. -* `@PersistenceConstructor`: Marks a given constructor - even a package protected one - to use when instantiating the object from the database. Constructor arguments are mapped by name to the key values in the retrieved Document. -* `@Value`: This annotation is part of the Spring Framework . Within the mapping framework it can be applied to constructor arguments. This lets you use a Spring Expression Language statement to transform a key's value retrieved in the database before it is used to construct a domain object. In order to reference a property of a given document one has to use expressions like: `@Value("#root.myProperty")` where `root` refers to the root of the given document. +* `@Transient`: By default, all fields are mapped to the document. +This annotation excludes the field where it is applied from being stored in the database. +Transient properties cannot be used within a persistence constructor as the converter cannot materialize a value for the constructor argument. +* `@PersistenceConstructor`: Marks a given constructor - even a package protected one - to use when instantiating the object from the database. +Constructor arguments are mapped by name to the key values in the retrieved Document. +* `@Value`: This annotation is part of the Spring Framework . Within the mapping framework it can be applied to constructor arguments. +This lets you use a Spring Expression Language statement to transform a key's value retrieved in the database before it is used to construct a domain object. +In order to reference a property of a given document one has to use expressions like: `@Value("#root.myProperty")` where `root` refers to the root of the given document. * `@Field`: Applied at the field level it allows to describe the name and type of the field as it will be represented in the MongoDB BSON document thus allowing the name and type to be different than the fieldname of the class as well as the property type. -* `@Version`: Applied at field level is used for optimistic locking and checked for modification on save operations. The initial value is `zero` (`one` for primitive types) which is bumped automatically on every update. +* `@Version`: Applied at field level is used for optimistic locking and checked for modification on save operations. +The initial value is `zero` (`one` for primitive types) which is bumped automatically on every update. -The mapping metadata infrastructure is defined in a separate spring-data-commons project that is technology agnostic. Specific subclasses are using in the MongoDB support to support annotation based metadata. Other strategies are also possible to put in place if there is demand. +The mapping metadata infrastructure is defined in a separate spring-data-commons project that is technology agnostic. +Specific subclasses are using in the MongoDB support to support annotation based metadata. +Other strategies are also possible to put in place if there is demand. .Here is an example of a more complex mapping [%collapsible] @@ -500,9 +523,9 @@ public class Person { [TIP] ==== -`@Field(targetType=...)` can come in handy when the native MongoDB type inferred by the mapping infrastructure does not -match the expected one. Like for `BigDecimal`, which is represented as `String` instead of `Decimal128`, just because earlier -versions of MongoDB Server did not have support for it. +`@Field(targetType=...)` can come in handy when the native MongoDB type inferred by the mapping infrastructure does not match the expected one. +Like for `BigDecimal`, which is represented as `String` instead of `Decimal128`, just because earlier versions of MongoDB Server did not have support for it. + [source,java] ---- public class Balance { @@ -535,13 +558,111 @@ public class Balance { ---- ==== +=== Special Field Names + +Generally speaking MongoDB uses the dot (`.`) character as a path separator for nested documents or arrays. +This means that in a query (or update statement) a key like `a.b.c` targets an object structure as outlined below: + +[source,json] +---- +{ + 'a' : { + 'b' : { + 'c' : … + } + } +} +---- + +Therefore, up until MongoDB 5.0 field names must not contain dots (`.`). + +Using a `MappingMongoConverter#setMapKeyDotReplacement` allowed circumvent some of the limitations when storing `Map` structures by substituting dots on write with another character. + +[source,java] +---- +converter.setMapKeyDotReplacement("-"); +// ... + +source.map = Map.of("key.with.dot", "value") +converter.write(source,...) // -> map : { 'key-with-dot', 'value' } +---- + +With the release of MongoDB 5.0 this restriction on `Document` field names containing special characters was lifted. +We highly recommend reading more about limitations on using dots in field names in the https://www.mongodb.com/docs/manual/core/dot-dollar-considerations/[MongoDB Reference]. + +To allow dots in `Map` structures please set `preserveMapKeys` on the `MappingMongoConverter`. + +Using `@Field` allows customizing the field name to consider dots in two ways. + +. `@Field(name = "a.b")`: The name is considered to be a path. +Operations expect a structure of nested objects such as `{ a : { b : … } }`. +. `@Field(name = "a.b", fieldNameType = KEY)`: The names is considered a name as-is. +Operations expect a field with the given value as `{ 'a.b' : ….. }` + +[WARNING] +==== +Due to the special nature of the dot character in both MongoDB query and update statements field names containing dots cannot be targeted directly and therefore are excluded from being used in derived query methods. +Consider the following `Item` having a `categoryId` property that is mapped to the field named `cat.id`. + +[source,java] +---- +public class Item { + + @Field(name = "cat.id", fieldNameType = KEY) + String categoryId; + + // ... +} +---- + +Its raw representation will look like + +[source,json] +---- +{ + 'cat.id' : "5b28b5e7-52c2", + ... +} +---- + +Since we cannot target the `cat.id` field directly (as this would be interpreted as a path) we need the help of the xref:mongodb/aggregation-framework.adoc#mongo.aggregation[Aggregation Framework]. + +.Query fields with a dot in its name +[source,java] +---- +template.query(Item.class) + // $expr : { $eq : [ { $getField : { input : '$$CURRENT', 'cat.id' }, '5b28b5e7-52c2' ] } + .matching(expr(ComparisonOperators.valueOf(ObjectOperators.getValueOf("value")).equalToValue("5b28b5e7-52c2"))) <1> + .all(); +---- + +<1> The mapping layer takes care of translating the property name `value` into the actual field name. +It is absolutely valid to use the target field name here as well. + +.Update fields with a dot in its name +[source,java] +---- +template.update(Item.class) + .matching(where("id").is("r2d2")) + // $replaceWith: { $setField : { input: '$$CURRENT', field : 'cat.id', value : 'af29-f87f4e933f97' } } + .apply(AggregationUpdate.newUpdate(ReplaceWithOperation.replaceWithValue(ObjectOperators.setValueTo("value", "af29-f87f4e933f97")))) <1> + .first(); +---- + +<1> The mapping layer takes care of translating the property name `value` into the actual field name. +It is absolutely valid to use the target field name here as well. + +The above shows a simple example where the special field is present on the top document level. +Increased levels of nesting increase the complexity of the aggregation expression required to interact with the field. +==== + [[mapping-custom-object-construction]] === Customized Object Construction -The mapping subsystem allows the customization of the object construction by annotating a constructor with the `@PersistenceConstructor` annotation. The values to be used for the constructor parameters are resolved in the following way: +The mapping subsystem allows the customization of the object construction by annotating a constructor with the `@PersistenceConstructor` annotation. +The values to be used for the constructor parameters are resolved in the following way: * If a parameter is annotated with the `@Value` annotation, the given expression is evaluated and the result is used as the parameter value. -* If the Java type has a property whose name matches the given field of the input document, then it's property information is used to select the appropriate constructor parameter to pass the input field value to. This works only if the parameter name information is present in the java `.class` files which can be achieved by compiling the source with debug information or using the new `-parameters` command-line switch for javac in Java 8. +* If the Java type has a property whose name matches the given field of the input document, then it's property information is used to select the appropriate constructor parameter to pass the input field value to. +This works only if the parameter name information is present in the java `.class` files which can be achieved by compiling the source with debug information or using the new `-parameters` command-line switch for javac in Java 8. * Otherwise, a `MappingException` will be thrown indicating that the given constructor parameter could not be bound. [source,java]