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 97cbfb536..fbc899d41 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 @@ -16,13 +16,18 @@ package org.springframework.data.mongodb.core; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; import java.util.function.Function; import org.bson.conversions.Bson; import org.springframework.data.mongodb.core.mapping.Field; import org.springframework.data.mongodb.core.query.Collation; +import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.QueryableJsonSchemaProperty; +import org.springframework.data.mongodb.core.schema.JsonSchemaProperty; import org.springframework.data.mongodb.core.schema.MongoJsonSchema; +import org.springframework.data.mongodb.core.schema.QueryCharacteristics; import org.springframework.data.mongodb.core.timeseries.Granularity; import org.springframework.data.mongodb.core.timeseries.GranularityDefinition; import org.springframework.data.mongodb.core.validation.Validator; @@ -57,7 +62,7 @@ public class CollectionOptions { private CollectionOptions(@Nullable Long size, @Nullable Long maxDocuments, @Nullable Boolean capped, @Nullable Collation collation, ValidationOptions validationOptions, @Nullable TimeSeriesOptions timeSeriesOptions, - @Nullable CollectionChangeStreamOptions changeStreamOptions, @Nullable Bson encryptedFields) { + @Nullable CollectionChangeStreamOptions changeStreamOptions, @Nullable Bson encryptedFields) { this.maxDocuments = maxDocuments; this.size = size; @@ -423,9 +428,9 @@ public class CollectionOptions { return "CollectionOptions{" + "maxDocuments=" + maxDocuments + ", size=" + size + ", capped=" + capped + ", collation=" + collation + ", validationOptions=" + validationOptions + ", timeSeriesOptions=" + timeSeriesOptions + ", changeStreamOptions=" + changeStreamOptions + ", encryptedFields=" + encryptedFields - + ", disableValidation=" + disableValidation() + ", strictValidation=" + strictValidation() + ", moderateValidation=" - + moderateValidation() + ", warnOnValidationError=" + warnOnValidationError() + ", failOnValidationError=" - + failOnValidationError() + '}'; + + ", disableValidation=" + disableValidation() + ", strictValidation=" + strictValidation() + + ", moderateValidation=" + moderateValidation() + ", warnOnValidationError=" + warnOnValidationError() + + ", failOnValidationError=" + failOnValidationError() + '}'; } @Override @@ -606,6 +611,18 @@ public class CollectionOptions { } } + public static class EncryptedCollectionOptions { + + private List queryableProperties = new ArrayList<>(); + + public EncryptedCollectionOptions queryable(JsonSchemaProperty schemaObject, QueryCharacteristics characteristics) { + + queryableProperties.add(JsonSchemaProperty.queryable(schemaObject, characteristics)); + return this; + + } + } + /** * Encapsulation of options applied to define collections change stream behaviour. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/DocumentJsonSchema.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/DocumentJsonSchema.java index 0407bac27..11bbb3498 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/DocumentJsonSchema.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/DocumentJsonSchema.java @@ -15,6 +15,9 @@ */ package org.springframework.data.mongodb.core.schema; +import java.util.Collections; +import java.util.Set; + import org.bson.Document; import org.springframework.util.Assert; 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 index 29cedfd6c..aa66873e6 100644 --- 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 @@ -21,10 +21,11 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; import org.bson.Document; - import org.springframework.data.domain.Range; +import org.springframework.data.mongodb.core.schema.QueryCharacteristics.QueryCharacteristic; 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.DateJsonSchemaObject; @@ -80,6 +81,40 @@ public class IdentifiableJsonSchemaProperty implemen return jsonSchemaObjectDelegate.getTypes(); } + public static class QueryableJsonSchemaProperty implements JsonSchemaProperty { + + private final JsonSchemaProperty targetProperty; + private final QueryCharacteristics characteristics; + + public QueryableJsonSchemaProperty(JsonSchemaProperty target, QueryCharacteristics characteristics) { + this.targetProperty = target; + this.characteristics = characteristics; + } + + + @Override + public Document toDocument() { + + Document doc = targetProperty.toDocument(); + Document propertySpecification = doc.get(targetProperty.getIdentifier(), Document.class); + + List queries = characteristics.getCharacteristics().stream().map(QueryCharacteristic::toDocument).toList(); + propertySpecification.append("queries", queries); + + return propertySpecification; + } + + @Override + public String getIdentifier() { + return targetProperty.getIdentifier(); + } + + @Override + public Set getTypes() { + return targetProperty.getTypes(); + } + } + /** * Convenience {@link JsonSchemaProperty} implementation without a {@code type} property. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaProperty.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaProperty.java index 8529951db..eadfdab87 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaProperty.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/JsonSchemaProperty.java @@ -69,6 +69,10 @@ public interface JsonSchemaProperty extends JsonSchemaObject { return EncryptedJsonSchemaProperty.encrypted(property); } + static QueryableJsonSchemaProperty queryable(JsonSchemaProperty property, QueryCharacteristics queries) { + return new QueryableJsonSchemaProperty(property, queries); + } + /** * Creates a new {@link StringJsonSchemaProperty} with given {@literal identifier} of {@code type : 'string'}. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MergedJsonSchema.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MergedJsonSchema.java index e0f3e2610..a6fc3ab8b 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MergedJsonSchema.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/MergedJsonSchema.java @@ -19,7 +19,9 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.BiFunction; +import java.util.stream.Collectors; import org.bson.Document; diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/QueryCharacteristics.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/QueryCharacteristics.java new file mode 100644 index 000000000..e71792de2 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/schema/QueryCharacteristics.java @@ -0,0 +1,147 @@ +/* + * Copyright 2025. 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. + */ + +/* + * Copyright 2025 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.ArrayList; +import java.util.List; + +import org.bson.BsonNull; +import org.bson.Document; +import org.springframework.data.domain.Range; +import org.springframework.data.domain.Range.Bound; +import org.springframework.lang.Nullable; + +/** + * @author Christoph Strobl + */ +public class QueryCharacteristics { + + private static final QueryCharacteristics NONE = new QueryCharacteristics(List.of()); + + private final List characteristics; + + public QueryCharacteristics(List characteristics) { + this.characteristics = characteristics; + } + + public static QueryCharacteristics none() { + return NONE; + } + + QueryCharacteristics(QueryCharacteristic... characteristics) { + + this.characteristics = new ArrayList<>(characteristics.length); + for (QueryCharacteristic characteristic : characteristics) { + addQuery(characteristic); + } + } + + public void addQuery(QueryCharacteristic characteristic) { + this.characteristics.add(characteristic); + } + + List getCharacteristics() { + return characteristics; + } + + public static RangeQuery range() { + return new RangeQuery<>(); + } + + public interface QueryCharacteristic { + + String type(); + + default Document toDocument() { + return new Document("queryType", type()); + } + } + + public static class RangeQuery implements QueryCharacteristic { + + private final @Nullable Range valueRange; + private final @Nullable Integer trimFactor; + private final @Nullable Long sparsity; + private final @Nullable Long contention; + + private RangeQuery() { + this(Range.unbounded(), null, null, null); + } + + public RangeQuery(Range valueRange, Integer trimFactor, Long sparsity, Long contention) { + this.valueRange = valueRange; + this.trimFactor = trimFactor; + this.sparsity = sparsity; + this.contention = contention; + } + + @Override + public String type() { + return "range"; + } + + public RangeQuery min(T lower) { + + Range range = Range.of(Bound.inclusive(lower), + valueRange != null ? valueRange.getUpperBound() : Bound.unbounded()); + return new RangeQuery<>(range, trimFactor, sparsity, contention); + } + + public RangeQuery max(T upper) { + + Range range = Range.of(valueRange != null ? valueRange.getLowerBound() : Bound.unbounded(), + Bound.inclusive(upper)); + return new RangeQuery<>(range, trimFactor, sparsity, contention); + } + + public RangeQuery trimFactor(int trimFactor) { + return new RangeQuery<>(valueRange, trimFactor, sparsity, contention); + } + + public RangeQuery sparsity(long sparsity) { + return new RangeQuery<>(valueRange, trimFactor, sparsity, contention); + } + + public RangeQuery contention(long contention) { + return new RangeQuery<>(valueRange, trimFactor, sparsity, contention); + } + + @Override + public Document toDocument() { + + return QueryCharacteristic.super.toDocument().append("contention", contention).append("trimFactor", trimFactor) + .append("sparsity", sparsity).append("min", valueRange.getLowerBound().getValue().orElse((T)BsonNull.VALUE)) + .append("max", valueRange.getUpperBound().getValue().orElse((T)BsonNull.VALUE)); + } + } + +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java index 2c5e3abc6..284cb9228 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/encryption/RangeEncryptionTests.java @@ -20,7 +20,9 @@ import static org.assertj.core.api.Assertions.*; import static org.springframework.data.mongodb.core.EncryptionAlgorithms.*; import static org.springframework.data.mongodb.core.query.Criteria.*; +import java.nio.charset.StandardCharsets; import java.security.SecureRandom; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.atomic.AtomicReference; @@ -41,6 +43,7 @@ import com.mongodb.client.model.CreateEncryptedCollectionParams; import com.mongodb.client.model.Filters; import com.mongodb.client.model.IndexOptions; import com.mongodb.client.model.Indexes; +import com.mongodb.client.model.vault.DataKeyOptions; import com.mongodb.client.vault.ClientEncryption; import com.mongodb.client.vault.ClientEncryptions; @@ -200,19 +203,38 @@ class RangeEncryptionTests { database.getCollection("test").drop(); ClientEncryption clientEncryption = mongoClientEncryption.getClientEncryption(); +// BsonDocument encryptedFields = new BsonDocument().append("fields", +// new BsonArray(asList( +// new BsonDocument("keyId", BsonNull.VALUE).append("path", new BsonString("encryptedInt")) +// .append("bsonType", new BsonString("int")) +// .append("queries", +// new BsonDocument("queryType", new BsonString("range")).append("contention", new BsonInt64(0L)) +// .append("trimFactor", new BsonInt32(1)).append("sparsity", new BsonInt64(1)) +// .append("min", new BsonInt32(0)).append("max", new BsonInt32(200))), +// new BsonDocument("keyId", BsonNull.VALUE).append("path", new BsonString("encryptedLong")) +// .append("bsonType", new BsonString("long")).append("queries", +// new BsonDocument("queryType", new BsonString("range")).append("contention", new BsonInt64(0L)) +// .append("trimFactor", new BsonInt32(1)).append("sparsity", new BsonInt64(1)) +// .append("min", new BsonInt64(1000)).append("max", new BsonInt64(9999)))))); + + BsonBinary dataKey1 = clientEncryption.createDataKey(LOCAL_KMS_PROVIDER, new DataKeyOptions().keyAltNames(List.of("dek-1"))); + BsonBinary dataKey2 = clientEncryption.createDataKey(LOCAL_KMS_PROVIDER, new DataKeyOptions().keyAltNames(List.of("dek-2"))); + BsonDocument encryptedFields = new BsonDocument().append("fields", - new BsonArray(asList( - new BsonDocument("keyId", BsonNull.VALUE).append("path", new BsonString("encryptedInt")) - .append("bsonType", new BsonString("int")) - .append("queries", - new BsonDocument("queryType", new BsonString("range")).append("contention", new BsonInt64(0L)) - .append("trimFactor", new BsonInt32(1)).append("sparsity", new BsonInt64(1)) - .append("min", new BsonInt32(0)).append("max", new BsonInt32(200))), - new BsonDocument("keyId", BsonNull.VALUE).append("path", new BsonString("encryptedLong")) - .append("bsonType", new BsonString("long")).append("queries", - new BsonDocument("queryType", new BsonString("range")).append("contention", new BsonInt64(0L)) - .append("trimFactor", new BsonInt32(1)).append("sparsity", new BsonInt64(1)) - .append("min", new BsonInt64(1000)).append("max", new BsonInt64(9999)))))); + new BsonArray(asList( + new BsonDocument("keyId", dataKey1).append("path", new BsonString("encryptedInt")) + .append("bsonType", new BsonString("int")) + .append("queries", + new BsonDocument("queryType", new BsonString("range")).append("contention", new BsonInt64(0L)) + .append("trimFactor", new BsonInt32(1)).append("sparsity", new BsonInt64(1)) + .append("min", new BsonInt32(0)).append("max", new BsonInt32(200))), + new BsonDocument("keyId", dataKey2).append("path", new BsonString("encryptedLong")) + .append("bsonType", new BsonString("long")).append("queries", + new BsonDocument("queryType", new BsonString("range")).append("contention", new BsonInt64(0L)) + .append("trimFactor", new BsonInt32(1)).append("sparsity", new BsonInt64(1)) + .append("min", new BsonInt64(1000)).append("max", new BsonInt64(9999)))))); + + BsonDocument local = clientEncryption.createEncryptedCollection(database, "test", new CreateCollectionOptions().encryptedFields(encryptedFields), @@ -239,6 +261,7 @@ class RangeEncryptionTests { builder.autoEncryptionSettings(AutoEncryptionSettings.builder() // .kmsProviders(clientEncryptionSettings.getKmsProviders()) // .keyVaultNamespace(clientEncryptionSettings.getKeyVaultNamespace()) // + .bypassAutoEncryption(true) .bypassQueryAnalysis(true).build()); } } @@ -298,9 +321,9 @@ class RangeEncryptionTests { String name; @ExplicitEncrypted(algorithm = RANGE, contentionFactor = 0L, - rangeOptions = "{\"min\": 0, \"max\": 200, \"trimFactor\": 1, \"sparsity\": 1}") Integer encryptedInt; + rangeOptions = "{\"min\": 0, \"max\": 200, \"trimFactor\": 1, \"sparsity\": 1}", keyAltName = "dek-1") Integer encryptedInt; @ExplicitEncrypted(algorithm = RANGE, contentionFactor = 0L, - rangeOptions = "{\"min\": {\"$numberLong\": \"1000\"}, \"max\": {\"$numberLong\": \"9999\"}, \"trimFactor\": 1, \"sparsity\": 1}") Long encryptedLong; + rangeOptions = "{\"min\": {\"$numberLong\": \"1000\"}, \"max\": {\"$numberLong\": \"9999\"}, \"trimFactor\": 1, \"sparsity\": 1}", keyAltName = "dek-2") Long encryptedLong; public String getId() { return this.id; 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 index 3514927b1..ec269814a 100644 --- 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 @@ -15,6 +15,7 @@ */ package org.springframework.data.mongodb.core.schema; +import static java.util.Arrays.asList; import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.*; import static org.springframework.data.mongodb.test.util.Assertions.*; @@ -22,10 +23,18 @@ import java.util.Arrays; import java.util.Collections; import java.util.UUID; +import org.bson.BsonArray; +import org.bson.BsonDocument; +import org.bson.BsonInt32; +import org.bson.BsonInt64; +import org.bson.BsonNull; +import org.bson.BsonString; import org.bson.Document; +import org.bson.json.JsonWriterSettings; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty.QueryableJsonSchemaProperty; /** * Unit tests for {@link MongoJsonSchema}. @@ -105,6 +114,35 @@ class MongoJsonSchemaUnitTests { .append("algorithm", "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic").append("bsonType", "string")))))); } + @Test // GH-4185 + void rendersQueryablePropertyCorrectly() { + + QueryCharacteristics characteristics = new QueryCharacteristics(); + characteristics.addQuery(QueryCharacteristics.range().contention(0).trimFactor(1).sparsity(1).min(0).max(200)); + + QueryableJsonSchemaProperty property = queryable(encrypted(number("mypath")), characteristics); + + //Document document = MongoJsonSchema.builder().property(property).build().schemaDocument(); + Document document = property.toDocument(); + System.out.println(document.toJson(JsonWriterSettings.builder().indent(true).build())); + + + BsonDocument encryptedFields = new BsonDocument().append("fields", + new BsonArray(asList( + new BsonDocument("keyId", BsonNull.VALUE).append("path", new BsonString("encryptedInt")) + .append("bsonType", new BsonString("int")) + .append("queries", + new BsonDocument("queryType", new BsonString("range")).append("contention", new BsonInt64(0L)) + .append("trimFactor", new BsonInt32(1)).append("sparsity", new BsonInt64(1)) + .append("min", new BsonInt32(0)).append("max", new BsonInt32(200))), + new BsonDocument("keyId", BsonNull.VALUE).append("path", new BsonString("encryptedLong")) + .append("bsonType", new BsonString("long")).append("queries", + new BsonDocument("queryType", new BsonString("range")).append("contention", new BsonInt64(0L)) + .append("trimFactor", new BsonInt32(1)).append("sparsity", new BsonInt64(1)) + .append("min", new BsonInt64(1000)).append("max", new BsonInt64(9999)))))); + + } + @Test // DATAMONGO-1835 void throwsExceptionOnNullRoot() { assertThatIllegalArgumentException().isThrownBy(() -> MongoJsonSchema.of((JsonSchemaObject) null));