Browse Source

Add support for mapping document fields with dots in the field name.

This commit introduces support for mapping (read/write) fields that contain dots in their name, preserving the name as is instead of considering the dot being a separator within a path of nested objects.
Query and Update functionality remains unaffected which means no automatic rewrite for field names containing paths will NOT take place. It's in the users responsibility to pick the appropriate query/update operator (eg. $expr) to interact with the field.

Closes #4464
Original pull request: #4512
pull/4525/head
Christoph Strobl 2 years ago committed by Mark Paluch
parent
commit
691fc055ed
No known key found for this signature in database
GPG Key ID: 4406B84C1661DCD1
  1. 61
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ObjectOperators.java
  2. 14
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/DocumentAccessor.java
  3. 15
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MappingMongoConverter.java
  4. 12
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java
  5. 94
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentProperty.java
  6. 8
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/CachingMongoPersistentProperty.java
  7. 19
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/Field.java
  8. 118
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/FieldName.java
  9. 130
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoField.java
  10. 6
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoPersistentProperty.java
  11. 10
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/UnwrappedMongoPersistentProperty.java
  12. 83
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java
  13. 175
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateTests.java
  14. 31
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/ObjectOperatorsUnitTests.java
  15. 116
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MappingMongoConverterUnitTests.java
  16. 15
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/QueryMapperUnitTests.java
  17. 3
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/support/MongoRepositoryFactoryUnitTests.java
  18. 30
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/util/json/BsonUtilsTest.java
  19. 88
      src/main/antora/modules/ROOT/pages/mongodb/mapping/mapping.adoc

61
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; @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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";

14
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/DocumentAccessor.java

@ -21,7 +21,7 @@ import java.util.Map; @@ -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 { @@ -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<String> parts = Arrays.asList(fieldName.split("\\.")).iterator();
Iterator<String> parts = Arrays.asList(prop.getMongoField().getFieldName().parts()).iterator();
Bson document = this.document;
while (parts.hasNext()) {
@ -153,8 +147,8 @@ class DocumentAccessor { @@ -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();
}
/**

15
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MappingMongoConverter.java

@ -72,6 +72,7 @@ import org.springframework.data.mongodb.CodecRegistryProvider; @@ -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 @@ -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 @@ -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();
}
};

12
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java

@ -47,6 +47,7 @@ import org.springframework.data.mongodb.MongoExpression; @@ -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 { @@ -168,6 +169,14 @@ public class QueryMapper {
Entry<String, Object> 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 { @@ -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());
}

94
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentProperty.java

@ -26,7 +26,6 @@ import java.util.Set; @@ -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; @@ -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 @@ -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 @@ -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 @@ -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 @@ -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<Object> getEncryptionKeyIds() {
@ -302,4 +281,57 @@ public class BasicMongoPersistentProperty extends AnnotationBasedPersistentPrope @@ -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;
}
}

8
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; @@ -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; @@ -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 @@ -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> mongoField = Lazy.of(super::getMongoField);
/**
* Creates a new {@link CachingMongoPersistentProperty}.
@ -134,4 +137,9 @@ public class CachingMongoPersistentProperty extends BasicMongoPersistentProperty @@ -134,4 +137,9 @@ public class CachingMongoPersistentProperty extends BasicMongoPersistentProperty
return this.dbref;
}
@Override
public MongoField getMongoField() {
return mongoField.get();
}
}

19
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/Field.java

@ -22,6 +22,7 @@ import java.lang.annotation.RetentionPolicy; @@ -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 { @@ -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 { @@ -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 { @@ -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 }.
* <br />
* explicitly adds an entry for the given field holding {@literal null} as a value {@code 'fieldName' : null }. <br />
* <strong>NOTE:</strong> Setting the value to {@link Write#ALWAYS} may lead to increased document size.
*
* @return {@link Write#NON_NULL} by default.

118
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/FieldName.java

@ -0,0 +1,118 @@ @@ -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
}
}

130
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoField.java

@ -0,0 +1,130 @@ @@ -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);
}
}
}

6
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/MongoPersistentProperty.java

@ -176,6 +176,12 @@ public interface MongoPersistentProperty extends PersistentProperty<MongoPersist @@ -176,6 +176,12 @@ public interface MongoPersistentProperty extends PersistentProperty<MongoPersist
*/
Collection<Object> 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.
*

10
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/UnwrappedMongoPersistentProperty.java

@ -144,6 +144,16 @@ class UnwrappedMongoPersistentProperty implements MongoPersistentProperty { @@ -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();

83
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java

@ -39,6 +39,8 @@ import org.bson.types.Decimal128; @@ -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 { @@ -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<String, Object> source, String key) {
public static Object resolveValue(Map<String, Object> 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 { @@ -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<String, Object> source = asMap(bson);
@Nullable
public static Object resolveValue(Map<String, Object> 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<String, Object> 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 { @@ -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));
}
/**

175
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateTests.java

@ -59,15 +59,22 @@ import org.springframework.data.mapping.context.PersistentEntities; @@ -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; @@ -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 { @@ -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 { @@ -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<String, String> 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<ImmutableVersioned> createAfterSaveReference() {
AtomicReference<ImmutableVersioned> saved = new AtomicReference<>();
@ -4053,11 +4180,12 @@ public class MongoTemplateTests { @@ -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<Sample> lazyDbRefAnnotatedList;
@Field("lazy_db_ref_map") @org.springframework.data.mongodb.core.mapping.DBRef(
lazy = true) public Map<String, Sample> lazyDbRefAnnotatedMap;
@Field("lazy_db_ref_map")
@org.springframework.data.mongodb.core.mapping.DBRef(lazy = true) public Map<String, Sample> lazyDbRefAnnotatedMap;
public DocumentWithLazyDBRefsAndConstructorCreation(String id, Sample lazyDbRefProperty,
List<Sample> lazyDbRefAnnotatedList, Map<String, Sample> lazyDbRefAnnotatedMap) {
@ -4848,4 +4976,37 @@ public class MongoTemplateTests { @@ -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<String, String> 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);
}
}
}

31
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.*; @@ -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 { @@ -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 { @@ -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() {

116
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 @@ -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; @@ -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 { @@ -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 { @@ -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> {
T content;
}
@ -2962,6 +3045,23 @@ class MappingMongoConverterUnitTests { @@ -2962,6 +3045,23 @@ class MappingMongoConverterUnitTests {
public Person(Set<Address> 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 { @@ -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;
}
}

15
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 @@ -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 { @@ -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<WithNestedArray> level0;
@ -1804,4 +1812,11 @@ public class QueryMapperUnitTests { @@ -1804,4 +1812,11 @@ public class QueryMapperUnitTests {
return doc;
}
}
static class WithPropertyHavingDotsInFieldName {
@Field(name = "field.name.with.dots", nameType = Type.KEY)
String value;
}
}

3
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/support/MongoRepositoryFactoryUnitTests.java

@ -29,6 +29,7 @@ import org.mockito.junit.jupiter.MockitoSettings; @@ -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; @@ -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;

30
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/util/json/BsonUtilsTest.java

@ -26,7 +26,9 @@ import java.util.ArrayList; @@ -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; @@ -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 { @@ -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<String, Object> 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<Arguments> 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<Arguments> javaTimeInstances() {
return Stream.of(Arguments.of(Instant.now()), Arguments.of(LocalDate.now()), Arguments.of(LocalDateTime.now()),

88
src/main/antora/modules/ROOT/pages/mongodb/mapping/mapping.adoc

@ -535,6 +535,94 @@ public class Balance { @@ -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

Loading…
Cancel
Save