From 7617099abe39847463dcc0b657475abc5d0d3b25 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Fri, 12 Nov 2021 11:21:15 +0100 Subject: [PATCH] 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. --- .../core/MappingMongoJsonSchemaCreator.java | 70 ++++++- .../mongodb/core/MongoJsonSchemaCreator.java | 41 +++- .../core/schema/CombinedJsonSchema.java | 65 +++++++ .../schema/CombinedJsonSchemaProperty.java | 77 ++++++++ .../core/schema/JsonSchemaProperty.java | 13 ++ .../mongodb/core/schema/MongoJsonSchema.java | 151 +++++++++++++++ .../schema/TypeUnifyingMergeFunction.java | 171 +++++++++++++++++ ...appingMongoJsonSchemaCreatorUnitTests.java | 177 ++++++++++++++++++ .../TypeUnifyingMergeFunctionUnitTests.java | 146 +++++++++++++++ .../asciidoc/reference/mongo-json-schema.adoc | 97 ++++++++++ 10 files changed, 998 insertions(+), 10 deletions(-) create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/CombinedJsonSchema.java create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/CombinedJsonSchemaProperty.java create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/TypeUnifyingMergeFunction.java create mode 100644 spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/TypeUnifyingMergeFunctionUnitTests.java diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java index 547f0f6b0..eda27e27c 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreator.java @@ -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, MongoPersistentProperty> mappingContext; private final Predicate filter; + private final LinkedMultiValueMap> mergeProperties; /** * Create a new instance of {@link MappingMongoJsonSchemaCreator}. @@ -72,23 +73,51 @@ class MappingMongoJsonSchemaCreator implements MongoJsonSchemaCreator { MappingMongoJsonSchemaCreator(MongoConverter converter) { this(converter, (MappingContext, MongoPersistentProperty>) converter.getMappingContext(), - (property) -> true); + (property) -> true, new LinkedMultiValueMap<>()); } @SuppressWarnings("unchecked") MappingMongoJsonSchemaCreator(MongoConverter converter, MappingContext, MongoPersistentProperty> mappingContext, - Predicate filter) { + Predicate filter, LinkedMultiValueMap> 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 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> 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 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 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 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 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); diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoJsonSchemaCreator.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoJsonSchemaCreator.java index 2cff9f6c7..2c65ac41e 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoJsonSchemaCreator.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoJsonSchemaCreator.java @@ -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 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); + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/CombinedJsonSchema.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/CombinedJsonSchema.java new file mode 100644 index 000000000..ee84fb1a8 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/CombinedJsonSchema.java @@ -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 schemaList; + private final BiFunction, Map, Document> mergeFunction; + + CombinedJsonSchema(List schemaList, ConflictResolutionFunction conflictResolutionFunction) { + this(schemaList, new TypeUnifyingMergeFunction(conflictResolutionFunction)); + } + + CombinedJsonSchema(List schemaList, + BiFunction, Map, Document> mergeFunction) { + + this.schemaList = new ArrayList<>(schemaList); + this.mergeFunction = mergeFunction; + } + + @Override + public MongoJsonSchema combineWith(Collection 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; + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/CombinedJsonSchemaProperty.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/CombinedJsonSchemaProperty.java new file mode 100644 index 000000000..9b375eb4c --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/CombinedJsonSchemaProperty.java @@ -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 properties; + private final BiFunction, Map, Document> mergeFunction; + + CombinedJsonSchemaProperty(Iterable properties) { + this(properties, (k, a, b) -> { + throw new IllegalStateException( + String.format("Error resolving conflict for %s. No conflict resolution function defined.", k)); + }); + } + + CombinedJsonSchemaProperty(Iterable properties, + ConflictResolutionFunction conflictResolutionFunction) { + this(properties, new TypeUnifyingMergeFunction(conflictResolutionFunction)); + } + + CombinedJsonSchemaProperty(Iterable properties, + BiFunction, Map, Document> mergeFunction) { + + this.properties = properties; + this.mergeFunction = mergeFunction; + } + + @Override + public Set 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(); + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaProperty.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaProperty.java index 297c87c5e..ff91bf115 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaProperty.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaProperty.java @@ -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 properties) { + return new CombinedJsonSchemaProperty(properties); + } + /** * Builder for {@link IdentifiableJsonSchemaProperty}. */ diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MongoJsonSchema.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MongoJsonSchema.java index 3166ae4a4..51f106568 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MongoJsonSchema.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MongoJsonSchema.java @@ -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 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 sources, + ConflictResolutionFunction conflictResolutionFunction) { + + List 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 { + + @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}. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/TypeUnifyingMergeFunction.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/TypeUnifyingMergeFunction.java new file mode 100644 index 000000000..bcba80a91 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/TypeUnifyingMergeFunction.java @@ -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, Document> { + + private final ConflictResolutionFunction conflictResolutionFunction; + + public TypeUnifyingMergeFunction(ConflictResolutionFunction conflictResolutionFunction) { + this.conflictResolutionFunction = conflictResolutionFunction; + } + + @Override + public Document apply(Map a, Map b) { + return merge(SimplePath.root(), a, b); + } + + Document merge(SimplePath path, Map a, Map 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 a, Map 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 path; + + SimplePath(List path) { + this.path = path; + } + + static SimplePath root() { + return new SimplePath(Collections.emptyList()); + } + + static SimplePath of(List path) { + return new SimplePath(new ArrayList<>(path)); + } + + static SimplePath of(List path, String next) { + + List 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(); + } + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreatorUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreatorUnitTests.java index 30ff5b6ff..26accdfc6 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreatorUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MappingMongoJsonSchemaCreatorUnitTests.java @@ -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 genericValue; + Object objectValue; + } + + static class RootWithGenerics { + S sValue; + T tValue; + } + + static class SubWithFixedGeneric extends RootWithGenerics { + + } + + static class Concrete extends SubWithFixedGeneric { + + } + + 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; + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/TypeUnifyingMergeFunctionUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/TypeUnifyingMergeFunctionUnitTests.java new file mode 100644 index 000000000..b6bbd35d2 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/TypeUnifyingMergeFunctionUnitTests.java @@ -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 a = new LinkedHashMap<>(); + a.put("a", "a-value"); + Map 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 a = new LinkedHashMap<>(); + a.put("type", "string"); + Map 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 a = new LinkedHashMap<>(); + a.put("a", Collections.singletonMap("nested", "value")); + Map 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 a = new LinkedHashMap<>(); + a.put("nested", Collections.singletonMap("a", "a-value")); + Map 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 aValueCaptor = ArgumentCaptor.forClass(Object.class); + ArgumentCaptor 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 a = new LinkedHashMap<>(); + a.put("nested", Collections.singletonMap("a", "a-value")); + Map 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 aValueCaptor = ArgumentCaptor.forClass(Object.class); + ArgumentCaptor bValueCaptor = ArgumentCaptor.forClass(Object.class); + + when(crf.resolveConflict(any(), aValueCaptor.capture(), bValueCaptor.capture())).thenReturn(Resolution.SKIP); + + Map a = new LinkedHashMap<>(); + a.put("nested", Collections.singletonMap("a", "a-value")); + a.put("some", "value"); + Map b = new LinkedHashMap<>(); + b.put("nested", "b-value"); + + Document target = mergeFunction.apply(a, b); + assertThat(target).hasSize(1).containsEntry("some", "value"); + } +} diff --git a/src/main/asciidoc/reference/mongo-json-schema.adoc b/src/main/asciidoc/reference/mongo-json-schema.adoc index 36c85f6fb..bddf7bdee 100644 --- a/src/main/asciidoc/reference/mongo-json-schema.adoc +++ b/src/main/asciidoc/reference/mongo-json-schema.adoc @@ -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 `` 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