MongoJsonSchema.java

/*
 * Copyright 2018-present the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.springframework.data.mongodb.core.schema;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.bson.Document;
import org.jspecify.annotations.Nullable;
import org.springframework.data.mongodb.core.schema.TypedJsonSchemaObject.ObjectJsonSchemaObject;
import org.springframework.lang.Contract;
import org.springframework.util.Assert;

/**
 * Interface defining MongoDB-specific JSON schema object. New objects can be built with {@link #builder()}, for
 * example:
 *
 * <pre class="code">
 * MongoJsonSchema schema = MongoJsonSchema.builder().required("firstname", "lastname")
 * 		.properties(string("firstname").possibleValues("luke", "han"),
 * 				object("address").properties(string("postCode").minLength(4).maxLength(5))
 *
 * 		).build();
 * </pre>
 *
 * resulting in the following schema:
 *
 * <pre>
 *  {
  "type": "object",
  "required": [ "firstname", "lastname" ],
  "properties": {
    "firstname": {
      "type": "string", "enum": [ "luke", "han" ],
    },
    "address": {
      "type": "object",
      "properties": {
        "postCode": { "type": "string", "minLength": 4, "maxLength": 5 }
      }
    }
  }
}
 * </pre>
 *
 * @author Christoph Strobl
 * @author Mark Paluch
 * @since 2.1
 * @see UntypedJsonSchemaObject
 * @see TypedJsonSchemaObject
 */
public interface MongoJsonSchema {

	/**
	 * Create the {@code $jsonSchema} {@link Document} containing the specified {@link #schemaDocument()}. <br />
	 * Property and field names need to be mapped to the domain type ones by running the {@link Document} through a
	 * {@link org.springframework.data.mongodb.core.convert.JsonSchemaMapper} to apply field name customization.
	 *
	 * @return never {@literal null}.
	 */
	default Document toDocument() {
		return new Document("$jsonSchema", schemaDocument());
	}

	/**
	 * Create the {@link Document} defining the schema. <br />
	 * Property and field names need to be mapped to the domain type property by running the {@link Document} through a
	 * {@link org.springframework.data.mongodb.core.convert.JsonSchemaMapper} to apply field name customization.
	 *
	 * @return never {@literal null}.
	 * @since 3.3
	 */
	Document schemaDocument();

	/**
	 * Create a new {@link MongoJsonSchema} for a given root object.
	 *
	 * @param root must not be {@literal null}.
	 * @return new instance of {@link MongoJsonSchema}.
	 */
	static MongoJsonSchema of(JsonSchemaObject root) {
		return new DefaultMongoJsonSchema(root);
	}

	/**
	 * Create a new {@link MongoJsonSchema} for a given root {@link Document} containing the schema definition.
	 *
	 * @param document must not be {@literal null}.
	 * @return new instance of {@link MongoJsonSchema}.
	 */
	static MongoJsonSchema of(Document document) {
		return new DocumentJsonSchema(document);
	}

	/**
	 * Create a new {@link MongoJsonSchema} merging properties from the given sources.
	 *
	 * @param sources must not be {@literal null}.
	 * @return new instance of {@link MongoJsonSchema}.
	 * @since 3.4
	 */
	static MongoJsonSchema merge(MongoJsonSchema... sources) {
		return merge((path, left, right) -> {
			throw new IllegalStateException(String.format("Cannot merge schema for path '%s' holding values '%s' and '%s'",
					path.dotPath(), left, right));
		}, sources);
	}

	/**
	 * Create a new {@link MongoJsonSchema} merging properties from the given sources.
	 *
	 * @param sources must not be {@literal null}.
	 * @return new instance of {@link MongoJsonSchema}.
	 * @since 3.4
	 */
	static MongoJsonSchema merge(ConflictResolutionFunction mergeFunction, MongoJsonSchema... sources) {
		return new MergedJsonSchema(Arrays.asList(sources), mergeFunction);
	}

	/**
	 * Create a new {@link MongoJsonSchema} merging properties from the given sources.
	 *
	 * @param sources must not be {@literal null}.
	 * @return new instance of {@link MongoJsonSchema}.
	 * @since 3.4
	 */
	default MongoJsonSchema mergeWith(MongoJsonSchema... sources) {
		return mergeWith(Arrays.asList(sources));
	}

	/**
	 * Create a new {@link MongoJsonSchema} merging properties from the given sources.
	 *
	 * @param sources must not be {@literal null}.
	 * @return new instance of {@link MongoJsonSchema}.
	 * @since 3.4
	 */
	default MongoJsonSchema mergeWith(Collection<MongoJsonSchema> sources) {
		return mergeWith(sources, (path, left, right) -> {
			throw new IllegalStateException(String.format("Cannot merge schema for path '%s' holding values '%s' and '%s'",
					path.dotPath(), left, right));
		});
	}

	/**
	 * Create a new {@link MongoJsonSchema} merging properties from the given sources.
	 *
	 * @param sources must not be {@literal null}.
	 * @return new instance of {@link MongoJsonSchema}.
	 * @since 3.4
	 */
	default MongoJsonSchema mergeWith(Collection<MongoJsonSchema> sources,
			ConflictResolutionFunction conflictResolutionFunction) {

		List<MongoJsonSchema> schemaList = new ArrayList<>(sources.size() + 1);
		schemaList.add(this);
		schemaList.addAll(new ArrayList<>(sources));
		return new MergedJsonSchema(schemaList, conflictResolutionFunction);
	}

	/**
	 * Obtain a new {@link MongoJsonSchemaBuilder} to fluently define the schema.
	 *
	 * @return new instance of {@link MongoJsonSchemaBuilder}.
	 */
	static MongoJsonSchemaBuilder builder() {
		return new MongoJsonSchemaBuilder();
	}

	/**
	 * A resolution function that is called on conflicting paths when trying to merge properties with different values
	 * into a single value.
	 *
	 * @author Christoph Strobl
	 * @since 3.4
	 */
	@FunctionalInterface
	interface ConflictResolutionFunction {

		/**
		 * Resolve the conflict for two values under the same {@code path}.
		 *
		 * @param path the {@link Path} leading to the conflict.
		 * @param left can be {@literal null}.
		 * @param right can be {@literal null}.
		 * @return never {@literal null}.
		 */
		Resolution resolveConflict(Path path, @Nullable Object left, @Nullable Object right);

		/**
		 * @author Christoph Strobl
		 * @since 3.4
		 */
		interface Path {

			/**
			 * @return the name of the currently processed element
			 */
			@Nullable String currentElement();

			/**
			 * @return the path leading to the currently processed element in dot {@literal '.'} notation.
			 */
			String dotPath();
		}

		/**
		 * The result after processing a conflict when merging schemas. May indicate to {@link #SKIP skip} the entry
		 * entirely.
		 *
		 * @author Christoph Strobl
		 * @since 3.4
		 */
		interface Resolution extends Map.Entry<String, Object> {

			@Override
			default Object setValue(Object value) {
				throw new IllegalStateException("Cannot set value result; Maybe you missed to override the method");
			}

			/**
			 * Resolution
			 */
			Resolution SKIP = new Resolution() {

				@Override
				public String getKey() {
					throw new IllegalStateException("No key for skipped result");
				}

				@Override
				public Object getValue() {
					throw new IllegalStateException("No value for skipped result");
				}

				@Override
				public Object setValue(Object value) {
					throw new IllegalStateException("Cannot set value on skipped result");
				}
			};

			/**
			 * Obtain a {@link Resolution} that will skip the entry and proceed computation.
			 *
			 * @return never {@literal null}.
			 */
			static Resolution skip() {
				return SKIP;
			}

			/**
			 * Construct a resolution for a {@link Path} using the given {@code value}.
			 *
			 * @param path the conflicting path.
			 * @param value the value to apply.
			 * @return
			 */
			static Resolution ofValue(Path path, Object value) {

				Assert.notNull(path, "Path must not be null");

				return ofValue(path.currentElement(), value);
			}

			/**
			 * Construct a resolution from a {@code key} and {@code value}.
			 *
			 * @param key name of the path segment, typically {@link Path#currentElement()}
			 * @param value the value to apply.
			 * @return
			 */
			static Resolution ofValue(@Nullable String key, Object value) {

				return new Resolution() {
					@Override
					public @Nullable String getKey() {
						return key;
					}

					@Override
					public Object getValue() {
						return value;
					}
				};
			}
		}
	}

	/**
	 * {@link MongoJsonSchemaBuilder} provides a fluent API for defining a {@link MongoJsonSchema}.
	 *
	 * @author Christoph Strobl
	 */
	class MongoJsonSchemaBuilder {

		private ObjectJsonSchemaObject root;

		private @Nullable Document encryptionMetadata;

		MongoJsonSchemaBuilder() {
			root = new ObjectJsonSchemaObject();
		}

		/**
		 * @param count
		 * @return {@code this} {@link MongoJsonSchemaBuilder}.
		 * @see ObjectJsonSchemaObject#minProperties(int)
		 */
		@Contract("_ -> this")
		public MongoJsonSchemaBuilder minProperties(int count) {

			root = root.minProperties(count);
			return this;
		}

		/**
		 * @param count
		 * @return {@code this} {@link MongoJsonSchemaBuilder}.
		 * @see ObjectJsonSchemaObject#maxProperties(int)
		 */
		@Contract("_ -> this")
		public MongoJsonSchemaBuilder maxProperties(int count) {

			root = root.maxProperties(count);
			return this;
		}

		/**
		 * @param properties must not be {@literal null}.
		 * @return {@code this} {@link MongoJsonSchemaBuilder}.
		 * @see ObjectJsonSchemaObject#required(String...)
		 */
		@Contract("_ -> this")
		public MongoJsonSchemaBuilder required(String... properties) {

			root = root.required(properties);
			return this;
		}

		/**
		 * @param additionalPropertiesAllowed
		 * @return {@code this} {@link MongoJsonSchemaBuilder}.
		 * @see ObjectJsonSchemaObject#additionalProperties(boolean)
		 */
		@Contract("_ -> this")
		public MongoJsonSchemaBuilder additionalProperties(boolean additionalPropertiesAllowed) {

			root = root.additionalProperties(additionalPropertiesAllowed);
			return this;
		}

		/**
		 * @param schema must not be {@literal null}.
		 * @return {@code this} {@link MongoJsonSchemaBuilder}.
		 * @see ObjectJsonSchemaObject#additionalProperties(ObjectJsonSchemaObject)
		 */
		@Contract("_ -> this")
		public MongoJsonSchemaBuilder additionalProperties(ObjectJsonSchemaObject schema) {

			root = root.additionalProperties(schema);
			return this;
		}

		/**
		 * @param properties must not be {@literal null}.
		 * @return {@code this} {@link MongoJsonSchemaBuilder}.
		 * @see ObjectJsonSchemaObject#properties(JsonSchemaProperty...)
		 */
		@Contract("_ -> this")
		public MongoJsonSchemaBuilder properties(JsonSchemaProperty... properties) {

			root = root.properties(properties);
			return this;
		}

		/**
		 * @param properties must not be {@literal null}.
		 * @return {@code this} {@link MongoJsonSchemaBuilder}.
		 * @see ObjectJsonSchemaObject#patternProperties(JsonSchemaProperty...)
		 */
		@Contract("_ -> this")
		public MongoJsonSchemaBuilder patternProperties(JsonSchemaProperty... properties) {

			root = root.patternProperties(properties);
			return this;
		}

		/**
		 * @param property must not be {@literal null}.
		 * @return {@code this} {@link MongoJsonSchemaBuilder}.
		 * @see ObjectJsonSchemaObject#property(JsonSchemaProperty)
		 */
		@Contract("_ -> this")
		public MongoJsonSchemaBuilder property(JsonSchemaProperty property) {

			root = root.property(property);
			return this;
		}

		/**
		 * @param possibleValues must not be {@literal null}.
		 * @return {@code this} {@link MongoJsonSchemaBuilder}.
		 * @see ObjectJsonSchemaObject#possibleValues(Collection)
		 */
		@Contract("_ -> this")
		public MongoJsonSchemaBuilder possibleValues(Set<Object> possibleValues) {

			root = root.possibleValues(possibleValues);
			return this;
		}

		/**
		 * @param allOf must not be {@literal null}.
		 * @return {@code this} {@link MongoJsonSchemaBuilder}.
		 * @see UntypedJsonSchemaObject#allOf(Collection)
		 */
		@Contract("_ -> this")
		public MongoJsonSchemaBuilder allOf(Set<JsonSchemaObject> allOf) {

			root = root.allOf(allOf);
			return this;
		}

		/**
		 * @param anyOf must not be {@literal null}.
		 * @return {@code this} {@link MongoJsonSchemaBuilder}.
		 * @see UntypedJsonSchemaObject#anyOf(Collection)
		 */
		@Contract("_ -> this")
		public MongoJsonSchemaBuilder anyOf(Set<JsonSchemaObject> anyOf) {

			root = root.anyOf(anyOf);
			return this;
		}

		/**
		 * @param oneOf must not be {@literal null}.
		 * @return {@code this} {@link MongoJsonSchemaBuilder}.
		 * @see UntypedJsonSchemaObject#oneOf(Collection)
		 */
		@Contract("_ -> this")
		public MongoJsonSchemaBuilder oneOf(Set<JsonSchemaObject> oneOf) {

			root = root.oneOf(oneOf);
			return this;
		}

		/**
		 * @param notMatch must not be {@literal null}.
		 * @return {@code this} {@link MongoJsonSchemaBuilder}.
		 * @see UntypedJsonSchemaObject#notMatch(JsonSchemaObject)
		 */
		@Contract("_ -> this")
		public MongoJsonSchemaBuilder notMatch(JsonSchemaObject notMatch) {

			root = root.notMatch(notMatch);
			return this;
		}

		/**
		 * @param description must not be {@literal null}.
		 * @return {@code this} {@link MongoJsonSchemaBuilder}.
		 * @see UntypedJsonSchemaObject#description(String)
		 */
		@Contract("_ -> this")
		public MongoJsonSchemaBuilder description(String description) {

			root = root.description(description);
			return this;
		}

		/**
		 * Define the {@literal encryptMetadata} element of the schema.
		 *
		 * @param encryptionMetadata can be {@literal null}.
		 * @since 3.3
		 */
		public void encryptionMetadata(@Nullable Document encryptionMetadata) {
			this.encryptionMetadata = encryptionMetadata;
		}

		/**
		 * Obtain the {@link MongoJsonSchema}.
		 *
		 * @return new instance of {@link MongoJsonSchema}.
		 */
		@Contract("-> new")
		public MongoJsonSchema build() {
			return new DefaultMongoJsonSchema(root, encryptionMetadata);
		}
	}
}