MongoExampleMapper.java
/*
* Copyright 2015-present 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.convert;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
import java.util.Stack;
import java.util.regex.Pattern;
import org.bson.Document;
import org.jspecify.annotations.Nullable;
import org.springframework.data.core.TypeInformation;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.ExampleMatcher.NullHandler;
import org.springframework.data.domain.ExampleMatcher.PropertyValueTransformer;
import org.springframework.data.domain.ExampleMatcher.StringMatcher;
import org.springframework.data.mapping.PropertyHandler;
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.mongodb.core.mapping.FieldName;
import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity;
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
import org.springframework.data.mongodb.core.query.MongoRegexCreator;
import org.springframework.data.mongodb.core.query.MongoRegexCreator.MatchMode;
import org.springframework.data.mongodb.core.query.SerializationUtils;
import org.springframework.data.mongodb.core.query.UntypedExampleMatcher;
import org.springframework.data.mongodb.util.DotPath;
import org.springframework.data.support.ExampleMatcherAccessor;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
/**
* Mapper from {@link Example} to a query {@link Document}.
*
* @author Christoph Strobl
* @author Mark Paluch
* @author Jens Schauder
* @since 1.8
* @see Example
* @see org.springframework.data.domain.ExampleMatcher
* @see UntypedExampleMatcher
*/
public class MongoExampleMapper {
private final MappingContext<? extends MongoPersistentEntity<?>, MongoPersistentProperty> mappingContext;
private final MongoConverter converter;
/**
* Create a new {@link MongoTypeMapper} given {@link MongoConverter}.
*
* @param converter must not be {@literal null}.
*/
public MongoExampleMapper(MongoConverter converter) {
this.converter = converter;
this.mappingContext = converter.getMappingContext();
}
/**
* Returns the given {@link Example} as {@link Document} holding matching values extracted from
* {@link Example#getProbe()}.
*
* @param example must not be {@literal null}.
* @return
*/
public Document getMappedExample(Example<?> example) {
Assert.notNull(example, "Example must not be null");
return getMappedExample(example, mappingContext.getRequiredPersistentEntity(example.getProbeType()));
}
/**
* Returns the given {@link Example} as {@link Document} holding matching values extracted from
* {@link Example#getProbe()}.
*
* @param example must not be {@literal null}.
* @param entity must not be {@literal null}.
* @return
*/
@SuppressWarnings("NullAway")
public Document getMappedExample(Example<?> example, @Nullable MongoPersistentEntity<?> entity) {
Assert.notNull(example, "Example must not be null");
Document reference = (Document) converter.convertToMongoType(example.getProbe());
if(entity != null) {
entity = mappingContext.getRequiredPersistentEntity(example.getProbeType());
}
if (entity.getIdProperty() != null && ClassUtils.isAssignable(entity.getType(), example.getProbeType())) {
Object identifier = entity.getIdentifierAccessor(example.getProbe()).getIdentifier();
if (identifier == null) {
reference.remove(entity.getIdProperty().getFieldName());
}
}
ExampleMatcherAccessor matcherAccessor = new ExampleMatcherAccessor(example.getMatcher());
applyPropertySpecs("", reference, example.getProbeType(), matcherAccessor);
Document flattened = ObjectUtils.nullSafeEquals(NullHandler.INCLUDE, matcherAccessor.getNullHandler()) ? reference
: new Document(SerializationUtils.flattenMap(reference));
Document result = example.getMatcher().isAllMatching() ? flattened : orConcatenate(flattened);
return updateTypeRestrictions(result, example);
}
private void applyPropertySpecs(String path, Document source, Class<?> probeType,
ExampleMatcherAccessor exampleSpecAccessor) {
if (source == null) {
return;
}
Iterator<Map.Entry<String, Object>> iter = source.entrySet().iterator();
while (iter.hasNext()) {
Map.Entry<String, Object> entry = iter.next();
String propertyPath = DotPath.from(path).append(entry.getKey()).toString();
String mappedPropertyPath = getMappedPropertyPath(propertyPath, probeType);
if (isEmptyIdProperty(entry)) {
iter.remove();
continue;
}
if (exampleSpecAccessor.isIgnoredPath(propertyPath) || exampleSpecAccessor.isIgnoredPath(mappedPropertyPath)) {
iter.remove();
continue;
}
StringMatcher stringMatcher = exampleSpecAccessor.getDefaultStringMatcher();
Object value = entry.getValue();
boolean ignoreCase = exampleSpecAccessor.isIgnoreCaseEnabled();
if (exampleSpecAccessor.hasPropertySpecifiers()) {
mappedPropertyPath = exampleSpecAccessor.hasPropertySpecifier(propertyPath) ? propertyPath
: getMappedPropertyPath(propertyPath, probeType);
stringMatcher = exampleSpecAccessor.getStringMatcherForPath(mappedPropertyPath);
ignoreCase = exampleSpecAccessor.isIgnoreCaseForPath(mappedPropertyPath);
}
// TODO: should a PropertySpecifier outrule the later on string matching?
if (exampleSpecAccessor.hasPropertySpecifier(mappedPropertyPath)) {
PropertyValueTransformer valueTransformer = exampleSpecAccessor.getValueTransformerForPath(mappedPropertyPath);
Optional converted = valueTransformer.apply(Optional.ofNullable(value));
if(!converted.isPresent()) {
iter.remove();
continue;
}
entry.setValue(converted.get());
}
if (entry.getValue() instanceof String) {
applyStringMatcher(entry, stringMatcher, ignoreCase);
} else if (entry.getValue() instanceof Document document) {
applyPropertySpecs(propertyPath, document, probeType, exampleSpecAccessor);
}
}
}
private String getMappedPropertyPath(String path, Class<?> probeType) {
MongoPersistentEntity<?> entity = mappingContext.getRequiredPersistentEntity(probeType);
Iterator<String> parts = Arrays.asList(path.split("\\.")).iterator();
final Stack<MongoPersistentProperty> stack = new Stack<>();
List<String> resultParts = new ArrayList<>();
while (parts.hasNext()) {
String part = parts.next();
MongoPersistentProperty prop = entity.getPersistentProperty(part);
if (prop == null) {
entity.doWithProperties((PropertyHandler<MongoPersistentProperty>) property -> {
if (property.getFieldName().equals(part)) {
stack.push(property);
}
});
if (stack.isEmpty()) {
return "";
}
prop = stack.pop();
}
resultParts.add(prop.getName());
if (prop.isEntity() && mappingContext.hasPersistentEntityFor(prop.getActualType())) {
entity = mappingContext.getRequiredPersistentEntity(prop.getActualType());
} else {
break;
}
}
return StringUtils.collectionToDelimitedString(resultParts, ".");
}
private Document updateTypeRestrictions(Document query, Example<?> example) {
Document result = new Document();
if (isTypeRestricting(example)) {
result.putAll(query);
this.converter.getTypeMapper().writeTypeRestrictions(result, getTypesToMatch(example));
return result;
}
for (Map.Entry<String, Object> entry : query.entrySet()) {
if (!this.converter.getTypeMapper().isTypeKey(entry.getKey())) {
result.put(entry.getKey(), entry.getValue());
}
}
return result;
}
private boolean isTypeRestricting(Example<?> example) {
if (example.getMatcher() instanceof UntypedExampleMatcher) {
return false;
}
if (example.getMatcher().getIgnoredPaths().isEmpty()) {
return true;
}
for (String path : example.getMatcher().getIgnoredPaths()) {
if (this.converter.getTypeMapper().isTypeKey(path)) {
return false;
}
}
return true;
}
private Set<Class<?>> getTypesToMatch(Example<?> example) {
Set<Class<?>> types = new HashSet<>();
for (TypeInformation<?> reference : mappingContext.getManagedTypes()) {
if (example.getProbeType().isAssignableFrom(reference.getType())) {
types.add(reference.getType());
}
}
return types;
}
private static boolean isEmptyIdProperty(Entry<String, Object> entry) {
return entry.getKey().equals(FieldName.ID.name())
&& (entry.getValue() == null || entry.getValue().equals(Optional.empty()));
}
private static void applyStringMatcher(Map.Entry<String, Object> entry, StringMatcher stringMatcher,
boolean ignoreCase) {
Document document = new Document();
if (StringMatcher.DEFAULT == stringMatcher) {
if (ignoreCase) {
document.put("$regex", Pattern.quote((String) entry.getValue()));
entry.setValue(document);
}
} else {
String expression = MongoRegexCreator.INSTANCE.toRegularExpression((String) entry.getValue(),
toMatchMode(stringMatcher));
document.put("$regex", expression);
entry.setValue(document);
}
if (ignoreCase) {
document.put("$options", "i");
}
}
private static Document orConcatenate(Document source) {
List<Document> or = new ArrayList<>(source.keySet().size());
for (String key : source.keySet()) {
or.add(new Document(key, source.get(key)));
}
return new Document("$or", or);
}
/**
* Return the {@link MatchMode} for the given {@link StringMatcher}.
*
* @param matcher must not be {@literal null}.
* @return
*/
private static MatchMode toMatchMode(StringMatcher matcher) {
return switch (matcher) {
case CONTAINING -> MatchMode.CONTAINING;
case STARTING -> MatchMode.STARTING_WITH;
case ENDING -> MatchMode.ENDING_WITH;
case EXACT -> MatchMode.EXACT;
case REGEX -> MatchMode.REGEX;
default -> MatchMode.DEFAULT;
};
}
}