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