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 0f81a4af5..90542fa99 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 @@ package org.springframework.data.mongodb.core.aggregation; 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 class ObjectOperators { 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 class ObjectOperators { * @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 class ObjectOperators { * @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 class ObjectOperators { * @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 @@ public class ObjectOperators { 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 class ObjectOperators { * @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 class ObjectOperators { 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/DocumentAccessor.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/DocumentAccessor.java index 271551dad..f9957ff5c 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 java.util.Map; 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 @@ class DocumentAccessor { 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().getFieldName().parts()).iterator(); Bson document = this.document; while (parts.hasNext()) { @@ -153,8 +147,8 @@ class DocumentAccessor { return BsonUtils.hasValue(document, getFieldName(property)); } - String getFieldName(MongoPersistentProperty prop) { - return prop.getFieldName(); + FieldName getFieldName(MongoPersistentProperty prop) { + return prop.getMongoField().getFieldName(); } /** 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 ec5d86646..f1e039745 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.CodecRegistryProvider; 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; @@ -244,6 +245,16 @@ public class MappingMongoConverter extends AbstractMongoConverter implements App 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 @@ public class MappingMongoConverter extends AbstractMongoConverter implements App 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().getFieldName(); } }; 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 06aee31af..b2baf20ca 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.MongoExpression; 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.Type; 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; @@ -168,6 +169,14 @@ public class QueryMapper { 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) { @@ -1213,6 +1222,9 @@ public class QueryMapper { @Override public String getMappedKey() { + if(getProperty() != null && getProperty().getMongoField().getFieldName().isOfType(Type.KEY)) { + return getProperty().getFieldName(); + } return path == null ? name : path.toDotPath(isAssociation() ? getAssociationConverter() : getPropertyConverter()); } 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 90f72855d..86954875a 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 java.util.Set; 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.FieldNamingStrategy; 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; @@ -131,30 +132,7 @@ public class BasicMongoPersistentProperty extends AnnotationBasedPersistentPrope * @return */ 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().getFieldName().name(); } @Override @@ -175,7 +153,7 @@ public class BasicMongoPersistentProperty extends AnnotationBasedPersistentPrope return FieldType.OBJECT_ID.getJavaClass(); } - FieldType fieldType = fieldAnnotation.targetType(); + FieldType fieldType = getMongoField().getFieldType(); if (fieldType == FieldType.IMPLICIT) { if (isEntity()) { @@ -207,11 +185,7 @@ public class BasicMongoPersistentProperty extends AnnotationBasedPersistentPrope } 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().getFieldOrder(); } @Override @@ -278,6 +252,11 @@ public class BasicMongoPersistentProperty extends AnnotationBasedPersistentPrope return rootObject != null ? new StandardEvaluationContext(rootObject) : new StandardEvaluationContext(); } + @Override + public MongoField getMongoField() { + return doGetMongoField(); + } + @Override public Collection getEncryptionKeyIds() { @@ -302,4 +281,57 @@ public class BasicMongoPersistentProperty extends AnnotationBasedPersistentPrope } return target; } + + protected MongoField doGetMongoField() { + + MongoFieldBuilder builder = MongoField.builder(); + if (isAnnotationPresent(Field.class) && Type.KEY.equals(findAnnotation(Field.class).nameType())) { + builder.fieldName(doGetFieldName()); + } else { + builder.fieldPath(doGetFieldName()); + } + builder.fieldType(doGetFieldType()); + builder.fieldOrderNumber(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 79675ef33..8b4cf6d2e 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 @@ package org.springframework.data.mongodb.core.mapping; 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 @@ import org.springframework.lang.Nullable; * * @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 class CachingMongoPersistentProperty extends BasicMongoPersistentProperty 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 2f74b0e9f..f5c38eafa 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.RetentionPolicy; 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 @@ public @interface Field { * 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 @@ public @interface Field { @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 @@ public @interface Field { /** * 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 000000000..99a71da12 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/FieldName.java @@ -0,0 +1,118 @@ +/* + * 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 ob their {@link Type type}. + * + * @author Christoph Strobl + * @since 4.2 + */ +public record FieldName(String name, Type type) { + + /** + * 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 (isOfType(Type.KEY)) { + 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); + } + + @Override + public String toString() { + return "FieldName{%s=%s}".formatted(isOfType(Type.KEY) ? "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 deliminators 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 000000000..069c3ff73 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoField.java @@ -0,0 +1,130 @@ +/* + * 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; + +/** + * 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 fieldName; + private final FieldType fieldType; + private final int 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 just(String name) { + return builder().fieldName(name).build(); + } + + /** + * @return new instance of {@link MongoFieldBuilder}. + */ + public static MongoFieldBuilder builder() { + return new MongoFieldBuilder(); + } + + protected MongoField(FieldName fieldName, Class targetFieldType, int fieldOrder) { + this(fieldName, FieldType.valueOf(targetFieldType.getSimpleName()), fieldOrder); + } + + protected MongoField(FieldName fieldName, FieldType fieldType, int fieldOrder) { + + this.fieldName = fieldName; + this.fieldType = fieldType; + this.fieldOrder = fieldOrder; + } + + /** + * @return never {@literal null}. + */ + public FieldName getFieldName() { + return fieldName; + } + + /** + * Get the position of the field within the target document. + * + * @return {@link Integer#MAX_VALUE} if undefined. + */ + public int getFieldOrder() { + return fieldOrder; + } + + /** + * @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 + fieldName.name(), fieldName.type()), fieldType, fieldOrder); + } + + /** + * Get the fields target type if defined. + * + * @return never {@literal null}. + */ + public FieldType getFieldType() { + return fieldType; + } + + public static class MongoFieldBuilder { + + private String fieldName; + private FieldType fieldType = FieldType.IMPLICIT; + private int orderNumber = Integer.MAX_VALUE; + private Type type = Type.PATH; + + public MongoFieldBuilder fieldType(FieldType fieldType) { + + this.fieldType = fieldType; + return this; + } + + public MongoFieldBuilder fieldName(String fieldName) { + + this.fieldName = fieldName; + this.type = Type.KEY; + return this; + } + + public MongoFieldBuilder fieldOrderNumber(int orderNumber) { + + this.orderNumber = orderNumber; + return this; + } + + public MongoFieldBuilder fieldPath(String path) { + + this.fieldName = path; + this.type = Type.PATH; + return this; + } + + public MongoField build() { + return new MongoField(new FieldName(fieldName, type), fieldType, orderNumber); + } + } +} 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 b155d50d5..b0ef8a1d4 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 @@ public interface MongoPersistentProperty extends PersistentProperty 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 c4f44e974..da2e39b05 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 @@ class UnwrappedMongoPersistentProperty implements MongoPersistentProperty { 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/util/BsonUtils.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java index 685815625..4eb5b162d 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.Decimal128; 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; @@ -518,24 +520,41 @@ public class BsonUtils { } /** - * 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 + * 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.isOfType(Type.KEY)) { + return source.get(fieldName.name()); } - String[] parts = key.split("\\."); + String[] parts = fieldName.parts(); for (int i = 1; i < parts.length; i++) { @@ -552,28 +571,34 @@ public class BsonUtils { } /** - * 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.isOfType(Type.KEY)) { + return source.get(fieldName.name()) != null; + } + String [] parts = fieldName.parts(); Object result; for (int i = 1; i < parts.length; i++) { @@ -587,6 +612,20 @@ public class BsonUtils { } 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/test/java/org/springframework/data/mongodb/core/MongoTemplateTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateTests.java index 76b4d25d8..9a5788a39 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.mapping.context.PersistentEntities; 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.Criteria; 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 @@ public class MongoTemplateTests { 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 class MongoTemplateTests { 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 @@ public class MongoTemplateTests { @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 class MongoTemplateTests { 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 a49cdd2c0..fd31f9ca9 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 static org.assertj.core.api.Assertions.*; 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 class ObjectOperatorsUnitTests { .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 class ObjectOperatorsUnitTests { .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 3d5b415f3..97ffd280e 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.convert.MappingMongoConverterUnitTe 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.mongodb.core.mapping.event.AfterConvertCallback; 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 @@ class MappingMongoConverterUnitTests { 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.just("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 @@ class MappingMongoConverterUnitTests { 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 @@ class MappingMongoConverterUnitTests { 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 @@ class MappingMongoConverterUnitTests { } } + 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 fc0345b3d..88984baa1 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.aggregation.TypeBasedAggregationOpe 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 @@ public class QueryMapperUnitTests { 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 class QueryMapperUnitTests { 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 1a95379fe..efd9cfc17 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.junit.jupiter.MockitoSettings; 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 @@ import org.springframework.data.repository.Repository; @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 e9cc62815..96a2dd92c 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.ArrayList; 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.api.Test; 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 @@ class BsonUtilsTest { .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 7d6db21b6..b10befc7b 100644 --- a/src/main/antora/modules/ROOT/pages/mongodb/mapping/mapping.adoc +++ b/src/main/antora/modules/ROOT/pages/mongodb/mapping/mapping.adoc @@ -535,6 +535,94 @@ public class Balance { ---- ==== +=== Special Field Names + +Generally speaking MongoDB uses the `.` (dot) sign as a path deliminator for nested objects. +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 `.` (dot). + +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 to customize the field name to consider `.` (dots) in two ways. + +. `@Field(name = "a.b")`: The name is considered to be a path. Writes will create nested objects such as `{ `a` : { `b` : ... } }`. +. `@Field(name = "a.b", fieldNameType = KEY)`: The names is considered a name as is. Writes will create a field with the given value as `{ 'a.b' : ... }` + +[WARNING] +==== +Due to the special nature of the `.` (dot) sign in both MongoDB query and update statements field names containing `.` 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; + + // ... +} +---- + +It's 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 `.` (dot) in 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 `.` (dot) in 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