Support generating JsonSchema for Polymorphic fields.

This commit introduces MergedJsonSchema and MergedJsonSchemaProperty that can be used to merge properties of multiple objects into one as long as the additions do not conflict with another (eg. due to usage of different types).
To resolve previously mentioned errors it is required to provide a ConflictResolutionFunction.

Closes #3870
Original pull request: #3986.
This commit is contained in:
Christoph Strobl
2021-11-12 11:21:15 +01:00
committed by Mark Paluch
parent cb2fe05f44
commit 7617099abe
10 changed files with 998 additions and 10 deletions

View File

@@ -24,7 +24,6 @@ import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.bson.Document;
import org.springframework.data.mapping.PersistentProperty;
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.mongodb.core.convert.MongoConverter;
@@ -45,6 +44,7 @@ import org.springframework.data.util.ClassTypeInformation;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
@@ -62,6 +62,7 @@ class MappingMongoJsonSchemaCreator implements MongoJsonSchemaCreator {
private final MongoConverter converter;
private final MappingContext<MongoPersistentEntity<?>, MongoPersistentProperty> mappingContext;
private final Predicate<JsonSchemaPropertyContext> filter;
private final LinkedMultiValueMap<String, Class<?>> mergeProperties;
/**
* Create a new instance of {@link MappingMongoJsonSchemaCreator}.
@@ -72,23 +73,51 @@ class MappingMongoJsonSchemaCreator implements MongoJsonSchemaCreator {
MappingMongoJsonSchemaCreator(MongoConverter converter) {
this(converter, (MappingContext<MongoPersistentEntity<?>, MongoPersistentProperty>) converter.getMappingContext(),
(property) -> true);
(property) -> true, new LinkedMultiValueMap<>());
}
@SuppressWarnings("unchecked")
MappingMongoJsonSchemaCreator(MongoConverter converter,
MappingContext<MongoPersistentEntity<?>, MongoPersistentProperty> mappingContext,
Predicate<JsonSchemaPropertyContext> filter) {
Predicate<JsonSchemaPropertyContext> filter, LinkedMultiValueMap<String, Class<?>> mergeProperties) {
Assert.notNull(converter, "Converter must not be null!");
this.converter = converter;
this.mappingContext = mappingContext;
this.filter = filter;
this.mergeProperties = mergeProperties;
}
@Override
public MongoJsonSchemaCreator filter(Predicate<JsonSchemaPropertyContext> filter) {
return new MappingMongoJsonSchemaCreator(converter, mappingContext, filter);
return new MappingMongoJsonSchemaCreator(converter, mappingContext, filter, mergeProperties);
}
@Override
public PropertySpecifier specify(String path) {
return new PropertySpecifier() {
@Override
public MongoJsonSchemaCreator types(Class<?>... types) {
return specifyTypesFor(path, types);
}
};
}
/**
* Specify additional types to be considered wehen rendering the schema for the given path.
*
* @param path path the path using {@literal dot '.'} notation.
* @param types must not be {@literal null}.
* @return new instance of {@link MongoJsonSchemaCreator}.
* @since 3.4
*/
public MongoJsonSchemaCreator specifyTypesFor(String path, Class<?>... types) {
LinkedMultiValueMap<String, Class<?>> clone = mergeProperties.clone();
for (Class<?> type : types) {
clone.add(path, type);
}
return new MappingMongoJsonSchemaCreator(converter, mappingContext, filter, clone);
}
/*
@@ -135,9 +164,12 @@ class MappingMongoJsonSchemaCreator implements MongoJsonSchemaCreator {
List<MongoPersistentProperty> currentPath = new ArrayList<>(path);
if (!filter.test(new PropertyContext(
currentPath.stream().map(PersistentProperty::getName).collect(Collectors.joining(".")), nested))) {
continue;
String stringPath = currentPath.stream().map(PersistentProperty::getName).collect(Collectors.joining("."));
stringPath = StringUtils.hasText(stringPath) ? (stringPath + "." + nested.getName()) : nested.getName();
if (!filter.test(new PropertyContext(stringPath, nested))) {
if (!mergeProperties.containsKey(stringPath)) {
continue;
}
}
if (path.contains(nested)) { // cycle guard
@@ -155,14 +187,34 @@ class MappingMongoJsonSchemaCreator implements MongoJsonSchemaCreator {
private JsonSchemaProperty computeSchemaForProperty(List<MongoPersistentProperty> path) {
String stringPath = path.stream().map(MongoPersistentProperty::getName).collect(Collectors.joining("."));
MongoPersistentProperty property = CollectionUtils.lastElement(path);
boolean required = isRequiredProperty(property);
Class<?> rawTargetType = computeTargetType(property); // target type before conversion
Class<?> targetType = converter.getTypeMapper().getWriteTargetTypeFor(rawTargetType); // conversion target type
if (!isCollection(property) && property.isEntity() && ObjectUtils.nullSafeEquals(rawTargetType, targetType)) {
return createObjectSchemaPropertyForEntity(path, property, required);
if (!isCollection(property) && ObjectUtils.nullSafeEquals(rawTargetType, targetType)) {
if (property.isEntity() || mergeProperties.containsKey(stringPath)) {
List<JsonSchemaProperty> targetProperties = new ArrayList<>();
if (property.isEntity()) {
targetProperties.add(createObjectSchemaPropertyForEntity(path, property, required));
}
if (mergeProperties.containsKey(stringPath)) {
for (Class<?> theType : mergeProperties.get(stringPath)) {
ObjectJsonSchemaProperty target = JsonSchemaProperty.object(property.getName());
List<JsonSchemaProperty> nestedProperties = computePropertiesForEntity(path,
mappingContext.getRequiredPersistentEntity(theType));
targetProperties.add(createPotentiallyRequiredSchemaProperty(
target.properties(nestedProperties.toArray(new JsonSchemaProperty[0])), required));
}
}
return targetProperties.size() == 1 ? targetProperties.iterator().next()
: JsonSchemaProperty.combined(targetProperties);
}
}
String fieldName = computePropertyFieldName(property);

View File

@@ -15,6 +15,7 @@
*/
package org.springframework.data.mongodb.core;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Predicate;
@@ -62,7 +63,6 @@ import org.springframework.util.Assert;
* {@link org.springframework.data.annotation.Id _id} properties using types that can be converted into
* {@link org.bson.types.ObjectId} like {@link String} will be mapped to {@code type : 'object'} unless there is more
* specific information available via the {@link org.springframework.data.mongodb.core.mapping.MongoId} annotation.
* {@link Encrypted} properties will contain {@literal encrypt} information.
*
* @author Christoph Strobl
@@ -78,6 +78,20 @@ public interface MongoJsonSchemaCreator {
*/
MongoJsonSchema createSchemaFor(Class<?> type);
/**
* Create a combined {@link MongoJsonSchema} out of the individual schemas of the given types by combining their
* properties into one large {@link MongoJsonSchema schema}.
*
* @param types must not be {@literal null} nor contain {@literal null}.
* @return new instance of {@link MongoJsonSchema}.
* @since 3.4
*/
default MongoJsonSchema combineSchemaFor(Class<?>... types) {
MongoJsonSchema[] schemas = Arrays.stream(types).map(this::createSchemaFor).toArray(MongoJsonSchema[]::new);
return MongoJsonSchema.combined(schemas);
}
/**
* Filter matching {@link JsonSchemaProperty properties}.
*
@@ -87,6 +101,15 @@ public interface MongoJsonSchemaCreator {
*/
MongoJsonSchemaCreator filter(Predicate<JsonSchemaPropertyContext> filter);
/**
* Entry point to specify additional behavior for a given path.
*
* @param path the path using {@literal dot '.'} notation.
* @return new instance of {@link PropertySpecifier}.
* @since 3.4
*/
PropertySpecifier specify(String path);
/**
* The context in which a specific {@link #getProperty()} is encountered during schema creation.
*
@@ -209,4 +232,20 @@ public interface MongoJsonSchemaCreator {
return create(converter);
}
/**
* @since 3.4
* @author Christoph Strobl
* @since 3.4
*/
interface PropertySpecifier {
/**
* Set additional type parameters for polymorphic ones.
*
* @param types must not be {@literal null}.
* @return the source
*/
MongoJsonSchemaCreator types(Class<?>... types);
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2022 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.schema;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import org.bson.Document;
/**
* {@link MongoJsonSchema} implementation that is capable of combining properties of different schemas into one.
*
* @author Christoph Strobl
* @since 3.4
*/
class CombinedJsonSchema implements MongoJsonSchema {
private final List<MongoJsonSchema> schemaList;
private final BiFunction<Map<String, Object>, Map<String, Object>, Document> mergeFunction;
CombinedJsonSchema(List<MongoJsonSchema> schemaList, ConflictResolutionFunction conflictResolutionFunction) {
this(schemaList, new TypeUnifyingMergeFunction(conflictResolutionFunction));
}
CombinedJsonSchema(List<MongoJsonSchema> schemaList,
BiFunction<Map<String, Object>, Map<String, Object>, Document> mergeFunction) {
this.schemaList = new ArrayList<>(schemaList);
this.mergeFunction = mergeFunction;
}
@Override
public MongoJsonSchema combineWith(Collection<MongoJsonSchema> sources) {
schemaList.addAll(sources);
return this;
}
@Override
public Document schemaDocument() {
Document targetSchema = new Document();
for (MongoJsonSchema schema : schemaList) {
targetSchema = mergeFunction.apply(targetSchema, schema.schemaDocument());
}
return targetSchema;
}
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2022 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.schema;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.function.BiFunction;
import org.bson.Document;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema.ConflictResolutionFunction;
/**
* {@link JsonSchemaProperty} implementation that is capable of combining multiple properties with different values into
* a single one.
*
* @author Christoph Strobl
* @since 3.4
*/
class CombinedJsonSchemaProperty implements JsonSchemaProperty {
private final Iterable<JsonSchemaProperty> properties;
private final BiFunction<Map<String, Object>, Map<String, Object>, Document> mergeFunction;
CombinedJsonSchemaProperty(Iterable<JsonSchemaProperty> properties) {
this(properties, (k, a, b) -> {
throw new IllegalStateException(
String.format("Error resolving conflict for %s. No conflict resolution function defined.", k));
});
}
CombinedJsonSchemaProperty(Iterable<JsonSchemaProperty> properties,
ConflictResolutionFunction conflictResolutionFunction) {
this(properties, new TypeUnifyingMergeFunction(conflictResolutionFunction));
}
CombinedJsonSchemaProperty(Iterable<JsonSchemaProperty> properties,
BiFunction<Map<String, Object>, Map<String, Object>, Document> mergeFunction) {
this.properties = properties;
this.mergeFunction = mergeFunction;
}
@Override
public Set<Type> getTypes() {
return Collections.emptySet();
}
@Override
public Document toDocument() {
Document document = new Document();
for (JsonSchemaProperty property : properties) {
document = mergeFunction.apply(document, property.toDocument());
}
return document;
}
@Override
public String getIdentifier() {
return properties.iterator().next().getIdentifier();
}
}

View File

@@ -15,6 +15,8 @@
*/
package org.springframework.data.mongodb.core.schema;
import java.util.Collection;
import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.NumericJsonSchemaObject;
import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.ObjectJsonSchemaObject;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.*;
@@ -233,6 +235,17 @@ public interface JsonSchemaProperty extends JsonSchemaObject {
return new RequiredJsonSchemaProperty(property, true);
}
/**
* Combines multiple {@link JsonSchemaProperty} with potentially different attributes into one.
*
* @param properties must not be {@literal null}.
* @return new instance of {@link JsonSchemaProperty}.
* @since 3.4
*/
static JsonSchemaProperty combined(Collection<JsonSchemaProperty> properties) {
return new CombinedJsonSchemaProperty(properties);
}
/**
* Builder for {@link IdentifiableJsonSchemaProperty}.
*/

View File

@@ -15,7 +15,11 @@
*/
package org.springframework.data.mongodb.core.schema;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.bson.Document;
@@ -103,6 +107,72 @@ public interface MongoJsonSchema {
return new DocumentJsonSchema(document);
}
/**
* Create a new {@link MongoJsonSchema} combining properties from the given sources.
*
* @param sources must not be {@literal null}.
* @return new instance of {@link MongoJsonSchema}.
* @since 3.4
*/
static MongoJsonSchema combined(MongoJsonSchema... sources) {
return combined((path, a, b) -> {
throw new IllegalStateException(
String.format("Failure combining schema for path %s holding values a) %s and b) %s.", path.dotPath(), a, b));
}, sources);
}
/**
* Create a new {@link MongoJsonSchema} combining properties from the given sources.
*
* @param sources must not be {@literal null}.
* @return new instance of {@link MongoJsonSchema}.
* @since 3.4
*/
static MongoJsonSchema combined(ConflictResolutionFunction mergeFunction, MongoJsonSchema... sources) {
return new CombinedJsonSchema(Arrays.asList(sources), mergeFunction);
}
/**
* Create a new {@link MongoJsonSchema} combining properties from the given sources.
*
* @param sources must not be {@literal null}.
* @return new instance of {@link MongoJsonSchema}.
* @since 3.4
*/
default MongoJsonSchema combineWith(MongoJsonSchema... sources) {
return combineWith(Arrays.asList(sources));
}
/**
* Create a new {@link MongoJsonSchema} combining properties from the given sources.
*
* @param sources must not be {@literal null}.
* @return new instance of {@link MongoJsonSchema}.
* @since 3.4
*/
default MongoJsonSchema combineWith(Collection<MongoJsonSchema> sources) {
return combineWith(sources, (path, a, b) -> {
throw new IllegalStateException(
String.format("Failure combining schema for path %s holding values a) %s and b) %s.", path.dotPath(), a, b));
});
}
/**
* Create a new {@link MongoJsonSchema} combining properties from the given sources.
*
* @param sources must not be {@literal null}.
* @return new instance of {@link MongoJsonSchema}.
* @since 3.4
*/
default MongoJsonSchema combineWith(Collection<MongoJsonSchema> sources,
ConflictResolutionFunction conflictResolutionFunction) {
List<MongoJsonSchema> schemaList = new ArrayList<>(sources.size() + 1);
schemaList.add(this);
schemaList.addAll(new ArrayList<>(sources));
return new CombinedJsonSchema(schemaList, conflictResolutionFunction);
}
/**
* Obtain a new {@link MongoJsonSchemaBuilder} to fluently define the schema.
*
@@ -112,6 +182,87 @@ public interface MongoJsonSchema {
return new MongoJsonSchemaBuilder();
}
/**
* A resolution function that may be called on conflicting paths. Eg. when trying to merge properties with different
* values into one.
*
* @author Christoph Strobl
* @since 3.4
*/
@FunctionalInterface
interface ConflictResolutionFunction {
/**
* @param path the {@link Path} leading to the conflict.
* @param a can be {@literal null}.
* @param b can be {@literal null}.
* @return never {@literal null}.
*/
Resolution resolveConflict(Path path, @Nullable Object a, @Nullable Object b);
/**
* @author Christoph Strobl
* @since 3.4
*/
interface Path {
/**
* @return the name of the currently processed element
*/
String currentElement();
/**
* @return the path leading to the currently processed element in dot {@literal '.'} notation.
*/
String dotPath();
}
/**
* The result after processing a conflict when combining schemas. May indicate to {@link #SKIP skip} the entry
* entirely.
*
* @author Christoph Strobl
* @since 3.4
*/
interface Resolution extends Map.Entry<String, Object> {
@Override
default Object setValue(Object value) {
throw new IllegalStateException("Cannot set value result. Maybe you missed to override the method.");
}
/**
* Resolution
*/
Resolution SKIP = new Resolution() {
@Override
public String getKey() {
throw new IllegalStateException("No key for skipped result.");
}
@Override
public Object getValue() {
throw new IllegalStateException("No value for skipped result.");
}
@Override
public Object setValue(Object value) {
throw new IllegalStateException("Cannot set value on skipped result.");
}
};
/**
* Obtain a {@link Resolution} that will skip the entry and proceed computation.
*
* @return never {@literal null}.
*/
static Resolution skip() {
return SKIP;
}
}
}
/**
* {@link MongoJsonSchemaBuilder} provides a fluent API for defining a {@link MongoJsonSchema}.
*

View File

@@ -0,0 +1,171 @@
/*
* Copyright 2022 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.schema;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import org.bson.Document;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema.ConflictResolutionFunction;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema.ConflictResolutionFunction.Path;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema.ConflictResolutionFunction.Resolution;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
/**
* @author Christoph Strobl
* @since 3.4
*/
class TypeUnifyingMergeFunction implements BiFunction<Map<String, Object>, Map<String, Object>, Document> {
private final ConflictResolutionFunction conflictResolutionFunction;
public TypeUnifyingMergeFunction(ConflictResolutionFunction conflictResolutionFunction) {
this.conflictResolutionFunction = conflictResolutionFunction;
}
@Override
public Document apply(Map<String, Object> a, Map<String, Object> b) {
return merge(SimplePath.root(), a, b);
}
Document merge(SimplePath path, Map<String, Object> a, Map<String, Object> b) {
Document target = new Document(a);
for (String key : b.keySet()) {
SimplePath currentPath = path.append(key);
if (isTypeKey(key)) {
Object unifiedExistingType = getUnifiedExistingType(key, target);
if (unifiedExistingType != null) {
if (!ObjectUtils.nullSafeEquals(unifiedExistingType, b.get(key))) {
resolveConflict(currentPath, a, b, target);
}
continue;
}
}
if (!target.containsKey(key)) {
target.put(key, b.get(key));
continue;
}
Object existingEntry = target.get(key);
Object newEntry = b.get(key);
if (existingEntry instanceof Map && newEntry instanceof Map) {
target.put(key, merge(currentPath, (Map) existingEntry, (Map) newEntry));
} else if (!ObjectUtils.nullSafeEquals(existingEntry, newEntry)) {
resolveConflict(currentPath, a, b, target);
}
}
return target;
}
private void resolveConflict(Path path, Map<String, Object> a, Map<String, Object> b, Document target) {
applyConflictResolution(path, target, conflictResolutionFunction.resolveConflict(path, a, b));
}
private void applyConflictResolution(Path path, Document target, Resolution resolution) {
if (Resolution.SKIP.equals(resolution) || resolution.getValue() == null) {
target.remove(path.currentElement());
return ;
}
if (isTypeKey(resolution.getKey())) {
target.put(getTypeKeyToUse(resolution.getKey(), target), resolution.getValue());
} else {
target.put(resolution.getKey(), resolution.getValue());
}
}
private static boolean isTypeKey(String key) {
return "bsonType".equals(key) || "type".equals(key);
}
private static String getTypeKeyToUse(String key, Document source) {
if ("bsonType".equals(key) && source.containsKey("type")) {
return "type";
}
if ("type".equals(key) && source.containsKey("bsonType")) {
return "bsonType";
}
return key;
}
private static Object getUnifiedExistingType(String key, Document source) {
return source.get(getTypeKeyToUse(key, source));
}
/**
* Trivial {@link List} based {@link Path} implementation.
*
* @author Christoph Strobl
* @since 3.4
*/
static class SimplePath implements Path {
private List<String> path;
SimplePath(List<String> path) {
this.path = path;
}
static SimplePath root() {
return new SimplePath(Collections.emptyList());
}
static SimplePath of(List<String> path) {
return new SimplePath(new ArrayList<>(path));
}
static SimplePath of(List<String> path, String next) {
List<String> fullPath = new ArrayList<>(path.size() + 1);
fullPath.addAll(path);
fullPath.add(next);
return new SimplePath(fullPath);
}
public SimplePath append(String next) {
return of(this.path, next);
}
@Override
public String currentElement() {
return CollectionUtils.lastElement(path);
}
@Override
public String dotPath() {
return StringUtils.collectionToDelimitedString(path, ".");
}
@Override
public String toString() {
return dotPath();
}
}
}

View File

@@ -38,7 +38,11 @@ import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.mapping.FieldType;
import org.springframework.data.mongodb.core.mapping.MongoId;
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
import org.springframework.data.mongodb.core.schema.JsonSchemaObject.Type;
import org.springframework.data.mongodb.core.schema.JsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema.ConflictResolutionFunction;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema.ConflictResolutionFunction.Resolution;
import org.springframework.data.spel.spi.EvaluationContextExtension;
import org.springframework.data.spel.spi.Function;
@@ -158,6 +162,132 @@ public class MappingMongoJsonSchemaCreatorUnitTests {
assertThat(schema.schemaDocument().toBsonDocument()).isEqualTo(BsonDocument.parse(ENC_FROM_METHOD_SCHEMA));
}
// --> Combining Schemas and Properties
@Test // GH-3870
void shouldAllowToSpecifyPolymorphicTypesForProperty() {
MongoJsonSchema schema = MongoJsonSchemaCreator.create() //
.specify("objectValue").types(A.class, B.class)
.createSchemaFor(SomeTestObject.class);
Document targetSchema = schema.schemaDocument();
assertThat(targetSchema) //
.containsEntry("properties.objectValue.properties.aNonEncrypted", new Document("type", "string")) //
.containsEntry("properties.objectValue.properties.aEncrypted", ENCRYPTED_BSON_STRING) //
.containsEntry("properties.objectValue.properties.bEncrypted", ENCRYPTED_BSON_STRING);
}
@Test // GH-3870
void shouldAllowToSpecifyNestedPolymorphicTypesForProperty() {
MongoJsonSchema schema = MongoJsonSchemaCreator.create() //
.specify("value.objectValue").types(A.class, B.class) //
.createSchemaFor(WrapperAroundA.class);
Document targetSchema = schema.schemaDocument();
assertThat(schema.schemaDocument()) //
.containsEntry("properties.value.properties.objectValue.properties.aNonEncrypted", new Document("type", "string")) //
.containsEntry("properties.value.properties.objectValue.properties.aEncrypted", ENCRYPTED_BSON_STRING) //
.containsEntry("properties.value.properties.objectValue.properties.bEncrypted", ENCRYPTED_BSON_STRING);
}
@Test // GH-3870
void shouldAllowToSpecifyGenericTypesForProperty() {
MongoJsonSchema schema = MongoJsonSchemaCreator.create() //
.specify("genericValue").types(A.class, B.class)
.createSchemaFor(SomeTestObject.class);
assertThat(schema.schemaDocument()) //
.containsEntry("properties.genericValue.properties.aNonEncrypted", new Document("type", "string")) //
.containsEntry("properties.genericValue.properties.aEncrypted", ENCRYPTED_BSON_STRING) //
.containsEntry("properties.genericValue.properties.bEncrypted", ENCRYPTED_BSON_STRING);
}
@Test // GH-3870
void encryptionFilterShouldCaptureSpecifiedPolymorphicTypesForProperty() {
MongoJsonSchema schema = MongoJsonSchemaCreator.create() //
.specify("objectValue").types(A.class, B.class) //
.filter(MongoJsonSchemaCreator.encryptedOnly()) //
.createSchemaFor(SomeTestObject.class);
assertThat(schema.schemaDocument()) //
.doesNotContainKey("properties.objectValue.properties.aNonEncrypted") //
.containsEntry("properties.objectValue.properties.aEncrypted", ENCRYPTED_BSON_STRING) //
.containsEntry("properties.objectValue.properties.bEncrypted", ENCRYPTED_BSON_STRING);
}
@Test // GH-3870
void allowsToCreateCombinedSchemaWhenPropertiesDoNotOverlap() {
MongoJsonSchema schema = MongoJsonSchemaCreator.create()
.combineSchemaFor(A.class, B.class, C.class);
assertThat(schema.schemaDocument()) //
.containsEntry("properties.aNonEncrypted", new Document("type", "string")) //
.containsEntry("properties.aEncrypted", ENCRYPTED_BSON_STRING) //
.containsEntry("properties.bEncrypted", ENCRYPTED_BSON_STRING) //
.containsEntry("properties.cEncrypted", ENCRYPTED_BSON_STRING);
}
@Test // GH-3870
void combinedSchemaFailsOnPropertyClash() {
MongoJsonSchema schemaA = MongoJsonSchemaCreator.create() //
.createSchemaFor(A.class);
MongoJsonSchema schemaAButDifferent = MongoJsonSchemaCreator.create() //
.createSchemaFor(PropertyClashWithA.class);
MongoJsonSchema targetSchema = schemaA.combineWith(schemaAButDifferent);
assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> targetSchema.schemaDocument());
}
@Test // GH-3870
void combinedSchemaAllowsToCompensateErrors() {
MongoJsonSchema schemaA = MongoJsonSchemaCreator.create() //
.createSchemaFor(A.class);
MongoJsonSchema schemaAButDifferent = MongoJsonSchemaCreator.create() //
.createSchemaFor(PropertyClashWithA.class);
MongoJsonSchema schema = schemaA.combineWith(Collections.singleton(schemaAButDifferent), (path, a, b) -> new Resolution() {
@Override
public String getKey() {
return path.currentElement();
}
@Override
public Object getValue() {
return "object";
}
});
assertThat(schema.schemaDocument()) //
.containsEntry("properties.aNonEncrypted", new Document("type", "object"));
}
@Test // GH-3870
void bsonTypeVsJustTypeValueResolutionIsDoneByDefault() {
MongoJsonSchema schemaUsingType = MongoJsonSchema.builder()
.property(JsonSchemaProperty.named("value").ofType(Type.jsonTypeOf("string")))
.build();
MongoJsonSchema schemaUsingBsonType = MongoJsonSchema.builder()
.property(JsonSchemaProperty.named("value").ofType(Type.bsonTypeOf("string")))
.build();
MongoJsonSchema targetSchema = MongoJsonSchema.combined(schemaUsingType, schemaUsingBsonType);
assertThat(targetSchema.schemaDocument()) //
.containsEntry("properties.value", new Document("type", "string"));
}
// --> TYPES AND JSON
// --> ENUM
@@ -526,4 +656,51 @@ public class MappingMongoJsonSchemaCreatorUnitTests {
return "xKVup8B1Q+CkHaVRx+qa+g==";
}
}
static final Document ENCRYPTED_BSON_STRING = Document.parse("{'encrypt': { 'bsonType': 'string','algorithm': 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'} }");
static class SomeTestObject<T> {
T genericValue;
Object objectValue;
}
static class RootWithGenerics<S,T> {
S sValue;
T tValue;
}
static class SubWithFixedGeneric<T> extends RootWithGenerics<A, T> {
}
static class Concrete extends SubWithFixedGeneric<B> {
}
static class WrapperAroundA {
SomeTestObject value;
}
static class A {
String aNonEncrypted;
@Encrypted(algorithm = "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic")
String aEncrypted;
}
static class B {
@Encrypted(algorithm = "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic")
String bEncrypted;
}
static class C extends A {
@Encrypted(algorithm = "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic")
String cEncrypted;
}
static class PropertyClashWithA {
Integer aNonEncrypted;
}
}

View File

@@ -0,0 +1,146 @@
/*
* Copyright 2022 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.schema;
import static org.mockito.Mockito.*;
import static org.springframework.data.mongodb.test.util.Assertions.*;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import org.bson.Document;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema.ConflictResolutionFunction;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema.ConflictResolutionFunction.Resolution;
/**
* @author Christoph Strobl
*/
@ExtendWith(MockitoExtension.class)
public class TypeUnifyingMergeFunctionUnitTests {
@Mock ConflictResolutionFunction crf;
TypeUnifyingMergeFunction mergeFunction;
@BeforeEach
void beforeEach() {
mergeFunction = new TypeUnifyingMergeFunction(crf);
}
@Test // GH-3870
void nonOverlapping() {
Map<String, Object> a = new LinkedHashMap<>();
a.put("a", "a-value");
Map<String, Object> b = new LinkedHashMap<>();
b.put("b", "b-value");
Document target = mergeFunction.apply(a, b);
assertThat(target).containsEntry("a", "a-value").containsEntry("b", "b-value");
}
@Test // GH-3870
void resolvesNonConflictingTypeKeys/* type vs bsonType */() {
Map<String, Object> a = new LinkedHashMap<>();
a.put("type", "string");
Map<String, Object> b = new LinkedHashMap<>();
b.put("bsonType", "string");
Document target = mergeFunction.apply(a, b);
assertThat(target).containsEntry("type", "string").doesNotContainKey("bsonType");
}
@Test // GH-3870
void nonOverlappingNestedMap() {
Map<String, Object> a = new LinkedHashMap<>();
a.put("a", Collections.singletonMap("nested", "value"));
Map<String, Object> b = new LinkedHashMap<>();
b.put("b", "b-value");
Document target = mergeFunction.apply(a, b);
assertThat(target).containsEntry("a", Collections.singletonMap("nested", "value")).containsEntry("b", "b-value");
}
@Test // GH-3870
void nonOverlappingNestedMaps() {
Map<String, Object> a = new LinkedHashMap<>();
a.put("nested", Collections.singletonMap("a", "a-value"));
Map<String, Object> b = new LinkedHashMap<>();
b.put("nested", Collections.singletonMap("b", "b-value"));
Document target = mergeFunction.apply(a, b);
assertThat(target).containsEntry("nested.a", "a-value").containsEntry("nested.b", "b-value");
}
@Test // GH-3870
void delegatesConflictToResolutionFunction() {
ArgumentCaptor<Object> aValueCaptor = ArgumentCaptor.forClass(Object.class);
ArgumentCaptor<Object> bValueCaptor = ArgumentCaptor.forClass(Object.class);
when(crf.resolveConflict(any(), aValueCaptor.capture(), bValueCaptor.capture())).thenReturn(new Resolution() {
@Override
public String getKey() {
return "nested";
}
@Override
public Object getValue() {
return "from-function";
}
});
Map<String, Object> a = new LinkedHashMap<>();
a.put("nested", Collections.singletonMap("a", "a-value"));
Map<String, Object> b = new LinkedHashMap<>();
b.put("nested", "b-value");
Document target = mergeFunction.apply(a, b);
assertThat(target).containsEntry("nested", "from-function") //
.doesNotContainKey("nested.a");
assertThat(aValueCaptor.getValue()).isEqualTo(a);
assertThat(bValueCaptor.getValue()).isEqualTo(b);
}
@Test // GH-3870
void skipsConflictItemsWhenAdvised() {
ArgumentCaptor<Object> aValueCaptor = ArgumentCaptor.forClass(Object.class);
ArgumentCaptor<Object> bValueCaptor = ArgumentCaptor.forClass(Object.class);
when(crf.resolveConflict(any(), aValueCaptor.capture(), bValueCaptor.capture())).thenReturn(Resolution.SKIP);
Map<String, Object> a = new LinkedHashMap<>();
a.put("nested", Collections.singletonMap("a", "a-value"));
a.put("some", "value");
Map<String, Object> b = new LinkedHashMap<>();
b.put("nested", "b-value");
Document target = mergeFunction.apply(a, b);
assertThat(target).hasSize(1).containsEntry("some", "value");
}
}

View File

@@ -190,6 +190,103 @@ unless there is more specific information available via the `@MongoId` annotatio
|===
The above example demonstrated how to derive the schema from a very precise typed source.
Using polymorphic elements within the domain model can lead to inaccurate schema representation for `Object` and generic `<T>` types, which are likely to represented as `{ type : 'object' }` without further specification.
`MongoJsonSchemaCreator.specify(...)` allows to define additional types that should be considered when rendering the schema.
.Specify additional types for properties
====
[source,java]
----
public class Root {
Object value;
}
public class A {
String aValue;
}
public class B {
String bValue;
}
MongoJsonSchemaCreator.create()
.specify("value").types(A.class, B.class) <1>
----
[source,json]
----
{
'type' : 'object',
'properties' : {
'value' : {
'type' : 'object',
'properties' : { <1>
'aValue' : { 'type' : 'string' },
'bValue' : { 'type' : 'string' }
}
}
}
}
----
<1> Properties of the given types are combined into one element.
====
MongoDBs schema free approach allows to store documents of different structure in one collection.
Those may be modeled having a common base class.
Regardless of the chosen approach `MongoJsonSchemaCreator.combine(...)` is can help circumvent the need of combining multiple schema into one.
.Combining multiple Schemas
====
[source,java]
----
public abstract class Root {
String rootValue;
}
public class A extends Root {
String aValue;
}
public class B extends Root {
String bValue;
}
MongoJsonSchemaCreator.combined(A.class, B.class) <1>
----
[source,json]
----
{
'type' : 'object',
'properties' : { <1>
'rootValue' : { 'type' : 'string' },
'aValue' : { 'type' : 'string' },
'bValue' : { 'type' : 'string' }
}
}
}
----
<1> Properties (and their inherited ones) of the given types are combined into one schema.
====
[NOTE]
====
Equally named properties need to refer to the same json schema in order to be combined.
The following example shows a definition that cannot be combined automatically because of a data type mismatch.
In this case a `ConflictResolutionFunction` has to be provided to `MongoJsonSchemaCreator`.
[source,java]
----
public class A extends Root {
String value;
}
public class B extends Root {
Integer value;
}
----
====
[[mongo.jsonSchema.query]]
==== Query a collection for matching JSON Schema