diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java index 53cb2348c..383ac1fae 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java @@ -18,9 +18,14 @@ package org.springframework.data.mongodb.core; import java.util.Optional; import org.springframework.data.mongodb.core.query.Collation; +import org.springframework.data.mongodb.core.schema.MongoJsonSchema; +import org.springframework.data.util.Optionals; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import com.mongodb.client.model.ValidationAction; +import com.mongodb.client.model.ValidationLevel; + /** * Provides a simple wrapper to encapsulate the variety of settings you can use when creating a collection. * @@ -34,6 +39,7 @@ public class CollectionOptions { private @Nullable Long size; private @Nullable Boolean capped; private @Nullable Collation collation; + private Validator validator; /** * Constructs a new CollectionOptions instance. @@ -46,16 +52,17 @@ public class CollectionOptions { */ @Deprecated public CollectionOptions(@Nullable Long size, @Nullable Long maxDocuments, @Nullable Boolean capped) { - this(size, maxDocuments, capped, null); + this(size, maxDocuments, capped, null, Validator.none()); } private CollectionOptions(@Nullable Long size, @Nullable Long maxDocuments, @Nullable Boolean capped, - @Nullable Collation collation) { + @Nullable Collation collation, Validator validator) { this.maxDocuments = maxDocuments; this.size = size; this.capped = capped; this.collation = collation; + this.validator = validator; } /** @@ -69,7 +76,7 @@ public class CollectionOptions { Assert.notNull(collation, "Collation must not be null!"); - return new CollectionOptions(null, null, null, collation); + return new CollectionOptions(null, null, null, collation, Validator.none()); } /** @@ -79,7 +86,7 @@ public class CollectionOptions { * @since 2.0 */ public static CollectionOptions empty() { - return new CollectionOptions(null, null, null, null); + return new CollectionOptions(null, null, null, null, Validator.none()); } /** @@ -90,7 +97,7 @@ public class CollectionOptions { * @since 2.0 */ public CollectionOptions capped() { - return new CollectionOptions(size, maxDocuments, true, collation); + return new CollectionOptions(size, maxDocuments, true, collation, validator); } /** @@ -101,7 +108,7 @@ public class CollectionOptions { * @since 2.0 */ public CollectionOptions maxDocuments(long maxDocuments) { - return new CollectionOptions(size, maxDocuments, capped, collation); + return new CollectionOptions(size, maxDocuments, capped, collation, validator); } /** @@ -112,7 +119,7 @@ public class CollectionOptions { * @since 2.0 */ public CollectionOptions size(long size) { - return new CollectionOptions(size, maxDocuments, capped, collation); + return new CollectionOptions(size, maxDocuments, capped, collation, validator); } /** @@ -123,7 +130,111 @@ public class CollectionOptions { * @since 2.0 */ public CollectionOptions collation(@Nullable Collation collation) { - return new CollectionOptions(size, maxDocuments, capped, collation); + return new CollectionOptions(size, maxDocuments, capped, collation, validator); + } + + /** + * Create new {@link CollectionOptions} with already given settings and {@code validator} set to given + * {@link MongoJsonSchema}. + * + * @param schema can be {@literal null}. + * @return new {@link CollectionOptions}. + * @since 2.1 + */ + public CollectionOptions schema(@Nullable MongoJsonSchema schema) { + return validation(new Validator(schema, validator.validationLevel, validator.validationAction)); + } + + /** + * Create new {@link CollectionOptions} with already given settings and {@code validationLevel} set to {@code off}. + * + * @return new {@link CollectionOptions}. + * @since 2.1 + */ + public CollectionOptions disableValidation() { + return schemaValidationLevel(ValidationLevel.OFF); + } + + /** + * Create new {@link CollectionOptions} with already given settings and {@code validationLevel} set to {@code strict}. + * + * @return new {@link CollectionOptions}. + * @since 2.1 + */ + public CollectionOptions strictValidation() { + return schemaValidationLevel(ValidationLevel.STRICT); + } + + /** + * Create new {@link CollectionOptions} with already given settings and {@code validationLevel} set to + * {@code moderate}. + * + * @return new {@link CollectionOptions}. + * @since 2.1 + */ + public CollectionOptions moderateValidation() { + return schemaValidationLevel(ValidationLevel.MODERATE); + } + + /** + * Create new {@link CollectionOptions} with already given settings and {@code validationAction} set to {@code warn}. + * + * @return new {@link CollectionOptions}. + * @since 2.1 + */ + public CollectionOptions warnOnValidationError() { + return schemaValidationAction(ValidationAction.WARN); + } + + /** + * Create new {@link CollectionOptions} with already given settings and {@code validationAction} set to {@code error}. + * + * @return new {@link CollectionOptions}. + * @since 2.1 + */ + public CollectionOptions failOnValidationError() { + return schemaValidationAction(ValidationAction.ERROR); + } + + /** + * Create new {@link CollectionOptions} with already given settings and {@code validationLevel} set given + * {@link ValidationLevel}. + * + * @param validationLevel must not be {@literal null}. + * @return new {@link CollectionOptions}. + * @since 2.1 + */ + public CollectionOptions schemaValidationLevel(ValidationLevel validationLevel) { + + Assert.notNull(validationLevel, "ValidationLevel must not be null!"); + return validation(new Validator(validator.schema, validationLevel, validator.validationAction)); + } + + /** + * Create new {@link CollectionOptions} with already given settings and {@code validationAction} set given + * {@link ValidationAction}. + * + * @param validationAction must not be {@literal null}. + * @return new {@link CollectionOptions}. + * @since 2.1 + */ + public CollectionOptions schemaValidationAction(ValidationAction validationAction) { + + Assert.notNull(validationAction, "ValidationAction must not be null!"); + return validation(new Validator(validator.schema, validator.validationLevel, validationAction)); + } + + /** + * Create new {@link CollectionOptions} with the given {@link Validator}. + * + * @param validator must not be {@literal null}. Use {@link Validator#none()} to remove validation. + * @return new {@link CollectionOptions}. + * @since 2.1 + */ + public CollectionOptions validation(Validator validator) { + + Assert.notNull(validator, "Validator must not be null!"); + return new CollectionOptions(size, maxDocuments, capped, collation, validator); } /** @@ -163,4 +274,83 @@ public class CollectionOptions { public Optional getCollation() { return Optional.ofNullable(collation); } + + /** + * Get the {@link MongoJsonSchema} for the collection. + * + * @return {@link Optional#empty()} if not set. + * @since 2.1 + */ + public Optional getValidator() { + return validator.isEmpty() ? Optional.empty() : Optional.of(validator); + } + + /** + * Encapsulation of Validator options. + * + * @author Christoph Strobl + * @since 2.1 + */ + public static class Validator { + + private static final Validator NONE = new Validator(null, null, null); + + private @Nullable MongoJsonSchema schema; + private @Nullable ValidationLevel validationLevel; + private @Nullable ValidationAction validationAction; + + private Validator(@Nullable MongoJsonSchema schema, @Nullable ValidationLevel validationLevel, + @Nullable ValidationAction validationAction) { + + this.schema = schema; + this.validationLevel = validationLevel; + this.validationAction = validationAction; + } + + /** + * Create an empty {@link Validator}. + * + * @return never {@literal null}. + */ + public static Validator none() { + return NONE; + } + + /** + * Get the {@code $jsonSchema} used for validation. + * + * @return {@link Optional#empty()} if not set. + */ + @Nullable + public Optional getSchema() { + return Optional.ofNullable(schema); + } + + /** + * Get the {@code validationLevel} to apply. + * + * @return {@link Optional#empty()} if not set. + */ + @Nullable + public Optional getValidationLevel() { + return Optional.ofNullable(validationLevel); + } + + /** + * Get the {@code validationAction} to perform. + * + * @return @return {@link Optional#empty()} if not set. + */ + @Nullable + public Optional getValidationAction() { + return Optional.ofNullable(validationAction); + } + + /** + * @return {@literal true} if no arguments set. + */ + boolean isEmpty() { + return !Optionals.isAnyPresent(getSchema(), getValidationAction(), getValidationLevel()); + } + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java index 338389d43..4878fe0ad 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java @@ -63,6 +63,7 @@ import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.mapping.model.ConvertingPropertyAccessor; import org.springframework.data.mongodb.MongoDbFactory; import org.springframework.data.mongodb.core.BulkOperations.BulkMode; +import org.springframework.data.mongodb.core.CollectionOptions.Validator; import org.springframework.data.mongodb.core.DefaultBulkOperations.BulkOperationContext; import org.springframework.data.mongodb.core.aggregation.Aggregation; import org.springframework.data.mongodb.core.aggregation.AggregationOperationContext; @@ -73,9 +74,11 @@ import org.springframework.data.mongodb.core.aggregation.TypeBasedAggregationOpe import org.springframework.data.mongodb.core.aggregation.TypedAggregation; import org.springframework.data.mongodb.core.convert.DbRefResolver; import org.springframework.data.mongodb.core.convert.DefaultDbRefResolver; +import org.springframework.data.mongodb.core.convert.JsonSchemaMapper; import org.springframework.data.mongodb.core.convert.MappingMongoConverter; import org.springframework.data.mongodb.core.convert.MongoConverter; import org.springframework.data.mongodb.core.convert.MongoCustomConversions; +import org.springframework.data.mongodb.core.convert.MongoJsonSchemaMapper; import org.springframework.data.mongodb.core.convert.MongoWriter; import org.springframework.data.mongodb.core.convert.QueryMapper; import org.springframework.data.mongodb.core.convert.UpdateMapper; @@ -133,14 +136,7 @@ import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoCursor; import com.mongodb.client.MongoDatabase; import com.mongodb.client.MongoIterable; -import com.mongodb.client.model.CountOptions; -import com.mongodb.client.model.CreateCollectionOptions; -import com.mongodb.client.model.DeleteOptions; -import com.mongodb.client.model.Filters; -import com.mongodb.client.model.FindOneAndDeleteOptions; -import com.mongodb.client.model.FindOneAndUpdateOptions; -import com.mongodb.client.model.ReturnDocument; -import com.mongodb.client.model.UpdateOptions; +import com.mongodb.client.model.*; import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.UpdateResult; import com.mongodb.util.JSONParseException; @@ -191,6 +187,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, private final PersistenceExceptionTranslator exceptionTranslator; private final QueryMapper queryMapper; private final UpdateMapper updateMapper; + private final JsonSchemaMapper schemaMapper; private final SpelAwareProxyProjectionFactory projectionFactory; private @Nullable WriteConcern writeConcern; @@ -235,6 +232,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, this.mongoConverter = mongoConverter == null ? getDefaultMongoConverter(mongoDbFactory) : mongoConverter; this.queryMapper = new QueryMapper(this.mongoConverter); this.updateMapper = new UpdateMapper(this.mongoConverter); + this.schemaMapper = new MongoJsonSchemaMapper(this.mongoConverter); this.projectionFactory = new SpelAwareProxyProjectionFactory(); // We always have a mapping context in the converter, whether it's a simple one or not @@ -545,7 +543,9 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, */ public MongoCollection createCollection(Class entityClass, @Nullable CollectionOptions collectionOptions) { - return createCollection(determineCollectionName(entityClass), collectionOptions); + + Assert.notNull(entityClass, "EntityClass must not be null!"); + return doCreateCollection(determineCollectionName(entityClass), convertToDocument(collectionOptions, entityClass)); } /* @@ -2227,6 +2227,21 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, co.collation(IndexConverters.fromDocument(collectionOptions.get("collation", Document.class))); } + if (collectionOptions.containsKey("validator")) { + + ValidationOptions options = new ValidationOptions(); + + if (collectionOptions.containsKey("validationLevel")) { + options.validationLevel(ValidationLevel.fromString(collectionOptions.getString("validationLevel"))); + } + if (collectionOptions.containsKey("validationAction")) { + options.validationAction(ValidationAction.fromString(collectionOptions.getString("validationAction"))); + } + + options.validator(collectionOptions.get("validator", Document.class)); + co.validationOptions(options); + } + db.createCollection(collectionName, co); MongoCollection coll = db.getCollection(collectionName, Document.class); @@ -2339,6 +2354,35 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, new ProjectingReadCallback<>(mongoConverter, sourceClass, targetClass, collectionName), collectionName); } + /** + * Convert given {@link CollectionOptions} to a document and take the domain type information into account when + * creating a mapped schema for validation.
+ * This method calls {@link #convertToDocument(CollectionOptions)} for backwards compatibility and potentially + * overwrites the validator with the mapped validator document. In the long run + * {@link #convertToDocument(CollectionOptions)} will be removed so that this one becomes the only source of truth. + * + * @param collectionOptions can be {@literal null}. + * @param targetType must not be {@literal null}. Use {@link Object} type instead. + * @return never {@literal null}. + * @since 2.1 + */ + protected Document convertToDocument(@Nullable CollectionOptions collectionOptions, Class targetType) { + + Document doc = convertToDocument(collectionOptions); + + if (collectionOptions.getValidator().isPresent()) { + Validator v = collectionOptions.getValidator().get(); + v.getSchema().ifPresent(val -> doc.put("validator", schemaMapper.mapSchema(val.toDocument(), targetType))); + } + return doc; + } + + /** + * @param collectionOptions can be {@literal null}. + * @return never {@literal null}. + * @deprecated since 2.1 in favor of {@link #convertToDocument(CollectionOptions, Class)}. + */ + @Deprecated protected Document convertToDocument(@Nullable CollectionOptions collectionOptions) { Document document = new Document(); @@ -2348,6 +2392,14 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, collectionOptions.getSize().ifPresent(val -> document.put("size", val)); collectionOptions.getMaxDocuments().ifPresent(val -> document.put("max", val)); collectionOptions.getCollation().ifPresent(val -> document.append("collation", val.toDocument())); + + if (collectionOptions.getValidator().isPresent()) { + Validator v = collectionOptions.getValidator().get(); + v.getValidationLevel().ifPresent(val -> document.append("validationLevel", val)); + v.getValidationAction().ifPresent(val -> document.append("validationAction", val)); + v.getSchema().ifPresent(val -> document.append("validator", + new MongoJsonSchemaMapper(getConverter()).mapSchema(val.toDocument(), Object.class))); + } } return document; } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/JsonSchemaMapper.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/JsonSchemaMapper.java new file mode 100644 index 000000000..1530221d3 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/JsonSchemaMapper.java @@ -0,0 +1,41 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.core.convert; + +import org.bson.Document; + +/** + * {@link JsonSchemaMapper} allows mapping a given {@link Document} containing a {@literal $jsonSchema} to the fields of + * a given domain type. The mapping considers {@link org.springframework.data.mongodb.core.mapping.Field} annotations + * and other Spring Data specifics. + * + * @author Christoph Strobl + * @since 2.1 + */ +public interface JsonSchemaMapper { + + /** + * Map the {@literal required} and {@literal properties} fields the given {@link Document} containing the + * {@literal $jsonSchema} against the given domain type.
+ * The source document remains untouched, fields that do not require mapping are simply copied over to the mapped + * instance. + * + * @param jsonSchema the {@link Document} holding the raw schema representation. Must not be {@literal null}. + * @param type the target type to map against. Must not be {@literal null}. + * @return a new {@link Document} containing the mapped {@literal $jsonSchema} never {@literal null}. + */ + Document mapSchema(Document jsonSchema, Class type); +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoJsonSchemaMapper.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoJsonSchemaMapper.java new file mode 100644 index 000000000..8b23b4258 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/MongoJsonSchemaMapper.java @@ -0,0 +1,162 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.core.convert; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +import org.bson.Document; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; +import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; +import org.springframework.data.mongodb.core.schema.JsonSchemaObject.Type; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * {@link JsonSchemaMapper} implementation using the conversion and mapping infrastructure for mapping fields to the + * provided domain type. + * + * @author Christoph Strobl + * @since 2.1 + */ +public class MongoJsonSchemaMapper implements JsonSchemaMapper { + + private static final String $JSON_SCHEMA = "$jsonSchema"; + private static final String REQUIRED_FIELD = "required"; + private static final String PROPERTIES_FIELD = "properties"; + private static final String ENUM_FIELD = "enum"; + + private final MappingContext, MongoPersistentProperty> mappingContext; + private final MongoConverter converter; + + /** + * Create a new {@link MongoJsonSchemaMapper} facilitating the given {@link MongoConverter}. + * + * @param converter must not be {@literal null}. + */ + public MongoJsonSchemaMapper(MongoConverter converter) { + + Assert.notNull(converter, "Converter must not be null!"); + + this.converter = converter; + this.mappingContext = converter.getMappingContext(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.convert.JsonSchemaMapper#mapSchema(org.springframework.data.mongodb.core.schema.MongoJsonSchema, java.lang.Class) + */ + public Document mapSchema(Document jsonSchema, Class type) { + + Assert.notNull(jsonSchema, "Schema must not be null!"); + Assert.notNull(type, "Type must not be null! Please consider Object.class."); + Assert.isTrue(jsonSchema.containsKey($JSON_SCHEMA), + () -> String.format("Document does not contain $jsonSchema field. Found %s.", jsonSchema)); + + if (Object.class.equals(type)) { + return new Document(jsonSchema); + } + + return new Document($JSON_SCHEMA, + mapSchemaObject(mappingContext.getPersistentEntity(type), jsonSchema.get($JSON_SCHEMA, Document.class))); + } + + private Document mapSchemaObject(@Nullable PersistentEntity entity, Document source) { + + Document sink = new Document(source); + + if (source.containsKey(REQUIRED_FIELD)) { + sink.replace(REQUIRED_FIELD, mapRequiredProperties(entity, source.get(REQUIRED_FIELD, Collection.class))); + } + + if (source.containsKey(PROPERTIES_FIELD)) { + sink.replace(PROPERTIES_FIELD, mapProperties(entity, source.get(PROPERTIES_FIELD, Document.class))); + } + + mapEnumValuesIfNecessary(sink); + + return sink; + } + + private Document mapProperties(@Nullable PersistentEntity entity, Document source) { + + Document sink = new Document(); + for (String fieldName : source.keySet()) { + + String mappedFieldName = getFieldName(entity, fieldName); + Document mappedProperty = mapProperty(entity, fieldName, source.get(fieldName, Document.class)); + + sink.append(mappedFieldName, mappedProperty); + } + return sink; + } + + private List mapRequiredProperties(@Nullable PersistentEntity entity, + Collection sourceFields) { + + return sourceFields.stream() /// + .map(fieldName -> getFieldName(entity, fieldName)) // + .collect(Collectors.toList()); + } + + private Document mapProperty(@Nullable PersistentEntity entity, String sourceFieldName, + Document source) { + + Document sink = new Document(source); + + if (entity != null && sink.containsKey(Type.objectType().representation())) { + + MongoPersistentProperty property = entity.getPersistentProperty(sourceFieldName); + if (property != null && property.isEntity()) { + sink = mapSchemaObject(mappingContext.getPersistentEntity(property.getActualType()), source); + } + } + + return mapEnumValuesIfNecessary(sink); + } + + private Document mapEnumValuesIfNecessary(Document source) { + + Document sink = new Document(source); + if (source.containsKey(ENUM_FIELD)) { + sink.replace(ENUM_FIELD, mapEnumValues(source.get(ENUM_FIELD, Iterable.class))); + } + return sink; + } + + private List mapEnumValues(Iterable values) { + + List converted = new ArrayList<>(); + for (Object val : values) { + converted.add(converter.convertToMongoType(val)); + } + return converted; + } + + private String getFieldName(@Nullable PersistentEntity entity, String sourceField) { + + if (entity == null) { + return sourceField; + } + + MongoPersistentProperty property = entity.getPersistentProperty(sourceField); + return property != null ? property.getFieldName() : sourceField; + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java index 74bb2e44f..4fc2f7af2 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java @@ -80,6 +80,7 @@ public class QueryMapper { private final MongoConverter converter; private final MappingContext, MongoPersistentProperty> mappingContext; private final MongoExampleMapper exampleMapper; + private final MongoJsonSchemaMapper schemaMapper; /** * Creates a new {@link QueryMapper} with the given {@link MongoConverter}. @@ -94,6 +95,7 @@ public class QueryMapper { this.converter = converter; this.mappingContext = converter.getMappingContext(); this.exampleMapper = new MongoExampleMapper(converter); + this.schemaMapper = new MongoJsonSchemaMapper(converter); } public Document getMappedObject(Bson query, Optional> entity) { @@ -272,6 +274,10 @@ public class QueryMapper { return exampleMapper.getMappedExample(keyword.> getValue(), entity); } + if (keyword.isJsonSchema()) { + return schemaMapper.mapSchema(new Document(keyword.getKey(), keyword.getValue()), entity.getType()); + } + return new Document(keyword.getKey(), convertSimpleOrDocument(keyword.getValue(), entity)); } @@ -599,6 +605,7 @@ public class QueryMapper { * Value object to capture a query keyword representation. * * @author Oliver Gierke + * @author Christoph Strobl */ static class Keyword { @@ -666,6 +673,16 @@ public class QueryMapper { public T getValue() { return (T) value; } + + /** + * Returns whether the current keyword indicates a {@literal $jsonSchema} object. + * + * @return {@literal true} if {@code key} equals {@literal $jsonSchema}. + * @since 2.1 + */ + public boolean isJsonSchema() { + return "$jsonSchema".equalsIgnoreCase(key); + } } /** diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/Criteria.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/Criteria.java index 650d18502..df8336285 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/Criteria.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/Criteria.java @@ -24,6 +24,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map.Entry; import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.bson.BSON; import org.bson.BsonRegularExpression; @@ -35,6 +36,9 @@ import org.springframework.data.geo.Shape; import org.springframework.data.mongodb.InvalidMongoDbApiUsageException; import org.springframework.data.mongodb.core.geo.GeoJson; import org.springframework.data.mongodb.core.geo.Sphere; +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.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; @@ -115,6 +119,20 @@ public class Criteria implements CriteriaDefinition { return new Criteria().alike(example); } + /** + * Static factory method to create a {@link Criteria} matching documents against a given structure defined by the + * {@link MongoJsonSchema} using ({@code $jsonSchema}) operator. + * + * @param schema must not be {@literal null}. + * @return this + * @since 2.1 + * @see MongoDB Query operator: + * $jsonSchema + */ + public static Criteria matchingDocumentStructure(MongoJsonSchema schema) { + return new Criteria().andDocumentStructureMatches(schema); + } + /** * Static factory method to create a Criteria using the provided key * @@ -335,6 +353,23 @@ public class Criteria implements CriteriaDefinition { return this; } + /** + * Creates a criterion using the {@literal $type} operator. + * + * @param type must not be {@literal null}. + * @return this + * @since 2.1 + * @see MongoDB Query operator: $type + */ + public Criteria type(Type... types) { + + Assert.notNull(types, "Types must not be null!"); + Assert.noNullElements(types, "Types must not contain null."); + + criteria.put("$type", Arrays.asList(types).stream().map(Type::value).collect(Collectors.toList())); + return this; + } + /** * Creates a criterion using the {@literal $not} meta operator which affects the clause directly following * @@ -563,6 +598,30 @@ public class Criteria implements CriteriaDefinition { return this; } + /** + * Creates a criterion ({@code $jsonSchema}) matching documents against a given structure defined by the + * {@link MongoJsonSchema}.
+ * NOTE: {@code $jsonSchema} cannot be used on field/property level but defines the whole document + * structure. Please use + * {@link org.springframework.data.mongodb.core.schema.MongoJsonSchema.MongoJsonSchemaBuilder#properties(JsonSchemaProperty...)} + * to specify nested fields or query them using the {@link #type(Type...) $type} operator. + * + * @param schema must not be {@literal null}. + * @return this + * @since 2.1 + * @see MongoDB Query operator: + * $jsonSchema + */ + public Criteria andDocumentStructureMatches(MongoJsonSchema schema) { + + Assert.notNull(schema, "Schema must not be null!"); + + Criteria schemaCriteria = new Criteria(); + schemaCriteria.criteria.putAll(schema.toDocument()); + + return registerCriteriaChainElement(schemaCriteria); + } + /** * Creates an 'or' criteria using the $or operator for all of the provided criteria *

diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/IdentifiableJsonSchemaProperty.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/IdentifiableJsonSchemaProperty.java new file mode 100644 index 000000000..50b5804f3 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/IdentifiableJsonSchemaProperty.java @@ -0,0 +1,882 @@ +/* + * Copyright 2018 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 + * + * http://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.Arrays; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.bson.Document; +import org.springframework.data.domain.Range; +import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.ArrayJsonSchemaObject; +import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.BooleanJsonSchemaObject; +import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.NullJsonSchemaObject; +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.TypedJsonSchemaObject.StringJsonSchemaObject; +import org.springframework.util.Assert; + +/** + * {@link JsonSchemaProperty} implementation. + * + * @author Christoph Strobl + * @since 2.1 + */ +public class IdentifiableJsonSchemaProperty implements JsonSchemaProperty { + + protected String identifier; + protected T jsonSchemaObjectDelegate; + + public IdentifiableJsonSchemaProperty(String identifier, T jsonSchemaObject) { + + Assert.notNull(identifier, "Identifier must not be null!"); + Assert.notNull(jsonSchemaObject, "JsonSchemaObject must not be null!"); + + this.identifier = identifier; + this.jsonSchemaObjectDelegate = jsonSchemaObject; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.JsonSchemaProperty#getIdentifier() + */ + @Override + public String getIdentifier() { + return identifier; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.JsonSchemaObject#toDocument() + */ + @Override + public Document toDocument() { + return new Document(identifier, jsonSchemaObjectDelegate.toDocument()); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.JsonSchemaObject#getTypes() + */ + @Override + public Set getTypes() { + return jsonSchemaObjectDelegate.getTypes(); + } + + /** + * Convenience {@link JsonSchemaProperty} implementation without a {@code type} property. + * + * @author Christoph Strobl + * @since 2.1 + */ + public static class UntypedJsonSchemaProperty extends IdentifiableJsonSchemaProperty { + + public UntypedJsonSchemaProperty(String identifier, UntypedJsonSchemaObject jsonSchemaObject) { + super(identifier, jsonSchemaObject); + } + + /** + * @param possibleValues must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#possibleValues(Collection) + */ + public UntypedJsonSchemaProperty possibleValues(Object... possibleValues) { + return possibleValues(Arrays.asList(possibleValues)); + } + + /** + * @param allOf must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#allOf(Collection) + */ + public UntypedJsonSchemaProperty allOf(JsonSchemaObject... allOf) { + return allOf(new LinkedHashSet<>(Arrays.asList(allOf))); + } + + /** + * @param anyOf must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#anyOf(Collection) + */ + public UntypedJsonSchemaProperty anyOf(JsonSchemaObject... anyOf) { + return anyOf(new LinkedHashSet<>(Arrays.asList(anyOf))); + } + + /** + * @param oneOf must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#oneOf(Collection) + */ + public UntypedJsonSchemaProperty oneOf(JsonSchemaObject... oneOf) { + return oneOf(new LinkedHashSet<>(Arrays.asList(oneOf))); + } + + /** + * @param possibleValues must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#possibleValues(Collection) + */ + public UntypedJsonSchemaProperty possibleValues(Collection possibleValues) { + return new UntypedJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.possibleValues(possibleValues)); + } + + /** + * @param allOf must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#allOf(Collection) + */ + public UntypedJsonSchemaProperty allOf(Collection allOf) { + return new UntypedJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.allOf(allOf)); + } + + /** + * @param anyOf must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#anyOf(Collection) + */ + public UntypedJsonSchemaProperty anyOf(Collection anyOf) { + return new UntypedJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.anyOf(anyOf)); + } + + /** + * @param oneOf must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#oneOf(Collection) + */ + public UntypedJsonSchemaProperty oneOf(Collection oneOf) { + return new UntypedJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.oneOf(oneOf)); + } + + /** + * @param notMatch must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#notMatch(JsonSchemaObject) + */ + public UntypedJsonSchemaProperty notMatch(JsonSchemaObject notMatch) { + return new UntypedJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.notMatch(notMatch)); + } + + /** + * @param description must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#description(String) + */ + public UntypedJsonSchemaProperty description(String description) { + return new UntypedJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.description(description)); + } + + /** + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#generateDescription() + */ + public UntypedJsonSchemaProperty generatedDescription() { + return new UntypedJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.generatedDescription()); + } + } + + /** + * Convenience {@link JsonSchemaProperty} implementation for a {@code type : 'string'} property. + * + * @author Christoph Strobl + * @since 2.1 + */ + public static class StringJsonSchemaProperty extends IdentifiableJsonSchemaProperty { + + /** + * @param identifier identifier the {@literal property} name or {@literal patternProperty} regex. Must not be + * {@literal null} nor {@literal empty}. + * @param schemaObject must not be {@literal null}. + */ + public StringJsonSchemaProperty(String identifier, StringJsonSchemaObject schemaObject) { + super(identifier, schemaObject); + } + + /** + * @param length + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#minLength(int) + */ + public StringJsonSchemaProperty minLength(int length) { + return new StringJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.minLength(length)); + } + + /** + * @param length + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#maxLength(int) + */ + public StringJsonSchemaProperty maxLength(int length) { + return new StringJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.maxLength(length)); + } + + /** + * @param pattern must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#matching(String) + */ + public StringJsonSchemaProperty matching(String pattern) { + return new StringJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.matching(pattern)); + } + + /** + * @param possibleValues must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#possibleValues(Collection) + */ + public StringJsonSchemaProperty possibleValues(String... possibleValues) { + return possibleValues(Arrays.asList(possibleValues)); + } + + /** + * @param allOf must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#allOf(Collection) + */ + public StringJsonSchemaProperty allOf(JsonSchemaObject... allOf) { + return allOf(new LinkedHashSet<>(Arrays.asList(allOf))); + } + + /** + * @param anyOf must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#anyOf(Collection) + */ + public StringJsonSchemaProperty anyOf(JsonSchemaObject... anyOf) { + return anyOf(new LinkedHashSet<>(Arrays.asList(anyOf))); + } + + /** + * @param oneOf must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#oneOf(Collection) + */ + public StringJsonSchemaProperty oneOf(JsonSchemaObject... oneOf) { + return oneOf(new LinkedHashSet<>(Arrays.asList(oneOf))); + } + + /** + * @param possibleValues must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#possibleValues(Collection) + */ + public StringJsonSchemaProperty possibleValues(Collection possibleValues) { + return new StringJsonSchemaProperty(identifier, + jsonSchemaObjectDelegate.possibleValues((Collection) possibleValues)); + } + + /** + * @param allOf must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#allOf(Collection) + */ + public StringJsonSchemaProperty allOf(Collection allOf) { + return new StringJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.allOf(allOf)); + } + + /** + * @param anyOf must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#anyOf(Collection) + */ + public StringJsonSchemaProperty anyOf(Collection anyOf) { + return new StringJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.anyOf(anyOf)); + } + + /** + * @param oneOf must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#oneOf(Collection) + */ + public StringJsonSchemaProperty oneOf(Collection oneOf) { + return new StringJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.oneOf(oneOf)); + } + + /** + * @param notMatch must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#notMatch(JsonSchemaObject) + */ + public StringJsonSchemaProperty notMatch(JsonSchemaObject notMatch) { + return new StringJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.notMatch(notMatch)); + } + + /** + * @param description must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#description(String) + */ + public StringJsonSchemaProperty description(String description) { + return new StringJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.description(description)); + } + + /** + * @return new instance of {@link StringJsonSchemaProperty}. + * @see StringJsonSchemaObject#generateDescription() + */ + public StringJsonSchemaProperty generatedDescription() { + return new StringJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.generatedDescription()); + } + } + + /** + * Convenience {@link JsonSchemaProperty} implementation for a {@code type : 'object'} property. + * + * @author Christoph Strobl + * @since 2.1 + */ + public static class ObjectJsonSchemaProperty extends IdentifiableJsonSchemaProperty { + + /** + * @param identifier identifier the {@literal property} name or {@literal patternProperty} regex. Must not be + * {@literal null} nor {@literal empty}. + * @param schemaObject must not be {@literal null}. + */ + public ObjectJsonSchemaProperty(String identifier, ObjectJsonSchemaObject schemaObject) { + super(identifier, schemaObject); + } + + /** + * @param range must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#nrProperties + */ + public ObjectJsonSchemaProperty nrProperties(Range range) { + return new ObjectJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.nrProperties(range)); + } + + /** + * @param nrProperties must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#minNrProperties(int) + */ + public ObjectJsonSchemaProperty minNrProperties(int nrProperties) { + return new ObjectJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.minNrProperties(nrProperties)); + } + + /** + * @param nrProperties must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#maxNrProperties(int) + */ + public ObjectJsonSchemaProperty maxNrProperties(int nrProperties) { + return new ObjectJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.maxNrProperties(nrProperties)); + } + + /** + * @param properties must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#required(String...) + */ + public ObjectJsonSchemaProperty required(String... properties) { + return new ObjectJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.required(properties)); + } + + /** + * @param additionalPropertiesAllowed + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#additionalProperties(boolean) + */ + public ObjectJsonSchemaProperty additionalProperties(boolean additionalPropertiesAllowed) { + return new ObjectJsonSchemaProperty(identifier, + jsonSchemaObjectDelegate.additionalProperties(additionalPropertiesAllowed)); + } + + /** + * @param additionalProperties must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#additionalProperties(ObjectJsonSchemaObject) + */ + public ObjectJsonSchemaProperty additionalProperties(ObjectJsonSchemaObject additionalProperties) { + return new ObjectJsonSchemaProperty(identifier, + jsonSchemaObjectDelegate.additionalProperties(additionalProperties)); + } + + /** + * @param properties must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#properties(JsonSchemaProperty...) + */ + public ObjectJsonSchemaProperty properties(JsonSchemaProperty... properties) { + return new ObjectJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.properties(properties)); + } + + /** + * @param possibleValues must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#possibleValues(Collection) + */ + public ObjectJsonSchemaProperty possibleValues(Object... possibleValues) { + return possibleValues(Arrays.asList(possibleValues)); + } + + /** + * @param allOf must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#allOf(Collection) + */ + public ObjectJsonSchemaProperty allOf(JsonSchemaObject... allOf) { + return allOf(new LinkedHashSet<>(Arrays.asList(allOf))); + } + + /** + * @param anyOf must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#anyOf(Collection) + */ + public ObjectJsonSchemaProperty anyOf(JsonSchemaObject... anyOf) { + return anyOf(new LinkedHashSet<>(Arrays.asList(anyOf))); + } + + /** + * @param oneOf must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#oneOf(Collection) + */ + public ObjectJsonSchemaProperty oneOf(JsonSchemaObject... oneOf) { + return oneOf(new LinkedHashSet<>(Arrays.asList(oneOf))); + } + + /** + * @param possibleValues must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#possibleValues(Collection) + */ + public ObjectJsonSchemaProperty possibleValues(Collection possibleValues) { + return new ObjectJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.possibleValues(possibleValues)); + } + + /** + * @param allOf must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#allOf(Collection) + */ + public ObjectJsonSchemaProperty allOf(Collection allOf) { + return new ObjectJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.allOf(allOf)); + } + + /** + * @param anyOf must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#anyOf(Collection) + */ + public ObjectJsonSchemaProperty anyOf(Collection anyOf) { + return new ObjectJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.anyOf(anyOf)); + } + + /** + * @param oneOf must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#oneOf(Collection) + */ + public ObjectJsonSchemaProperty oneOf(Collection oneOf) { + return new ObjectJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.oneOf(oneOf)); + } + + /** + * @param notMatch must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#notMatch(JsonSchemaObject) + */ + public ObjectJsonSchemaProperty notMatch(JsonSchemaObject notMatch) { + return new ObjectJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.notMatch(notMatch)); + } + + /** + * @param description must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#description(String) + */ + public ObjectJsonSchemaProperty description(String description) { + return new ObjectJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.description(description)); + } + + /** + * @return new instance of {@link ObjectJsonSchemaProperty}. + * @see ObjectJsonSchemaObject#generateDescription() + */ + public ObjectJsonSchemaProperty generatedDescription() { + return new ObjectJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.generatedDescription()); + } + } + + /** + * Convenience {@link JsonSchemaProperty} implementation for a {@code type : 'number'} property. + * + * @author Christoph Strobl + * @since 2.1 + */ + public static class NumericJsonSchemaProperty extends IdentifiableJsonSchemaProperty { + + /** + * @param identifier identifier the {@literal property} name or {@literal patternProperty} regex. Must not be + * {@literal null} nor {@literal empty}. + * @param schemaObject must not be {@literal null}. + */ + public NumericJsonSchemaProperty(String identifier, NumericJsonSchemaObject schemaObject) { + super(identifier, schemaObject); + } + + /** + * @param value must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#multipleOf + */ + public NumericJsonSchemaProperty multipleOf(Number value) { + return new NumericJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.multipleOf(value)); + } + + /** + * @param range must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#within(Range) + */ + public NumericJsonSchemaProperty within(Range range) { + return new NumericJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.within(range)); + } + + /** + * @param min must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#gt(Number) + */ + public NumericJsonSchemaProperty gt(Number min) { + return new NumericJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.gt(min)); + } + + /** + * @param min must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#gte(Number) + */ + public NumericJsonSchemaProperty gte(Number min) { + return new NumericJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.gte(min)); + } + + /** + * @param max must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#lt(Number) + */ + public NumericJsonSchemaProperty lt(Number max) { + return new NumericJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.lt(max)); + } + + /** + * @param max must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#lte(Number) + */ + public NumericJsonSchemaProperty lte(Number max) { + return new NumericJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.lte(max)); + } + + /** + * @param possibleValues must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#possibleValues(Collection) + */ + public NumericJsonSchemaProperty possibleValues(Number... possibleValues) { + return possibleValues(new LinkedHashSet<>(Arrays.asList(possibleValues))); + } + + /** + * @param allOf must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#allOf(Collection) + */ + public NumericJsonSchemaProperty allOf(JsonSchemaObject... allOf) { + return allOf(Arrays.asList(allOf)); + } + + /** + * @param anyOf must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#anyOf(Collection) + */ + public NumericJsonSchemaProperty anyOf(JsonSchemaObject... anyOf) { + return anyOf(new LinkedHashSet<>(Arrays.asList(anyOf))); + } + + /** + * @param oneOf must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#oneOf(Collection) + */ + public NumericJsonSchemaProperty oneOf(JsonSchemaObject... oneOf) { + return oneOf(new LinkedHashSet<>(Arrays.asList(oneOf))); + } + + /** + * @param possibleValues must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#possibleValues(Collection) + */ + public NumericJsonSchemaProperty possibleValues(Collection possibleValues) { + return new NumericJsonSchemaProperty(identifier, + jsonSchemaObjectDelegate.possibleValues((Collection) possibleValues)); + } + + /** + * @param allOf must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#allOf(Collection) + */ + public NumericJsonSchemaProperty allOf(Collection allOf) { + return new NumericJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.allOf(allOf)); + } + + /** + * @param anyOf must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#anyOf(Collection) + */ + public NumericJsonSchemaProperty anyOf(Collection anyOf) { + return new NumericJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.anyOf(anyOf)); + } + + /** + * @param oneOf must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#oneOf(Collection) + */ + public NumericJsonSchemaProperty oneOf(Collection oneOf) { + return new NumericJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.oneOf(oneOf)); + } + + /** + * @param notMatch must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#notMatch(JsonSchemaObject) + */ + public NumericJsonSchemaProperty notMatch(JsonSchemaObject notMatch) { + return new NumericJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.notMatch(notMatch)); + } + + /** + * @param description must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#description(String) + */ + public NumericJsonSchemaProperty description(String description) { + return new NumericJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.description(description)); + } + + /** + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NumericJsonSchemaObject#generateDescription() + */ + public NumericJsonSchemaProperty generatedDescription() { + return new NumericJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.generatedDescription()); + } + } + + /** + * Convenience {@link JsonSchemaProperty} implementation for a {@code type : 'array'} property. + * + * @author Christoph Strobl + * @since 2.1 + */ + public static class ArrayJsonSchemaProperty extends IdentifiableJsonSchemaProperty { + + /** + * @param identifier identifier the {@literal property} name or {@literal patternProperty} regex. Must not be + * {@literal null} nor {@literal empty}. + * @param schemaObject must not be {@literal null}. + */ + public ArrayJsonSchemaProperty(String identifier, ArrayJsonSchemaObject schemaObject) { + super(identifier, schemaObject); + } + + /** + * @param uniqueItems + * @return new instance of {@link ArrayJsonSchemaProperty}. + * @see ArrayJsonSchemaObject#uniqueItems(boolean) + */ + public ArrayJsonSchemaProperty uniqueItems(boolean uniqueItems) { + return new ArrayJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.uniqueItems(uniqueItems)); + } + + /** + * @param range must not be {@literal null}. + * @return new instance of {@link ArrayJsonSchemaProperty}. + * @see ArrayJsonSchemaObject#range(Range) + */ + public ArrayJsonSchemaProperty range(Range range) { + return new ArrayJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.range(range)); + } + + /** + * @param items must not be {@literal null}. + * @return new instance of {@link ArrayJsonSchemaProperty}. + * @see ArrayJsonSchemaObject#items(Collection) + */ + public ArrayJsonSchemaProperty items(Collection items) { + return new ArrayJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.items(items)); + } + + /** + * @param possibleValues must not be {@literal null}. + * @return new instance of {@link ArrayJsonSchemaProperty}. + * @see ArrayJsonSchemaObject#possibleValues(Collection) + */ + public ArrayJsonSchemaProperty possibleValues(Object... possibleValues) { + return possibleValues(new LinkedHashSet<>(Arrays.asList(possibleValues))); + } + + /** + * @param allOf must not be {@literal null}. + * @return new instance of {@link ArrayJsonSchemaProperty}. + * @see ArrayJsonSchemaObject#allOf(Collection) + */ + public ArrayJsonSchemaProperty allOf(JsonSchemaObject... allOf) { + return allOf(new LinkedHashSet<>(Arrays.asList(allOf))); + } + + /** + * @param anyOf must not be {@literal null}. + * @return new instance of {@link ArrayJsonSchemaProperty}. + * @see ArrayJsonSchemaObject#anyOf(Collection) + */ + public ArrayJsonSchemaProperty anyOf(JsonSchemaObject... anyOf) { + return anyOf(new LinkedHashSet<>(Arrays.asList(anyOf))); + } + + /** + * @param oneOf must not be {@literal null}. + * @return new instance of {@link ArrayJsonSchemaProperty}. + * @see ArrayJsonSchemaObject#oneOf(Collection) + */ + public ArrayJsonSchemaProperty oneOf(JsonSchemaObject... oneOf) { + return oneOf(new LinkedHashSet<>(Arrays.asList(oneOf))); + } + + /** + * @param possibleValues must not be {@literal null}. + * @return new instance of {@link ArrayJsonSchemaProperty}. + * @see ArrayJsonSchemaObject#possibleValues(Collection) + */ + public ArrayJsonSchemaProperty possibleValues(Collection possibleValues) { + return new ArrayJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.possibleValues(possibleValues)); + } + + /** + * @param allOf must not be {@literal null}. + * @return new instance of {@link ArrayJsonSchemaProperty}. + * @see ArrayJsonSchemaObject#allOf(Collection) + */ + public ArrayJsonSchemaProperty allOf(Collection allOf) { + return new ArrayJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.allOf(allOf)); + } + + /** + * @param anyOf must not be {@literal null}. + * @return new instance of {@link ArrayJsonSchemaProperty}. + * @see ArrayJsonSchemaObject#anyOf(Collection) + */ + public ArrayJsonSchemaProperty anyOf(Collection anyOf) { + return new ArrayJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.anyOf(anyOf)); + } + + /** + * @param oneOf must not be {@literal null}. + * @return new instance of {@link ArrayJsonSchemaProperty}. + * @see ArrayJsonSchemaObject#oneOf(Collection) + */ + public ArrayJsonSchemaProperty oneOf(Collection oneOf) { + return new ArrayJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.oneOf(oneOf)); + } + + /** + * @param notMatch must not be {@literal null}. + * @return new instance of {@link ArrayJsonSchemaProperty}. + * @see ArrayJsonSchemaObject#notMatch(JsonSchemaObject) + */ + public ArrayJsonSchemaProperty notMatch(JsonSchemaObject notMatch) { + return new ArrayJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.notMatch(notMatch)); + } + + /** + * @param description must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see ArrayJsonSchemaObjecty#description(String) + */ + public ArrayJsonSchemaProperty description(String description) { + return new ArrayJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.description(description)); + } + + /** + * @return new instance of {@link ArrayJsonSchemaProperty}. + * @see ArrayJsonSchemaObject#generateDescription() + */ + public ArrayJsonSchemaProperty generatedDescription() { + return new ArrayJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.generatedDescription()); + } + } + + /** + * Convenience {@link JsonSchemaProperty} implementation for a {@code type : 'boolean'} property. + * + * @author Christoph Strobl + * @since 2.1 + */ + public static class BooleanJsonSchemaProperty extends IdentifiableJsonSchemaProperty { + + public BooleanJsonSchemaProperty(String identifier, BooleanJsonSchemaObject schemaObject) { + super(identifier, schemaObject); + } + + /** + * @param description must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see BooleanJsonSchemaObject#description(String) + */ + public BooleanJsonSchemaProperty description(String description) { + return new BooleanJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.description(description)); + } + + /** + * @return new instance of {@link BooleanJsonSchemaProperty}. + * @see BooleanJsonSchemaObject#generateDescription() + */ + public BooleanJsonSchemaProperty generatedDescription() { + return new BooleanJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.generatedDescription()); + } + } + + /** + * Convenience {@link JsonSchemaProperty} implementation for a {@code type : 'null'} property. + * + * @author Christoph Strobl + * @since 2.1 + */ + public static class NullJsonSchemaProperty extends IdentifiableJsonSchemaProperty { + + public NullJsonSchemaProperty(String identifier, NullJsonSchemaObject schemaObject) { + super(identifier, schemaObject); + } + + /** + * @param description must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaProperty}. + * @see NullJsonSchemaObject#description(String) + */ + public NullJsonSchemaProperty description(String description) { + return new NullJsonSchemaProperty(identifier, jsonSchemaObjectDelegate.description(description)); + } + } + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaObject.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaObject.java new file mode 100644 index 000000000..3aaa7826f --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaObject.java @@ -0,0 +1,427 @@ +/* + * Copyright 2018 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 + * + * http://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 lombok.RequiredArgsConstructor; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Date; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Pattern; + +import org.bson.Document; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.ArrayJsonSchemaObject; +import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.BooleanJsonSchemaObject; +import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.NullJsonSchemaObject; +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.TypedJsonSchemaObject.StringJsonSchemaObject; +import org.springframework.lang.Nullable; +import org.springframework.util.ClassUtils; + +/** + * @author Christoph Strobl + * @since 2.1 + */ +public interface JsonSchemaObject { + + /** + * Get the set of types defined for this schema element.
+ * The {@link Set} is likely to contain only one element in most cases. + * + * @return never {@literal null}. + */ + Set getTypes(); + + /** + * Get the MongoDB specific representation.
+ * The Document may contain fields (eg. like {@literal bsonType}) not contained in the JsonSchema specification. It + * may also contain types not directly processable by the MongoDB java driver. Make sure to run the produced + * {@link Document} through the mapping infrastructure. + * + * @return never {@literal null}. + */ + Document toDocument(); + + /** + * Create a new {@link JsonSchemaObject} of {@code type : 'object'}. + * + * @return never {@literal null}. + */ + static ObjectJsonSchemaObject object() { + return new ObjectJsonSchemaObject(); + } + + /** + * Create a new {@link JsonSchemaObject} of {@code type : 'string'}. + * + * @return never {@literal null}. + */ + static StringJsonSchemaObject string() { + return new StringJsonSchemaObject(); + } + + /** + * Create a new {@link JsonSchemaObject} of {@code type : 'number'}. + * + * @return never {@literal null}. + */ + static NumericJsonSchemaObject number() { + return new NumericJsonSchemaObject(); + } + + /** + * Create a new {@link JsonSchemaObject} of {@code type : 'array'}. + * + * @return never {@literal null}. + */ + static ArrayJsonSchemaObject array() { + return new ArrayJsonSchemaObject(); + } + + /** + * Create a new {@link JsonSchemaObject} of {@code type : 'boolean'}. + * + * @return never {@literal null}. + */ + static BooleanJsonSchemaObject bool() { + return new BooleanJsonSchemaObject(); + } + + /** + * Create a new {@link JsonSchemaObject} of {@code type : 'null'}. + * + * @return never {@literal null}. + */ + static NullJsonSchemaObject nil() { + return new NullJsonSchemaObject(); + } + + /** + * Create a new {@link JsonSchemaObject} of given {@link Type}. + * + * @return never {@literal null}. + */ + static TypedJsonSchemaObject of(Type type) { + return TypedJsonSchemaObject.of(type); + } + + /** + * Create a new {@link UntypedJsonSchemaObject}. + * + * @return never {@literal null}. + */ + static UntypedJsonSchemaObject untyped() { + return new UntypedJsonSchemaObject(null, null, false); + } + + /** + * Create a new {@link JsonSchemaObject} matching the given {@code type}. + * + * @return never {@literal null}. + */ + static TypedJsonSchemaObject of(@Nullable Class type) { + + if (type == null) { + return of(Type.nullType()); + } + + if (type.isArray()) { + + if (type.equals(byte[].class)) { + return of(Type.binaryType()); + } + + return of(Type.arrayType()); + } + + if (type.equals(Object.class)) { + return of(Type.objectType()); + } + + if (type.equals(ObjectId.class)) { + return of(Type.objectIdType()); + } + + if (ClassUtils.isAssignable(String.class, type)) { + return of(Type.stringType()); + } + + if (ClassUtils.isAssignable(Date.class, type)) { + return of(Type.dateType()); + } + + if (ClassUtils.isAssignable(Pattern.class, type)) { + return of(Type.regexType()); + } + + if (ClassUtils.isAssignable(Boolean.class, type)) { + return of(Type.booleanType()); + } + + if (ClassUtils.isAssignable(Number.class, type)) { + + if (type.equals(Long.class)) { + return of(Type.longType()); + } + + if (type.equals(Float.class)) { + return of(Type.doubleType()); + } + + if (type.equals(Double.class)) { + return of(Type.doubleType()); + } + + if (type.equals(Integer.class)) { + return of(Type.intType()); + } + + if (type.equals(BigDecimal.class)) { + return of(Type.bigDecimalType()); + } + + return of(Type.numberType()); + } + + throw new IllegalArgumentException(String.format("No json schema type found for %s.", type)); + } + + /** + * Type represents either a json schema {@literal type} or a MongoDB specific {@literal bsonType}. + * + * @author Christoph Strobl + * @since 2.1 + */ + interface Type { + + // BSON TYPES + final Type OBJECT_ID = bsonTypeOf("objectId"); + final Type REGULAR_EXPRESSION = bsonTypeOf("regex"); + final Type DOUBLE = bsonTypeOf("double"); + final Type BINARY_DATA = bsonTypeOf("binData"); + final Type DATE = bsonTypeOf("date"); + final Type JAVA_SCRIPT = bsonTypeOf("javascript"); + final Type INT_32 = bsonTypeOf("int"); + final Type INT_64 = bsonTypeOf("long"); + final Type DECIMAL_128 = bsonTypeOf("decimal"); + final Type TIMESTAMP = bsonTypeOf("timestamp"); + + final Set BSON_TYPES = new HashSet<>(Arrays.asList(OBJECT_ID, REGULAR_EXPRESSION, DOUBLE, BINARY_DATA, DATE, + JAVA_SCRIPT, INT_32, INT_64, DECIMAL_128, TIMESTAMP)); + + // JSON SCHEMA TYPES + final Type OBJECT = jsonTypeOf("object"); + final Type ARRAY = jsonTypeOf("array"); + final Type NUMBER = jsonTypeOf("number"); + final Type BOOLEAN = jsonTypeOf("boolean"); + final Type STRING = jsonTypeOf("string"); + final Type NULL = jsonTypeOf("null"); + + final Set JSON_TYPES = new HashSet<>(Arrays.asList(OBJECT, ARRAY, NUMBER, BOOLEAN, STRING, NULL)); + + /** + * @return a constant {@link Type} representing {@code bsonType : 'objectId' }. + */ + static Type objectIdType() { + return OBJECT_ID; + } + + /** + * @return a constant {@link Type} representing {@code bsonType : 'regex' }. + */ + static Type regexType() { + return REGULAR_EXPRESSION; + } + + /** + * @return a constant {@link Type} representing {@code bsonType : 'double' }. + */ + static Type doubleType() { + return DOUBLE; + } + + /** + * @return a constant {@link Type} representing {@code bsonType : 'binData' }. + */ + static Type binaryType() { + return BINARY_DATA; + } + + /** + * @return a constant {@link Type} representing {@code bsonType : 'date' }. + */ + static Type dateType() { + return DATE; + } + + /** + * @return a constant {@link Type} representing {@code bsonType : 'javascript' }. + */ + static Type javascriptType() { + return JAVA_SCRIPT; + } + + /** + * @return a constant {@link Type} representing {@code bsonType : 'int' }. + */ + static Type intType() { + return INT_32; + } + + /** + * @return a constant {@link Type} representing {@code bsonType : 'long' }. + */ + static Type longType() { + return INT_64; + } + + /** + * @return a constant {@link Type} representing {@code bsonType : 'decimal128' }. + */ + static Type bigDecimalType() { + return DECIMAL_128; + } + + /** + * @return a constant {@link Type} representing {@code bsonType : 'timestamp' }. + */ + static Type timestampType() { + return TIMESTAMP; + } + + /** + * @return a constant {@link Type} representing {@code type : 'object' }. + */ + static Type objectType() { + return OBJECT; + } + + /** + * @return a constant {@link Type} representing {@code type : 'array' }. + */ + static Type arrayType() { + return ARRAY; + } + + /** + * @return a constant {@link Type} representing {@code type : 'number' }. + */ + static Type numberType() { + return NUMBER; + } + + /** + * @return a constant {@link Type} representing {@code type : 'boolean' }. + */ + static Type booleanType() { + return BOOLEAN; + } + + /** + * @return a constant {@link Type} representing {@code type : 'string' }. + */ + static Type stringType() { + return STRING; + } + + /** + * @return a constant {@link Type} representing {@code type : 'null' }. + */ + static Type nullType() { + return NULL; + } + + /** + * @return new {@link Type} representing the given {@code bsonType}. + */ + static Type bsonTypeOf(String name) { + return new BsonType(name); + } + + /** + * @return new {@link Type} representing the given {@code type}. + */ + static Type jsonTypeOf(String name) { + return new JsonType(name); + } + + static Set jsonTypes() { + return JSON_TYPES; + } + + static Set bsonTypes() { + return BSON_TYPES; + } + + /** + * Get the {@link Type} representation. Either {@code type} or {@code bsonType}. + * + * @return never {@literal null}. + */ + String representation(); + + /** + * Get the {@link Type} value. Like {@literal string}, {@literal number},... + * + * @return never {@literal null}. + */ + Object value(); + + /** + * @author Christpoh Strobl + * @since 2.1 + */ + @RequiredArgsConstructor + static class JsonType implements Type { + + private final String name; + + @Override + public String representation() { + return "type"; + } + + @Override + public String value() { + return name; + } + } + + /** + * @author Christpoh Strobl + * @since 2.1 + */ + @RequiredArgsConstructor + static class BsonType implements Type { + + private final String name; + + @Override + public String representation() { + return "bsonType"; + } + + @Override + public String value() { + return name; + } + } + } +} 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 new file mode 100644 index 000000000..02e4190af --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaProperty.java @@ -0,0 +1,197 @@ +/* + * Copyright 2018 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 + * + * http://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 org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.ArrayJsonSchemaProperty; +import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.BooleanJsonSchemaProperty; +import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.NullJsonSchemaProperty; +import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.NumericJsonSchemaProperty; +import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.ObjectJsonSchemaProperty; +import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.StringJsonSchemaProperty; +import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.UntypedJsonSchemaProperty; +import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.NumericJsonSchemaObject; +import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.ObjectJsonSchemaObject; + +/** + * A {@literal property} or {@literal patternProperty} within a {@link JsonSchemaObject} of {@code type : 'object'}. + * + * @author Christoph Strobl + * @since 2.1 + */ +public interface JsonSchemaProperty extends JsonSchemaObject { + + /** + * The identifier can be either the property name or the regex expression properties have to match when used along + * with {@link ObjectJsonSchemaObject#patternProperties(JsonSchemaProperty...)}. + * + * @return never {@literal null}. + */ + String getIdentifier(); + + /** + * Creates a new {@link UntypedJsonSchemaProperty} with given {@literal identifier} without {@code type}. + * + * @param identifier the {@literal property} name or {@literal patternProperty} regex. Must not be {@literal null} nor + * {@literal empty}. + * @return new instance of {@link UntypedJsonSchemaProperty}. + */ + static UntypedJsonSchemaProperty untyped(String identifier) { + return new UntypedJsonSchemaProperty(identifier, JsonSchemaObject.untyped()); + } + + /** + * Creates a new {@link StringJsonSchemaProperty} with given {@literal identifier} of {@code type : 'string'}. + * + * @param identifier the {@literal property} name or {@literal patternProperty} regex. Must not be {@literal null} nor + * {@literal empty}. + * @return new instance of {@link StringJsonSchemaProperty}. + */ + static StringJsonSchemaProperty string(String identifier) { + return new StringJsonSchemaProperty(identifier, JsonSchemaObject.string()); + } + + /** + * Creates a new {@link ObjectJsonSchemaProperty} with given {@literal identifier} of {@code type : 'object'}. + * + * @param identifier the {@literal property} name or {@literal patternProperty} regex. Must not be {@literal null} nor + * {@literal empty}. + * @return new instance of {@link ObjectJsonSchemaProperty}. + */ + static ObjectJsonSchemaProperty object(String identifier) { + return new ObjectJsonSchemaProperty(identifier, JsonSchemaObject.object()); + } + + /** + * Creates a new {@link NumericJsonSchemaProperty} with given {@literal identifier} of {@code type : 'number'}. + * + * @param identifier the {@literal property} name or {@literal patternProperty} regex. Must not be {@literal null} nor + * {@literal empty}. + * @return new instance of {@link NumericJsonSchemaProperty}. + */ + static NumericJsonSchemaProperty number(String identifier) { + return new NumericJsonSchemaProperty(identifier, JsonSchemaObject.number()); + } + + /** + * Creates a new {@link NumericJsonSchemaProperty} with given {@literal identifier} of {@code bsonType : 'int'}. + * + * @param identifier the {@literal property} name or {@literal patternProperty} regex. Must not be {@literal null} nor + * {@literal empty}. + * @return new instance of {@link NumericJsonSchemaProperty}. + */ + static NumericJsonSchemaProperty int32(String identifier) { + return new NumericJsonSchemaProperty(identifier, new NumericJsonSchemaObject(Type.intType())); + } + + /** + * Creates a new {@link NumericJsonSchemaProperty} with given {@literal identifier} of {@code bsonType : 'long'}. + * + * @param identifier the {@literal property} name or {@literal patternProperty} regex. Must not be {@literal null} nor + * {@literal empty}. + * @return new instance of {@link NumericJsonSchemaProperty}. + */ + static NumericJsonSchemaProperty int64(String identifier) { + return new NumericJsonSchemaProperty(identifier, new NumericJsonSchemaObject(Type.longType())); + } + + /** + * Creates a new {@link NumericJsonSchemaProperty} with given {@literal identifier} of {@code bsonType : 'double'}. + * + * @param identifier the {@literal property} name or {@literal patternProperty} regex. Must not be {@literal null} nor + * {@literal empty}. + * @return new instance of {@link NumericJsonSchemaProperty}. + */ + static NumericJsonSchemaProperty float64(String identifier) { + return new NumericJsonSchemaProperty(identifier, new NumericJsonSchemaObject(Type.doubleType())); + } + + /** + * Creates a new {@link NumericJsonSchemaProperty} with given {@literal identifier} of + * {@code bsonType : 'decimal128'}. + * + * @param identifier the {@literal property} name or {@literal patternProperty} regex. Must not be {@literal null} nor + * {@literal empty}. + * @return new instance of {@link NumericJsonSchemaProperty}. + */ + static NumericJsonSchemaProperty decimal128(String identifier) { + return new NumericJsonSchemaProperty(identifier, new NumericJsonSchemaObject(Type.bigDecimalType())); + } + + /** + * Creates a new {@link ArrayJsonSchemaProperty} with given {@literal identifier} of {@code type : 'array'}. + * + * @param identifier the {@literal property} name or {@literal patternProperty} regex. Must not be {@literal null} nor + * {@literal empty}. + * @return new instance of {@link ArrayJsonSchemaProperty}. + */ + static ArrayJsonSchemaProperty array(String identifier) { + return new ArrayJsonSchemaProperty(identifier, JsonSchemaObject.array()); + } + + /** + * Creates a new {@link BooleanJsonSchemaProperty} with given {@literal identifier} of {@code type : 'boolean'}. + * + * @param identifier the {@literal property} name or {@literal patternProperty} regex. Must not be {@literal null} nor + * {@literal empty}. + * @return new instance of {@link ArrayJsonSchemaProperty}. + */ + static BooleanJsonSchemaProperty bool(String identifier) { + return new BooleanJsonSchemaProperty(identifier, JsonSchemaObject.bool()); + } + + /** + * Creates a new {@link BooleanJsonSchemaProperty} with given {@literal identifier} of {@code type : 'null'}. + * + * @param identifier the {@literal property} name or {@literal patternProperty} regex. Must not be {@literal null} nor + * {@literal empty}. + * @return new instance of {@link ArrayJsonSchemaProperty}. + */ + static NullJsonSchemaProperty nil(String identifier) { + return new NullJsonSchemaProperty(identifier, JsonSchemaObject.nil()); + } + + /** + * Obtain a builder to create a {@link JsonSchemaProperty}. + * + * @param identifier + * @return + */ + static JsonSchemaPropertyBuilder named(String identifier) { + return new JsonSchemaPropertyBuilder(identifier); + } + + class JsonSchemaPropertyBuilder { + + private String identifier; + + public JsonSchemaPropertyBuilder(String identifier) { + this.identifier = identifier; + } + + public IdentifiableJsonSchemaProperty ofType(Type type) { + return new IdentifiableJsonSchemaProperty(identifier, TypedJsonSchemaObject.of(type)); + } + + public IdentifiableJsonSchemaProperty with(TypedJsonSchemaObject schemaObject) { + return new IdentifiableJsonSchemaProperty(identifier, schemaObject); + } + + public IdentifiableJsonSchemaProperty withoutType() { + return new IdentifiableJsonSchemaProperty(identifier, UntypedJsonSchemaObject.newInstance()); + } + } + +} 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 new file mode 100644 index 000000000..f65814850 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MongoJsonSchema.java @@ -0,0 +1,232 @@ +/* + * Copyright 2018 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 + * + * http://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.Collection; +import java.util.Set; + +import org.bson.Document; +import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.ObjectJsonSchemaObject; +import org.springframework.util.Assert; + +/** + * @author Christoph Strobl + * @since 2.1 + **/ +public class MongoJsonSchema { + + private final JsonSchemaObject root; + + private MongoJsonSchema(JsonSchemaObject root) { + + Assert.notNull(root, "Root must not be null!"); + this.root = root; + } + + /** + * Create the {@link Document} containing the specified {@code $jsonSchema}.
+ * Property and field names still need to be mapped to the domain type ones by running the {@link Document} through a + * {@link org.springframework.data.mongodb.core.convert.JsonSchemaMapper}. + * + * @return never {@literal null}. + */ + public Document toDocument() { + return new Document("$jsonSchema", root.toDocument()); + } + + /** + * Create a new {@link MongoJsonSchema} for a given root object. + * + * @param root must not be {@literal null}. + * @return + */ + public static MongoJsonSchema of(JsonSchemaObject root) { + return new MongoJsonSchema(root); + } + + /** + * Obtain a new {@link MongoJsonSchemaBuilder} to fluently define the schema. + * + * @return new instance of {@link MongoJsonSchemaBuilder}. + */ + public static MongoJsonSchemaBuilder builder() { + return new MongoJsonSchemaBuilder(); + } + + /** + * {@link MongoJsonSchemaBuilder} provides a fluent API for defining a {@link MongoJsonSchema}. + * + * @since 2.1 + */ + public static class MongoJsonSchemaBuilder { + + private ObjectJsonSchemaObject root; + + MongoJsonSchemaBuilder() { + root = new ObjectJsonSchemaObject(); + } + + /** + * @param nrProperties + * @return this + * @see ObjectJsonSchemaObject#minNrProperties(int) + */ + public MongoJsonSchemaBuilder minNrProperties(int nrProperties) { + root = root.minNrProperties(nrProperties); + return this; + } + + /** + * @param nrProperties + * @return this + * @see ObjectJsonSchemaObject#maxNrProperties(int) + */ + public MongoJsonSchemaBuilder maxNrProperties(int nrProperties) { + root = root.maxNrProperties(nrProperties); + return this; + } + + /** + * @param properties + * @return + * @see ObjectJsonSchemaObject#required(String...) + */ + public MongoJsonSchemaBuilder required(String... properties) { + root = root.required(properties); + return this; + } + + /** + * @param additionalPropertiesAllowed + * @return this + * @see ObjectJsonSchemaObject#additionalProperties(boolean) + */ + public MongoJsonSchemaBuilder additionalProperties(boolean additionalPropertiesAllowed) { + root = root.additionalProperties(additionalPropertiesAllowed); + return this; + } + + /** + * @param schema + * @return this + * @see ObjectJsonSchemaObject#additionalProperties(ObjectJsonSchemaObject) + */ + public MongoJsonSchemaBuilder additionalProperties(ObjectJsonSchemaObject schema) { + root = root.additionalProperties(schema); + return this; + } + + /** + * @param properties + * @return this + * @see ObjectJsonSchemaObject#properties(JsonSchemaProperty...) + */ + public MongoJsonSchemaBuilder properties(JsonSchemaProperty... properties) { + root = root.properties(properties); + return this; + } + + /** + * @param properties + * @return this + * @see ObjectJsonSchemaObject#patternProperties(JsonSchemaProperty...) + */ + public MongoJsonSchemaBuilder patternProperties(JsonSchemaProperty... properties) { + root = root.patternProperties(properties); + return this; + } + + /** + * @param property + * @return this + * @see ObjectJsonSchemaObject#property(JsonSchemaProperty) + */ + public MongoJsonSchemaBuilder property(JsonSchemaProperty property) { + root = root.property(property); + return this; + } + + /** + * @param possibleValues + * @return this + * @see ObjectJsonSchemaObject#possibleValues(Collection) + */ + public MongoJsonSchemaBuilder possibleValues(Set possibleValues) { + root = root.possibleValues(possibleValues); + return this; + } + + /** + * @param allOf + * @return this + * @see UntypedJsonSchemaObject#allOf(Collection) + */ + public MongoJsonSchemaBuilder allOf(Set allOf) { + root = root.allOf(allOf); + return this; + } + + /** + * @param anyOf + * @return this + * @see UntypedJsonSchemaObject#anyOf(Collection) + */ + public MongoJsonSchemaBuilder anyOf(Set anyOf) { + root = root.anyOf(anyOf); + return this; + } + + /** + * @param oneOf + * @return this + * @see UntypedJsonSchemaObject#oneOf(Collection) + */ + public MongoJsonSchemaBuilder oneOf(Set oneOf) { + root = root.oneOf(oneOf); + return this; + } + + /** + * @param notMatch + * @return this + * @see UntypedJsonSchemaObject#notMatch(JsonSchemaObject) + */ + public MongoJsonSchemaBuilder notMatch(JsonSchemaObject notMatch) { + root = root.notMatch(notMatch); + return this; + } + + /** + * @param description + * @return this + * @see UntypedJsonSchemaObject#description(String) + */ + public MongoJsonSchemaBuilder description(String description) { + root = root.description(description); + return this; + } + + /** + * Obtain the {@link MongoJsonSchema}. + * + * @return new instance of {@link MongoJsonSchema}. + */ + public MongoJsonSchema build() { + return MongoJsonSchema.of(root); + } + } + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/TypedJsonSchemaObject.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/TypedJsonSchemaObject.java new file mode 100644 index 000000000..6dcb16cce --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/TypedJsonSchemaObject.java @@ -0,0 +1,1288 @@ +/* + * Copyright 2018 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 + * + * http://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.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.bson.Document; +import org.springframework.data.domain.Range; +import org.springframework.data.domain.Range.Bound; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * A {@link JsonSchemaObject} of a given {@link org.springframework.data.mongodb.core.schema.JsonSchemaObject.Type}. + * + * @author Christoph Strobl + * @since 2.1 + */ +public class TypedJsonSchemaObject extends UntypedJsonSchemaObject { + + protected final Set types; + + /** + * @param type can be {@literal null}. + * @param description can be {@literal null}. + * @param restrictions can be {@literal null}. + */ + protected TypedJsonSchemaObject(@Nullable Type type, @Nullable String description, boolean generateDescription, + @Nullable Restrictions restrictions) { + this(type != null ? Collections.singleton(type) : Collections.emptySet(), description, generateDescription, + restrictions); + } + + /** + * @param types must not be {@literal null}. + * @param description can be {@literal null}. + * @param restrictions can be {@literal null}. Defaults to {@link Restrictions#empty()}. + */ + protected TypedJsonSchemaObject(Set types, @Nullable String description, boolean generateDescription, + @Nullable Restrictions restrictions) { + + super(restrictions, description, generateDescription); + Assert.notNull(types, "Types must not be null! Please consider using 'Collections.emptySet()'."); + + this.types = types; + } + + /** + * Creates new {@link TypedJsonSchemaObject} of given types. + * + * @param types must not be {@literal null}. + * @return + */ + public static TypedJsonSchemaObject of(Type... types) { + + Assert.noNullElements(types, "Types must not contain null!"); + return new TypedJsonSchemaObject(new LinkedHashSet<>(Arrays.asList(types)), null, false, Restrictions.empty()); + } + + /** + * Set the {@literal description}. + * + * @param description must not be {@literal null}. + * @return new instance of {@link TypedJsonSchemaObject}. + */ + @Override + public TypedJsonSchemaObject description(String description) { + return new TypedJsonSchemaObject(types, description, generateDescription, restrictions); + } + + /** + * Auto generate the {@literal description} if not explicitly set. + * + * @param description must not be {@literal null}. + * @return new instance of {@link TypedJsonSchemaObject}. + */ + @Override + public TypedJsonSchemaObject generatedDescription() { + return new TypedJsonSchemaObject(types, description, true, restrictions); + } + + /** + * {@literal enum}erates all possible values of the field. + * + * @param possibleValues must not be {@literal null}. + * @return new instance of {@link TypedJsonSchemaObject}. + */ + @Override + public TypedJsonSchemaObject possibleValues(Collection possibleValues) { + return new TypedJsonSchemaObject(types, description, generateDescription, + restrictions.possibleValues(possibleValues)); + } + + /** + * The field value must match all specified schemas. + * + * @param allOf must not be {@literal null}. + * @return new instance of {@link TypedJsonSchemaObject}. + */ + @Override + public TypedJsonSchemaObject allOf(Collection allOf) { + return new TypedJsonSchemaObject(types, description, generateDescription, restrictions.allOf(allOf)); + } + + /** + * The field value must match at least one of the specified schemas. + * + * @param anyOf must not be {@literal null}. + * @return new instance of {@link TypedJsonSchemaObject}. + */ + @Override + public TypedJsonSchemaObject anyOf(Collection anyOf) { + return new TypedJsonSchemaObject(types, description, generateDescription, restrictions.anyOf(anyOf)); + } + + /** + * The field value must match exactly one of the specified schemas. + * + * @param oneOf must not be {@literal null}. + * @return new instance of {@link TypedJsonSchemaObject}. + */ + @Override + public TypedJsonSchemaObject oneOf(Collection oneOf) { + return new TypedJsonSchemaObject(types, description, generateDescription, restrictions.oneOf(oneOf)); + } + + /** + * The field value must not match the specified schemas. + * + * @param oneOf must not be {@literal null}. + * @return new instance of {@link TypedJsonSchemaObject}. + */ + @Override + public TypedJsonSchemaObject notMatch(JsonSchemaObject notMatch) { + return new TypedJsonSchemaObject(types, description, generateDescription, restrictions.notMatch(notMatch)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.JsonSchemaObject#getTypes() + */ + @Override + public Set getTypes() { + return types; + } + + /** + * Create the json schema complying {@link Document} representation. This includes {@literal type}, + * {@literal description} and the fields of {@link Restrictions#toDocument()} if set. + */ + @Override + public Document toDocument() { + + Document document = new Document(); + + if (!CollectionUtils.isEmpty(types)) { + + Type theType = types.iterator().next(); + if (types.size() == 1) { + document.append(theType.representation(), theType.value()); + } else { + document.append(theType.representation(), types.stream().map(Type::value).collect(Collectors.toList())); + } + } + + getOrCreateDescription().ifPresent(val -> document.append("description", val)); + + if (restrictions != null) { + document.putAll(restrictions.toDocument()); + } + + return document; + } + + private Optional getOrCreateDescription() { + + if (description != null) { + return description.isEmpty() ? Optional.empty() : Optional.of(description); + } + + return generateDescription ? Optional.ofNullable(generateDescription()) : Optional.empty(); + } + + /** + * Customization hook for creating description out of defined values.
+ * Called by {@link #toDocument()} when no explicit {@link #description} is set. + * + * @return can be {@literal null}. + */ + @Nullable + protected String generateDescription() { + return null; + } + + /** + * {@link JsonSchemaObject} implementation of {@code type : 'object'} schema elements.
+ * Provides programmatic access to schema specifics like {@literal required, properties, patternProperties,...} via a + * fluent API producing immutable {@link JsonSchemaObject schema objects}. + * + * @author Christoph Strobl + * @since 2.1 + */ + static class ObjectJsonSchemaObject extends TypedJsonSchemaObject { + + private @Nullable Range nrProperties; + private @Nullable Object additionalProperties; + private List requiredProperties = Collections.emptyList(); + private List properties = Collections.emptyList(); + private List patternProperties = Collections.emptyList(); + + public ObjectJsonSchemaObject() { + this(null, false, null); + } + + /** + * @param description can be {@literal null}. + * @param restrictions can be {@literal null}; + */ + ObjectJsonSchemaObject(@Nullable String description, boolean generateDescription, + @Nullable Restrictions restrictions) { + super(Type.objectType(), description, generateDescription, restrictions); + } + + /** + * Define the {@literal minProperties} and {@literal maxProperties} via the given {@link Range}.
+ * In-/Exclusions via {@link Bound#isInclusive() range bounds} are not taken into account. + * + * @param range must not be {@literal null}. Consider {@link Range#unbounded()} instead. + * @return new instance of {@link ObjectJsonSchemaObject}. + */ + public ObjectJsonSchemaObject nrProperties(Range range) { + + ObjectJsonSchemaObject newInstance = newInstance(description, generateDescription, restrictions); + newInstance.nrProperties = range; + return newInstance; + } + + /** + * Define the {@literal minProperties}. + * + * @param nrProperties the allowed minimal number of properties. + * @return new instance of {@link ObjectJsonSchemaObject}. + */ + public ObjectJsonSchemaObject minNrProperties(int nrProperties) { + + Bound upper = this.nrProperties != null ? this.nrProperties.getUpperBound() : Bound.unbounded(); + return nrProperties(Range.of(Bound.inclusive(nrProperties), upper)); + } + + /** + * Define the {@literal maxProperties}. + * + * @param nrProperties the allowed maximum number of properties. + * @return new instance of {@link ObjectJsonSchemaObject}. + */ + public ObjectJsonSchemaObject maxNrProperties(int nrProperties) { + + Bound lower = this.nrProperties != null ? this.nrProperties.getLowerBound() : Bound.unbounded(); + return nrProperties(Range.of(lower, Bound.inclusive(nrProperties))); + } + + /** + * Define the Object’s {@literal required} properties. + * + * @param properties the names of required properties. + * @return new instance of {@link ObjectJsonSchemaObject}. + */ + public ObjectJsonSchemaObject required(String... properties) { + + ObjectJsonSchemaObject newInstance = newInstance(description, generateDescription, restrictions); + newInstance.requiredProperties = new ArrayList<>(this.requiredProperties.size() + properties.length); + newInstance.requiredProperties.addAll(this.requiredProperties); + newInstance.requiredProperties.addAll(Arrays.asList(properties)); + return newInstance; + } + + /** + * If set to {@literal false}, no additional fields are allowed. + * + * @param additionalPropertiesAllowed + * @return new instance of {@link ObjectJsonSchemaObject}. + */ + public ObjectJsonSchemaObject additionalProperties(boolean additionalPropertiesAllowed) { + + ObjectJsonSchemaObject newInstance = newInstance(description, generateDescription, restrictions); + newInstance.additionalProperties = additionalPropertiesAllowed; + return newInstance; + } + + /** + * If specified, additional fields must validate against the given schema. + * + * @param schema must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaObject}. + */ + public ObjectJsonSchemaObject additionalProperties(ObjectJsonSchemaObject schema) { + + ObjectJsonSchemaObject newInstance = newInstance(description, generateDescription, restrictions); + newInstance.additionalProperties = schema; + return newInstance; + } + + /** + * Append the objects properties along with the {@link JsonSchemaObject} validating against. + * + * @param properties must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaObject}. + */ + public ObjectJsonSchemaObject properties(JsonSchemaProperty... properties) { + + ObjectJsonSchemaObject newInstance = newInstance(description, generateDescription, restrictions); + newInstance.properties = new ArrayList<>(this.properties.size() + properties.length); + newInstance.properties.addAll(this.properties); + newInstance.properties.addAll(Arrays.asList(properties)); + return newInstance; + } + + /** + * Append regular expression patterns along with the {@link JsonSchemaObject} matching properties validating + * against. + * + * @param regularExpressions must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaObject}. + */ + public ObjectJsonSchemaObject patternProperties(JsonSchemaProperty... regularExpressions) { + + ObjectJsonSchemaObject newInstance = newInstance(description, generateDescription, restrictions); + newInstance.patternProperties = new ArrayList<>(this.patternProperties.size() + regularExpressions.length); + newInstance.patternProperties.addAll(this.patternProperties); + newInstance.patternProperties.addAll(Arrays.asList(regularExpressions)); + return newInstance; + } + + /** + * Append the objects property along with the {@link JsonSchemaObject} validating against. + * + * @param properties must not be {@literal null}. + * @return new instance of {@link ObjectJsonSchemaObject}. + */ + public ObjectJsonSchemaObject property(JsonSchemaProperty property) { + return properties(property); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.UntypedJsonSchemaObject#possibleValues(java.util.Collection) + */ + @Override + public ObjectJsonSchemaObject possibleValues(Collection possibleValues) { + return newInstance(description, generateDescription, restrictions.possibleValues(possibleValues)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.UntypedJsonSchemaObject#allOf(java.util.Collection) + */ + @Override + public ObjectJsonSchemaObject allOf(Collection allOf) { + return newInstance(description, generateDescription, restrictions.allOf(allOf)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.UntypedJsonSchemaObject#anyOf(java.util.Collection) + */ + @Override + public ObjectJsonSchemaObject anyOf(Collection anyOf) { + return newInstance(description, generateDescription, restrictions.anyOf(anyOf)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.UntypedJsonSchemaObject#oneOf(java.util.Collection) + */ + @Override + public ObjectJsonSchemaObject oneOf(Collection oneOf) { + return newInstance(description, generateDescription, restrictions.oneOf(oneOf)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.UntypedJsonSchemaObject#notMatch(org.springframework.data.mongodb.core.schema.JsonSchemaObject) + */ + @Override + public ObjectJsonSchemaObject notMatch(JsonSchemaObject notMatch) { + return newInstance(description, generateDescription, restrictions.notMatch(notMatch)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.UntypedJsonSchemaObject#description(java.lang.String) + */ + @Override + public ObjectJsonSchemaObject description(String description) { + return newInstance(description, generateDescription, restrictions); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.UntypedJsonSchemaObject#generatedDescription() + */ + @Override + public ObjectJsonSchemaObject generatedDescription() { + return newInstance(description, true, restrictions); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.JsonSchemaObject#toDocument() + */ + @Override + public Document toDocument() { + + Document doc = new Document(super.toDocument()); + if (!CollectionUtils.isEmpty(requiredProperties)) { + doc.append("required", requiredProperties); + } + + if (nrProperties != null) { + + if (nrProperties.getLowerBound().isBounded()) { + doc.append("minProperties", nrProperties.getLowerBound().getValue().get()); + + } + + if (nrProperties.getUpperBound().isBounded()) { + doc.append("maxProperties", nrProperties.getUpperBound().getValue().get()); + } + } + if (!CollectionUtils.isEmpty(properties)) { + doc.append("properties", reduceToDocument(properties)); + } + + if (!CollectionUtils.isEmpty(patternProperties)) { + doc.append("patternProperties", reduceToDocument(patternProperties)); + } + + if (additionalProperties != null) { + + doc.append("additionalProperties", additionalProperties instanceof JsonSchemaObject + ? ((JsonSchemaObject) additionalProperties).toDocument() : additionalProperties); + } + return doc; + } + + private ObjectJsonSchemaObject newInstance(String description, boolean generateDescription, + Restrictions restrictions) { + + ObjectJsonSchemaObject newInstance = new ObjectJsonSchemaObject(description, generateDescription, restrictions); + newInstance.properties = this.properties; + newInstance.requiredProperties = this.requiredProperties; + newInstance.additionalProperties = this.additionalProperties; + newInstance.nrProperties = this.nrProperties; + newInstance.patternProperties = this.patternProperties; + return newInstance; + } + + private Document reduceToDocument(Collection source) { + + return source.stream() // + .map(JsonSchemaProperty::toDocument) // + .collect(Document::new, (target, propertyDocument) -> target.putAll(propertyDocument), + (target, propertyDocument) -> {}); + } + + @Nullable + @Override + protected String generateDescription() { + + String errorMsg = "Must be an object"; + + if (nrProperties != null) { + errorMsg += " with " + nrProperties + " properties"; + } + + if (!CollectionUtils.isEmpty(requiredProperties)) { + + if (requiredProperties.size() == 1) { + errorMsg += " where " + requiredProperties.iterator().next() + "is mandatory"; + } else { + errorMsg += " where " + StringUtils.collectionToDelimitedString(requiredProperties, ", ") + " are mandatory"; + } + } + if (additionalProperties instanceof Boolean) { + errorMsg += (((Boolean) additionalProperties) ? " " : " not ") + "allowing additional properties"; + } + + if (!CollectionUtils.isEmpty(properties)) { + errorMsg += " defining restrictions for " + StringUtils.collectionToDelimitedString( + properties.stream().map(val -> val.getIdentifier()).collect(Collectors.toList()), ", "); + } + + if (!CollectionUtils.isEmpty(patternProperties)) { + errorMsg += " defining restrictions for patterns " + StringUtils.collectionToDelimitedString( + patternProperties.stream().map(val -> val.getIdentifier()).collect(Collectors.toList()), ", "); + } + + return errorMsg + "."; + } + } + + /** + * {@link JsonSchemaObject} implementation of {@code type : 'number'}, {@code bsonType : 'int'}, + * {@code bsonType : 'long'}, {@code bsonType : 'double'} and {@code bsonType : 'decimal128'} schema elements.
+ * Provides programmatic access to schema specifics like {@literal multipleOf, minimum, maximum,...} via a fluent API + * producing immutable {@link JsonSchemaObject schema objects}. + * + * @author Christoph Strobl + * @since 2.1 + */ + static class NumericJsonSchemaObject extends TypedJsonSchemaObject { + + private static final Set NUMERIC_TYPES = new HashSet<>( + Arrays.asList(Type.doubleType(), Type.intType(), Type.longType(), Type.numberType(), Type.bigDecimalType())); + + @Nullable Number multipleOf; + @Nullable Range range; + + public NumericJsonSchemaObject() { + this(Type.numberType()); + } + + public NumericJsonSchemaObject(Type type) { + this(type, null, false); + } + + private NumericJsonSchemaObject(Type type, @Nullable String description, boolean generateDescription) { + this(Collections.singleton(type), description, generateDescription, null); + } + + private NumericJsonSchemaObject(Set types, @Nullable String description, boolean generateDescription, + @Nullable Restrictions restrictions) { + + super(validateTypes(types), description, generateDescription, restrictions); + } + + /** + * Set the value a valid field value must be the multiple of. + * + * @param value must not be {@literal null}. + * @return must not be {@literal null}. + */ + NumericJsonSchemaObject multipleOf(Number value) { + + Assert.notNull(value, "Value must not be null!"); + NumericJsonSchemaObject newInstance = newInstance(description, generateDescription, restrictions); + newInstance.multipleOf = value; + return newInstance; + } + + /** + * Set the {@link Range} of valid field values translating to {@literal minimum}, {@literal exclusiveMinimum}, + * {@literal maximum} and {@literal exclusiveMaximum}. + * + * @param range must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaObject}. + */ + public NumericJsonSchemaObject within(Range range) { + + Assert.notNull(range, "Range must not be null!"); + + NumericJsonSchemaObject newInstance = newInstance(description, generateDescription, restrictions); + newInstance.range = range; + return newInstance; + } + + /** + * Set {@literal minimum} to given {@code min} value and {@literal exclusiveMinimum} to {@literal true}. + * + * @param min must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaObject}. + */ + public NumericJsonSchemaObject gt(Number min) { + + Assert.notNull(min, "Min must not be null!"); + + Bound upper = this.range != null ? this.range.getUpperBound() : Bound.unbounded(); + return within(Range.of(createBound(min, false), upper)); + } + + /** + * Set {@literal minimum} to given {@code min} value and {@literal exclusiveMinimum} to {@literal false}. + * + * @param min must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaObject}. + */ + public NumericJsonSchemaObject gte(Number min) { + + Assert.notNull(min, "Min must not be null!"); + + Bound upper = this.range != null ? this.range.getUpperBound() : Bound.unbounded(); + return within(Range.of(createBound(min, true), upper)); + } + + /** + * Set {@literal maximum} to given {@code max} value and {@literal exclusiveMaximum} to {@literal true}. + * + * @param max must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaObject}. + */ + public NumericJsonSchemaObject lt(Number max) { + + Assert.notNull(max, "Max must not be null!"); + + Bound lower = this.range != null ? this.range.getLowerBound() : Bound.unbounded(); + return within(Range.of(lower, createBound(max, false))); + } + + /** + * Set {@literal maximum} to given {@code max} value and {@literal exclusiveMaximum} to {@literal false}. + * + * @param max must not be {@literal null}. + * @return new instance of {@link NumericJsonSchemaObject}. + */ + NumericJsonSchemaObject lte(Number max) { + + Assert.notNull(max, "Max must not be null!"); + + Bound lower = this.range != null ? this.range.getLowerBound() : Bound.unbounded(); + return within(Range.of(lower, createBound(max, true))); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#possibleValues(java.util.Collection) + */ + @Override + public NumericJsonSchemaObject possibleValues(Collection possibleValues) { + return newInstance(description, generateDescription, restrictions.possibleValues(possibleValues)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#allOf(java.util.Collection) + */ + @Override + public NumericJsonSchemaObject allOf(Collection allOf) { + return newInstance(description, generateDescription, restrictions.allOf(allOf)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#anyOf(java.util.Collection) + */ + @Override + public NumericJsonSchemaObject anyOf(Collection anyOf) { + return newInstance(description, generateDescription, restrictions.anyOf(anyOf)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#oneOf(java.util.Collection) + */ + @Override + public NumericJsonSchemaObject oneOf(Collection oneOf) { + return newInstance(description, generateDescription, restrictions.oneOf(oneOf)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#notMatch(org.springframework.data.mongodb.core.schema.JsonSchemaObject) + */ + @Override + public NumericJsonSchemaObject notMatch(JsonSchemaObject notMatch) { + return newInstance(description, generateDescription, restrictions.notMatch(notMatch)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#description(java.lang.String) + */ + @Override + public NumericJsonSchemaObject description(String description) { + return newInstance(description, generateDescription, restrictions); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#generatedDescription() + */ + @Override + public NumericJsonSchemaObject generatedDescription() { + + return newInstance(description, true, restrictions); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.JsonSchemaObject#toDocument() + */ + @Override + public Document toDocument() { + + Document doc = new Document(super.toDocument()); + + if (multipleOf != null) { + doc.append("multipleOf", multipleOf); + } + + if (range != null) { + + if (range.getLowerBound().isBounded()) { + doc.append("minimum", range.getLowerBound().getValue().get()); + if (!range.getLowerBound().isInclusive()) { + doc.append("exclusiveMinimum", true); + } + } + + if (range.getUpperBound().isBounded()) { + doc.append("maximum", range.getUpperBound().getValue().get()); + if (!range.getUpperBound().isInclusive()) { + doc.append("exclusiveMaximum", true); + } + } + } + + return doc; + } + + private NumericJsonSchemaObject newInstance(String description, boolean generateDescription, + Restrictions restrictions) { + + NumericJsonSchemaObject newInstance = new NumericJsonSchemaObject(types, description, generateDescription, + restrictions); + + newInstance.multipleOf = this.multipleOf; + newInstance.range = this.range; + return newInstance; + + } + + private Bound createBound(Number number, boolean inclusive) { + + if (number instanceof Long) { + return inclusive ? Bound.inclusive((Long) number) : Bound.exclusive((Long) number); + } + if (number instanceof Double) { + return inclusive ? Bound.inclusive((Double) number) : Bound.exclusive((Double) number); + } + if (number instanceof Float) { + return inclusive ? Bound.inclusive((Float) number) : Bound.exclusive((Float) number); + } + if (number instanceof Integer) { + return inclusive ? Bound.inclusive((Integer) number) : Bound.exclusive((Integer) number); + } + if (number instanceof BigDecimal) { + return inclusive ? Bound.inclusive((BigDecimal) number) : Bound.exclusive((BigDecimal) number); + } + + throw new IllegalArgumentException("Unsupported numeric value."); + } + + private static Set validateTypes(Set types) { + + types.forEach(type -> { + Assert.isTrue(NUMERIC_TYPES.contains(type), + () -> String.format("%s is not a valid numeric type. Expected one of %s.", type, NUMERIC_TYPES)); + }); + + return types; + } + + @Nullable + @Override + protected String generateDescription() { + + String errorMsg = "Must be a numeric value"; + + if (multipleOf != null) { + errorMsg += " multiple of " + multipleOf; + } + if (range != null) { + errorMsg += " within range " + range; + } + + return errorMsg + "."; + } + } + + /** + * {@link JsonSchemaObject} implementation of {@code type : 'string'} schema elements.
+ * Provides programmatic access to schema specifics like {@literal minLength, maxLength, pattern,...} via a fluent API + * producing immutable {@link JsonSchemaObject schema objects}. + * + * @author Christoph Strobl + * @since 2.1 + */ + static class StringJsonSchemaObject extends TypedJsonSchemaObject { + + @Nullable Range length; + @Nullable String pattern; + + public StringJsonSchemaObject() { + this(null, false, null); + } + + private StringJsonSchemaObject(@Nullable String description, boolean generateDescription, + @Nullable Restrictions restrictions) { + super(Type.stringType(), description, generateDescription, restrictions); + } + + /** + * Define the valid length range ({@literal minLength} and {@literal maxLength}) for a valid field. + * + * @param range must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaObject}. + */ + public StringJsonSchemaObject length(Range range) { + + Assert.notNull(range, "Range must not be null!"); + + StringJsonSchemaObject newInstance = newInstance(description, generateDescription, restrictions); + newInstance.length = range; + return newInstance; + } + + /** + * Define the valid length range ({@literal minLength} for a valid field. + * + * @param length + * @return new instance of {@link StringJsonSchemaObject}. + */ + public StringJsonSchemaObject minLength(int length) { + + Bound upper = this.length != null ? this.length.getUpperBound() : Bound.unbounded(); + return length(Range.of(Bound.inclusive(length), upper)); + } + + /** + * Define the valid length range ({@literal maxLength} for a valid field. + * + * @param length + * @return new instance of {@link StringJsonSchemaObject}. + */ + public StringJsonSchemaObject maxLength(int length) { + + Bound lower = this.length != null ? this.length.getLowerBound() : Bound.unbounded(); + return length(Range.of(lower, Bound.inclusive(length))); + } + + /** + * Define the regex pattern to validate field values against. + * + * @param pattern must not be {@literal null}. + * @return new instance of {@link StringJsonSchemaObject}. + */ + public StringJsonSchemaObject matching(String pattern) { + + Assert.notNull(pattern, "Pattern must not be null!"); + + StringJsonSchemaObject newInstance = newInstance(description, generateDescription, restrictions); + newInstance.pattern = pattern; + return newInstance; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#possibleValues(java.util.Collection) + */ + @Override + public StringJsonSchemaObject possibleValues(Collection possibleValues) { + return newInstance(description, generateDescription, restrictions.possibleValues(possibleValues)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#allOf(java.util.Collection) + */ + @Override + public StringJsonSchemaObject allOf(Collection allOf) { + return newInstance(description, generateDescription, restrictions.allOf(allOf)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#anyOf(java.util.Collection) + */ + @Override + public StringJsonSchemaObject anyOf(Collection anyOf) { + return newInstance(description, generateDescription, restrictions.anyOf(anyOf)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#oneOf(java.util.Collection) + */ + @Override + public StringJsonSchemaObject oneOf(Collection oneOf) { + return newInstance(description, generateDescription, restrictions.oneOf(oneOf)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#notMatch(org.springframework.data.mongodb.core.schema.JsonSchemaObject) + */ + @Override + public StringJsonSchemaObject notMatch(JsonSchemaObject notMatch) { + return newInstance(description, generateDescription, restrictions.notMatch(notMatch)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#description(java.lang.String) + */ + @Override + public StringJsonSchemaObject description(String description) { + return newInstance(description, generateDescription, restrictions); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#generatedDescription() + */ + @Override + public StringJsonSchemaObject generatedDescription() { + return newInstance(description, true, restrictions); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.JsonSchemaObject#toDocument() + */ + @Override + public Document toDocument() { + + Document doc = new Document(super.toDocument()); + + if (length != null) { + + if (length.getLowerBound().isBounded()) { + doc.append("minLength", length.getLowerBound().getValue().get()); + } + + if (length.getUpperBound().isBounded()) { + doc.append("maxLength", length.getUpperBound().getValue().get()); + } + } + + if (!StringUtils.isEmpty(pattern)) { + doc.append("pattern", pattern); + } + + return doc; + } + + private StringJsonSchemaObject newInstance(String description, boolean generateDescription, + Restrictions restrictions) { + + StringJsonSchemaObject newInstance = new StringJsonSchemaObject(description, generateDescription, restrictions); + + newInstance.length = this.length; + newInstance.pattern = this.pattern; + return newInstance; + } + + @Nullable + @Override + protected String generateDescription() { + + String errorMsg = "Must be a string"; + + if (length != null) { + errorMsg += " with length " + length; + } + if (pattern != null) { + errorMsg += " matching " + pattern; + } + + return errorMsg + "."; + } + } + + static class ArrayJsonSchemaObject extends TypedJsonSchemaObject { + + private @Nullable Boolean uniqueItems; + private @Nullable Range range; + private Collection items = Collections.emptyList(); + + public ArrayJsonSchemaObject() { + this(null, false, null); + } + + private ArrayJsonSchemaObject(@Nullable String description, boolean generateDescription, + @Nullable Restrictions restrictions) { + super(Collections.singleton(Type.arrayType()), description, generateDescription, restrictions); + } + + public ArrayJsonSchemaObject uniqueItems(boolean uniqueItems) { + + ArrayJsonSchemaObject newInstance = newInstance(description, generateDescription, restrictions); + newInstance.uniqueItems = uniqueItems; + return newInstance; + } + + public ArrayJsonSchemaObject range(Range range) { + + ArrayJsonSchemaObject newInstance = newInstance(description, generateDescription, restrictions); + newInstance.range = range; + return newInstance; + } + + public ArrayJsonSchemaObject maxItems(int nrItems) { + + Bound lower = this.range != null ? this.range.getLowerBound() : Bound.unbounded(); + return range(Range.of(lower, Bound.inclusive(nrItems))); + } + + public ArrayJsonSchemaObject minItems(int nrItems) { + + Bound upper = this.range != null ? this.range.getUpperBound() : Bound.unbounded(); + return range(Range.of(Bound.inclusive(nrItems), upper)); + } + + public ArrayJsonSchemaObject items(Collection items) { + + ArrayJsonSchemaObject newInstance = newInstance(description, generateDescription, restrictions); + newInstance.items = new ArrayList<>(items); + return newInstance; + } + + @Override + public ArrayJsonSchemaObject possibleValues(Collection possibleValues) { + return newInstance(description, generateDescription, restrictions.possibleValues(possibleValues)); + } + + @Override + public ArrayJsonSchemaObject allOf(Collection allOf) { + return newInstance(description, generateDescription, restrictions.allOf(allOf)); + } + + @Override + public ArrayJsonSchemaObject anyOf(Collection anyOf) { + return newInstance(description, generateDescription, restrictions.anyOf(anyOf)); + } + + @Override + public ArrayJsonSchemaObject oneOf(Collection oneOf) { + return newInstance(description, generateDescription, restrictions.oneOf(oneOf)); + } + + @Override + public ArrayJsonSchemaObject notMatch(JsonSchemaObject notMatch) { + return newInstance(description, generateDescription, restrictions.notMatch(notMatch)); + } + + @Override + public ArrayJsonSchemaObject description(String description) { + return newInstance(description, generateDescription, restrictions); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#generatedDescription() + */ + @Override + public ArrayJsonSchemaObject generatedDescription() { + return newInstance(description, true, restrictions); + } + + @Override + public Document toDocument() { + Document doc = new Document(super.toDocument()); + + if (!CollectionUtils.isEmpty(items)) { + doc.append("items", items.size() == 1 ? items.iterator().next() + : items.stream().map(JsonSchemaObject::toDocument).collect(Collectors.toList())); + } + + if (range != null) { + + if (range.getLowerBound().isBounded()) { + doc.append("minItems", range.getLowerBound().getValue().get()); + } + + if (range.getUpperBound().isBounded()) { + doc.append("maxItems", range.getUpperBound().getValue().get()); + } + } + + if (ObjectUtils.nullSafeEquals(uniqueItems, Boolean.TRUE)) { + doc.append("uniqueItems", true); + } + + return doc; + } + + private ArrayJsonSchemaObject newInstance(String description, boolean generateDescription, + Restrictions restrictions) { + + ArrayJsonSchemaObject newInstance = new ArrayJsonSchemaObject(description, generateDescription, restrictions); + newInstance.uniqueItems = this.uniqueItems; + newInstance.range = this.range; + newInstance.items = this.items; + return newInstance; + } + + @Nullable + @Override + protected String generateDescription() { + + String errorMsg = "Must be an array"; + + if (ObjectUtils.nullSafeEquals(uniqueItems, Boolean.TRUE)) { + errorMsg += " of unique values"; + } + + if (range != null) { + errorMsg += " having size " + range; + } + + if (!ObjectUtils.isEmpty(items)) { + errorMsg += " with items " + StringUtils.collectionToDelimitedString( + items.stream().map(val -> val.toDocument()).collect(Collectors.toList()), ", "); + } + + return errorMsg + "."; + + } + } + + static class BooleanJsonSchemaObject extends TypedJsonSchemaObject { + + public BooleanJsonSchemaObject() { + this(null, false, null); + } + + private BooleanJsonSchemaObject(@Nullable String description, boolean generateDescription, + @Nullable Restrictions restrictions) { + super(Type.booleanType(), description, generateDescription, restrictions); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#possibleValues(java.util.Collection) + */ + @Override + public BooleanJsonSchemaObject possibleValues(Collection possibleValues) { + return new BooleanJsonSchemaObject(description, generateDescription, restrictions.possibleValues(possibleValues)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#allOf(java.util.Collection) + */ + @Override + public BooleanJsonSchemaObject allOf(Collection allOf) { + return new BooleanJsonSchemaObject(description, generateDescription, restrictions.allOf(allOf)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#anyOf(java.util.Collection) + */ + @Override + public BooleanJsonSchemaObject anyOf(Collection anyOf) { + return new BooleanJsonSchemaObject(description, generateDescription, restrictions.anyOf(anyOf)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#oneOf(java.util.Collection) + */ + @Override + public BooleanJsonSchemaObject oneOf(Collection oneOf) { + return new BooleanJsonSchemaObject(description, generateDescription, restrictions.oneOf(oneOf)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#notMatch(org.springframework.data.mongodb.core.schema.JsonSchemaObject) + */ + @Override + public BooleanJsonSchemaObject notMatch(JsonSchemaObject notMatch) { + return new BooleanJsonSchemaObject(description, generateDescription, restrictions.notMatch(notMatch)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#description(java.lang.String) + */ + @Override + public BooleanJsonSchemaObject description(String description) { + return new BooleanJsonSchemaObject(description, generateDescription, restrictions); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#generatedDescription() + */ + @Override + public BooleanJsonSchemaObject generatedDescription() { + return new BooleanJsonSchemaObject(description, true, restrictions); + } + + } + + static class NullJsonSchemaObject extends TypedJsonSchemaObject { + + public NullJsonSchemaObject() { + this(null, false, null); + } + + private NullJsonSchemaObject(@Nullable String description, boolean generateDescription, + @Nullable Restrictions restrictions) { + super(Type.nullType(), description, generateDescription, restrictions); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#possibleValues(java.util.Collection) + */ + @Override + public NullJsonSchemaObject possibleValues(Collection possibleValues) { + return new NullJsonSchemaObject(description, generateDescription, restrictions.possibleValues(possibleValues)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#allOf(java.util.Collection) + */ + @Override + public NullJsonSchemaObject allOf(Collection allOf) { + return new NullJsonSchemaObject(description, generateDescription, restrictions.allOf(allOf)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#anyOf(java.util.Collection) + */ + @Override + public NullJsonSchemaObject anyOf(Collection anyOf) { + return new NullJsonSchemaObject(description, generateDescription, restrictions.anyOf(anyOf)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#oneOf(java.util.Collection) + */ + @Override + public NullJsonSchemaObject oneOf(Collection oneOf) { + return new NullJsonSchemaObject(description, generateDescription, restrictions.oneOf(oneOf)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#notMatch(org.springframework.data.mongodb.core.schema.JsonSchemaObject) + */ + @Override + public NullJsonSchemaObject notMatch(JsonSchemaObject notMatch) { + return new NullJsonSchemaObject(description, generateDescription, restrictions.notMatch(notMatch)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject#description(java.lang.String) + */ + @Override + public NullJsonSchemaObject description(String description) { + return new NullJsonSchemaObject(description, generateDescription, restrictions); + } + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/UntypedJsonSchemaObject.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/UntypedJsonSchemaObject.java new file mode 100644 index 000000000..dcf8407af --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/UntypedJsonSchemaObject.java @@ -0,0 +1,277 @@ +/* + * Copyright 2018 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 + * + * http://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.Collection; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.bson.Document; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * Common base for {@link JsonSchemaObject} with shared types and {@link JsonSchemaObject#toDocument()} implementation. + * + * @author Christoph Strobl + * @since 2.1 + */ +public class UntypedJsonSchemaObject implements JsonSchemaObject { + + protected final @Nullable String description; + protected final Restrictions restrictions; + protected final boolean generateDescription; + + protected UntypedJsonSchemaObject(Restrictions restrictions, @Nullable String description, boolean generateDescription) { + + this.description = description; + this.restrictions = restrictions != null ? restrictions : Restrictions.empty(); + this.generateDescription = generateDescription; + } + + public static UntypedJsonSchemaObject newInstance() { + return new UntypedJsonSchemaObject(null, null, false); + } + + @Override + public Set getTypes() { + return Collections.emptySet(); + } + + /** + * Set the {@literal description}. + * + * @param description must not be {@literal null}. + * @return new instance of {@link TypedJsonSchemaObject}. + */ + public UntypedJsonSchemaObject description(String description) { + return new UntypedJsonSchemaObject(restrictions, description, generateDescription); + } + + /** + * Auto generate the {@literal description} if not explicitly set. + * + * @param description must not be {@literal null}. + * @return new instance of {@link TypedJsonSchemaObject}. + */ + public UntypedJsonSchemaObject generatedDescription() { + return new UntypedJsonSchemaObject(restrictions, description, true); + } + + /** + * {@literal enum}erates all possible values of the field. + * + * @param possibleValues must not be {@literal null}. + * @return new instance of {@link TypedJsonSchemaObject}. + */ + public UntypedJsonSchemaObject possibleValues(Collection possibleValues) { + return new UntypedJsonSchemaObject(restrictions.possibleValues(possibleValues), description, generateDescription); + } + + /** + * The field value must match all specified schemas. + * + * @param allOf must not be {@literal null}. + * @return new instance of {@link TypedJsonSchemaObject}. + */ + public UntypedJsonSchemaObject allOf(Collection allOf) { + return new UntypedJsonSchemaObject(restrictions.allOf(allOf), description, generateDescription); + } + + /** + * The field value must match at least one of the specified schemas. + * + * @param anyOf must not be {@literal null}. + * @return new instance of {@link TypedJsonSchemaObject}. + */ + public UntypedJsonSchemaObject anyOf(Collection anyOf) { + return new UntypedJsonSchemaObject(restrictions.anyOf(anyOf), description, generateDescription); + } + + /** + * The field value must match exactly one of the specified schemas. + * + * @param oneOf must not be {@literal null}. + * @return new instance of {@link TypedJsonSchemaObject}. + */ + public UntypedJsonSchemaObject oneOf(Collection oneOf) { + return new UntypedJsonSchemaObject(restrictions.oneOf(oneOf), description, generateDescription); + } + + /** + * The field value must not match the specified schemas. + * + * @param oneOf must not be {@literal null}. + * @return new instance of {@link TypedJsonSchemaObject}. + */ + public UntypedJsonSchemaObject notMatch(JsonSchemaObject notMatch) { + return new UntypedJsonSchemaObject(restrictions.notMatch(notMatch), description, generateDescription); + } + + /** + * Create the json schema complying {@link Document} representation. This includes {@literal type}, + * {@literal description} and the fields of {@link Restrictions#toDocument()} if set. + */ + @Override + public Document toDocument() { + + Document document = new Document(); + + getOrCreateDescription().ifPresent(val -> document.append("description", val)); + + if (restrictions != null) { + document.putAll(restrictions.toDocument()); + } + + return document; + } + + private Optional getOrCreateDescription() { + + if (description != null) { + return description.isEmpty() ? Optional.empty() : Optional.of(description); + } + + return generateDescription ? Optional.ofNullable(generateDescription()) : Optional.empty(); + } + + /** + * Customization hook for creating description out of defined values.
+ * Called by {@link #toDocument()} when no explicit {@link #description} is set. + * + * @return can be {@literal null}. + */ + @Nullable + protected String generateDescription() { + return null; + } + + /** + * {@link Restrictions} encapsulate common json schema restrictions like {@literal enum}, {@literal allOf}, ... + * + * @author Christoph Strobl + * @since 2.1 + */ + static class Restrictions { + + private final Collection possibleValues; + private final Collection allOf; + private final Collection anyOf; + private final Collection oneOf; + private final @Nullable JsonSchemaObject notMatch; + + Restrictions(Collection possibleValues, Collection allOf, + Collection anyOf, Collection oneOf, @Nullable JsonSchemaObject notMatch) { + + this.possibleValues = possibleValues; + this.allOf = allOf; + this.anyOf = anyOf; + this.oneOf = oneOf; + this.notMatch = notMatch; + } + + /** + * @return new empty {@link Restrictions}. + */ + static Restrictions empty() { + + return new Restrictions(Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), + Collections.emptySet(), null); + } + + /** + * @param possibleValues must not be {@literal null}. + * @return + */ + Restrictions possibleValues(Collection possibleValues) { + + Assert.notNull(possibleValues, "PossibleValues must not be null!"); + return new Restrictions(possibleValues, allOf, anyOf, oneOf, notMatch); + } + + /** + * @param allOf must not be {@literal null}. + * @return + */ + Restrictions allOf(Collection allOf) { + + Assert.notNull(allOf, "AllOf must not be null!"); + return new Restrictions(possibleValues, allOf, anyOf, oneOf, notMatch); + } + + /** + * @param anyOf must not be {@literal null}. + * @return + */ + Restrictions anyOf(Collection anyOf) { + + Assert.notNull(anyOf, "AnyOf must not be null!"); + return new Restrictions(possibleValues, allOf, anyOf, oneOf, notMatch); + } + + /** + * @param oneOf must not be {@literal null}. + * @return + */ + Restrictions oneOf(Collection oneOf) { + + Assert.notNull(oneOf, "OneOf must not be null!"); + return new Restrictions(possibleValues, allOf, anyOf, oneOf, notMatch); + } + + /** + * @param notMatch must not be {@literal null}. + * @return + */ + Restrictions notMatch(JsonSchemaObject notMatch) { + + Assert.notNull(notMatch, "NotMatch must not be null!"); + return new Restrictions(possibleValues, allOf, anyOf, oneOf, notMatch); + } + + /** + * Create the json schema complying {@link Document} representation. This includes {@literal enum}, + * {@literal allOf}, {@literal anyOf}, {@literal oneOf}, {@literal notMatch} if set. + * + * @return never {@literal null} + */ + Document toDocument() { + + Document document = new Document(); + + if (!CollectionUtils.isEmpty(possibleValues)) { + document.append("enum", possibleValues); + } + if (!CollectionUtils.isEmpty(allOf)) { + document.append("allOf", allOf.stream().map(JsonSchemaObject::toDocument).collect(Collectors.toList())); + } + if (!CollectionUtils.isEmpty(anyOf)) { + document.append("anyOf", anyOf.stream().map(JsonSchemaObject::toDocument).collect(Collectors.toList())); + } + if (!CollectionUtils.isEmpty(oneOf)) { + document.append("oneOf", oneOf.stream().map(JsonSchemaObject::toDocument).collect(Collectors.toList())); + } + if (notMatch != null) { + document.append("not", notMatch.toDocument()); + } + + return document; + } + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/package-info.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/package-info.java new file mode 100644 index 000000000..51e1aa2f5 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/package-info.java @@ -0,0 +1,18 @@ +/* + * Copyright 2018 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 + * + * http://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. + */ +@org.springframework.lang.NonNullApi +@org.springframework.lang.NonNullFields +package org.springframework.data.mongodb.core.schema; diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/JsonSchemaQueryTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/JsonSchemaQueryTests.java new file mode 100644 index 000000000..b7eef7e29 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/JsonSchemaQueryTests.java @@ -0,0 +1,204 @@ +/* + * Copyright 2018 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 + * + * http://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; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.data.mongodb.core.query.Criteria.*; +import static org.springframework.data.mongodb.core.query.Query.*; +import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.*; + +import lombok.Data; +import reactor.test.StepVerifier; + +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.core.schema.MongoJsonSchema; +import org.springframework.data.mongodb.test.util.MongoVersionRule; +import org.springframework.data.util.Version; + +import com.mongodb.MongoClient; +import com.mongodb.reactivestreams.client.MongoClients; + +/** + * @author Christoph Strobl + */ +public class JsonSchemaQueryTests { + + public static final String DATABASE_NAME = "json-schema-query-tests"; + + public static @ClassRule MongoVersionRule REQUIRES_AT_LEAST_3_6_0 = MongoVersionRule.atLeast(Version.parse("3.6.0")); + + MongoTemplate template; + Person jellyBelly, roseSpringHeart, kazmardBoombub; + + @Before + public void setUp() { + + template = new MongoTemplate(new MongoClient(), DATABASE_NAME); + + jellyBelly = new Person(); + jellyBelly.id = "1"; + jellyBelly.name = "Jelly Belly"; + jellyBelly.gender = Gender.PIXY; + jellyBelly.address = new Address(); + jellyBelly.address.city = "Candy Hill"; + jellyBelly.address.street = "Apple Mint Street"; + jellyBelly.value = 42; + + roseSpringHeart = new Person(); + roseSpringHeart.id = "2"; + roseSpringHeart.name = "Rose SpringHeart"; + roseSpringHeart.gender = Gender.UNICORN; + roseSpringHeart.address = new Address(); + roseSpringHeart.address.city = "Rainbow Valley"; + roseSpringHeart.address.street = "Twinkle Ave."; + roseSpringHeart.value = 42L; + + kazmardBoombub = new Person(); + kazmardBoombub.id = "3"; + kazmardBoombub.name = "Kazmard Boombub"; + kazmardBoombub.gender = Gender.GOBLIN; + kazmardBoombub.value = "green"; + + template.save(jellyBelly); + template.save(roseSpringHeart); + template.save(kazmardBoombub); + } + + @Test // DATAMONGO-1835 + public void findsDocumentsWithRequiredFieldsCorrectly() { + + MongoJsonSchema schema = MongoJsonSchema.builder().required("address").build(); + + assertThat(template.find(query(matchingDocumentStructure(schema)), Person.class)) + .containsExactlyInAnyOrder(jellyBelly, roseSpringHeart); + } + + @Test // DATAMONGO-1835 + public void findsDocumentsWithRequiredFieldsReactively() { + + MongoJsonSchema schema = MongoJsonSchema.builder().required("address").build(); + + StepVerifier.create(new ReactiveMongoTemplate(MongoClients.create(), DATABASE_NAME) + .find(query(matchingDocumentStructure(schema)), Person.class)).expectNextCount(2).verifyComplete(); + } + + @Test // DATAMONGO-1835 + public void findsDocumentsWithBsonFieldTypesCorrectly() { + + MongoJsonSchema schema = MongoJsonSchema.builder().property(int32("value")).build(); + + assertThat(template.find(query(matchingDocumentStructure(schema)), Person.class)) + .containsExactlyInAnyOrder(jellyBelly); + } + + @Test // DATAMONGO-1835 + public void findsDocumentsWithJsonFieldTypesCorrectly() { + + MongoJsonSchema schema = MongoJsonSchema.builder().property(number("value")).build(); + + assertThat(template.find(query(matchingDocumentStructure(schema)), Person.class)) + .containsExactlyInAnyOrder(jellyBelly, roseSpringHeart); + } + + @Test // DATAMONGO-1835 + public void combineSchemaWithOtherCriteria() { + + MongoJsonSchema schema = MongoJsonSchema.builder().property(number("value")).build(); + + assertThat( + template.find(query(matchingDocumentStructure(schema).and("name").is(roseSpringHeart.name)), Person.class)) + .containsExactlyInAnyOrder(roseSpringHeart); + } + + @Test // DATAMONGO-1835 + public void usesMappedFieldNameForRequiredProperties() { + + MongoJsonSchema schema = MongoJsonSchema.builder().required("name").build(); + + assertThat(template.find(query(matchingDocumentStructure(schema)), Person.class)) + .containsExactlyInAnyOrder(jellyBelly, roseSpringHeart, kazmardBoombub); + } + + @Test // DATAMONGO-1835 + public void usesMappedFieldNameForProperties() { + + MongoJsonSchema schema = MongoJsonSchema.builder().property(string("name").matching("^R.*")).build(); + + assertThat(template.find(query(matchingDocumentStructure(schema)), Person.class)) + .containsExactlyInAnyOrder(roseSpringHeart); + } + + @Test // DATAMONGO-1835 + public void mapsNestedFieldName() { + + MongoJsonSchema schema = MongoJsonSchema.builder() // + .required("address") // + .property(object("address").properties(string("street").matching("^Apple.*"))).build(); + + assertThat(template.find(query(matchingDocumentStructure(schema)), Person.class)) + .containsExactlyInAnyOrder(jellyBelly); + } + + @Test // DATAMONGO-1835 + public void mapsEnumValuesCorrectly() { + + MongoJsonSchema schema = MongoJsonSchema.builder() + .property(untyped("gender").possibleValues(Gender.PIXY, Gender.GOBLIN)).build(); + + assertThat(template.find(query(matchingDocumentStructure(schema)), Person.class)) + .containsExactlyInAnyOrder(jellyBelly, kazmardBoombub); + } + + @Test // DATAMONGO-1835 + public void useTypeOperatorOnFieldLevel() { + assertThat(template.find(query(where("value").type(Type.intType())), Person.class)).containsExactly(jellyBelly); + } + + @Test // DATAMONGO-1835 + public void useTypeOperatorWithMultipleTypesOnFieldLevel() { + + assertThat(template.find(query(where("value").type(Type.intType(), Type.stringType())), Person.class)) + .containsExactlyInAnyOrder(jellyBelly, kazmardBoombub); + } + + @Data + static class Person { + + @Id String id; + + @Field("full_name") String name; + Gender gender; + Address address; + Object value; + } + + @Data + static class Address { + + String city; + + @Field("str") String street; + } + + static enum Gender { + PIXY, UNICORN, GOBLIN + } + +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MongoJsonSchemaMapperUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MongoJsonSchemaMapperUnitTests.java new file mode 100644 index 000000000..492d24189 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/convert/MongoJsonSchemaMapperUnitTests.java @@ -0,0 +1,218 @@ +/* + * Copyright 2018 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.core.convert; + +import static org.mockito.Mockito.*; +import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.*; +import static org.springframework.data.mongodb.test.util.Assertions.*; + +import java.util.Arrays; +import java.util.List; + +import org.bson.Document; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.core.schema.MongoJsonSchema; + +/** + * Unit tests for {@link MongoJsonSchemaMapper}. + * + * @author Christoph Strobl + */ +public class MongoJsonSchemaMapperUnitTests { + + public @Rule ExpectedException exception = ExpectedException.none(); + + MongoJsonSchemaMapper mapper; + + Document addressProperty = new Document("type", "object").append("required", Arrays.asList("street", "postCode")) + .append("properties", + new Document("street", new Document("type", "string")).append("postCode", new Document("type", "string"))); + + Document mappedAddressProperty = new Document("type", "object") + .append("required", Arrays.asList("street", "post_code")).append("properties", + new Document("street", new Document("type", "string")).append("post_code", new Document("type", "string"))); + + Document nameProperty = new Document("type", "string"); + Document gradePointAverageProperty = new Document("bsonType", "double"); + Document yearProperty = new Document("bsonType", "int").append("minimum", 2017).append("maximum", 3017) + .append("exclusiveMaximum", true); + + Document properties = new Document("name", nameProperty) // + .append("gradePointAverage", gradePointAverageProperty) // + .append("year", yearProperty); + + Document mappedProperties = new Document("name", new Document(nameProperty)) // + .append("gpa", new Document(gradePointAverageProperty)) // + .append("year", new Document(yearProperty)); + + List requiredProperties = Arrays.asList("name", "gradePointAverage"); + List mappedRequiredProperties = Arrays.asList("name", "gpa"); + + Document $jsonSchema = new Document("type", "object") // + .append("required", requiredProperties) // + .append("properties", properties); + + Document mapped$jsonSchema = new Document("type", "object") // + .append("required", mappedRequiredProperties) // + .append("properties", mappedProperties); + + Document sourceSchemaDocument = new Document("$jsonSchema", $jsonSchema); + Document mappedSchemaDocument = new Document("$jsonSchema", mapped$jsonSchema); + + String complexSchemaJsonString = "{ $jsonSchema: {" + // + " type: \"object\"," + // + " required: [ \"name\", \"year\", \"major\", \"gpa\" ]," + // + " properties: {" + // + " name: {" + // + " type: \"string\"," + // + " description: \"must be a string and is required\"" + // + " }," + // + " gender: {" + // + " type: \"string\"," + // + " description: \"must be a string and is not required\"" + // + " }," + // + " year: {" + // + " bsonType: \"int\"," + // + " minimum: 2017," + // + " maximum: 3017," + // + " exclusiveMaximum: true," + // + " description: \"must be an integer in [ 2017, 3017 ] and is required\"" + // + " }," + // + " major: {" + // + " type: \"string\"," + // + " enum: [ \"Math\", \"English\", \"Computer Science\", \"History\", null ]," + // + " description: \"can only be one of the enum values and is required\"" + // + " }," + // + " gpa: {" + // + " bsonType: \"double\"," + // + " description: \"must be a double and is required\"" + // + " }" + // + " }" + // + " } }"; + + @Before + public void setUp() { + mapper = new MongoJsonSchemaMapper(new MappingMongoConverter(mock(DbRefResolver.class), new MongoMappingContext())); + } + + @Test // DATAMONGO-1835 + public void noNullSchemaAllowed() { + + exception.expect(IllegalArgumentException.class); + + mapper.mapSchema(null, Object.class); + } + + @Test // DATAMONGO-1835 + public void noNullDomainTypeAllowed() { + + exception.expect(IllegalArgumentException.class); + + mapper.mapSchema(new Document("$jsonSchema", new Document()), null); + } + + @Test // DATAMONGO-1835 + public void schemaDocumentMustContain$jsonSchemaField() { + + exception.expect(IllegalArgumentException.class); + exception.expectMessage("contain $jsonSchema"); + + mapper.mapSchema(new Document("foo", new Document()), Object.class); + } + + @Test // DATAMONGO-1835 + public void objectTypeSkipsFieldMapping() { + assertThat(mapper.mapSchema(sourceSchemaDocument, Object.class)).isEqualTo(sourceSchemaDocument); + } + + @Test // DATAMONGO-1835 + public void mapSchemaProducesNewDocument() { + assertThat(mapper.mapSchema(sourceSchemaDocument, Object.class)).isNotSameAs(sourceSchemaDocument); + } + + @Test // DATAMONGO-1835 + public void mapSchemaMapsPropertiesToFieldNames() { + assertThat(mapper.mapSchema(sourceSchemaDocument, Student.class)).isEqualTo(mappedSchemaDocument); + } + + @Test // DATAMONGO-1835 + public void mapSchemaLeavesSourceDocumentUntouched() { + + Document source = Document.parse(complexSchemaJsonString); + mapper.mapSchema(source, Student.class); + + assertThat(source).isEqualTo(Document.parse(complexSchemaJsonString)); + } + + @Test // DATAMONGO-1835 + public void mapsNestedPropertiesCorrectly() { + + Document schema = new Document("$jsonSchema", new Document("type", "object") // + .append("properties", new Document(properties).append("address", addressProperty))); + + Document expectedSchema = new Document("$jsonSchema", new Document("type", "object") // + .append("properties", new Document(mappedProperties).append("address", mappedAddressProperty))); + + assertThat(mapper.mapSchema(schema, Student.class)).isEqualTo(expectedSchema); + } + + @Test // DATAMONGO-1835 + public void constructReferenceSchemaCorrectly() { + + MongoJsonSchema schema = MongoJsonSchema.builder() // + .required("name", "year", "major", "gradePointAverage").description("") // + .properties(string("name").description("must be a string and is required"), // + string("gender").description("must be a string and is not required"), // + int32("year").description("must be an integer in [ 2017, 3017 ] and is required").gte(2017).lt(3017), // + string("major").description("can only be one of the enum values and is required").possibleValues("Math", + "English", "Computer Science", "History", null), // + float64("gradePointAverage").description("must be a double and is required") // + ).build(); + + assertThat(mapper.mapSchema(schema.toDocument(), Student.class)).isEqualTo(Document.parse(complexSchemaJsonString)); + } + + static class Student { + + String name; + Gender gender; + Integer year; + String major; + + @Field("gpa") // + Double gradePointAverage; + Address address; + } + + static class Address { + + String city; + String street; + + @Field("post_code") // + String postCode; + } + + static enum Gender { + M, F + } + +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/CriteriaTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/CriteriaTests.java index 50938886c..f0950b037 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/CriteriaTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/CriteriaTests.java @@ -19,12 +19,15 @@ import static org.hamcrest.CoreMatchers.*; import static org.junit.Assert.*; import static org.springframework.data.mongodb.test.util.IsBsonObject.*; +import java.util.Collections; + import org.bson.Document; import org.junit.Test; import org.springframework.data.geo.Point; import org.springframework.data.mongodb.InvalidMongoDbApiUsageException; import org.springframework.data.mongodb.core.geo.GeoJsonLineString; import org.springframework.data.mongodb.core.geo.GeoJsonPoint; +import org.springframework.data.mongodb.core.schema.MongoJsonSchema; /** * @author Oliver Gierke @@ -213,4 +216,24 @@ public class CriteriaTests { assertThat(document, isBsonObject().containing("foo.$geoIntersects.$geometry", lineString)); } + + @Test // DATAMONGO-1835 + public void extractsJsonSchemaInChainCorrectly() { + + MongoJsonSchema schema = MongoJsonSchema.builder().required("name").build(); + Criteria critera = Criteria.where("foo").is("bar").andDocumentStructureMatches(schema); + + assertThat(critera.getCriteriaObject(), is(equalTo(new Document("foo", "bar").append("$jsonSchema", + new Document("type", "object").append("required", Collections.singletonList("name")))))); + } + + @Test // DATAMONGO-1835 + public void extractsJsonSchemaFromFactoryMethodCorrectly() { + + MongoJsonSchema schema = MongoJsonSchema.builder().required("name").build(); + Criteria critera = Criteria.matchingDocumentStructure(schema); + + assertThat(critera.getCriteriaObject(), is(equalTo(new Document("$jsonSchema", + new Document("type", "object").append("required", Collections.singletonList("name")))))); + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/JsonSchemaObjectUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/JsonSchemaObjectUnitTests.java new file mode 100644 index 000000000..491f14ce9 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/JsonSchemaObjectUnitTests.java @@ -0,0 +1,269 @@ +/* + * Copyright 2018 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 + * + * http://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.springframework.data.domain.Range.from; +import static org.springframework.data.domain.Range.Bound.*; +import static org.springframework.data.mongodb.core.schema.JsonSchemaObject.*; +import static org.springframework.data.mongodb.core.schema.JsonSchemaObject.of; +import static org.springframework.data.mongodb.test.util.Assertions.*; + +import java.util.Arrays; + +import org.bson.Document; +import org.junit.Test; +import org.springframework.data.domain.Range; +import org.springframework.data.domain.Range.*; + +/** + * Tests verifying {@link org.bson.Document} representation of {@link JsonSchemaObject}s. + * + * @author Christoph Strobl + */ +public class JsonSchemaObjectUnitTests { + + // ----------------- + // type : 'object' + // ----------------- + + @Test // DATAMONGO-1835 + public void objectObjectShouldRenderTypeCorrectly() { + + assertThat(object().generatedDescription().toDocument()) + .isEqualTo(new Document("type", "object").append("description", "Must be an object.")); + } + + @Test // DATAMONGO-1835 + public void objectObjectShouldRenderNrPropertiesCorrectly() { + + assertThat(object().nrProperties(from(inclusive(10)).to(inclusive(20))).generatedDescription().toDocument()) + .isEqualTo(new Document("type", "object").append("description", "Must be an object with [10-20] properties.") + .append("minProperties", 10).append("maxProperties", 20)); + } + + @Test // DATAMONGO-1835 + public void objectObjectShouldRenderRequiredPropertiesCorrectly() { + + assertThat(object().required("spring", "data", "mongodb").generatedDescription().toDocument()).isEqualTo( + new Document("type", "object") + .append("description", "Must be an object where spring, data, mongodb are mandatory.").append("required", + Arrays.asList("spring", "data", "mongodb"))); + } + + @Test // DATAMONGO-1835 + public void objectObjectShouldRenderAdditionalPropertiesCorrectlyWhenBoolean() { + + assertThat(object().additionalProperties(true).generatedDescription().toDocument()).isEqualTo( + new Document("type", "object").append("description", "Must be an object allowing additional properties.") + .append("additionalProperties", true)); + + assertThat(object().additionalProperties(false).generatedDescription().toDocument()).isEqualTo( + new Document("type", "object").append("description", "Must be an object not allowing additional properties.") + .append("additionalProperties", false)); + } + + @Test // DATAMONGO-1835 + public void objectObjectShouldRenderPropertiesCorrectly() { + + Document expected = new Document("type", "object") + .append("description", "Must be an object defining restrictions for name, active.").append("properties", + new Document("name", new Document("type", "string") + .append("description", "Must be a string with length unbounded-10].").append("maxLength", 10)) + .append("active", new Document("type", "boolean"))); + + assertThat(object().generatedDescription() + .properties(JsonSchemaProperty.string("name").maxLength(10).generatedDescription(), + JsonSchemaProperty.bool("active")) + .generatedDescription().toDocument()).isEqualTo(expected); + } + + @Test // DATAMONGO-1835 + public void objectObjectShouldRenderNestedObjectPropertiesCorrectly() { + + Document expected = new Document("type", "object") + .append("description", "Must be an object defining restrictions for address.") + .append("properties", new Document("address", + new Document("type", "object").append("description", "Must be an object defining restrictions for city.") + .append("properties", new Document("city", new Document("type", "string") + .append("description", "Must be a string with length [3-unbounded.").append("minLength", 3))))); + + assertThat(object() + .properties(JsonSchemaProperty.object("address") + .properties(JsonSchemaProperty.string("city").minLength(3).generatedDescription()).generatedDescription()) + .generatedDescription().toDocument()).isEqualTo(expected); + } + + @Test // DATAMONGO-1835 + public void objectObjectShouldRenderPatternPropertiesCorrectly() { + + Document expected = new Document("type", "object") + .append("description", "Must be an object defining restrictions for patterns na.*.") + .append("patternProperties", new Document("na.*", new Document("type", "string") + .append("description", "Must be a string with length unbounded-10].").append("maxLength", 10))); + + assertThat(object().patternProperties(JsonSchemaProperty.string("na.*").maxLength(10).generatedDescription()) + .generatedDescription().toDocument()).isEqualTo(expected); + } + + // ----------------- + // type : 'string' + // ----------------- + + @Test // DATAMONGO-1835 + public void stringObjectShouldRenderTypeCorrectly() { + + assertThat(string().generatedDescription().toDocument()) + .isEqualTo(new Document("type", "string").append("description", "Must be a string.")); + } + + @Test // DATAMONGO-1835 + public void stringObjectShouldRenderDescriptionCorrectly() { + + assertThat(string().description("error msg").toDocument()) + .isEqualTo(new Document("type", "string").append("description", "error msg")); + } + + @Test // DATAMONGO-1835 + public void stringObjectShouldRenderRangeCorrectly() { + + assertThat(string().length(from(inclusive(10)).to(inclusive(20))).generatedDescription().toDocument()) + .isEqualTo(new Document("type", "string").append("description", "Must be a string with length [10-20].") + .append("minLength", 10).append("maxLength", 20)); + } + + @Test // DATAMONGO-1835 + public void stringObjectShouldRenderPatternCorrectly() { + + assertThat(string().matching("^spring$").generatedDescription().toDocument()) + .isEqualTo(new Document("type", "string").append("description", "Must be a string matching ^spring$.") + .append("pattern", "^spring$")); + } + + // ----------------- + // type : 'number' + // ----------------- + + @Test // DATAMONGO-1835 + public void numberObjectShouldRenderMultipleOfCorrectly() { + + assertThat(number().multipleOf(3.141592F).generatedDescription().toDocument()) + .isEqualTo(new Document("type", "number").append("description", "Must be a numeric value multiple of 3.141592.") + .append("multipleOf", 3.141592F)); + } + + @Test // DATAMONGO-1835 + public void numberObjectShouldRenderMaximumCorrectly() { + + assertThat( + number().within(Range.of(Bound.unbounded(), Bound.inclusive(3.141592F))).generatedDescription().toDocument()) + .isEqualTo(new Document("type", "number") + .append("description", "Must be a numeric value within range unbounded-3.141592].") + .append("maximum", 3.141592F)); + + assertThat( + number().within(Range.of(Bound.unbounded(), Bound.exclusive(3.141592F))).generatedDescription().toDocument()) + .isEqualTo(new Document("type", "number") + .append("description", "Must be a numeric value within range unbounded-3.141592).") + .append("maximum", 3.141592F).append("exclusiveMaximum", true)); + } + + @Test // DATAMONGO-1835 + public void numberObjectShouldRenderMinimumCorrectly() { + + assertThat( + number().within(Range.of(Bound.inclusive(3.141592F), Bound.unbounded())).generatedDescription().toDocument()) + .isEqualTo(new Document("type", "number") + .append("description", "Must be a numeric value within range [3.141592-unbounded.") + .append("minimum", 3.141592F)); + + assertThat( + number().within(Range.of(Bound.exclusive(3.141592F), Bound.unbounded())).generatedDescription().toDocument()) + .isEqualTo(new Document("type", "number") + .append("description", "Must be a numeric value within range (3.141592-unbounded.") + .append("minimum", 3.141592F).append("exclusiveMinimum", true)); + } + + // ----------------- + // type : 'arrays' + // ----------------- + + @Test // DATAMONGO-1835 + public void arrayObjectShouldRenderItemsCorrectly() { + + assertThat(array().items(Arrays.asList(string(), bool())).toDocument()).isEqualTo(new Document("type", "array") + .append("items", Arrays.asList(new Document("type", "string"), new Document("type", "boolean")))); + } + + @Test // DATAMONGO-1835 + public void arrayObjectShouldRenderMaxItemsCorrectly() { + + assertThat(array().maxItems(5).generatedDescription().toDocument()).isEqualTo(new Document("type", "array") + .append("description", "Must be an array having size unbounded-5].").append("maxItems", 5)); + } + + @Test // DATAMONGO-1835 + public void arrayObjectShouldRenderMinItemsCorrectly() { + + assertThat(array().minItems(5).generatedDescription().toDocument()).isEqualTo(new Document("type", "array") + .append("description", "Must be an array having size [5-unbounded.").append("minItems", 5)); + } + + @Test // DATAMONGO-1835 + public void arrayObjectShouldRenderUniqueItemsCorrectly() { + + assertThat(array().uniqueItems(true).generatedDescription().toDocument()).isEqualTo(new Document("type", "array") + .append("description", "Must be an array of unique values.").append("uniqueItems", true)); + } + + // ----------------- + // type : 'any' + // ----------------- + + @Test // DATAMONGO-1835 + public void typedObjectShouldRenderEnumCorrectly() { + + assertThat(of(String.class).possibleValues(Arrays.asList("one", "two")).toDocument()) + .isEqualTo(new Document("type", "string").append("enum", Arrays.asList("one", "two"))); + } + + @Test // DATAMONGO-1835 + public void typedObjectShouldRenderAllOfCorrectly() { + + assertThat(of(Object.class).allOf(Arrays.asList(string())).toDocument()) + .isEqualTo(new Document("type", "object").append("allOf", Arrays.asList(new Document("type", "string")))); + } + + @Test // DATAMONGO-1835 + public void typedObjectShouldRenderAnyOfCorrectly() { + + assertThat(of(String.class).anyOf(Arrays.asList(string())).toDocument()) + .isEqualTo(new Document("type", "string").append("anyOf", Arrays.asList(new Document("type", "string")))); + } + + @Test // DATAMONGO-1835 + public void typedObjectShouldRenderOneOfCorrectly() { + + assertThat(of(String.class).oneOf(Arrays.asList(string())).toDocument()) + .isEqualTo(new Document("type", "string").append("oneOf", Arrays.asList(new Document("type", "string")))); + } + + @Test // DATAMONGO-1835 + public void typedObjectShouldRenderNotCorrectly() { + + assertThat(untyped().notMatch(string()).toDocument()) + .isEqualTo(new Document("not", new Document("type", "string"))); + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/MongoJsonSchemaTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/MongoJsonSchemaTests.java new file mode 100644 index 000000000..44246f4fd --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/MongoJsonSchemaTests.java @@ -0,0 +1,190 @@ +/* + * Copyright 2018 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 + * + * http://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.springframework.data.mongodb.test.util.Assertions.*; + +import lombok.Data; + +import java.util.List; + +import org.bson.Document; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.data.mongodb.config.AbstractMongoConfiguration; +import org.springframework.data.mongodb.core.CollectionOptions; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.convert.MongoJsonSchemaMapper; +import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.test.util.MongoVersionRule; +import org.springframework.data.util.Version; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import com.mongodb.MongoClient; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.CreateCollectionOptions; +import com.mongodb.client.model.ValidationAction; +import com.mongodb.client.model.ValidationLevel; +import com.mongodb.client.model.ValidationOptions; + +/** + * @author Christoph Strobl + * @since 2017/12 + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration +public class MongoJsonSchemaTests { + + public static @ClassRule MongoVersionRule REQUIRES_AT_LEAST_3_6_0 = MongoVersionRule.atLeast(Version.parse("3.6.0")); + + @Configuration + static class Config extends AbstractMongoConfiguration { + + @Override + public MongoClient mongoClient() { + return new MongoClient(); + } + + @Override + protected String getDatabaseName() { + return "json-schema-tests"; + } + } + + @Autowired MongoTemplate template; + + @Before + public void setUp() { + + template.dropCollection(Person.class); + } + + @Test // DATAMONGO-1835 + public void writeSchemaViaTemplate() { + + MongoJsonSchema schema = MongoJsonSchema.builder() // + .required("firstname", "lastname") // + .properties( // + JsonSchemaProperty.string("firstname").possibleValues("luke", "han").maxLength(10), // + JsonSchemaProperty.object("address") // + .properties(JsonSchemaProperty.string("postCode").minLength(4).maxLength(5)) + + ).build(); + + template.createCollection(Person.class, CollectionOptions.empty().schema(schema)); + + Document $jsonSchema = new MongoJsonSchemaMapper(template.getConverter()).mapSchema(schema.toDocument(), + Person.class); + + Document fromDb = readSchemaFromDatabase("persons"); + assertThat(fromDb).isEqualTo($jsonSchema); + } + + @Test // DATAMONGO-1835 + public void nonMappedSchema() { + + MongoJsonSchema schema = MongoJsonSchema.builder() // + .required("firstname", "lastname") // + .properties( // + JsonSchemaProperty.string("firstname").possibleValues("luke", "han").maxLength(10), // + JsonSchemaProperty.object("address") // + .properties(JsonSchemaProperty.string("postCode").minLength(4).maxLength(5)) + + ).build(); + + template.createCollection("persons", CollectionOptions.empty().schema(schema)); + + Document fromDb = readSchemaFromDatabase("persons"); + assertThat(fromDb) + .isNotEqualTo(new MongoJsonSchemaMapper(template.getConverter()).mapSchema(schema.toDocument(), Person.class)); + } + + @Test // DATAMONGO-1835 + public void writeSchemaManually() { + + MongoJsonSchema schema = MongoJsonSchema.builder() // + .required("firstname", "lastname") // + .properties( // + JsonSchemaProperty.string("firstname").possibleValues("luke", "han").maxLength(10), // + JsonSchemaProperty.object("address") // + .properties(JsonSchemaProperty.string("postCode").minLength(4).maxLength(5)) + + ).build(); + + Document $jsonSchema = new MongoJsonSchemaMapper(template.getConverter()).mapSchema(schema.toDocument(), + Person.class); + + ValidationOptions options = new ValidationOptions(); + options.validationLevel(ValidationLevel.MODERATE); + options.validationAction(ValidationAction.ERROR); + options.validator($jsonSchema); + + CreateCollectionOptions cco = new CreateCollectionOptions(); + cco.validationOptions(options); + + MongoDatabase db = template.getDb(); + db.createCollection("persons", cco); + + Document fromDb = readSchemaFromDatabase("persons"); + assertThat(fromDb).isEqualTo($jsonSchema); + } + + Document readSchemaFromDatabase(String collectionName) { + + Document collectionInfo = template + .executeCommand(new Document("listCollections", 1).append("filter", new Document("name", collectionName))); + + if (collectionInfo == null) { + throw new DataRetrievalFailureException(String.format("Collection %s was not found.")); + } + + if (collectionInfo.containsKey("cursor")) { + collectionInfo = (Document) collectionInfo.get("cursor", Document.class).get("firstBatch", List.class).iterator() + .next(); + } + + if (!collectionInfo.containsKey("options")) { + return new Document(); + } + + return collectionInfo.get("options", Document.class).get("validator", Document.class); + } + + @Data + @org.springframework.data.mongodb.core.mapping.Document(collection = "persons") + static class Person { + + @Field("first_name") String firstname; + String lastname; + Address address; + + } + + static class Address { + + String city; + String street; + + @Field("post_code") String postCode; + } + +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/MongoJsonSchemaUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/MongoJsonSchemaUnitTests.java new file mode 100644 index 000000000..2d3bf03a6 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/schema/MongoJsonSchemaUnitTests.java @@ -0,0 +1,48 @@ +/* + * Copyright 2018 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 + * + * http://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.springframework.data.mongodb.test.util.Assertions.*; + +import java.util.Arrays; + +import org.bson.Document; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +/** + * @author Christoph Strobl + */ +@RunWith(MockitoJUnitRunner.class) +public class MongoJsonSchemaUnitTests { + + @Test // DATAMONGO-1835 + public void toDocumentRendersSchemaCorrectly() { + + MongoJsonSchema schema = MongoJsonSchema.builder() // + .required("firstname", "lastname") // + .build(); + + assertThat(schema.toDocument()).isEqualTo(new Document("$jsonSchema", + new Document("type", "object").append("required", Arrays.asList("firstname", "lastname")))); + } + + @Test(expected = IllegalArgumentException.class) // DATAMONGO-1835 + public void throwsExceptionOnNullRoot() { + MongoJsonSchema.of(null); + } +} diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc index 00c8abd3c..ec3fbeaf2 100644 --- a/src/main/asciidoc/new-features.adoc +++ b/src/main/asciidoc/new-features.adoc @@ -5,6 +5,8 @@ == What's new in Spring Data MongoDB 2.1 * Cursor-based aggregation execution. * <> for imperative and reactive Template API. +* `validator` support for collections. +* `$jsonSchema` support for queries. [[new-features.2-0-0]] == What's new in Spring Data MongoDB 2.0 diff --git a/src/main/asciidoc/reference/mongodb.adoc b/src/main/asciidoc/reference/mongodb.adoc index a4b994505..9ee53cab9 100644 --- a/src/main/asciidoc/reference/mongodb.adoc +++ b/src/main/asciidoc/reference/mongodb.adoc @@ -1464,6 +1464,33 @@ AggregationResults results = template.aggregate(aggregation, "tags", T WARNING: Indexes are only used if the collation used for the operation and the index collation matches. +[[mongo.jsonSchema]] +=== JSON Schema + +As of version 3.6 MongoDB supports collections that validate ``Document``s against a provided JSON Schema. The schema itself and both validation action and level can be defined when creating the collection. + +`CollectionOptions` provides the entry point to schema support for collections. + +.Create collection with `$jsonSchema` +==== +[source,java] +---- +// TODO: add sample here! +---- +==== + +Additionally it is also possible to query any collection for documents that match a given structure defined by a JSON Schema. + +.Query collation for Documents matching a `$jsonSchema` +==== +[source,java] +---- +// TODO: add sample here! +---- +==== + +NOTE: `$jsonSchema` can only be applied on the top level of a query and not property specific. Use the `properties` attribute of the schema to match against nested fields. + [[mongo.query.fluent-template-api]] === Fluent Template API