diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/aot/generated/MongoBlocks.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/aot/generated/MongoBlocks.java deleted file mode 100644 index d811ee9a5..000000000 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/aot/generated/MongoBlocks.java +++ /dev/null @@ -1,315 +0,0 @@ -/* - * 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 - * - * 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.aot.generated; - -import java.lang.reflect.Parameter; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -import org.bson.Document; - -import org.springframework.core.annotation.MergedAnnotation; -import org.springframework.data.mongodb.BindableMongoExpression; -import org.springframework.data.mongodb.core.ExecutableFindOperation.FindWithQuery; -import org.springframework.data.mongodb.core.ExecutableRemoveOperation.ExecutableRemove; -import org.springframework.data.mongodb.core.MongoOperations; -import org.springframework.data.mongodb.core.query.BasicQuery; -import org.springframework.data.mongodb.repository.Hint; -import org.springframework.data.mongodb.repository.ReadPreference; -import org.springframework.data.mongodb.repository.query.MongoQueryExecution.DeleteExecutionX; -import org.springframework.data.mongodb.repository.query.MongoQueryExecution.DeleteExecutionX.Type; -import org.springframework.data.mongodb.repository.query.MongoQueryExecution.PagedExecution; -import org.springframework.data.mongodb.repository.query.MongoQueryExecution.SlicedExecution; -import org.springframework.data.mongodb.repository.query.MongoQueryMethod; -import org.springframework.data.repository.aot.generate.AotQueryMethodGenerationContext; -import org.springframework.javapoet.ClassName; -import org.springframework.javapoet.CodeBlock; -import org.springframework.javapoet.CodeBlock.Builder; -import org.springframework.javapoet.TypeName; -import org.springframework.lang.Nullable; -import org.springframework.util.ClassUtils; -import org.springframework.util.ObjectUtils; -import org.springframework.util.StringUtils; - -/** - * @author Christoph Strobl - */ -public class MongoBlocks { - - private static final Pattern PARAMETER_BINDING_PATTERN = Pattern.compile("\\?(\\d+)"); - - static QueryBlockBuilder queryBlockBuilder(AotQueryMethodGenerationContext context, MongoQueryMethod queryMethod) { - return new QueryBlockBuilder(context, queryMethod); - } - - static QueryExecutionBlockBuilder queryExecutionBlockBuilder(AotQueryMethodGenerationContext context, - MongoQueryMethod queryMethod) { - return new QueryExecutionBlockBuilder(context, queryMethod); - } - - static DeleteExecutionBuilder deleteExecutionBlockBuilder(AotQueryMethodGenerationContext context, - MongoQueryMethod queryMethod) { - return new DeleteExecutionBuilder(context, queryMethod); - } - - static class DeleteExecutionBuilder { - - private final AotQueryMethodGenerationContext context; - private final MongoQueryMethod queryMethod; - String queryVariableName; - - public DeleteExecutionBuilder(AotQueryMethodGenerationContext context, MongoQueryMethod queryMethod) { - this.context = context; - this.queryMethod = queryMethod; - } - - public DeleteExecutionBuilder referencing(String queryVariableName) { - this.queryVariableName = queryVariableName; - return this; - } - - public CodeBlock build() { - - String mongoOpsRef = context.fieldNameOf(MongoOperations.class); - Builder builder = CodeBlock.builder(); - - boolean isProjecting = context.getActualReturnType() != null - && !ObjectUtils.nullSafeEquals(TypeName.get(context.getRepositoryInformation().getDomainType()), - context.getActualReturnType()); - - Object actualReturnType = isProjecting ? context.getActualReturnType().getType() - : context.getRepositoryInformation().getDomainType(); - - builder.add("\n"); - builder.addStatement("$T<$T> remover = $L.remove($T.class)", ExecutableRemove.class, - context.getRepositoryInformation().getDomainType(), - mongoOpsRef, context.getRepositoryInformation().getDomainType()); - - Type type = Type.FIND_AND_REMOVE_ALL; - if (!queryMethod.isCollectionQuery()) { - if (!ClassUtils.isPrimitiveOrWrapper(context.getMethod().getReturnType())) { - type = Type.FIND_AND_REMOVE_ONE; - } else { - type = Type.ALL; - } - } - - actualReturnType = ClassUtils.isPrimitiveOrWrapper(context.getMethod().getReturnType()) - ? ClassName.get(context.getMethod().getReturnType()) - : queryMethod.isCollectionQuery() ? context.getReturnTypeName() : actualReturnType; - - builder.addStatement("return ($T) new $T(remover, $T.$L).execute($L)", actualReturnType, DeleteExecutionX.class, - DeleteExecutionX.Type.class, type.name(), queryVariableName); - - return builder.build(); - } - } - - static class QueryExecutionBlockBuilder { - - private final AotQueryMethodGenerationContext context; - private final MongoQueryMethod queryMethod; - private String queryVariableName; - private boolean count, exists; - - public QueryExecutionBlockBuilder(AotQueryMethodGenerationContext context, MongoQueryMethod queryMethod) { - this.context = context; - this.queryMethod = queryMethod; - } - - QueryExecutionBlockBuilder referencing(String queryVariableName) { - - this.queryVariableName = queryVariableName; - return this; - } - - QueryExecutionBlockBuilder count(boolean count) { - this.count = count; - return this; - } - - QueryExecutionBlockBuilder exists(boolean exists) { - this.exists = exists; - return this; - } - - CodeBlock build() { - - String mongoOpsRef = context.fieldNameOf(MongoOperations.class); - - Builder builder = CodeBlock.builder(); - - boolean isProjecting = context.getReturnedType().isProjecting(); - Object actualReturnType = isProjecting ? context.getActualReturnType().getType() - : context.getRepositoryInformation().getDomainType(); - - builder.add("\n"); - - if (isProjecting) { - builder.addStatement("$T<$T> finder = $L.query($T.class).as($T.class)", FindWithQuery.class, actualReturnType, - mongoOpsRef, context.getRepositoryInformation().getDomainType(), actualReturnType); - } else { - - builder.addStatement("$T<$T> finder = $L.query($T.class)", FindWithQuery.class, actualReturnType, mongoOpsRef, - context.getRepositoryInformation().getDomainType()); - } - - String terminatingMethod; - - if (queryMethod.isCollectionQuery() || queryMethod.isPageQuery() || queryMethod.isSliceQuery()) { - terminatingMethod = "all()"; - } else if (count) { - terminatingMethod = "count()"; - - } else if (exists) { - terminatingMethod = "exists()"; - } else { - terminatingMethod = Optional.class.isAssignableFrom(context.getReturnType().toClass()) ? "one()" : "oneValue()"; - } - - if (queryMethod.isPageQuery()) { - builder.addStatement("return new $T(finder, $L).execute($L)", PagedExecution.class, - context.getPageableParameterName(), queryVariableName); - } else if (queryMethod.isSliceQuery()) { - builder.addStatement("return new $T(finder, $L).execute($L)", SlicedExecution.class, - context.getPageableParameterName(), queryVariableName); - } else { - builder.addStatement("return finder.matching($L).$L", queryVariableName, terminatingMethod); - } - - return builder.build(); - - } - } - - static class QueryBlockBuilder { - - private final AotQueryMethodGenerationContext context; - private final MongoQueryMethod queryMethod; - - StringQuery source; - List arguments; - private String queryVariableName; - - public QueryBlockBuilder(AotQueryMethodGenerationContext context, MongoQueryMethod queryMethod) { - this.context = context; - this.arguments = Arrays.stream(context.getMethod().getParameters()).map(Parameter::getName) - .collect(Collectors.toList()); - - // ParametersSource parametersSource = ParametersSource.of(repositoryInformation, metadata.getRepositoryMethod()); - // this.argumentSource = new MongoParameters(parametersSource, false); - - this.queryMethod = queryMethod; - } - - public QueryBlockBuilder filter(StringQuery query) { - this.source = query; - return this; - } - - public QueryBlockBuilder usingQueryVariableName(String queryVariableName) { - this.queryVariableName = queryVariableName; - return this; - } - - CodeBlock build() { - - CodeBlock.Builder builder = CodeBlock.builder(); - - builder.add("\n"); - String queryDocumentVariableName = "%sDocument".formatted(queryVariableName); - builder.add(renderExpressionToDocument(source.getQueryString(), queryVariableName)); - builder.addStatement("$T $L = new $T($L)", BasicQuery.class, queryVariableName, BasicQuery.class, - queryDocumentVariableName); - - if (StringUtils.hasText(source.getFieldsString())) { - builder.add(renderExpressionToDocument(source.getFieldsString(), "fields")); - builder.addStatement("$L.setFieldsObject(fieldsDocument)", queryVariableName); - } - - String sortParameter = context.getSortParameterName(); - if (StringUtils.hasText(sortParameter)) { - - builder.addStatement("$L.with($L)", queryVariableName, sortParameter); - } else if (StringUtils.hasText(source.getSortString())) { - - builder.add(renderExpressionToDocument(source.getSortString(), "sort")); - builder.addStatement("$L.setSortObject(sortDocument)", queryVariableName); - } - - String limitParameter = context.getLimitParameterName(); - if (StringUtils.hasText(limitParameter)) { - builder.addStatement("$L.limit($L)", queryVariableName, limitParameter); - } else if (context.getPageableParameterName() == null && source.isLimited()) { - builder.addStatement("$L.limit($L)", queryVariableName, source.getLimit()); - } - - String pageableParameter = context.getPageableParameterName(); - if (StringUtils.hasText(pageableParameter) && !queryMethod.isPageQuery() && !queryMethod.isSliceQuery()) { - builder.addStatement("$L.with($L)", queryVariableName, pageableParameter); - } - - MergedAnnotation hintAnnotation = context.getAnnotation(Hint.class); - String hint = hintAnnotation.isPresent() ? hintAnnotation.getString("value") : null; - - if (StringUtils.hasText(hint)) { - builder.addStatement("$L.withHint($S)", queryVariableName, hint); - } - - MergedAnnotation readPreferenceAnnotation = context.getAnnotation(ReadPreference.class); - String readPreference = readPreferenceAnnotation.isPresent() ? readPreferenceAnnotation.getString("value") : null; - - if (StringUtils.hasText(readPreference)) { - builder.addStatement("$L.withReadPreference($T.valueOf($S))", queryVariableName, - com.mongodb.ReadPreference.class, readPreference); - } - - // TODO: all the meta stuff - - return builder.build(); - } - - private CodeBlock renderExpressionToDocument(@Nullable String source, String variableName) { - - Builder builder = CodeBlock.builder(); - if (!StringUtils.hasText(source)) { - builder.addStatement("$T $L = new $T()", Document.class, "%sDocument".formatted(variableName), Document.class); - } else if (!containsPlaceholder(source)) { - builder.addStatement("$T $L = $T.parse($S)", Document.class, "%sDocument".formatted(variableName), - Document.class, source); - } else { - - String mongoOpsRef = context.fieldNameOf(MongoOperations.class); - String tmpVarName = "%sString".formatted(variableName); - - builder.addStatement("String $L = $S", tmpVarName, source); - builder.addStatement("$T $L = new $T($L, $L.getConverter(), new $T[]{ $L }).toDocument()", Document.class, - "%sDocument".formatted(variableName), BindableMongoExpression.class, tmpVarName, mongoOpsRef, Object.class, - StringUtils.collectionToDelimitedString(arguments, ", ")); - } - - return builder.build(); - } - - private boolean containsPlaceholder(String source) { - return PARAMETER_BINDING_PATTERN.matcher(source).find(); - } - - } -} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/aot/generated/MongoRepositoryContributor.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/aot/generated/MongoRepositoryContributor.java deleted file mode 100644 index 2ad12cfba..000000000 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/aot/generated/MongoRepositoryContributor.java +++ /dev/null @@ -1,136 +0,0 @@ -/* - * 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 - * - * 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.aot.generated; - -import java.lang.reflect.Method; -import java.util.regex.Pattern; - -import org.jspecify.annotations.Nullable; - -import org.springframework.core.annotation.AnnotatedElementUtils; -import org.springframework.data.mongodb.aot.generated.MongoBlocks.QueryBlockBuilder; -import org.springframework.data.mongodb.core.MongoOperations; -import org.springframework.data.mongodb.core.mapping.MongoMappingContext; -import org.springframework.data.mongodb.repository.Aggregation; -import org.springframework.data.mongodb.repository.Query; -import org.springframework.data.mongodb.repository.query.MongoQueryMethod; -import org.springframework.data.repository.aot.generate.AotQueryMethodGenerationContext; -import org.springframework.data.repository.aot.generate.AotRepositoryConstructorBuilder; -import org.springframework.data.repository.aot.generate.MethodContributor; -import org.springframework.data.repository.aot.generate.RepositoryContributor; -import org.springframework.data.repository.config.AotRepositoryContext; -import org.springframework.data.repository.core.RepositoryInformation; -import org.springframework.data.repository.query.QueryMethod; -import org.springframework.data.repository.query.parser.PartTree; -import org.springframework.javapoet.CodeBlock; -import org.springframework.javapoet.TypeName; -import org.springframework.util.StringUtils; - -/** - * @author Christoph Strobl - * @since 2025/01 - */ -public class MongoRepositoryContributor extends RepositoryContributor { - - private final AotQueryCreator queryCreator; - private final MongoMappingContext mappingContext; - - public MongoRepositoryContributor(AotRepositoryContext repositoryContext) { - super(repositoryContext); - this.queryCreator = new AotQueryCreator(); - this.mappingContext = new MongoMappingContext(); - } - - @Override - protected void customizeConstructor(AotRepositoryConstructorBuilder constructorBuilder) { - constructorBuilder.addParameter("operations", TypeName.get(MongoOperations.class)); - } - - @Override - protected @Nullable MethodContributor contributeQueryMethod(Method method, - RepositoryInformation repositoryInformation) { - - if (AnnotatedElementUtils.hasAnnotation(method, Aggregation.class)) { - return null; - } - - Query queryAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, Query.class); - if (queryAnnotation != null) { - if (StringUtils.hasText(queryAnnotation.value()) - && Pattern.compile("[\\?:][#$]\\{.*\\}").matcher(queryAnnotation.value()).find()) { - return null; - } - } - - MongoQueryMethod queryMethod = new MongoQueryMethod(method, repositoryInformation, getProjectionFactory(), - mappingContext); - - return MethodContributor.forQueryMethod(queryMethod).contribute(context -> { - CodeBlock.Builder builder = CodeBlock.builder(); - - boolean count, delete, exists; - StringQuery query; - if (queryAnnotation != null && StringUtils.hasText(queryAnnotation.value())) { - query = new StringQuery(queryAnnotation.value()); - count = queryAnnotation.count(); - delete = queryAnnotation.delete(); - exists = queryAnnotation.exists(); - - } else { - PartTree partTree = new PartTree(context.getMethod().getName(), - context.getRepositoryInformation().getDomainType()); - query = queryCreator.createQuery(partTree, context.getMethod().getParameterCount()); - count = partTree.isCountProjection(); - delete = partTree.isDelete(); - exists = partTree.isExistsProjection(); - - } - - if (queryAnnotation != null && StringUtils.hasText(queryAnnotation.sort())) { - query.sort(queryAnnotation.sort()); - } - if (queryAnnotation != null && StringUtils.hasText(queryAnnotation.fields())) { - query.fields(queryAnnotation.fields()); - } - - writeStringQuery(context, builder, count, delete, exists, query, queryMethod); - - return builder.build(); - }); - } - - private static void writeStringQuery(AotQueryMethodGenerationContext context, CodeBlock.Builder body, boolean count, - boolean delete, boolean exists, StringQuery query, MongoQueryMethod queryMethod) { - - body.add(context.codeBlocks().logDebug("invoking [%s]".formatted(context.getMethod().getName()))); - QueryBlockBuilder queryBlockBuilder = MongoBlocks.queryBlockBuilder(context, queryMethod).filter(query); - - if (delete) { - - String deleteQueryVariableName = "deleteQuery"; - body.add(queryBlockBuilder.usingQueryVariableName(deleteQueryVariableName).build()); - body.add( - MongoBlocks.deleteExecutionBlockBuilder(context, queryMethod).referencing(deleteQueryVariableName).build()); - } else { - - String filterQueryVariableName = "filterQuery"; - body.add(queryBlockBuilder.usingQueryVariableName(filterQueryVariableName).build()); - body.add(MongoBlocks.queryExecutionBlockBuilder(context, queryMethod).exists(exists).count(count) - .referencing(filterQueryVariableName).build()); - } - } - -} 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 c03f1bb6d..d25b98ab1 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 @@ -15,7 +15,7 @@ */ package org.springframework.data.mongodb.core.query; -import static org.springframework.util.ObjectUtils.*; +import static org.springframework.util.ObjectUtils.nullSafeHashCode; import java.util.ArrayList; import java.util.Arrays; @@ -29,21 +29,9 @@ import java.util.Map.Entry; import java.util.regex.Pattern; import java.util.stream.Collectors; -import com.mongodb.MongoClientSettings; -import org.bson.BsonReader; import org.bson.BsonRegularExpression; import org.bson.BsonType; -import org.bson.BsonWriter; import org.bson.Document; -import org.bson.codecs.Codec; -import org.bson.codecs.DecoderContext; -import org.bson.codecs.DocumentCodec; -import org.bson.codecs.DocumentCodecProvider; -import org.bson.codecs.Encoder; -import org.bson.codecs.EncoderContext; -import org.bson.codecs.configuration.CodecProvider; -import org.bson.codecs.configuration.CodecRegistries; -import org.bson.codecs.configuration.CodecRegistry; import org.bson.types.Binary; import org.jspecify.annotations.Nullable; import org.springframework.data.domain.Example; @@ -342,8 +330,7 @@ public class Criteria implements CriteriaDefinition { throw new InvalidMongoDbApiUsageException( "You can only pass in one argument of type " + values[1].getClass().getName()); } - criteria.put("$in", Arrays.asList(values)); - return this; + return this.in(Arrays.asList(values)); } /** @@ -355,7 +342,13 @@ public class Criteria implements CriteriaDefinition { */ @Contract("_ -> this") public Criteria in(Collection values) { - criteria.put("$in", values); + + ArrayList objects = new ArrayList<>(values); + if (objects.size() == 1 && CollectionUtils.firstElement(objects) instanceof Placeholder placeholder) { + criteria.put("$in", placeholder); + } else { + criteria.put("$in", objects); + } return this; } @@ -380,7 +373,13 @@ public class Criteria implements CriteriaDefinition { */ @Contract("_ -> this") public Criteria nin(Collection values) { - criteria.put("$nin", values); + + ArrayList objects = new ArrayList<>(values); + if (objects.size() == 1 && CollectionUtils.firstElement(objects) instanceof Placeholder placeholder) { + criteria.put("$nin", placeholder); + } else { + criteria.put("$nin", objects); + } return this; } @@ -926,6 +925,19 @@ public class Criteria implements CriteriaDefinition { return registerCriteriaChainElement(new Criteria("$and").is(bsonList)); } + /** + * Creates a criterion using the given {@literal operator}. + * + * @param operator the native MongoDB operator. + * @param value the operator value + * @return this + * @since 5.0 + */ + public Criteria raw(String operator, Object value) { + criteria.put(operator, value); + return this; + } + private Criteria registerCriteriaChainElement(Criteria criteria) { if (lastOperatorWasNot()) { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/CriteriaDefinition.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/CriteriaDefinition.java index 6fc5d60a7..7777e5f55 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/CriteriaDefinition.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/CriteriaDefinition.java @@ -40,16 +40,36 @@ public interface CriteriaDefinition { @Nullable String getKey(); + /** + * A placeholder expression used when rending queries to JSON. + * + * @since 5.0 + * @author Christoph Strobl + */ class Placeholder { - Object value; + private final Object expression; + + /** + * Create a new placeholder for index bindable parameter. + * + * @param position the index of the parameter to bind. + * @return new instance of {@link Placeholder}. + */ + public static Placeholder indexed(int position) { + return new Placeholder("?%s".formatted(position)); + } + + public static Placeholder placeholder(String expression) { + return new Placeholder(expression); + } - public Placeholder(Object value) { - this.value = value; + Placeholder(Object value) { + this.expression = value; } public Object getValue() { - return value; + return expression; } @Override diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/AggregationInteraction.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/AggregationInteraction.java new file mode 100644 index 000000000..003982daf --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/AggregationInteraction.java @@ -0,0 +1,56 @@ +/* + * 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 + * + * 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.repository.aot; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.springframework.data.repository.aot.generate.QueryMetadata; + +/** + * An {@link MongoInteraction aggregation interaction}. + * + * @author Christoph Strobl + * @since 5.0 + */ +class AggregationInteraction extends MongoInteraction implements QueryMetadata { + + private final StringAggregation aggregation; + + AggregationInteraction(String[] raw) { + this.aggregation = new StringAggregation(raw); + } + + List stages() { + return Arrays.asList(aggregation.pipeline()); + } + + @Override + InteractionType getExecutionType() { + return InteractionType.AGGREGATION; + } + + @Override + public Map serialize() { + + return Map.of(pipelineSerializationKey(), stages()); + } + + protected String pipelineSerializationKey() { + return "pipeline"; + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/AggregationUpdateInteraction.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/AggregationUpdateInteraction.java new file mode 100644 index 000000000..cc672ed1e --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/AggregationUpdateInteraction.java @@ -0,0 +1,52 @@ +/* + * 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 + * + * 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.repository.aot; + +import java.util.Map; + +/** + * An {@link MongoInteraction} to execute an aggregation update. + * + * @author Christoph Strobl + * @since 5.0 + */ +class AggregationUpdateInteraction extends AggregationInteraction { + + private final QueryInteraction filter; + + AggregationUpdateInteraction(QueryInteraction filter, String[] raw) { + + super(raw); + this.filter = filter; + } + + QueryInteraction getFilter() { + return filter; + } + + @Override + public Map serialize() { + + Map serialized = filter.serialize(); + serialized.putAll(super.serialize()); + return serialized; + } + + @Override + protected String pipelineSerializationKey() { + return "update-" + super.pipelineSerializationKey(); + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/AotMongoRepositoryPostProcessor.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/AotMongoRepositoryPostProcessor.java index 2de77fc9b..324871b47 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/AotMongoRepositoryPostProcessor.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/AotMongoRepositoryPostProcessor.java @@ -15,11 +15,11 @@ */ package org.springframework.data.mongodb.repository.aot; +import org.jspecify.annotations.Nullable; import org.springframework.aot.generate.GenerationContext; import org.springframework.data.aot.AotContext; import org.springframework.data.mongodb.aot.LazyLoadingProxyAotProcessor; import org.springframework.data.mongodb.aot.MongoAotPredicates; -import org.springframework.data.mongodb.aot.generated.MongoRepositoryContributor; import org.springframework.data.repository.aot.generate.RepositoryContributor; import org.springframework.data.repository.config.AotRepositoryContext; import org.springframework.data.repository.config.RepositoryRegistrationAotProcessor; @@ -34,7 +34,7 @@ public class AotMongoRepositoryPostProcessor extends RepositoryRegistrationAotPr private final LazyLoadingProxyAotProcessor lazyLoadingProxyAotProcessor = new LazyLoadingProxyAotProcessor(); @Override - protected RepositoryContributor contribute(AotRepositoryContext repositoryContext, + protected @Nullable RepositoryContributor contribute(AotRepositoryContext repositoryContext, GenerationContext generationContext) { // do some custom type registration here super.contribute(repositoryContext, generationContext); diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/aot/generated/AotQueryCreator.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/AotQueryCreator.java similarity index 84% rename from spring-data-mongodb/src/main/java/org/springframework/data/mongodb/aot/generated/AotQueryCreator.java rename to spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/AotQueryCreator.java index c0fbfc4ee..831d21bb4 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/aot/generated/AotQueryCreator.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/AotQueryCreator.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.mongodb.aot.generated; +package org.springframework.data.mongodb.repository.aot; import java.util.Iterator; import java.util.List; @@ -21,6 +21,8 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; import org.bson.conversions.Bson; +import org.jspecify.annotations.NullUnmarked; +import org.jspecify.annotations.Nullable; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Range; import org.springframework.data.domain.ScrollPosition; @@ -41,15 +43,14 @@ import org.springframework.data.mongodb.repository.query.MongoParameterAccessor; import org.springframework.data.mongodb.repository.query.MongoQueryCreator; import org.springframework.data.repository.query.parser.PartTree; import org.springframework.data.util.TypeInformation; -import org.springframework.lang.Nullable; import com.mongodb.DBRef; /** * @author Christoph Strobl - * @since 2025/01 + * @since 5.0 */ -public class AotQueryCreator { +class AotQueryCreator { private MongoMappingContext mappingContext; @@ -64,13 +65,14 @@ public class AotQueryCreator { this.mappingContext = mongoMappingContext; } + @SuppressWarnings("NullAway") StringQuery createQuery(PartTree partTree, int parameterCount) { Query query = new MongoQueryCreator(partTree, new PlaceholderConvertingParameterAccessor(new PlaceholderParameterAccessor(parameterCount)), mappingContext) .createQuery(); - if(partTree.isLimiting()) { + if (partTree.isLimiting()) { query.limit(partTree.getMaxResults()); } return new StringQuery(query); @@ -88,13 +90,13 @@ public class AotQueryCreator { } } + @NullUnmarked enum PlaceholderWriter implements MongoWriter { INSTANCE; - @Nullable @Override - public Object convertToMongoType(@Nullable Object obj, @Nullable TypeInformation typeInformation) { + public @Nullable Object convertToMongoType(@Nullable Object obj, @Nullable TypeInformation typeInformation) { return obj instanceof Placeholder p ? p.getValue() : obj; } @@ -109,6 +111,7 @@ public class AotQueryCreator { } } + @NullUnmarked static class PlaceholderParameterAccessor implements MongoParameterAccessor { private final List placeholders; @@ -117,8 +120,7 @@ public class AotQueryCreator { if (parameterCount == 0) { placeholders = List.of(); } else { - placeholders = IntStream.range(0, parameterCount).mapToObj(it -> new Placeholder("?" + it)) - .collect(Collectors.toList()); + placeholders = IntStream.range(0, parameterCount).mapToObj(Placeholder::indexed).collect(Collectors.toList()); } } @@ -127,21 +129,18 @@ public class AotQueryCreator { return null; } - @Nullable @Override - public Point getGeoNearLocation() { + public @Nullable Point getGeoNearLocation() { return null; } - @Nullable @Override - public TextCriteria getFullText() { + public @Nullable TextCriteria getFullText() { return null; } - @Nullable @Override - public Collation getCollation() { + public @Nullable Collation getCollation() { return null; } @@ -150,15 +149,13 @@ public class AotQueryCreator { return placeholders.toArray(); } - @Nullable @Override - public UpdateDefinition getUpdate() { + public @Nullable UpdateDefinition getUpdate() { return null; } - @Nullable @Override - public ScrollPosition getScrollPosition() { + public @Nullable ScrollPosition getScrollPosition() { return null; } @@ -172,15 +169,13 @@ public class AotQueryCreator { return null; } - @Nullable @Override - public Class findDynamicProjection() { + public @Nullable Class findDynamicProjection() { return null; } - @Nullable @Override - public Object getBindableValue(int index) { + public @Nullable Object getBindableValue(int index) { return placeholders.get(index).getValue(); } @@ -195,5 +190,4 @@ public class AotQueryCreator { return ((List) placeholders).iterator(); } } - } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/MongoAotRepositoryFragmentSupport.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/MongoAotRepositoryFragmentSupport.java new file mode 100644 index 000000000..1537b6c72 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/MongoAotRepositoryFragmentSupport.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 + * + * 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.repository.aot; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.bson.Document; +import org.jspecify.annotations.Nullable; +import org.springframework.data.mongodb.BindableMongoExpression; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.aggregation.AggregationOperation; +import org.springframework.data.mongodb.core.aggregation.AggregationPipeline; +import org.springframework.data.mongodb.core.convert.MongoConverter; +import org.springframework.data.mongodb.core.mapping.FieldName; +import org.springframework.data.mongodb.core.query.BasicQuery; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport; +import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; + +/** + * @author Christoph Strobl + */ +public class MongoAotRepositoryFragmentSupport { + + private final RepositoryMetadata repositoryMetadata; + private final MongoOperations mongoOperations; + private final MongoConverter mongoConverter; + private final ProjectionFactory projectionFactory; + + protected MongoAotRepositoryFragmentSupport(MongoOperations mongoOperations, + RepositoryFactoryBeanSupport.FragmentCreationContext context) { + this(mongoOperations, context.getRepositoryMetadata(), context.getProjectionFactory()); + } + + protected MongoAotRepositoryFragmentSupport(MongoOperations mongoOperations, RepositoryMetadata repositoryMetadata, + ProjectionFactory projectionFactory) { + + this.mongoOperations = mongoOperations; + this.mongoConverter = mongoOperations.getConverter(); + this.repositoryMetadata = repositoryMetadata; + this.projectionFactory = projectionFactory; + } + + protected Document bindParameters(String source, Object[] parameters) { + return new BindableMongoExpression(source, this.mongoConverter, parameters).toDocument(); + } + + protected BasicQuery createQuery(String queryString, Object[] parameters) { + + Document queryDocument = bindParameters(queryString, parameters); + return new BasicQuery(queryDocument); + } + + protected AggregationPipeline createPipeline(List rawStages) { + + List stages = new ArrayList<>(rawStages.size()); + boolean first = true; + for (Object rawStage : rawStages) { + if (rawStage instanceof Document stageDocument) { + if (first) { + stages.add((ctx) -> ctx.getMappedObject(stageDocument)); + } else { + stages.add((ctx) -> stageDocument); + } + } else if (rawStage instanceof AggregationOperation aggregationOperation) { + stages.add(aggregationOperation); + } else { + throw new RuntimeException("%s cannot be converted to AggregationOperation".formatted(rawStage.getClass())); + } + if (first) { + first = false; + } + } + return new AggregationPipeline(stages); + } + + protected List convertSimpleRawResults(Class targetType, List rawResults) { + + List list = new ArrayList<>(rawResults.size()); + for (Document it : rawResults) { + list.add(extractSimpleTypeResult(it, targetType, mongoConverter)); + } + return list; + } + + private static @Nullable T extractSimpleTypeResult(@Nullable Document source, Class targetType, + MongoConverter converter) { + + if (ObjectUtils.isEmpty(source)) { + return null; + } + + if (source.size() == 1) { + return getPotentiallyConvertedSimpleTypeValue(converter, source.values().iterator().next(), targetType); + } + + Document intermediate = new Document(source); + intermediate.remove(FieldName.ID.name()); + + if (intermediate.size() == 1) { + return getPotentiallyConvertedSimpleTypeValue(converter, intermediate.values().iterator().next(), targetType); + } + + for (Map.Entry entry : intermediate.entrySet()) { + if (entry != null && ClassUtils.isAssignable(targetType, entry.getValue().getClass())) { + return targetType.cast(entry.getValue()); + } + } + + throw new IllegalArgumentException( + String.format("o_O no entry of type %s found in %s.", targetType.getSimpleName(), source.toJson())); + } + + @Nullable + @SuppressWarnings("unchecked") + private static T getPotentiallyConvertedSimpleTypeValue(MongoConverter converter, @Nullable Object value, + Class targetType) { + + if (value == null) { + return null; + } + + if (ClassUtils.isAssignableValue(targetType, value)) { + return (T) value; + } + + return converter.getConversionService().convert(value, targetType); + } + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/MongoCodeBlocks.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/MongoCodeBlocks.java new file mode 100644 index 000000000..8338ffe6b --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/MongoCodeBlocks.java @@ -0,0 +1,777 @@ +/* + * 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 + * + * 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.repository.aot; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +import org.bson.Document; +import org.jspecify.annotations.NullUnmarked; +import org.jspecify.annotations.Nullable; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Sort.Order; +import org.springframework.data.mongodb.core.ExecutableFindOperation.FindWithQuery; +import org.springframework.data.mongodb.core.ExecutableRemoveOperation.ExecutableRemove; +import org.springframework.data.mongodb.core.ExecutableUpdateOperation.ExecutableUpdate; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationOptions; +import org.springframework.data.mongodb.core.aggregation.AggregationPipeline; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.core.aggregation.TypedAggregation; +import org.springframework.data.mongodb.core.mapping.MongoSimpleTypes; +import org.springframework.data.mongodb.core.query.BasicQuery; +import org.springframework.data.mongodb.core.query.BasicUpdate; +import org.springframework.data.mongodb.core.query.Collation; +import org.springframework.data.mongodb.repository.Hint; +import org.springframework.data.mongodb.repository.ReadPreference; +import org.springframework.data.mongodb.repository.query.MongoQueryExecution.DeleteExecution; +import org.springframework.data.mongodb.repository.query.MongoQueryExecution.PagedExecution; +import org.springframework.data.mongodb.repository.query.MongoQueryExecution.SlicedExecution; +import org.springframework.data.mongodb.repository.query.MongoQueryMethod; +import org.springframework.data.repository.aot.generate.AotQueryMethodGenerationContext; +import org.springframework.data.util.ReflectionUtils; +import org.springframework.javapoet.ClassName; +import org.springframework.javapoet.CodeBlock; +import org.springframework.javapoet.CodeBlock.Builder; +import org.springframework.javapoet.TypeName; +import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; +import org.springframework.util.NumberUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * {@link CodeBlock} generator for common tasks. + * + * @author Christoph Strobl + * @since 5.0 + */ +class MongoCodeBlocks { + + private static final Pattern PARAMETER_BINDING_PATTERN = Pattern.compile("\\?(\\d+)"); + + /** + * Builder for generating query parsing {@link CodeBlock}. + * + * @param context + * @param queryMethod + * @return new instance of {@link QueryCodeBlockBuilder}. + */ + static QueryCodeBlockBuilder queryBlockBuilder(AotQueryMethodGenerationContext context, + MongoQueryMethod queryMethod) { + return new QueryCodeBlockBuilder(context, queryMethod); + } + + /** + * Builder for generating finder query execution {@link CodeBlock}. + * + * @param context + * @param queryMethod + * @return + */ + static QueryExecutionCodeBlockBuilder queryExecutionBlockBuilder(AotQueryMethodGenerationContext context, + MongoQueryMethod queryMethod) { + + return new QueryExecutionCodeBlockBuilder(context, queryMethod); + } + + /** + * Builder for generating delete execution {@link CodeBlock}. + * + * @param context + * @param queryMethod + * @return + */ + static DeleteExecutionCodeBlockBuilder deleteExecutionBlockBuilder(AotQueryMethodGenerationContext context, + MongoQueryMethod queryMethod) { + + return new DeleteExecutionCodeBlockBuilder(context, queryMethod); + } + + /** + * Builder for generating update parsing {@link CodeBlock}. + * + * @param context + * @param queryMethod + * @return + */ + static UpdateCodeBlockBuilder updateBlockBuilder(AotQueryMethodGenerationContext context, + MongoQueryMethod queryMethod) { + return new UpdateCodeBlockBuilder(context, queryMethod); + } + + /** + * Builder for generating update execution {@link CodeBlock}. + * + * @param context + * @param queryMethod + * @return + */ + static UpdateExecutionCodeBlockBuilder updateExecutionBlockBuilder(AotQueryMethodGenerationContext context, + MongoQueryMethod queryMethod) { + + return new UpdateExecutionCodeBlockBuilder(context, queryMethod); + } + + /** + * Builder for generating aggregation (pipeline) parsing {@link CodeBlock}. + * + * @param context + * @param queryMethod + * @return + */ + static AggregationCodeBlockBuilder aggregationBlockBuilder(AotQueryMethodGenerationContext context, + MongoQueryMethod queryMethod) { + + return new AggregationCodeBlockBuilder(context, queryMethod); + } + + /** + * Builder for generating aggregation execution {@link CodeBlock}. + * + * @param context + * @param queryMethod + * @return + */ + static AggregationExecutionCodeBlockBuilder aggregationExecutionBlockBuilder(AotQueryMethodGenerationContext context, + MongoQueryMethod queryMethod) { + + return new AggregationExecutionCodeBlockBuilder(context, queryMethod); + } + + @NullUnmarked + static class DeleteExecutionCodeBlockBuilder { + + private final AotQueryMethodGenerationContext context; + private final MongoQueryMethod queryMethod; + private String queryVariableName; + + DeleteExecutionCodeBlockBuilder(AotQueryMethodGenerationContext context, MongoQueryMethod queryMethod) { + + this.context = context; + this.queryMethod = queryMethod; + } + + DeleteExecutionCodeBlockBuilder referencing(String queryVariableName) { + + this.queryVariableName = queryVariableName; + return this; + } + + CodeBlock build() { + + String mongoOpsRef = context.fieldNameOf(MongoOperations.class); + Builder builder = CodeBlock.builder(); + + boolean isProjecting = context.getActualReturnType() != null + && !ObjectUtils.nullSafeEquals(TypeName.get(context.getRepositoryInformation().getDomainType()), + context.getActualReturnType()); + + Object actualReturnType = isProjecting ? context.getActualReturnType().getType() + : context.getRepositoryInformation().getDomainType(); + + builder.add("\n"); + builder.addStatement("$T<$T> remover = $L.remove($T.class)", ExecutableRemove.class, + context.getRepositoryInformation().getDomainType(), mongoOpsRef, + context.getRepositoryInformation().getDomainType()); + + DeleteExecution.Type type = DeleteExecution.Type.FIND_AND_REMOVE_ALL; + if (!queryMethod.isCollectionQuery()) { + if (!ClassUtils.isPrimitiveOrWrapper(context.getMethod().getReturnType())) { + type = DeleteExecution.Type.FIND_AND_REMOVE_ONE; + } else { + type = DeleteExecution.Type.ALL; + } + } + + actualReturnType = ClassUtils.isPrimitiveOrWrapper(context.getMethod().getReturnType()) + ? ClassName.get(context.getMethod().getReturnType()) + : queryMethod.isCollectionQuery() ? context.getReturnTypeName() : actualReturnType; + + builder.addStatement("return ($T) new $T(remover, $T.$L).execute($L)", actualReturnType, DeleteExecution.class, + DeleteExecution.Type.class, type.name(), queryVariableName); + + return builder.build(); + } + } + + @NullUnmarked + static class UpdateExecutionCodeBlockBuilder { + + private final AotQueryMethodGenerationContext context; + private final MongoQueryMethod queryMethod; + private String queryVariableName; + private String updateVariableName; + + UpdateExecutionCodeBlockBuilder(AotQueryMethodGenerationContext context, MongoQueryMethod queryMethod) { + + this.context = context; + this.queryMethod = queryMethod; + } + + UpdateExecutionCodeBlockBuilder withFilter(String queryVariableName) { + + this.queryVariableName = queryVariableName; + return this; + } + + UpdateExecutionCodeBlockBuilder referencingUpdate(String updateVariableName) { + + this.updateVariableName = updateVariableName; + return this; + } + + CodeBlock build() { + + String mongoOpsRef = context.fieldNameOf(MongoOperations.class); + Builder builder = CodeBlock.builder(); + + builder.add("\n"); + + String updateReference = updateVariableName; + builder.addStatement("$T<$T> updater = $L.update($T.class)", ExecutableUpdate.class, + context.getRepositoryInformation().getDomainType(), mongoOpsRef, + context.getRepositoryInformation().getDomainType()); + + Class returnType = ClassUtils.resolvePrimitiveIfNecessary(queryMethod.getReturnedObjectType()); + if (ReflectionUtils.isVoid(returnType)) { + builder.addStatement("updater.matching($L).apply($L).all()", queryVariableName, updateReference); + } else if (ClassUtils.isAssignable(Long.class, returnType)) { + builder.addStatement("return updater.matching($L).apply($L).all().getModifiedCount()", queryVariableName, + updateReference); + } else { + builder.addStatement("$T modifiedCount = updater.matching($L).apply($L).all().getModifiedCount()", Long.class, + queryVariableName, updateReference); + builder.addStatement("return $T.convertNumberToTargetClass(modifiedCount, $T.class)", NumberUtils.class, + returnType); + } + + return builder.build(); + } + } + + @NullUnmarked + static class AggregationExecutionCodeBlockBuilder { + + private final AotQueryMethodGenerationContext context; + private final MongoQueryMethod queryMethod; + private String aggregationVariableName; + + AggregationExecutionCodeBlockBuilder(AotQueryMethodGenerationContext context, MongoQueryMethod queryMethod) { + + this.context = context; + this.queryMethod = queryMethod; + } + + AggregationExecutionCodeBlockBuilder referencing(String aggregationVariableName) { + + this.aggregationVariableName = aggregationVariableName; + return this; + } + + CodeBlock build() { + + String mongoOpsRef = context.fieldNameOf(MongoOperations.class); + Builder builder = CodeBlock.builder(); + + builder.add("\n"); + + Class outputType = queryMethod.getReturnedObjectType(); + if (MongoSimpleTypes.HOLDER.isSimpleType(outputType)) { + outputType = Document.class; + } else if (ClassUtils.isAssignable(AggregationResults.class, outputType)) { + outputType = queryMethod.getReturnType().getComponentType().getType(); + } + + if (ReflectionUtils.isVoid(queryMethod.getReturnedObjectType())) { + builder.addStatement("$L.aggregate($L, $T.class)", mongoOpsRef, aggregationVariableName, outputType); + return builder.build(); + } + + if (ClassUtils.isAssignable(AggregationResults.class, context.getMethod().getReturnType())) { + builder.addStatement("return $L.aggregate($L, $T.class)", mongoOpsRef, aggregationVariableName, outputType); + return builder.build(); + } + + if (outputType == Document.class) { + + Class returnType = ClassUtils.resolvePrimitiveIfNecessary(queryMethod.getReturnedObjectType()); + + builder.addStatement("$T results = $L.aggregate($L, $T.class)", AggregationResults.class, mongoOpsRef, + aggregationVariableName, outputType); + if (!queryMethod.isCollectionQuery()) { + builder.addStatement( + "return $T.<$T>firstElement(convertSimpleRawResults($T.class, results.getMappedResults()))", + CollectionUtils.class, returnType, returnType); + } else { + builder.addStatement("return convertSimpleRawResults($T.class, results.getMappedResults())", returnType); + } + } else { + if (queryMethod.isSliceQuery()) { + builder.addStatement("$T results = $L.aggregate($L, $T.class)", AggregationResults.class, mongoOpsRef, + aggregationVariableName, outputType); + builder.addStatement("boolean hasNext = results.getMappedResults().size() > $L.getPageSize()", + context.getPageableParameterName()); + builder.addStatement( + "return new $T<>(hasNext ? results.getMappedResults().subList(0, $L.getPageSize()) : results.getMappedResults(), $L, hasNext)", + SliceImpl.class, context.getPageableParameterName(), context.getPageableParameterName()); + } else { + builder.addStatement("return $L.aggregate($L, $T.class).getMappedResults()", mongoOpsRef, + aggregationVariableName, outputType); + } + } + + return builder.build(); + } + } + + @NullUnmarked + static class QueryExecutionCodeBlockBuilder { + + private final AotQueryMethodGenerationContext context; + private final MongoQueryMethod queryMethod; + private QueryInteraction query; + + QueryExecutionCodeBlockBuilder(AotQueryMethodGenerationContext context, MongoQueryMethod queryMethod) { + + this.context = context; + this.queryMethod = queryMethod; + } + + QueryExecutionCodeBlockBuilder forQuery(QueryInteraction query) { + + this.query = query; + return this; + } + + CodeBlock build() { + + String mongoOpsRef = context.fieldNameOf(MongoOperations.class); + + Builder builder = CodeBlock.builder(); + + boolean isProjecting = context.getReturnedType().isProjecting(); + Object actualReturnType = isProjecting ? context.getActualReturnType().getType() + : context.getRepositoryInformation().getDomainType(); + + builder.add("\n"); + + if (isProjecting) { + builder.addStatement("$T<$T> finder = $L.query($T.class).as($T.class)", FindWithQuery.class, actualReturnType, + mongoOpsRef, context.getRepositoryInformation().getDomainType(), actualReturnType); + } else { + + builder.addStatement("$T<$T> finder = $L.query($T.class)", FindWithQuery.class, actualReturnType, mongoOpsRef, + context.getRepositoryInformation().getDomainType()); + } + + String terminatingMethod; + + if (queryMethod.isCollectionQuery() || queryMethod.isPageQuery() || queryMethod.isSliceQuery()) { + terminatingMethod = "all()"; + } else if (query.isCount()) { + terminatingMethod = "count()"; + } else if (query.isExists()) { + terminatingMethod = "exists()"; + } else { + terminatingMethod = Optional.class.isAssignableFrom(context.getReturnType().toClass()) ? "one()" : "oneValue()"; + } + + if (queryMethod.isPageQuery()) { + builder.addStatement("return new $T(finder, $L).execute($L)", PagedExecution.class, + context.getPageableParameterName(), query.name()); + } else if (queryMethod.isSliceQuery()) { + builder.addStatement("return new $T(finder, $L).execute($L)", SlicedExecution.class, + context.getPageableParameterName(), query.name()); + } else { + builder.addStatement("return finder.matching($L).$L", query.name(), terminatingMethod); + } + + return builder.build(); + } + } + + @NullUnmarked + static class AggregationCodeBlockBuilder { + + private final AotQueryMethodGenerationContext context; + private final MongoQueryMethod queryMethod; + + private AggregationInteraction source; + private List arguments; + private String aggregationVariableName; + private boolean pipelineOnly; + + AggregationCodeBlockBuilder(AotQueryMethodGenerationContext context, MongoQueryMethod queryMethod) { + + this.context = context; + this.arguments = context.getBindableParameterNames(); + this.queryMethod = queryMethod; + } + + AggregationCodeBlockBuilder stages(AggregationInteraction aggregation) { + + this.source = aggregation; + return this; + } + + AggregationCodeBlockBuilder usingAggregationVariableName(String aggregationVariableName) { + + this.aggregationVariableName = aggregationVariableName; + return this; + } + + AggregationCodeBlockBuilder pipelineOnly(boolean pipelineOnly) { + + this.pipelineOnly = pipelineOnly; + return this; + } + + CodeBlock build() { + + CodeBlock.Builder builder = CodeBlock.builder(); + builder.add("\n"); + + String pipelineName = aggregationVariableName + (pipelineOnly ? "" : "Pipeline"); + builder.add(pipeline(pipelineName)); + + if (!pipelineOnly) { + + builder.addStatement("$T<$T> $L = $T.newAggregation($T.class, $L.getOperations())", TypedAggregation.class, + context.getRepositoryInformation().getDomainType(), aggregationVariableName, Aggregation.class, + context.getRepositoryInformation().getDomainType(), pipelineName); + + builder.add(aggregationOptions(aggregationVariableName)); + } + + return builder.build(); + } + + private CodeBlock pipeline(String pipelineVariableName) { + + String sortParameter = context.getSortParameterName(); + String limitParameter = context.getLimitParameterName(); + String pageableParameter = context.getPageableParameterName(); + + boolean mightBeSorted = StringUtils.hasText(sortParameter); + boolean mightBeLimited = StringUtils.hasText(limitParameter); + boolean mightBePaged = StringUtils.hasText(pageableParameter); + + int stageCount = source.stages().size(); + if (mightBeSorted) { + stageCount++; + } + if (mightBeLimited) { + stageCount++; + } + if (mightBePaged) { + stageCount += 3; + } + + Builder builder = CodeBlock.builder(); + String stagesVariableName = "stages"; + builder.add(aggregationStages(stagesVariableName, source.stages(), stageCount, arguments)); + + if (mightBeSorted) { + builder.add(sortingStage(sortParameter)); + } + + if (mightBeLimited) { + builder.add(limitingStage(limitParameter)); + } + + if (mightBePaged) { + builder.add(pagingStage(pageableParameter, queryMethod.isSliceQuery())); + } + + builder.addStatement("$T $L = createPipeline($L)", AggregationPipeline.class, pipelineVariableName, + stagesVariableName); + return builder.build(); + } + + private CodeBlock aggregationOptions(String aggregationVariableName) { + + Builder builder = CodeBlock.builder(); + List options = new ArrayList<>(5); + if (ReflectionUtils.isVoid(queryMethod.getReturnedObjectType())) { + options.add(CodeBlock.of(".skipOutput()")); + } + + MergedAnnotation hintAnnotation = context.getAnnotation(Hint.class); + String hint = hintAnnotation.isPresent() ? hintAnnotation.getString("value") : null; + if (StringUtils.hasText(hint)) { + options.add(CodeBlock.of(".hint($S)", hint)); + } + + MergedAnnotation readPreferenceAnnotation = context.getAnnotation(ReadPreference.class); + String readPreference = readPreferenceAnnotation.isPresent() ? readPreferenceAnnotation.getString("value") : null; + if (StringUtils.hasText(readPreference)) { + options.add(CodeBlock.of(".readPreference($T.valueOf($S))", com.mongodb.ReadPreference.class, readPreference)); + } + + if (queryMethod.hasAnnotatedCollation()) { + options.add(CodeBlock.of(".collation($T.parse($S))", Collation.class, queryMethod.getAnnotatedCollation())); + } + + if (!options.isEmpty()) { + + Builder optionsBuilder = CodeBlock.builder(); + optionsBuilder.add("$T aggregationOptions = $T.builder()\n", AggregationOptions.class, + AggregationOptions.class); + optionsBuilder.indent(); + for (CodeBlock optionBlock : options) { + optionsBuilder.add(optionBlock); + optionsBuilder.add("\n"); + } + optionsBuilder.add(".build();\n"); + optionsBuilder.unindent(); + builder.add(optionsBuilder.build()); + + builder.addStatement("$L = $L.withOptions(aggregationOptions)", aggregationVariableName, + aggregationVariableName); + } + return builder.build(); + } + + private static CodeBlock aggregationStages(String stageListVariableName, Iterable stages, int stageCount, + List arguments) { + + Builder builder = CodeBlock.builder(); + builder.addStatement("$T<$T> $L = new $T($L)", List.class, Object.class, stageListVariableName, ArrayList.class, + stageCount); + int stageCounter = 0; + for (String stage : stages) { + String stageName = "stage_%s".formatted(stageCounter++); + builder.add(renderExpressionToDocument(stage, stageName, arguments)); + builder.addStatement("stages.add($L)", stageName); + } + return builder.build(); + } + + private static CodeBlock sortingStage(String sortProvider) { + + Builder builder = CodeBlock.builder(); + builder.beginControlFlow("if($L.isSorted())", sortProvider); + builder.addStatement("$T sortDocument = new $T()", Document.class, Document.class); + builder.beginControlFlow("for ($T order : $L)", Order.class, sortProvider); + builder.addStatement("sortDocument.append(order.getProperty(), order.isAscending() ? 1 : -1);"); + builder.endControlFlow(); + builder.addStatement("stages.add(new $T($S, sortDocument))", Document.class, "$sort"); + builder.endControlFlow(); + return builder.build(); + } + + private static CodeBlock pagingStage(String pageableProvider, boolean slice) { + + Builder builder = CodeBlock.builder(); + builder.add(sortingStage(pageableProvider + ".getSort()")); + + builder.beginControlFlow("if($L.isPaged())", pageableProvider); + builder.beginControlFlow("if($L.getOffset() > 0)", pageableProvider); + builder.addStatement("stages.add($T.skip($L.getOffset()))", Aggregation.class, pageableProvider); + builder.endControlFlow(); + if (slice) { + builder.addStatement("stages.add($T.limit($L.getPageSize() + 1))", Aggregation.class, pageableProvider); + } else { + builder.addStatement("stages.add($T.limit($L.getPageSize()))", Aggregation.class, pageableProvider); + } + builder.endControlFlow(); + + return builder.build(); + } + + private static CodeBlock limitingStage(String limitProvider) { + + Builder builder = CodeBlock.builder(); + builder.beginControlFlow("if($L.isLimited())", limitProvider); + builder.addStatement("stages.add($T.limit($L.max()))", Aggregation.class, limitProvider); + builder.endControlFlow(); + return builder.build(); + } + } + + @NullUnmarked + static class QueryCodeBlockBuilder { + + private final AotQueryMethodGenerationContext context; + private final MongoQueryMethod queryMethod; + + private QueryInteraction source; + private List arguments; + private String queryVariableName; + + QueryCodeBlockBuilder(AotQueryMethodGenerationContext context, MongoQueryMethod queryMethod) { + + this.context = context; + this.arguments = context.getBindableParameterNames(); + this.queryMethod = queryMethod; + } + + QueryCodeBlockBuilder filter(QueryInteraction query) { + + this.source = query; + return this; + } + + QueryCodeBlockBuilder usingQueryVariableName(String queryVariableName) { + this.queryVariableName = queryVariableName; + return this; + } + + CodeBlock build() { + + CodeBlock.Builder builder = CodeBlock.builder(); + + builder.add("\n"); + builder.add(renderExpressionToQuery(source.getQuery().getQueryString(), queryVariableName)); + + if (StringUtils.hasText(source.getQuery().getFieldsString())) { + + builder.add(renderExpressionToDocument(source.getQuery().getFieldsString(), "fields", arguments)); + builder.addStatement("$L.setFieldsObject(fields)", queryVariableName); + } + + String sortParameter = context.getSortParameterName(); + if (StringUtils.hasText(sortParameter)) { + builder.addStatement("$L.with($L)", queryVariableName, sortParameter); + } else if (StringUtils.hasText(source.getQuery().getSortString())) { + + builder.add(renderExpressionToDocument(source.getQuery().getSortString(), "sort", arguments)); + builder.addStatement("$L.setSortObject(sort)", queryVariableName); + } + + String limitParameter = context.getLimitParameterName(); + if (StringUtils.hasText(limitParameter)) { + builder.addStatement("$L.limit($L)", queryVariableName, limitParameter); + } else if (context.getPageableParameterName() == null && source.getQuery().isLimited()) { + builder.addStatement("$L.limit($L)", queryVariableName, source.getQuery().getLimit()); + } + + String pageableParameter = context.getPageableParameterName(); + if (StringUtils.hasText(pageableParameter) && !queryMethod.isPageQuery() && !queryMethod.isSliceQuery()) { + builder.addStatement("$L.with($L)", queryVariableName, pageableParameter); + } + + MergedAnnotation hintAnnotation = context.getAnnotation(Hint.class); + String hint = hintAnnotation.isPresent() ? hintAnnotation.getString("value") : null; + + if (StringUtils.hasText(hint)) { + builder.addStatement("$L.withHint($S)", queryVariableName, hint); + } + + MergedAnnotation readPreferenceAnnotation = context.getAnnotation(ReadPreference.class); + String readPreference = readPreferenceAnnotation.isPresent() ? readPreferenceAnnotation.getString("value") : null; + + if (StringUtils.hasText(readPreference)) { + builder.addStatement("$L.withReadPreference($T.valueOf($S))", queryVariableName, + com.mongodb.ReadPreference.class, readPreference); + } + + // TODO: Meta annotation + + return builder.build(); + } + + private CodeBlock renderExpressionToQuery(@Nullable String source, String variableName) { + + Builder builder = CodeBlock.builder(); + if (!StringUtils.hasText(source)) { + + builder.addStatement("$T $L = new $T(new $T())", BasicQuery.class, variableName, BasicQuery.class, + Document.class); + } else if (!containsPlaceholder(source)) { + + String tmpVarName = "%sString".formatted(variableName); + builder.addStatement("String $L = $S", tmpVarName, source); + + builder.addStatement("$T $L = new $T($T.parse($L))", BasicQuery.class, variableName, BasicQuery.class, + Document.class, tmpVarName); + } else { + + String tmpVarName = "%sString".formatted(variableName); + builder.addStatement("String $L = $S", tmpVarName, source); + builder.addStatement("$T $L = createQuery($L, new $T[]{ $L })", BasicQuery.class, variableName, tmpVarName, + Object.class, StringUtils.collectionToDelimitedString(arguments, ", ")); + } + + return builder.build(); + } + } + + @NullUnmarked + static class UpdateCodeBlockBuilder { + + private UpdateInteraction source; + private List arguments; + private String updateVariableName; + + public UpdateCodeBlockBuilder(AotQueryMethodGenerationContext context, MongoQueryMethod queryMethod) { + this.arguments = context.getBindableParameterNames(); + } + + public UpdateCodeBlockBuilder update(UpdateInteraction update) { + this.source = update; + return this; + } + + public UpdateCodeBlockBuilder usingUpdateVariableName(String updateVariableName) { + this.updateVariableName = updateVariableName; + return this; + } + + CodeBlock build() { + + CodeBlock.Builder builder = CodeBlock.builder(); + + builder.add("\n"); + String tmpVariableName = updateVariableName + "Document"; + builder.add(renderExpressionToDocument(source.getUpdate().getUpdateString(), tmpVariableName, arguments)); + builder.addStatement("$T $L = new $T($L)", BasicUpdate.class, updateVariableName, BasicUpdate.class, + tmpVariableName); + + return builder.build(); + } + } + + private static CodeBlock renderExpressionToDocument(@Nullable String source, String variableName, + List arguments) { + + Builder builder = CodeBlock.builder(); + if (!StringUtils.hasText(source)) { + builder.addStatement("$T $L = new $T()", Document.class, variableName, Document.class); + } else if (!containsPlaceholder(source)) { + + String tmpVarName = "%sString".formatted(variableName); + builder.addStatement("String $L = $S", tmpVarName, source); + builder.addStatement("$T $L = $T.parse($L)", Document.class, variableName, Document.class, tmpVarName); + } else { + + String tmpVarName = "%sString".formatted(variableName); + builder.addStatement("String $L = $S", tmpVarName, source); + builder.addStatement("$T $L = bindParameters($L, new $T[]{ $L })", Document.class, variableName, tmpVarName, + Object.class, StringUtils.collectionToDelimitedString(arguments, ", ")); + } + return builder.build(); + } + + private static boolean containsPlaceholder(String source) { + return PARAMETER_BINDING_PATTERN.matcher(source).find(); + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/MongoInteraction.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/MongoInteraction.java new file mode 100644 index 000000000..fa9ca2f99 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/MongoInteraction.java @@ -0,0 +1,62 @@ +/* + * 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 + * + * 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.repository.aot; + +/** + * Base abstraction for interactions with MongoDB. + * + * @author Christoph Strobl + * @since 5.0 + */ +abstract class MongoInteraction { + + abstract InteractionType getExecutionType(); + + boolean isAggregation() { + return InteractionType.AGGREGATION.equals(getExecutionType()); + } + + boolean isCount() { + return InteractionType.COUNT.equals(getExecutionType()); + } + + boolean isDelete() { + return InteractionType.DELETE.equals(getExecutionType()); + } + + boolean isExists() { + return InteractionType.EXISTS.equals(getExecutionType()); + } + + boolean isUpdate() { + return InteractionType.UPDATE.equals(getExecutionType()); + } + + String name() { + + if (isDelete()) { + return "deleteQuery"; + } + if (isCount()) { + return "countQuery"; + } + return "filterQuery"; + } + + enum InteractionType { + QUERY, COUNT, DELETE, EXISTS, UPDATE, AGGREGATION + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/MongoRepositoryContributor.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/MongoRepositoryContributor.java new file mode 100644 index 000000000..def03c797 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/MongoRepositoryContributor.java @@ -0,0 +1,286 @@ +/* + * 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 + * + * 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.repository.aot; + +import static org.springframework.data.mongodb.repository.aot.MongoCodeBlocks.aggregationBlockBuilder; +import static org.springframework.data.mongodb.repository.aot.MongoCodeBlocks.aggregationExecutionBlockBuilder; +import static org.springframework.data.mongodb.repository.aot.MongoCodeBlocks.deleteExecutionBlockBuilder; +import static org.springframework.data.mongodb.repository.aot.MongoCodeBlocks.queryBlockBuilder; +import static org.springframework.data.mongodb.repository.aot.MongoCodeBlocks.queryExecutionBlockBuilder; +import static org.springframework.data.mongodb.repository.aot.MongoCodeBlocks.updateBlockBuilder; +import static org.springframework.data.mongodb.repository.aot.MongoCodeBlocks.updateExecutionBlockBuilder; + +import java.lang.reflect.Method; +import java.util.regex.Pattern; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.aggregation.AggregationUpdate; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.repository.Query; +import org.springframework.data.mongodb.repository.Update; +import org.springframework.data.mongodb.repository.aot.MongoCodeBlocks.QueryCodeBlockBuilder; +import org.springframework.data.mongodb.repository.query.MongoQueryMethod; +import org.springframework.data.repository.aot.generate.AotRepositoryConstructorBuilder; +import org.springframework.data.repository.aot.generate.AotRepositoryFragmentMetadata; +import org.springframework.data.repository.aot.generate.MethodContributor; +import org.springframework.data.repository.aot.generate.RepositoryContributor; +import org.springframework.data.repository.config.AotRepositoryContext; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport; +import org.springframework.data.repository.query.QueryMethod; +import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.javapoet.CodeBlock; +import org.springframework.javapoet.TypeName; +import org.springframework.javapoet.TypeSpec.Builder; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * MongoDB specific {@link RepositoryContributor}. + * + * @author Christoph Strobl + * @since 5.0 + */ +public class MongoRepositoryContributor extends RepositoryContributor { + + private static final Log logger = LogFactory.getLog(RepositoryContributor.class); + + private final AotQueryCreator queryCreator; + private final MongoMappingContext mappingContext; + + public MongoRepositoryContributor(AotRepositoryContext repositoryContext) { + + super(repositoryContext); + this.queryCreator = new AotQueryCreator(); + this.mappingContext = new MongoMappingContext(); + } + + @Override + protected void customizeClass(RepositoryInformation information, AotRepositoryFragmentMetadata metadata, + Builder builder) { + builder.superclass(TypeName.get(MongoAotRepositoryFragmentSupport.class)); + } + + @Override + protected void customizeConstructor(AotRepositoryConstructorBuilder constructorBuilder) { + + constructorBuilder.addParameter("operations", TypeName.get(MongoOperations.class)); + constructorBuilder.addParameter("context", TypeName.get(RepositoryFactoryBeanSupport.FragmentCreationContext.class), + false); + + constructorBuilder.customize((repositoryInformation, builder) -> { + builder.addStatement("super(operations, context)"); + }); + } + + @Override + @SuppressWarnings("NullAway") + protected @Nullable MethodContributor contributeQueryMethod(Method method, + RepositoryInformation repositoryInformation) { + + MongoQueryMethod queryMethod = new MongoQueryMethod(method, repositoryInformation, getProjectionFactory(), + mappingContext); + + if (backoff(queryMethod)) { + return null; + } + + try { + if (queryMethod.hasAnnotatedAggregation()) { + + AggregationInteraction aggregation = new AggregationInteraction(queryMethod.getAnnotatedAggregation()); + return aggregationMethodContributor(queryMethod, aggregation); + } + + QueryInteraction query = createStringQuery(repositoryInformation, queryMethod, + AnnotatedElementUtils.findMergedAnnotation(method, Query.class), method.getParameterCount()); + + if (queryMethod.hasAnnotatedQuery()) { + if (StringUtils.hasText(queryMethod.getAnnotatedQuery()) + && Pattern.compile("[\\?:][#$]\\{.*\\}").matcher(queryMethod.getAnnotatedQuery()).find()) { + + if (logger.isDebugEnabled()) { + logger.debug( + "Skipping AOT generation for [%s]. SpEL expressions are not supported".formatted(method.getName())); + } + return MethodContributor.forQueryMethod(queryMethod).metadataOnly(query); + } + } + + if (query.isDelete()) { + return deleteMethodContributor(queryMethod, query); + } + + if (queryMethod.isModifyingQuery()) { + + Update updateSource = queryMethod.getUpdateSource(); + if (StringUtils.hasText(updateSource.value())) { + UpdateInteraction update = new UpdateInteraction(query, new StringUpdate(updateSource.value())); + return updateMethodContributor(queryMethod, update); + } + if (!ObjectUtils.isEmpty(updateSource.pipeline())) { + AggregationUpdateInteraction update = new AggregationUpdateInteraction(query, updateSource.pipeline()); + return aggregationUpdateMethodContributor(queryMethod, update); + } + } + + return queryMethodContributor(queryMethod, query); + } catch (RuntimeException codeGenerationError) { + if (logger.isErrorEnabled()) { + logger.error("Failed to generate code for [%s] [%s]".formatted(repositoryInformation.getRepositoryInterface(), + method.getName()), codeGenerationError); + } + } + return null; + } + + @SuppressWarnings("NullAway") + private QueryInteraction createStringQuery(RepositoryInformation repositoryInformation, MongoQueryMethod queryMethod, + @Nullable Query queryAnnotation, int parameterCount) { + + QueryInteraction query; + if (queryMethod.hasAnnotatedQuery() && queryAnnotation != null) { + query = new QueryInteraction(new StringQuery(queryMethod.getAnnotatedQuery()), queryAnnotation.count(), + queryAnnotation.delete(), queryAnnotation.exists()); + } else { + + PartTree partTree = new PartTree(queryMethod.getName(), repositoryInformation.getDomainType()); + query = new QueryInteraction(queryCreator.createQuery(partTree, parameterCount), partTree.isCountProjection(), + partTree.isDelete(), partTree.isExistsProjection()); + } + + if (queryAnnotation != null && StringUtils.hasText(queryAnnotation.sort())) { + query = query.withSort(queryAnnotation.sort()); + } + if (queryAnnotation != null && StringUtils.hasText(queryAnnotation.fields())) { + query = query.withFields(queryAnnotation.fields()); + } + + return query; + } + + private static boolean backoff(MongoQueryMethod method) { + + boolean skip = method.isGeoNearQuery() || method.isScrollQuery() || method.isStreamQuery(); + + if (skip && logger.isDebugEnabled()) { + logger.debug("Skipping AOT generation for [%s]. Method is either geo-near, streaming or scrolling query" + .formatted(method.getName())); + } + return skip; + } + + private static MethodContributor aggregationMethodContributor(MongoQueryMethod queryMethod, + AggregationInteraction aggregation) { + + return MethodContributor.forQueryMethod(queryMethod).withMetadata(aggregation).contribute(context -> { + + CodeBlock.Builder builder = CodeBlock.builder(); + builder.add(context.codeBlocks().logDebug("invoking [%s]".formatted(context.getMethod().getName()))); + + builder.add(aggregationBlockBuilder(context, queryMethod).stages(aggregation) + .usingAggregationVariableName("aggregation").build()); + builder.add(aggregationExecutionBlockBuilder(context, queryMethod).referencing("aggregation").build()); + + return builder.build(); + }); + } + + private static MethodContributor updateMethodContributor(MongoQueryMethod queryMethod, + UpdateInteraction update) { + + return MethodContributor.forQueryMethod(queryMethod).withMetadata(update).contribute(context -> { + + CodeBlock.Builder builder = CodeBlock.builder(); + builder.add(context.codeBlocks().logDebug("invoking [%s]".formatted(context.getMethod().getName()))); + + // update filter + String filterVariableName = update.name(); + builder.add(queryBlockBuilder(context, queryMethod).filter(update.getFilter()) + .usingQueryVariableName(filterVariableName).build()); + + // update definition + String updateVariableName = "updateDefinition"; + builder.add( + updateBlockBuilder(context, queryMethod).update(update).usingUpdateVariableName(updateVariableName).build()); + + builder.add(updateExecutionBlockBuilder(context, queryMethod).withFilter(filterVariableName) + .referencingUpdate(updateVariableName).build()); + return builder.build(); + }); + } + + private static MethodContributor aggregationUpdateMethodContributor(MongoQueryMethod queryMethod, + AggregationUpdateInteraction update) { + + return MethodContributor.forQueryMethod(queryMethod).withMetadata(update).contribute(context -> { + + CodeBlock.Builder builder = CodeBlock.builder(); + builder.add(context.codeBlocks().logDebug("invoking [%s]".formatted(context.getMethod().getName()))); + + // update filter + String filterVariableName = update.name(); + QueryCodeBlockBuilder queryCodeBlockBuilder = queryBlockBuilder(context, queryMethod).filter(update.getFilter()); + builder.add(queryCodeBlockBuilder.usingQueryVariableName(filterVariableName).build()); + + // update definition + String updateVariableName = "updateDefinition"; + builder.add(aggregationBlockBuilder(context, queryMethod).stages(update) + .usingAggregationVariableName(updateVariableName).pipelineOnly(true).build()); + + builder.addStatement("$T aggregationUpdate = $T.from($L.getOperations())", AggregationUpdate.class, + AggregationUpdate.class, updateVariableName); + + builder.add(updateExecutionBlockBuilder(context, queryMethod).withFilter(filterVariableName) + .referencingUpdate("aggregationUpdate").build()); + return builder.build(); + }); + } + + private static MethodContributor deleteMethodContributor(MongoQueryMethod queryMethod, + QueryInteraction query) { + + return MethodContributor.forQueryMethod(queryMethod).withMetadata(query).contribute(context -> { + + CodeBlock.Builder builder = CodeBlock.builder(); + builder.add(context.codeBlocks().logDebug("invoking [%s]".formatted(context.getMethod().getName()))); + QueryCodeBlockBuilder queryCodeBlockBuilder = queryBlockBuilder(context, queryMethod).filter(query); + + builder.add(queryCodeBlockBuilder.usingQueryVariableName(query.name()).build()); + builder.add(deleteExecutionBlockBuilder(context, queryMethod).referencing(query.name()).build()); + return builder.build(); + }); + } + + private static MethodContributor queryMethodContributor(MongoQueryMethod queryMethod, + QueryInteraction query) { + + return MethodContributor.forQueryMethod(queryMethod).withMetadata(query).contribute(context -> { + + CodeBlock.Builder builder = CodeBlock.builder(); + builder.add(context.codeBlocks().logDebug("invoking [%s]".formatted(context.getMethod().getName()))); + QueryCodeBlockBuilder queryCodeBlockBuilder = queryBlockBuilder(context, queryMethod).filter(query); + + builder.add(queryCodeBlockBuilder.usingQueryVariableName(query.name()).build()); + builder.add(queryExecutionBlockBuilder(context, queryMethod).forQuery(query).build()); + return builder.build(); + }); + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/QueryInteraction.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/QueryInteraction.java new file mode 100644 index 000000000..563079c03 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/QueryInteraction.java @@ -0,0 +1,83 @@ +/* + * 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 + * + * 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.repository.aot; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.data.repository.aot.generate.QueryMetadata; +import org.springframework.util.StringUtils; + +/** + * An {@link MongoInteraction} to execute a query. + * + * @author Christoph Strobl + * @since 5.0 + */ +class QueryInteraction extends MongoInteraction implements QueryMetadata { + + private final StringQuery query; + private final InteractionType interactionType; + + QueryInteraction(StringQuery query, boolean count, boolean delete, boolean exists) { + + this.query = query; + if (count) { + interactionType = InteractionType.COUNT; + } else if (exists) { + interactionType = InteractionType.EXISTS; + } else if (delete) { + interactionType = InteractionType.DELETE; + } else { + interactionType = InteractionType.QUERY; + } + } + + StringQuery getQuery() { + return query; + } + + QueryInteraction withSort(String sort) { + query.sort(sort); + return this; + } + + QueryInteraction withFields(String fields) { + query.fields(fields); + return this; + } + + @Override + InteractionType getExecutionType() { + return interactionType; + } + + @Override + public Map serialize() { + + Map serialized = new LinkedHashMap<>(); + + serialized.put("filter", query.getQueryString()); + if (query.isSorted()) { + serialized.put("sort", query.getSortString()); + } + if (StringUtils.hasText(query.getFieldsString())) { + serialized.put("fields", query.getFieldsString()); + } + + return serialized; + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/StringAggregation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/StringAggregation.java new file mode 100644 index 000000000..7b73215e9 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/StringAggregation.java @@ -0,0 +1,26 @@ +/* + * 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 + * + * 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.repository.aot; + +/** + * Value object holding the raw representation of an Aggregation Pipeline. + * + * @author Christoph Strobl + * @since 5.0 + */ +record StringAggregation(String[] pipeline) { + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/aot/generated/StringQuery.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/StringQuery.java similarity index 73% rename from spring-data-mongodb/src/main/java/org/springframework/data/mongodb/aot/generated/StringQuery.java rename to spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/StringQuery.java index c8d7b7ab2..d037198bb 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/aot/generated/StringQuery.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/StringQuery.java @@ -1,19 +1,3 @@ -/* - * 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. * @@ -21,7 +5,7 @@ * 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 + * 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, @@ -29,27 +13,29 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.mongodb.aot.generated; +package org.springframework.data.mongodb.repository.aot; import java.util.Optional; import java.util.Set; import org.bson.Document; +import org.jspecify.annotations.Nullable; import org.springframework.data.domain.KeysetScrollPosition; import org.springframework.data.mongodb.core.query.Collation; import org.springframework.data.mongodb.core.query.Field; import org.springframework.data.mongodb.core.query.Meta; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.util.BsonUtils; -import org.springframework.lang.Nullable; import org.springframework.util.StringUtils; import com.mongodb.ReadConcern; import com.mongodb.ReadPreference; /** + * Helper to capture setting for AOT queries. + * * @author Christoph Strobl - * @since 2025/01 + * @since 5.0 */ class StringQuery extends Query { @@ -58,8 +44,6 @@ class StringQuery extends Query { private @Nullable String sort; private @Nullable String fields; - private ExecutionType executionType = ExecutionType.QUERY; - public StringQuery(Query query) { this.delegate = query; } @@ -69,11 +53,6 @@ class StringQuery extends Query { this.raw = query; } - public StringQuery forCount() { - this.executionType = ExecutionType.COUNT; - return this; - } - @Nullable String getQueryString() { @@ -104,7 +83,7 @@ class StringQuery extends Query { } @Override - public ReadConcern getReadConcern() { + public @Nullable ReadConcern getReadConcern() { return delegate.getReadConcern(); } @@ -114,7 +93,7 @@ class StringQuery extends Query { } @Override - public ReadPreference getReadPreference() { + public @Nullable ReadPreference getReadPreference() { return delegate.getReadPreference(); } @@ -124,8 +103,7 @@ class StringQuery extends Query { } @Override - @Nullable - public KeysetScrollPosition getKeyset() { + public @Nullable KeysetScrollPosition getKeyset() { return delegate.getKeyset(); } @@ -170,8 +148,7 @@ class StringQuery extends Query { } @Override - @Nullable - public String getHint() { + public @Nullable String getHint() { return delegate.getHint(); } @@ -216,12 +193,6 @@ class StringQuery extends Query { } String toJson(Document source) { - StringBuffer buffer = new StringBuffer(); - BsonUtils.writeJson(source).to(buffer); - return buffer.toString(); - } - - enum ExecutionType { - QUERY, COUNT, DELETE + return BsonUtils.writeJson(source).toJsonString(); } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/StringUpdate.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/StringUpdate.java new file mode 100644 index 000000000..f65ee7912 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/StringUpdate.java @@ -0,0 +1,27 @@ +/* + * 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 + * + * 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.repository.aot; + +/** + * @author Christoph Strobl + * @since 5.0 + */ +record StringUpdate(String raw) { + + String getUpdateString() { + return raw; + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/UpdateInteraction.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/UpdateInteraction.java new file mode 100644 index 000000000..bbc76bec5 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/aot/UpdateInteraction.java @@ -0,0 +1,58 @@ +/* + * 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 + * + * 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.repository.aot; + +import java.util.Map; + +import org.springframework.data.repository.aot.generate.QueryMetadata; + +/** + * An {@link MongoInteraction} to execute an update. + * + * @author Christoph Strobl + * @since 5.0 + */ +class UpdateInteraction extends MongoInteraction implements QueryMetadata { + + private final QueryInteraction filter; + private final StringUpdate update; + + UpdateInteraction(QueryInteraction filter, StringUpdate update) { + this.filter = filter; + this.update = update; + } + + QueryInteraction getFilter() { + return filter; + } + + StringUpdate getUpdate() { + return update; + } + + @Override + public Map serialize() { + + Map serialized = filter.serialize(); + serialized.put("update", update.getUpdateString()); + return serialized; + } + + @Override + InteractionType getExecutionType() { + return InteractionType.UPDATE; + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AbstractMongoQuery.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AbstractMongoQuery.java index e160fd879..f56c2c7a2 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AbstractMongoQuery.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AbstractMongoQuery.java @@ -27,6 +27,7 @@ import org.springframework.data.mapping.model.ValueExpressionEvaluator; import org.springframework.data.mongodb.core.ExecutableFindOperation.ExecutableFind; import org.springframework.data.mongodb.core.ExecutableFindOperation.FindWithQuery; import org.springframework.data.mongodb.core.ExecutableFindOperation.TerminatingFind; +import org.springframework.data.mongodb.core.ExecutableRemoveOperation.ExecutableRemove; import org.springframework.data.mongodb.core.ExecutableUpdateOperation.ExecutableUpdate; import org.springframework.data.mongodb.core.MongoOperations; import org.springframework.data.mongodb.core.aggregation.AggregationOperation; @@ -69,6 +70,7 @@ public abstract class AbstractMongoQuery implements RepositoryQuery { private final MongoOperations operations; private final ExecutableFind executableFind; private final ExecutableUpdate executableUpdate; + private final ExecutableRemove executableRemove; private final Lazy codec = Lazy .of(() -> new ParameterBindingDocumentCodec(getCodecRegistry())); private final ValueExpressionDelegate valueExpressionDelegate; @@ -95,6 +97,7 @@ public abstract class AbstractMongoQuery implements RepositoryQuery { this.executableFind = operations.query(type); this.executableUpdate = operations.update(type); + this.executableRemove = operations.remove(type); this.valueExpressionDelegate = delegate; this.valueEvaluationContextProvider = delegate.createValueContextProvider(method.getParameters()); } @@ -164,7 +167,7 @@ public abstract class AbstractMongoQuery implements RepositoryQuery { private MongoQueryExecution getExecution(ConvertingParameterAccessor accessor, FindWithQuery operation) { if (isDeleteQuery()) { - return new DeleteExecution(operations, method); + return new DeleteExecution<>(executableRemove, method); } if (method.isModifyingQuery()) { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryCreator.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryCreator.java index 86223e83d..b8a8c34f4 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryCreator.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryCreator.java @@ -184,9 +184,17 @@ public class MongoQueryCreator extends AbstractQueryCreator { case IS_NULL: return criteria.is(null); case NOT_IN: - return criteria.nin(nextAsList(parameters, part)); + Object ninValue = parameters.next(); + if(ninValue instanceof Placeholder) { + return criteria.raw("$nin", ninValue); + } + return criteria.nin(valueAsList(ninValue, part)); case IN: - return criteria.in(nextAsList(parameters, part)); + Object inValue = parameters.next(); + if(inValue instanceof Placeholder) { + return criteria.raw("$in", inValue); + } + return criteria.in(valueAsList(inValue, part)); case LIKE: case STARTING_WITH: case ENDING_WITH: @@ -201,7 +209,12 @@ public class MongoQueryCreator extends AbstractQueryCreator { Object param = parameters.next(); return param instanceof Pattern pattern ? criteria.regex(pattern) : criteria.regex(param.toString()); case EXISTS: - return criteria.exists((Boolean) parameters.next()); + Object next = parameters.next(); + if(next instanceof Placeholder placeholder) { + return criteria.raw("$exists", placeholder); + } else { + return criteria.exists((Boolean) next); + } case TRUE: return criteria.is(true); case FALSE: @@ -320,7 +333,11 @@ public class MongoQueryCreator extends AbstractQueryCreator { Iterator parameters) { if (property.isCollectionLike()) { - return criteria.in(nextAsList(parameters, part)); + Object next = parameters.next(); + if(next instanceof Placeholder) { + return criteria.raw("$in", next); + } + return criteria.in(valueAsList(next, part)); } return addAppropriateLikeRegexTo(criteria, part, parameters.next()); @@ -384,19 +401,19 @@ public class MongoQueryCreator extends AbstractQueryCreator { String.format("Expected parameter type of %s but got %s", type, parameter.getClass())); } - private java.util.List nextAsList(Iterator iterator, Part part) { + private java.util.List valueAsList(Object value, Part part) { - Streamable streamable = asStreamable(iterator.next()); + Streamable streamable = asStreamable(value); if (!isSimpleComparisonPossible(part)) { MatchMode matchMode = toMatchMode(part.getType()); String regexOptions = toRegexOptions(part); streamable = streamable.map(it -> { - if (it instanceof String value) { + if (it instanceof String sv) { - return new BsonRegularExpression(MongoRegexCreator.INSTANCE.toRegularExpression(value, matchMode), - regexOptions); + return new BsonRegularExpression(MongoRegexCreator.INSTANCE.toRegularExpression(sv, matchMode), + regexOptions); } return it; }); diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryExecution.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryExecution.java index a9a631646..01d4e0c63 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryExecution.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryExecution.java @@ -37,11 +37,11 @@ import org.springframework.data.mongodb.core.ExecutableRemoveOperation; import org.springframework.data.mongodb.core.ExecutableRemoveOperation.ExecutableRemove; import org.springframework.data.mongodb.core.ExecutableRemoveOperation.TerminatingRemove; import org.springframework.data.mongodb.core.ExecutableUpdateOperation.ExecutableUpdate; -import org.springframework.data.mongodb.core.MongoOperations; import org.springframework.data.mongodb.core.query.NearQuery; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.UpdateDefinition; import org.springframework.data.mongodb.repository.util.SliceUtils; +import org.springframework.data.repository.query.QueryMethod; import org.springframework.data.support.PageableExecutionUtils; import org.springframework.data.util.TypeInformation; import org.springframework.util.Assert; @@ -251,19 +251,39 @@ public interface MongoQueryExecution { } } - final class DeleteExecutionX implements MongoQueryExecution { + /** + * {@link MongoQueryExecution} removing documents matching the query. + * + * @author Oliver Gierke + * @author Mark Paluch + * @author Artyom Gabeev + * @author Christoph Strobl + * @since 1.5 + */ + final class DeleteExecution implements MongoQueryExecution { - ExecutableRemoveOperation.ExecutableRemove remove; - Type type; + private ExecutableRemoveOperation.ExecutableRemove remove; + private Type type; - public DeleteExecutionX(ExecutableRemove remove, Type type) { + public DeleteExecution(ExecutableRemove remove, QueryMethod queryMethod) { + this.remove = remove; + if (queryMethod.isCollectionQuery()) { + this.type = Type.FIND_AND_REMOVE_ALL; + } else if (queryMethod.isQueryForEntity() + && !ClassUtils.isPrimitiveOrWrapper(queryMethod.getReturnedObjectType())) { + this.type = Type.FIND_AND_REMOVE_ONE; + } else { + this.type = Type.ALL; + } + } + + public DeleteExecution(ExecutableRemove remove, Type type) { this.remove = remove; this.type = type; } - @Nullable @Override - public Object execute(Query query) { + public @Nullable Object execute(Query query) { TerminatingRemove doRemove = remove.matching(query); if (Type.ALL.equals(type)) { @@ -274,7 +294,6 @@ public interface MongoQueryExecution { } else if (Type.FIND_AND_REMOVE_ONE.equals(type)) { Iterator removed = doRemove.findAndRemove().iterator(); return removed.hasNext() ? removed.next() : null; - } throw new RuntimeException(); } @@ -284,48 +303,6 @@ public interface MongoQueryExecution { } } - /** - * {@link MongoQueryExecution} removing documents matching the query. - * - * @author Oliver Gierke - * @author Mark Paluch - * @author Artyom Gabeev - * @author Christoph Strobl - * @since 1.5 - */ - final class DeleteExecution implements MongoQueryExecution { - - private final MongoOperations operations; - private final MongoQueryMethod method; - - public DeleteExecution(MongoOperations operations, MongoQueryMethod method) { - - Assert.notNull(operations, "Operations must not be null"); - Assert.notNull(method, "Method must not be null"); - - this.operations = operations; - this.method = method; - } - - @Override - public @Nullable Object execute(Query query) { - - String collectionName = method.getEntityInformation().getCollectionName(); - Class type = method.getEntityInformation().getJavaType(); - - if (method.isCollectionQuery()) { - return operations.findAllAndRemove(query, type, collectionName); - } - - if (method.isQueryForEntity() && !ClassUtils.isPrimitiveOrWrapper(method.getReturnedObjectType())) { - return operations.findAndRemove(query, type, collectionName); - } - - DeleteResult writeResult = operations.remove(query, type, collectionName); - return writeResult.wasAcknowledged() ? writeResult.getDeletedCount() : 0L; - } - } - /** * {@link MongoQueryExecution} updating documents matching the query. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryMethod.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryMethod.java index 4bd6e7db5..52c5e3255 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryMethod.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryMethod.java @@ -117,7 +117,7 @@ public class MongoQueryMethod extends QueryMethod { * @return */ @Nullable - String getAnnotatedQuery() { + public String getAnnotatedQuery() { return findAnnotatedQuery().orElse(null); } @@ -204,7 +204,7 @@ public class MongoQueryMethod extends QueryMethod { return doFindAnnotation(Query.class); } - TypeInformation getReturnType() { + public TypeInformation getReturnType() { return TypeInformation.fromReturnTypeOf(method); } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java index 99acb1294..dc51da84e 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/BsonUtils.java @@ -67,13 +67,13 @@ import org.bson.json.JsonParseException; import org.bson.types.Binary; import org.bson.types.Decimal128; import org.bson.types.ObjectId; +import org.jspecify.annotations.NullUnmarked; import org.jspecify.annotations.Nullable; import org.springframework.core.convert.converter.Converter; import org.springframework.data.mongodb.CodecRegistryProvider; import org.springframework.data.mongodb.core.mapping.FieldName; import org.springframework.data.mongodb.core.mapping.FieldName.Type; import org.springframework.data.mongodb.core.query.CriteriaDefinition.Placeholder; -import org.springframework.data.mongodb.util.json.SpringJsonWriter; import org.springframework.lang.Contract; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -337,7 +337,7 @@ public class BsonUtils { case BINARY -> { BsonBinary binary = value.asBinary(); - if(binary.getType() != BsonBinarySubType.VECTOR.getValue()) { + if (binary.getType() != BsonBinarySubType.VECTOR.getValue()) { yield binary.getData(); } yield value.asBinary().asVector(); @@ -783,15 +783,39 @@ public class BsonUtils { return new Document(target); } + /** + * Obtain a preconfigured {@link JsonWriter} allowing to render the given {@link Document} using a + * {@link CodecRegistry} containing a {@link PlaceholderCodec}. + * + * @param document the source document. Must not be {@literal null}. + * @return new instance of {@link JsonWriter}. + * @since 5.0 + */ public static JsonWriter writeJson(Document document) { - return sink -> { - SpringJsonWriter writer = new SpringJsonWriter(sink); - JSON_CODEC_REGISTRY.get(Document.class).encode(writer, document, EncoderContext.builder().build()); - }; + return sink -> JSON_CODEC_REGISTRY.get(Document.class).encode(new SpringJsonWriter(sink), document, + EncoderContext.builder().build()); } + /** + * Interface to pipe json rendering to a given sink. + * + * @since 5.0 + */ public interface JsonWriter { + + /** + * Write the json output to the given sink. + * + * @param sink the output target + */ void to(StringBuffer sink); + + default String toJsonString() { + + StringBuffer buffer = new StringBuffer(); + to(buffer); + return buffer.toString(); + } } @Contract("null -> null") @@ -1007,6 +1031,14 @@ public class BsonUtils { } } + /** + * Internal {@link Codec} implementation to write + * {@link org.springframework.data.mongodb.core.query.CriteriaDefinition.Placeholder placeholders}. + * + * @since 5.0 + * @author Christoph Strobl + */ + @NullUnmarked static class PlaceholderCodec implements Codec { @Override diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/SpringJsonWriter.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/SpringJsonWriter.java similarity index 96% rename from spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/SpringJsonWriter.java rename to spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/SpringJsonWriter.java index 370a272f5..07eab92a0 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/SpringJsonWriter.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/SpringJsonWriter.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.mongodb.util.json; +package org.springframework.data.mongodb.util; import static java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME; @@ -30,13 +30,18 @@ import org.bson.BsonTimestamp; import org.bson.BsonWriter; import org.bson.types.Decimal128; import org.bson.types.ObjectId; +import org.jspecify.annotations.NullUnmarked; import org.springframework.util.StringUtils; /** + * Internal {@link BsonWriter} implementation that allows to render {@link #writePlaceholder(String) placeholders} as + * {@code ?0}. + * * @author Christoph Strobl - * @since 2025/01 + * @since 5.0 */ -public class SpringJsonWriter implements BsonWriter { +@NullUnmarked +class SpringJsonWriter implements BsonWriter { private final StringBuffer buffer; @@ -49,6 +54,7 @@ public class SpringJsonWriter implements BsonWriter { } private static class JsonContext { + private final JsonContext parentContext; private final JsonContextType contextType; private boolean hasElements; @@ -450,6 +456,9 @@ public class SpringJsonWriter implements BsonWriter { } + /** + * @param placeholder + */ public void writePlaceholder(String placeholder) { write(placeholder); } diff --git a/spring-data-mongodb/src/test/java/example/aot/User.java b/spring-data-mongodb/src/test/java/example/aot/User.java index 28ea5911e..06022c0a5 100644 --- a/spring-data-mongodb/src/test/java/example/aot/User.java +++ b/spring-data-mongodb/src/test/java/example/aot/User.java @@ -1,19 +1,3 @@ -/* - * 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. * @@ -21,7 +5,7 @@ * 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 + * 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, @@ -37,7 +21,6 @@ import org.springframework.data.mongodb.core.mapping.Field; /** * @author Christoph Strobl - * @since 2025/01 */ public class User { @@ -51,6 +34,7 @@ public class User { Instant registrationDate; Instant lastSeen; + Long visits; public String getId() { return id; @@ -99,4 +83,12 @@ public class User { public void setLastSeen(Instant lastSeen) { this.lastSeen = lastSeen; } + + public Long getVisits() { + return visits; + } + + public void setVisits(Long visits) { + this.visits = visits; + } } diff --git a/spring-data-mongodb/src/test/java/example/aot/UserProjection.java b/spring-data-mongodb/src/test/java/example/aot/UserProjection.java index 06c70f806..e59598d3a 100644 --- a/spring-data-mongodb/src/test/java/example/aot/UserProjection.java +++ b/spring-data-mongodb/src/test/java/example/aot/UserProjection.java @@ -19,7 +19,6 @@ import java.time.Instant; /** * @author Christoph Strobl - * @since 2025/01 */ public interface UserProjection { diff --git a/spring-data-mongodb/src/test/java/example/aot/UserRepository.java b/spring-data-mongodb/src/test/java/example/aot/UserRepository.java index 104fd8d08..cdebb4fc5 100644 --- a/spring-data-mongodb/src/test/java/example/aot/UserRepository.java +++ b/spring-data-mongodb/src/test/java/example/aot/UserRepository.java @@ -1,11 +1,11 @@ /* - * Copyright 2025. the original author or authors. + * 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 + * 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, @@ -15,21 +15,30 @@ */ package example.aot; +import java.time.Instant; +import java.util.Collection; +import java.util.HashSet; import java.util.List; +import java.util.Objects; import java.util.Optional; +import java.util.Set; +import org.springframework.data.annotation.Id; import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.repository.Aggregation; +import org.springframework.data.mongodb.repository.Hint; import org.springframework.data.mongodb.repository.Query; import org.springframework.data.mongodb.repository.ReadPreference; +import org.springframework.data.mongodb.repository.Update; import org.springframework.data.repository.CrudRepository; /** * @author Christoph Strobl - * @since 2025/01 */ public interface UserRepository extends CrudRepository { @@ -47,6 +56,28 @@ public interface UserRepository extends CrudRepository { List findByLastnameStartingWith(String lastname); + List findByLastnameEndsWith(String postfix); + + List findByFirstnameLike(String firstname); + + List findByFirstnameNotLike(String firstname); + + List findByUsernameIn(Collection usernames); + + List findByUsernameNotIn(Collection usernames); + + List findByFirstnameAndLastname(String firstname, String lastname); + + List findByFirstnameOrLastname(String firstname, String lastname); + + List findByVisitsBetween(long from, long to); + + List findByLastSeenGreaterThan(Instant time); + + List findByVisitsExists(boolean exists); + + List findByLastnameNot(String lastname); + List findTop2ByLastnameStartingWith(String lastname); List findByLastnameStartingWithOrderByUsername(String lastname); @@ -66,6 +97,7 @@ public interface UserRepository extends CrudRepository { // TODO: Streaming // TODO: Scrolling // TODO: GeoQueries + // TODO: TextSearch /* Annotated Queries */ @@ -121,8 +153,17 @@ public interface UserRepository extends CrudRepository { @Query(value = "{ 'lastname' : { '$regex' : '^?0' } }", delete = true) List deleteUsersAnnotatedQueryByLastnameStartingWith(String lastname); - // TODO: updates - // TODO: Aggregations + /* Updates */ + + @Update("{ '$inc' : { 'visits' : ?1 } }") + int findUserAndIncrementVisitsByLastname(String lastname, int increment); + + @Query("{ 'lastname' : ?0 }") + @Update("{ '$inc' : { 'visits' : ?1 } }") + int updateAllByLastname(String lastname, int increment); + + @Update(pipeline = { "{ '$set' : { 'visits' : { '$ifNull' : [ {'$add' : [ '$visits', ?1 ] }, ?1 ] } } }" }) + void findAndIncrementVisitsViaPipelineByLastname(String lastname, int increment); /* Derived With Annotated Options */ @@ -143,4 +184,95 @@ public interface UserRepository extends CrudRepository { Page findUserProjectionByLastnameStartingWith(String lastname, Pageable page); + /* Aggregations */ + + @Aggregation(pipeline = { // + "{ '$match' : { 'last_name' : { '$ne' : null } } }", // + "{ '$project': { '_id' : '$last_name' } }" }) + List findAllLastnames(); + + @Aggregation(pipeline = { // + "{ '$match' : { 'last_name' : { '$ne' : null } } }", // + "{ '$group': { '_id' : '$last_name', names : { $addToSet : '$?0' } } }" }) + List groupByLastnameAnd(String property); + + @Aggregation(pipeline = { // + "{ '$match' : { 'last_name' : { '$ne' : null } } }", // + "{ '$group': { '_id' : '$last_name', names : { $addToSet : '$?0' } } }" }) + List groupByLastnameAnd(String property, Pageable pageable); + + @Aggregation(pipeline = { // + "{ '$match' : { 'last_name' : { '$ne' : null } } }", // + "{ '$group': { '_id' : '$last_name', names : { $addToSet : '$?0' } } }" }) + Slice groupByLastnameAndReturnPage(String property, Pageable pageable); + + @Aggregation(pipeline = { // + "{ '$match' : { 'last_name' : { '$ne' : null } } }", // + "{ '$group': { '_id' : '$last_name', names : { $addToSet : '$?0' } } }" }) + AggregationResults groupByLastnameAndAsAggregationResults(String property); + + @Aggregation(pipeline = { // + "{ '$match' : { 'posts' : { '$ne' : null } } }", // + "{ '$project': { 'nrPosts' : {'$size': '$posts' } } }", // + "{ '$group' : { '_id' : null, 'total' : { $sum: '$nrPosts' } } }" }) + int sumPosts(); + + @Hint("ln-idx") + @Aggregation(pipeline = { // + "{ '$match' : { 'last_name' : { '$ne' : null } } }", // + "{ '$project': { '_id' : '$last_name' } }" }) + List findAllLastnamesUsingIndex(); + + @ReadPreference("no-such-read-preference") + @Aggregation(pipeline = { // + "{ '$match' : { 'last_name' : { '$ne' : null } } }", // + "{ '$project': { '_id' : '$last_name' } }" }) + List findAllLastnamesWithReadPreference(); + + @Aggregation(pipeline = { // + "{ '$match' : { 'last_name' : { '$ne' : null } } }", // + "{ '$project': { '_id' : '$last_name' } }" }, collation = "no_collation") + List findAllLastnamesWithCollation(); + + class UserAggregate { + + @Id // + private final String lastname; + private final Set names; + + public UserAggregate(String lastname, Collection names) { + this.lastname = lastname; + this.names = new HashSet<>(names); + } + + public String getLastname() { + return this.lastname; + } + + public Set getNames() { + return this.names; + } + + @Override + public String toString() { + return "UserAggregate{" + "lastname='" + lastname + '\'' + ", names=" + names + '}'; + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + UserAggregate that = (UserAggregate) o; + return Objects.equals(lastname, that.lastname) && names.equals(that.names); + } + + @Override + public int hashCode() { + return Objects.hash(lastname, names); + } + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/aot/generated/DemoRepo.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/aot/generated/DemoRepo.java index bef0d34cb..41759e68c 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/aot/generated/DemoRepo.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/aot/generated/DemoRepo.java @@ -1,19 +1,3 @@ -/* - * 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. * @@ -21,7 +5,7 @@ * 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 + * 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, diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/aot/generated/MongoRepositoryContributorTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/aot/generated/MongoRepositoryContributorTests.java deleted file mode 100644 index 9caf74f31..000000000 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/aot/generated/MongoRepositoryContributorTests.java +++ /dev/null @@ -1,662 +0,0 @@ -/* - * 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.aot.generated; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; - -import example.aot.User; -import example.aot.UserProjection; -import example.aot.UserRepository; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Optional; -import java.util.function.Consumer; -import java.util.function.Supplier; - -import org.bson.Document; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.springframework.aot.test.generate.TestGenerationContext; -import org.springframework.beans.factory.config.BeanDefinition; -import org.springframework.beans.factory.support.AbstractBeanDefinition; -import org.springframework.beans.factory.support.BeanDefinitionBuilder; -import org.springframework.beans.factory.support.DefaultListableBeanFactory; -import org.springframework.core.test.tools.TestCompiler; -import org.springframework.data.domain.Limit; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Slice; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.test.util.Client; -import org.springframework.data.mongodb.test.util.MongoClientExtension; -import org.springframework.data.mongodb.test.util.MongoTestTemplate; -import org.springframework.data.mongodb.test.util.MongoTestUtils; -import org.springframework.data.util.Lazy; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.util.StringUtils; - -import com.mongodb.client.MongoClient; - -/** - * @author Christoph Strobl - * @since 2025/01 - */ -@ExtendWith(MongoClientExtension.class) -public class MongoRepositoryContributorTests { - - private static final String DB_NAME = "aot-repo-tests"; - private static Verifyer generated; - - @Client static MongoClient client; - - @BeforeAll - static void beforeAll() { - - TestMongoAotRepositoryContext aotContext = new TestMongoAotRepositoryContext(UserRepository.class, null); - TestGenerationContext generationContext = new TestGenerationContext(UserRepository.class); - - new MongoRepositoryContributor(aotContext).contribute(generationContext); - - AbstractBeanDefinition mongoTemplate = BeanDefinitionBuilder.rootBeanDefinition(MongoTestTemplate.class) - .addConstructorArgValue(DB_NAME).getBeanDefinition(); - AbstractBeanDefinition aotGeneratedRepository = BeanDefinitionBuilder - .genericBeanDefinition("example.aot.UserRepositoryImpl__Aot").addConstructorArgReference("mongoOperations") - .getBeanDefinition(); - - generated = generateContext(generationContext) // - .register("mongoOperations", mongoTemplate) // - .register("aotUserRepository", aotGeneratedRepository); - } - - @BeforeEach - void beforeEach() { - - MongoTestUtils.flushCollection(DB_NAME, "user", client); - initUsers(); - } - - @Test - void testFindDerivedFinderSingleEntity() { - - generated.verify(methodInvoker -> { - - User user = methodInvoker.invoke("findOneByUsername", "yoda").onBean("aotUserRepository"); - assertThat(user).isNotNull().extracting(User::getUsername).isEqualTo("yoda"); - }); - } - - @Test - void testFindDerivedFinderOptionalEntity() { - - generated.verify(methodInvoker -> { - - Optional user = methodInvoker.invoke("findOptionalOneByUsername", "yoda").onBean("aotUserRepository"); - assertThat(user).isNotNull().containsInstanceOf(User.class) - .hasValueSatisfying(it -> assertThat(it).extracting(User::getUsername).isEqualTo("yoda")); - }); - } - - @Test - void testDerivedCount() { - - generated.verify(methodInvoker -> { - - Long value = methodInvoker.invoke("countUsersByLastname", "Skywalker").onBean("aotUserRepository"); - assertThat(value).isEqualTo(2L); - }); - } - - @Test - void testDerivedExists() { - - generated.verify(methodInvoker -> { - - Boolean exists = methodInvoker.invoke("existsUserByLastname", "Skywalker").onBean("aotUserRepository"); - assertThat(exists).isTrue(); - }); - } - - @Test - void testDerivedFinderWithoutArguments() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findUserNoArgumentsBy").onBean("aotUserRepository"); - assertThat(users).hasSize(7).hasOnlyElementsOfType(User.class); - }); - } - - @Test - void testCountWorksAsExpected() { - - generated.verify(methodInvoker -> { - - Long value = methodInvoker.invoke("countUsersByLastname", "Skywalker").onBean("aotUserRepository"); - assertThat(value).isEqualTo(2L); - }); - } - - @Test - void testDerivedFinderReturningList() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findByLastnameStartingWith", "S").onBean("aotUserRepository"); - assertThat(users).extracting(User::getUsername).containsExactlyInAnyOrder("luke", "vader", "kylo", "han"); - }); - } - - @Test - void testLimitedDerivedFinder() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findTop2ByLastnameStartingWith", "S").onBean("aotUserRepository"); - assertThat(users).hasSize(2); - }); - } - - @Test - void testSortedDerivedFinder() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findByLastnameStartingWithOrderByUsername", "S") - .onBean("aotUserRepository"); - assertThat(users).extracting(User::getUsername).containsExactly("han", "kylo", "luke", "vader"); - }); - } - - @Test - void testDerivedFinderWithLimitArgument() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findByLastnameStartingWith", "S", Limit.of(2)) - .onBean("aotUserRepository"); - assertThat(users).hasSize(2); - }); - } - - @Test - void testDerivedFinderWithSort() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findByLastnameStartingWith", "S", Sort.by("username")) - .onBean("aotUserRepository"); - assertThat(users).extracting(User::getUsername).containsExactly("han", "kylo", "luke", "vader"); - }); - } - - @Test - void testDerivedFinderWithSortAndLimit() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findByLastnameStartingWith", "S", Sort.by("username"), Limit.of(2)) - .onBean("aotUserRepository"); - assertThat(users).extracting(User::getUsername).containsExactly("han", "kylo"); - }); - } - - @Test - void testDerivedFinderReturningListWithPageable() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker - .invoke("findByLastnameStartingWith", "S", PageRequest.of(0, 2, Sort.by("username"))) - .onBean("aotUserRepository"); - assertThat(users).extracting(User::getUsername).containsExactly("han", "kylo"); - }); - } - - @Test - void testDerivedFinderReturningPage() { - - generated.verify(methodInvoker -> { - - Page page = methodInvoker - .invoke("findPageOfUsersByLastnameStartingWith", "S", PageRequest.of(0, 2, Sort.by("username"))) - .onBean("aotUserRepository"); - assertThat(page.getTotalElements()).isEqualTo(4); - assertThat(page.getSize()).isEqualTo(2); - assertThat(page.getContent()).extracting(User::getUsername).containsExactly("han", "kylo"); - }); - } - - @Test - void testDerivedFinderReturningSlice() { - - generated.verify(methodInvoker -> { - - Slice slice = methodInvoker - .invoke("findSliceOfUserByLastnameStartingWith", "S", PageRequest.of(0, 2, Sort.by("username"))) - .onBean("aotUserRepository"); - assertThat(slice.hasNext()).isTrue(); - assertThat(slice.getSize()).isEqualTo(2); - assertThat(slice.getContent()).extracting(User::getUsername).containsExactly("han", "kylo"); - }); - } - - @Test - void testAnnotatedFinderReturningSingleValueWithQuery() { - - generated.verify(methodInvoker -> { - - User user = methodInvoker.invoke("findAnnotatedQueryByUsername", "yoda").onBean("aotUserRepository"); - assertThat(user).isNotNull().extracting(User::getUsername).isEqualTo("yoda"); - }); - } - - @Test - void testAnnotatedCount() { - - generated.verify(methodInvoker -> { - - Long value = methodInvoker.invoke("countAnnotatedQueryByLastname", "Skywalker").onBean("aotUserRepository"); - assertThat(value).isEqualTo(2L); - }); - } - - @Test - void testAnnotatedFinderReturningListWithQuery() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findAnnotatedQueryByLastname", "S").onBean("aotUserRepository"); - assertThat(users).extracting(User::getUsername).containsExactlyInAnyOrder("han", "kylo", "luke", "vader"); - }); - } - - @Test - void testAnnotatedMultilineFinderWithQuery() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findAnnotatedMultilineQueryByLastname", "S").onBean("aotUserRepository"); - assertThat(users).extracting(User::getUsername).containsExactlyInAnyOrder("han", "kylo", "luke", "vader"); - }); - } - - @Test - void testAnnotatedFinderWithQueryAndLimit() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findAnnotatedQueryByLastname", "S", Limit.of(2)) - .onBean("aotUserRepository"); - assertThat(users).hasSize(2); - }); - } - - @Test - void testAnnotatedFinderWithQueryAndSort() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findAnnotatedQueryByLastname", "S", Sort.by("username")) - .onBean("aotUserRepository"); - assertThat(users).extracting(User::getUsername).containsExactly("han", "kylo", "luke", "vader"); - }); - } - - @Test - void testAnnotatedFinderWithQueryLimitAndSort() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findAnnotatedQueryByLastname", "S", Limit.of(2), Sort.by("username")) - .onBean("aotUserRepository"); - assertThat(users).extracting(User::getUsername).containsExactly("han", "kylo"); - }); - } - - @Test - void testAnnotatedFinderReturningListWithPageable() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker - .invoke("findAnnotatedQueryByLastname", "S", PageRequest.of(0, 2, Sort.by("username"))) - .onBean("aotUserRepository"); - assertThat(users).extracting(User::getUsername).containsExactly("han", "kylo"); - }); - } - - @Test - void testAnnotatedFinderReturningPage() { - - generated.verify(methodInvoker -> { - - Page page = methodInvoker - .invoke("findAnnotatedQueryPageOfUsersByLastname", "S", PageRequest.of(0, 2, Sort.by("username"))) - .onBean("aotUserRepository"); - assertThat(page.getTotalElements()).isEqualTo(4); - assertThat(page.getSize()).isEqualTo(2); - assertThat(page.getContent()).extracting(User::getUsername).containsExactly("han", "kylo"); - }); - } - - @Test - void testAnnotatedFinderReturningSlice() { - - generated.verify(methodInvoker -> { - - Slice slice = methodInvoker - .invoke("findAnnotatedQuerySliceOfUsersByLastname", "S", PageRequest.of(0, 2, Sort.by("username"))) - .onBean("aotUserRepository"); - assertThat(slice.hasNext()).isTrue(); - assertThat(slice.getSize()).isEqualTo(2); - assertThat(slice.getContent()).extracting(User::getUsername).containsExactly("han", "kylo"); - }); - } - - @ParameterizedTest - @ValueSource(strings = { "deleteByUsername", "deleteAnnotatedQueryByUsername" }) - void testDeleteSingle(String methodName) { - - generated.verify(methodInvoker -> { - - User result = methodInvoker.invoke(methodName, "yoda").onBean("aotUserRepository"); - - assertThat(result).isNotNull().extracting(User::getUsername).isEqualTo("yoda"); - }); - - assertThat(client.getDatabase(DB_NAME).getCollection("user").countDocuments()).isEqualTo(6L); - } - - @ParameterizedTest - @ValueSource(strings = { "deleteByLastnameStartingWith", "deleteAnnotatedQueryByLastnameStartingWith" }) - void testDerivedDeleteMultipleReturningDeleteCount(String methodName) { - - generated.verify(methodInvoker -> { - - Long result = methodInvoker.invoke(methodName, "S").onBean("aotUserRepository"); - - assertThat(result).isEqualTo(4L); - }); - - assertThat(client.getDatabase(DB_NAME).getCollection("user").countDocuments()).isEqualTo(3L); - } - - @ParameterizedTest - @ValueSource(strings = { "deleteUsersByLastnameStartingWith", "deleteUsersAnnotatedQueryByLastnameStartingWith" }) - void testDerivedDeleteMultipleReturningDeleted(String methodName) { - - generated.verify(methodInvoker -> { - - List result = methodInvoker.invoke(methodName, "S").onBean("aotUserRepository"); - - assertThat(result).extracting(User::getUsername).containsExactlyInAnyOrder("han", "kylo", "luke", "vader"); - }); - - assertThat(client.getDatabase(DB_NAME).getCollection("user").countDocuments()).isEqualTo(3L); - } - - @Test - void testDerivedFinderWithAnnotatedSort() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findWithAnnotatedSortByLastnameStartingWith", "S") - .onBean("aotUserRepository"); - assertThat(users).extracting(User::getUsername).containsExactly("han", "kylo", "luke", "vader"); - }); - } - - @Test - void testDerivedFinderWithAnnotatedFieldsProjection() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findWithAnnotatedFieldsProjectionByLastnameStartingWith", "S") - .onBean("aotUserRepository"); - assertThat(users).allMatch( - user -> StringUtils.hasText(user.getUsername()) && user.getLastname() == null && user.getFirstname() == null); - }); - } - - @Test - void testReadPreferenceAppliedToQuery() { - - generated.verify(methodInvoker -> { - - // check if it fails when trying to parse the read preference to indicate it would get applied - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> methodInvoker.invoke("findWithReadPreferenceByUsername", "S").onBean("aotUserRepository")) - .withMessageContaining("No match for read preference"); - }); - } - - @Test - void testDerivedFinderReturningListOfProjections() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findUserProjectionByLastnameStartingWith", "S") - .onBean("aotUserRepository"); - assertThat(users).extracting(UserProjection::getUsername).containsExactlyInAnyOrder("han", "kylo", "luke", - "vader"); - }); - } - - @Test - void testDerivedFinderReturningPageOfProjections() { - - generated.verify(methodInvoker -> { - - Page users = methodInvoker - .invoke("findUserProjectionByLastnameStartingWith", "S", PageRequest.of(0, 2, Sort.by("username"))) - .onBean("aotUserRepository"); - assertThat(users).extracting(UserProjection::getUsername).containsExactly("han", "kylo"); - }); - } - - private static void initUsers() { - - Document luke = Document.parse(""" - { - "_id": "id-1", - "username": "luke", - "first_name": "Luke", - "last_name": "Skywalker", - "posts": [ - { - "message": "I have a bad feeling about this.", - "date": { - "$date": "2025-01-15T12:50:33.855Z" - } - } - ], - "_class": "example.springdata.aot.User" - }"""); - - Document leia = Document.parse(""" - { - "_id": "id-2", - "username": "leia", - "first_name": "Leia", - "last_name": "Organa", - "_class": "example.springdata.aot.User" - }"""); - - Document han = Document.parse(""" - { - "_id": "id-3", - "username": "han", - "first_name": "Han", - "last_name": "Solo", - "posts": [ - { - "message": "It's the ship that made the Kessel Run in less than 12 Parsecs.", - "date": { - "$date": "2025-01-15T13:30:33.855Z" - } - } - ], - "_class": "example.springdata.aot.User" - }"""); - - Document chwebacca = Document.parse(""" - { - "_id": "id-4", - "username": "chewbacca", - "_class": "example.springdata.aot.User" - }"""); - - Document yoda = Document.parse( - """ - { - "_id": "id-5", - "username": "yoda", - "posts": [ - { - "message": "Do. Or do not. There is no try.", - "date": { - "$date": "2025-01-15T13:09:33.855Z" - } - }, - { - "message": "Decide you must, how to serve them best. If you leave now, help them you could; but you would destroy all for which they have fought, and suffered.", - "date": { - "$date": "2025-01-15T13:53:33.855Z" - } - } - ] - }"""); - - Document vader = Document.parse(""" - { - "_id": "id-6", - "username": "vader", - "first_name": "Anakin", - "last_name": "Skywalker", - "posts": [ - { - "message": "I am your father", - "date": { - "$date": "2025-01-15T13:46:33.855Z" - } - } - ] - }"""); - - Document kylo = Document.parse(""" - { - "_id": "id-7", - "username": "kylo", - "first_name": "Ben", - "last_name": "Solo" - } - """); - - client.getDatabase(DB_NAME).getCollection("user") - .insertMany(List.of(luke, leia, han, chwebacca, yoda, vader, kylo)); - } - - static GeneratedContextBuilder generateContext(TestGenerationContext generationContext) { - return new GeneratedContextBuilder(generationContext); - } - - static class GeneratedContextBuilder implements Verifyer { - - TestGenerationContext generationContext; - Map beanDefinitions = new LinkedHashMap<>(); - Lazy lazyFactory; - - public GeneratedContextBuilder(TestGenerationContext generationContext) { - - this.generationContext = generationContext; - this.lazyFactory = Lazy.of(() -> { - DefaultListableBeanFactory freshBeanFactory = new DefaultListableBeanFactory(); - TestCompiler.forSystem().with(generationContext).compile(compiled -> { - - freshBeanFactory.setBeanClassLoader(compiled.getClassLoader()); - for (Entry entry : beanDefinitions.entrySet()) { - freshBeanFactory.registerBeanDefinition(entry.getKey(), entry.getValue()); - } - }); - return freshBeanFactory; - }); - } - - GeneratedContextBuilder register(String name, BeanDefinition beanDefinition) { - this.beanDefinitions.put(name, beanDefinition); - return this; - } - - public Verifyer verify(Consumer methodInvoker) { - methodInvoker.accept(new GeneratedContext(lazyFactory)); - return this; - } - - } - - interface Verifyer { - Verifyer verify(Consumer methodInvoker); - } - - static class GeneratedContext { - - private Supplier delegate; - - public GeneratedContext(Supplier defaultListableBeanFactory) { - this.delegate = defaultListableBeanFactory; - } - - InvocationBuilder invoke(String method, Object... arguments) { - - return new InvocationBuilder() { - @Override - public T onBean(String beanName) { - Object bean = delegate.get().getBean(beanName); - return ReflectionTestUtils.invokeMethod(bean, method, arguments); - } - }; - } - - interface InvocationBuilder { - T onBean(String beanName); - } - - } -} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/aot/AotFragmentTestConfigurationSupport.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/aot/AotFragmentTestConfigurationSupport.java new file mode 100644 index 000000000..2427aec84 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/aot/AotFragmentTestConfigurationSupport.java @@ -0,0 +1,127 @@ +/* + * 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 + * + * 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.repository.aot; + +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + +import org.springframework.aot.test.generate.TestGenerationContext; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.core.test.tools.TestCompiler; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport; +import org.springframework.data.repository.query.ValueExpressionDelegate; +import org.springframework.util.ReflectionUtils; + +/** + * Test Configuration Support Class for generated AOT Repository Fragments based on a Repository Interface. + *

+ * This configuration generates the AOT repository, compiles sources and configures a BeanFactory to contain the AOT + * fragment. Additionally, the fragment is exposed through a {@code repositoryInterface} JDK proxy forwarding method + * invocations to the backing AOT fragment. Note that {@code repositoryInterface} is not a repository proxy. + * + * @author Christoph Strobl + */ +public class AotFragmentTestConfigurationSupport implements BeanFactoryPostProcessor { + + private final Class repositoryInterface; + private final TestMongoAotRepositoryContext repositoryContext; + + public AotFragmentTestConfigurationSupport(Class repositoryInterface) { + + this.repositoryInterface = repositoryInterface; + this.repositoryContext = new TestMongoAotRepositoryContext(repositoryInterface, null); + } + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { + + TestGenerationContext generationContext = new TestGenerationContext(repositoryInterface); + + new MongoRepositoryContributor(repositoryContext).contribute(generationContext); + + AbstractBeanDefinition aotGeneratedRepository = BeanDefinitionBuilder + .genericBeanDefinition(repositoryInterface.getName() + "Impl__Aot") // + .addConstructorArgReference("mongoOperations") // + .addConstructorArgValue(getCreationContext(repositoryContext)).getBeanDefinition(); + + TestCompiler.forSystem().withCompilerOptions("-parameters").with(generationContext).compile(compiled -> { + beanFactory.setBeanClassLoader(compiled.getClassLoader()); + ((BeanDefinitionRegistry) beanFactory).registerBeanDefinition("fragment", aotGeneratedRepository); + }); + + BeanDefinition fragmentFacade = BeanDefinitionBuilder.rootBeanDefinition((Class) repositoryInterface, () -> { + + Object fragment = beanFactory.getBean("fragment"); + Object proxy = getFragmentFacadeProxy(fragment); + + return repositoryInterface.cast(proxy); + }).getBeanDefinition(); + + ((BeanDefinitionRegistry) beanFactory).registerBeanDefinition("fragmentFacade", fragmentFacade); + } + + private Object getFragmentFacadeProxy(Object fragment) { + + return Proxy.newProxyInstance(repositoryInterface.getClassLoader(), new Class[] { repositoryInterface }, + (p, method, args) -> { + + Method target = ReflectionUtils.findMethod(fragment.getClass(), method.getName(), method.getParameterTypes()); + + if (target == null) { + throw new NoSuchMethodException("Method [%s] is not implemented by [%s]".formatted(method, target)); + } + + try { + return target.invoke(fragment, args); + } catch (ReflectiveOperationException e) { + ReflectionUtils.handleReflectionException(e); + } + + return null; + }); + } + + private RepositoryFactoryBeanSupport.FragmentCreationContext getCreationContext( + TestMongoAotRepositoryContext repositoryContext) { + + return new RepositoryFactoryBeanSupport.FragmentCreationContext() { + + @Override + public RepositoryMetadata getRepositoryMetadata() { + return repositoryContext.getRepositoryInformation(); + } + + @Override + public ValueExpressionDelegate getValueExpressionDelegate() { + return ValueExpressionDelegate.create(); + } + + @Override + public ProjectionFactory getProjectionFactory() { + return new SpelAwareProxyProjectionFactory(); + } + }; + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/aot/MongoRepositoryContributorTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/aot/MongoRepositoryContributorTests.java new file mode 100644 index 000000000..1ec0c3609 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/aot/MongoRepositoryContributorTests.java @@ -0,0 +1,650 @@ +/* + * 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 + * + * 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.repository.aot; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatException; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import example.aot.User; +import example.aot.UserProjection; +import example.aot.UserRepository; +import example.aot.UserRepository.UserAggregate; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import org.bson.Document; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.test.util.Client; +import org.springframework.data.mongodb.test.util.MongoClientExtension; +import org.springframework.data.mongodb.test.util.MongoTestUtils; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.util.StringUtils; + +import com.mongodb.client.MongoClient; + +/** + * @author Christoph Strobl + */ +@ExtendWith(MongoClientExtension.class) +@SpringJUnitConfig(classes = MongoRepositoryContributorTests.JpaRepositoryContributorConfiguration.class) +public class MongoRepositoryContributorTests { + + private static final String DB_NAME = "aot-repo-tests"; + + @Client static MongoClient client; + @Autowired UserRepository fragment; + + @Configuration + static class JpaRepositoryContributorConfiguration extends AotFragmentTestConfigurationSupport { + + public JpaRepositoryContributorConfiguration() { + super(UserRepository.class); + } + + @Bean + MongoOperations mongoOperations() { + return new MongoTemplate(client, DB_NAME); + } + } + + @BeforeEach + void beforeEach() { + + MongoTestUtils.flushCollection(DB_NAME, "user", client); + initUsers(); + } + + @Test + void testFindDerivedFinderSingleEntity() { + + User user = fragment.findOneByUsername("yoda"); + assertThat(user).isNotNull().extracting(User::getUsername).isEqualTo("yoda"); + } + + @Test + void testFindDerivedFinderOptionalEntity() { + + Optional user = fragment.findOptionalOneByUsername("yoda"); + assertThat(user).isNotNull().containsInstanceOf(User.class) + .hasValueSatisfying(it -> assertThat(it).extracting(User::getUsername).isEqualTo("yoda")); + } + + @Test + void testDerivedCount() { + + Long value = fragment.countUsersByLastname("Skywalker"); + assertThat(value).isEqualTo(2L); + } + + @Test + void testDerivedExists() { + + assertThat(fragment.existsUserByLastname("Skywalker")).isTrue(); + } + + @Test + void testDerivedFinderWithoutArguments() { + + List users = fragment.findUserNoArgumentsBy(); + assertThat(users).hasSize(7).hasOnlyElementsOfType(User.class); + } + + @Test + void testCountWorksAsExpected() { + + Long value = fragment.countUsersByLastname("Skywalker"); + assertThat(value).isEqualTo(2L); + } + + @Test + void testDerivedFinderReturningList() { + + List users = fragment.findByLastnameStartingWith("S"); + assertThat(users).extracting(User::getUsername).containsExactlyInAnyOrder("luke", "vader", "kylo", "han"); + } + + @Test + void testEndingWith() { + + List users = fragment.findByLastnameEndsWith("er"); + assertThat(users).extracting(User::getUsername).containsExactlyInAnyOrder("luke", "vader"); + } + + @Test + void testLike() { + + List users = fragment.findByFirstnameLike("ei"); + assertThat(users).extracting(User::getUsername).containsExactlyInAnyOrder("leia"); + } + + @Test + void testNotLike() { + + List users = fragment.findByFirstnameNotLike("ei"); + assertThat(users).extracting(User::getUsername).isNotEmpty().doesNotContain("leia"); + } + + @Test + void testIn() { + + List users = fragment.findByUsernameIn(List.of("chewbacca", "kylo")); + assertThat(users).extracting(User::getUsername).containsExactlyInAnyOrder("chewbacca", "kylo"); + } + + @Test + void testNotIn() { + + List users = fragment.findByUsernameNotIn(List.of("chewbacca", "kylo")); + assertThat(users).extracting(User::getUsername).isNotEmpty().doesNotContain("chewbacca", "kylo"); + } + + @Test + void testAnd() { + + List users = fragment.findByFirstnameAndLastname("Han", "Solo"); + assertThat(users).extracting(User::getUsername).containsExactly("han"); + } + + @Test + void testOr() { + + List users = fragment.findByFirstnameOrLastname("Han", "Skywalker"); + assertThat(users).extracting(User::getUsername).containsExactlyInAnyOrder("han", "vader", "luke"); + } + + @Test + void testBetween() { + + List users = fragment.findByVisitsBetween(10, 100); + assertThat(users).extracting(User::getUsername).containsExactly("vader"); + } + + @Test + void testTimeValue() { + + List users = fragment.findByLastSeenGreaterThan(Instant.parse("2025-01-01T00:00:00.000Z")); + assertThat(users).extracting(User::getUsername).containsExactly("luke"); + } + + @Test + void testNot() { + + List users = fragment.findByLastnameNot("Skywalker"); + assertThat(users).extracting(User::getUsername).isNotEmpty().doesNotContain("luke", "vader"); + } + + @Test + void testExistsCriteria() { + + List users = fragment.findByVisitsExists(false); + assertThat(users).extracting(User::getUsername).contains("kylo"); + } + + @Test + void testLimitedDerivedFinder() { + + List users = fragment.findTop2ByLastnameStartingWith("S"); + assertThat(users).hasSize(2); + } + + @Test + void testSortedDerivedFinder() { + + List users = fragment.findByLastnameStartingWithOrderByUsername("S"); + assertThat(users).extracting(User::getUsername).containsExactly("han", "kylo", "luke", "vader"); + } + + @Test + void testDerivedFinderWithLimitArgument() { + + List users = fragment.findByLastnameStartingWith("S", Limit.of(2)); + assertThat(users).hasSize(2); + } + + @Test + void testDerivedFinderWithSort() { + + List users = fragment.findByLastnameStartingWith("S", Sort.by("username")); + assertThat(users).extracting(User::getUsername).containsExactly("han", "kylo", "luke", "vader"); + } + + @Test + void testDerivedFinderWithSortAndLimit() { + + List users = fragment.findByLastnameStartingWith("S", Sort.by("username"), Limit.of(2)); + assertThat(users).extracting(User::getUsername).containsExactly("han", "kylo"); + } + + @Test + void testDerivedFinderReturningListWithPageable() { + + List users = fragment.findByLastnameStartingWith("S", PageRequest.of(0, 2, Sort.by("username"))); + assertThat(users).extracting(User::getUsername).containsExactly("han", "kylo"); + } + + @Test + void testDerivedFinderReturningPage() { + + Page page = fragment.findPageOfUsersByLastnameStartingWith("S", PageRequest.of(0, 2, Sort.by("username"))); + assertThat(page.getTotalElements()).isEqualTo(4); + assertThat(page.getSize()).isEqualTo(2); + assertThat(page.getContent()).extracting(User::getUsername).containsExactly("han", "kylo"); + } + + @Test + void testDerivedFinderReturningSlice() { + + Slice slice = fragment.findSliceOfUserByLastnameStartingWith("S", PageRequest.of(0, 2, Sort.by("username"))); + assertThat(slice.hasNext()).isTrue(); + assertThat(slice.getSize()).isEqualTo(2); + assertThat(slice.getContent()).extracting(User::getUsername).containsExactly("han", "kylo"); + } + + @Test + void testAnnotatedFinderReturningSingleValueWithQuery() { + + User user = fragment.findAnnotatedQueryByUsername("yoda"); + assertThat(user).isNotNull().extracting(User::getUsername).isEqualTo("yoda"); + } + + @Test + void testAnnotatedCount() { + + Long value = fragment.countAnnotatedQueryByLastname("Skywalker"); + assertThat(value).isEqualTo(2L); + } + + @Test + void testAnnotatedFinderReturningListWithQuery() { + + List users = fragment.findAnnotatedQueryByLastname("S"); + assertThat(users).extracting(User::getUsername).containsExactlyInAnyOrder("han", "kylo", "luke", "vader"); + } + + @Test + void testAnnotatedMultilineFinderWithQuery() { + + List users = fragment.findAnnotatedMultilineQueryByLastname("S"); + assertThat(users).extracting(User::getUsername).containsExactlyInAnyOrder("han", "kylo", "luke", "vader"); + } + + @Test + void testAnnotatedFinderWithQueryAndLimit() { + + List users = fragment.findAnnotatedQueryByLastname("S", Limit.of(2)); + assertThat(users).hasSize(2); + } + + @Test + void testAnnotatedFinderWithQueryAndSort() { + + List users = fragment.findAnnotatedQueryByLastname("S", Sort.by("username")); + assertThat(users).extracting(User::getUsername).containsExactly("han", "kylo", "luke", "vader"); + } + + @Test + void testAnnotatedFinderWithQueryLimitAndSort() { + + List users = fragment.findAnnotatedQueryByLastname("S", Limit.of(2), Sort.by("username")); + assertThat(users).extracting(User::getUsername).containsExactly("han", "kylo"); + } + + @Test + void testAnnotatedFinderReturningListWithPageable() { + + List users = fragment.findAnnotatedQueryByLastname("S", PageRequest.of(0, 2, Sort.by("username"))); + assertThat(users).extracting(User::getUsername).containsExactly("han", "kylo"); + } + + @Test + void testAnnotatedFinderReturningPage() { + + Page page = fragment.findAnnotatedQueryPageOfUsersByLastname("S", PageRequest.of(0, 2, Sort.by("username"))); + assertThat(page.getTotalElements()).isEqualTo(4); + assertThat(page.getSize()).isEqualTo(2); + assertThat(page.getContent()).extracting(User::getUsername).containsExactly("han", "kylo"); + } + + @Test + void testAnnotatedFinderReturningSlice() { + + Slice slice = fragment.findAnnotatedQuerySliceOfUsersByLastname("S", + PageRequest.of(0, 2, Sort.by("username"))); + assertThat(slice.hasNext()).isTrue(); + assertThat(slice.getSize()).isEqualTo(2); + assertThat(slice.getContent()).extracting(User::getUsername).containsExactly("han", "kylo"); + } + + @Test + void testDeleteSingle() { + + User result = fragment.deleteByUsername("yoda"); + + assertThat(result).isNotNull().extracting(User::getUsername).isEqualTo("yoda"); + assertThat(client.getDatabase(DB_NAME).getCollection("user").countDocuments()).isEqualTo(6L); + } + + @Test + void testDeleteSingleAnnotatedQuery() { + + User result = fragment.deleteAnnotatedQueryByUsername("yoda"); + + assertThat(result).isNotNull().extracting(User::getUsername).isEqualTo("yoda"); + assertThat(client.getDatabase(DB_NAME).getCollection("user").countDocuments()).isEqualTo(6L); + } + + @Test + void testDerivedDeleteMultipleReturningDeleteCount() { + + Long result = fragment.deleteByLastnameStartingWith("S"); + + assertThat(result).isEqualTo(4L); + assertThat(client.getDatabase(DB_NAME).getCollection("user").countDocuments()).isEqualTo(3L); + } + + @Test + void testDerivedDeleteMultipleReturningDeleteCountAnnotatedQuery() { + + Long result = fragment.deleteAnnotatedQueryByLastnameStartingWith("S"); + + assertThat(result).isEqualTo(4L); + assertThat(client.getDatabase(DB_NAME).getCollection("user").countDocuments()).isEqualTo(3L); + } + + @Test + void testDerivedDeleteMultipleReturningDeleted() { + + List result = fragment.deleteUsersByLastnameStartingWith("S"); + + assertThat(result).extracting(User::getUsername).containsExactlyInAnyOrder("han", "kylo", "luke", "vader"); + assertThat(client.getDatabase(DB_NAME).getCollection("user").countDocuments()).isEqualTo(3L); + } + + @Test + void testDerivedDeleteMultipleReturningDeletedAnnotatedQuery() { + + List result = fragment.deleteUsersAnnotatedQueryByLastnameStartingWith("S"); + + assertThat(result).extracting(User::getUsername).containsExactlyInAnyOrder("han", "kylo", "luke", "vader"); + assertThat(client.getDatabase(DB_NAME).getCollection("user").countDocuments()).isEqualTo(3L); + } + + @Test + void testDerivedFinderWithAnnotatedSort() { + + List users = fragment.findWithAnnotatedSortByLastnameStartingWith("S"); + assertThat(users).extracting(User::getUsername).containsExactly("han", "kylo", "luke", "vader"); + } + + @Test + void testDerivedFinderWithAnnotatedFieldsProjection() { + + List users = fragment.findWithAnnotatedFieldsProjectionByLastnameStartingWith("S"); + assertThat(users).allMatch( + user -> StringUtils.hasText(user.getUsername()) && user.getLastname() == null && user.getFirstname() == null); + } + + @Test + void testReadPreferenceAppliedToQuery() { + + // check if it fails when trying to parse the read preference to indicate it would get applied + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> fragment.findWithReadPreferenceByUsername("S")) + .withMessageContaining("No match for read preference"); + } + + @Test + void testDerivedFinderReturningListOfProjections() { + + List users = fragment.findUserProjectionByLastnameStartingWith("S"); + assertThat(users).extracting(UserProjection::getUsername).containsExactlyInAnyOrder("han", "kylo", "luke", "vader"); + } + + @Test + void testDerivedFinderReturningPageOfProjections() { + + Page users = fragment.findUserProjectionByLastnameStartingWith("S", + PageRequest.of(0, 2, Sort.by("username"))); + assertThat(users).extracting(UserProjection::getUsername).containsExactly("han", "kylo"); + } + + @Test + void testUpdateWithDerivedQuery() { + + int modifiedCount = fragment.findUserAndIncrementVisitsByLastname("Organa", 42); + + assertThat(modifiedCount).isOne(); + assertThat(client.getDatabase(DB_NAME).getCollection("user").find(new Document("_id", "id-2")).first().get("visits", + Integer.class)).isEqualTo(42); + } + + @Test + void testUpdateWithAnnotatedQuery() { + + int modifiedCount = fragment.updateAllByLastname("Organa", 42); + + assertThat(modifiedCount).isOne(); + assertThat(client.getDatabase(DB_NAME).getCollection("user").find(new Document("_id", "id-2")).first().get("visits", + Integer.class)).isEqualTo(42); + } + + @Test + void testAggregationPipelineUpdate() { + + fragment.findAndIncrementVisitsViaPipelineByLastname("Organa", 42); + + assertThat(client.getDatabase(DB_NAME).getCollection("user").find(new Document("_id", "id-2")).first().get("visits", + Integer.class)).isEqualTo(42); + } + + @Test + void testAggregationWithExtractedSimpleResults() { + + List allLastnames = fragment.findAllLastnames(); + assertThat(allLastnames).containsExactlyInAnyOrder("Skywalker", "Solo", "Organa", "Solo", "Skywalker"); + } + + @Test + void testAggregationWithProjectedResults() { + + List allLastnames = fragment.groupByLastnameAnd("first_name"); + assertThat(allLastnames).containsExactlyInAnyOrder(// + new UserAggregate("Skywalker", List.of("Anakin", "Luke")), // + new UserAggregate("Organa", List.of("Leia")), // + new UserAggregate("Solo", List.of("Han", "Ben"))); + } + + @Test + void testAggregationWithProjectedResultsLimitedByPageable() { + + List allLastnames = fragment.groupByLastnameAnd("first_name", PageRequest.of(1, 1, Sort.by("_id"))); + assertThat(allLastnames).containsExactly(// + new UserAggregate("Skywalker", List.of("Anakin", "Luke")) // + ); + } + + @Test + void testAggregationWithProjectedResultsAsPage() { + + Slice allLastnames = fragment.groupByLastnameAndReturnPage("first_name", + PageRequest.of(1, 1, Sort.by("_id"))); + assertThat(allLastnames.hasPrevious()).isTrue(); + assertThat(allLastnames.hasNext()).isTrue(); + assertThat(allLastnames.getContent()).containsExactly(// + new UserAggregate("Skywalker", List.of("Anakin", "Luke")) // + ); + } + + @Test + void testAggregationWithProjectedResultsWrappedInAggregationResults() { + + AggregationResults allLastnames = fragment.groupByLastnameAndAsAggregationResults("first_name"); + assertThat(allLastnames.getMappedResults()).containsExactlyInAnyOrder(// + new UserAggregate("Skywalker", List.of("Anakin", "Luke")), // + new UserAggregate("Organa", List.of("Leia")), // + new UserAggregate("Solo", List.of("Han", "Ben"))); + } + + @Test + void testAggregationWithSingleResultExtraction() { + assertThat(fragment.sumPosts()).isEqualTo(5); + } + + @Test + void testAggregationWithHint() { + assertThatException().isThrownBy(() -> fragment.findAllLastnamesUsingIndex()) + .withMessageContaining("hint provided does not correspond to an existing index"); + } + + @Test + void testAggregationWithReadPreference() { + assertThatException().isThrownBy(() -> fragment.findAllLastnamesWithReadPreference()) + .withMessageContaining("No match for read preference"); + } + + @Test + void testAggregationWithCollation() { + assertThatException().isThrownBy(() -> fragment.findAllLastnamesWithCollation()) + .withMessageContaining("'locale' is invalid"); + } + + private static void initUsers() { + + Document luke = Document.parse(""" + { + "_id": "id-1", + "username": "luke", + "first_name": "Luke", + "last_name": "Skywalker", + "visits" : 2, + "lastSeen" : { + "$date": "2025-04-01T00:00:00.000Z" + }, + "posts": [ + { + "message": "I have a bad feeling about this.", + "date": { + "$date": "2025-01-15T12:50:33.855Z" + } + } + ], + "_class": "example.springdata.aot.User" + }"""); + + Document leia = Document.parse(""" + { + "_id": "id-2", + "username": "leia", + "first_name": "Leia", + "last_name": "Organa", + "_class": "example.springdata.aot.User" + }"""); + + Document han = Document.parse(""" + { + "_id": "id-3", + "username": "han", + "first_name": "Han", + "last_name": "Solo", + "posts": [ + { + "message": "It's the ship that made the Kessel Run in less than 12 Parsecs.", + "date": { + "$date": "2025-01-15T13:30:33.855Z" + } + } + ], + "_class": "example.springdata.aot.User" + }"""); + + Document chwebacca = Document.parse(""" + { + "_id": "id-4", + "username": "chewbacca", + "lastSeen" : { + "$date": "2025-01-01T00:00:00.000Z" + }, + "_class": "example.springdata.aot.User" + }"""); + + Document yoda = Document.parse( + """ + { + "_id": "id-5", + "username": "yoda", + "visits" : 1000, + "posts": [ + { + "message": "Do. Or do not. There is no try.", + "date": { + "$date": "2025-01-15T13:09:33.855Z" + } + }, + { + "message": "Decide you must, how to serve them best. If you leave now, help them you could; but you would destroy all for which they have fought, and suffered.", + "date": { + "$date": "2025-01-15T13:53:33.855Z" + } + } + ] + }"""); + + Document vader = Document.parse(""" + { + "_id": "id-6", + "username": "vader", + "first_name": "Anakin", + "last_name": "Skywalker", + "visits" : 50, + "posts": [ + { + "message": "I am your father", + "date": { + "$date": "2025-01-15T13:46:33.855Z" + } + } + ] + }"""); + + Document kylo = Document.parse(""" + { + "_id": "id-7", + "username": "kylo", + "first_name": "Ben", + "last_name": "Solo" + } + """); + + client.getDatabase(DB_NAME).getCollection("user") + .insertMany(List.of(luke, leia, han, chwebacca, yoda, vader, kylo)); + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/aot/generated/StubRepositoryInformation.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/aot/StubRepositoryInformation.java similarity index 78% rename from spring-data-mongodb/src/test/java/org/springframework/data/mongodb/aot/generated/StubRepositoryInformation.java rename to spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/aot/StubRepositoryInformation.java index 52d609be6..36b01fa99 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/aot/generated/StubRepositoryInformation.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/aot/StubRepositoryInformation.java @@ -1,19 +1,3 @@ -/* - * 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. * @@ -21,7 +5,7 @@ * 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 + * 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, @@ -29,11 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.mongodb.aot.generated; +package org.springframework.data.mongodb.repository.aot; import java.lang.reflect.Method; +import java.util.List; import java.util.Set; +import org.jspecify.annotations.Nullable; import org.springframework.data.mongodb.repository.support.SimpleMongoRepository; import org.springframework.data.repository.core.CrudMethods; import org.springframework.data.repository.core.RepositoryInformation; @@ -41,15 +27,11 @@ import org.springframework.data.repository.core.RepositoryMetadata; import org.springframework.data.repository.core.support.AbstractRepositoryMetadata; import org.springframework.data.repository.core.support.RepositoryComposition; import org.springframework.data.repository.core.support.RepositoryFragment; -import org.springframework.data.util.Streamable; import org.springframework.data.util.TypeInformation; -import org.springframework.lang.Nullable; /** * @author Christoph Strobl - * @since 2025/01 */ - class StubRepositoryInformation implements RepositoryInformation { private final RepositoryMetadata metadata; @@ -124,11 +106,15 @@ class StubRepositoryInformation implements RepositoryInformation { @Override public boolean isQueryMethod(Method method) { - return false; + if (isBaseClassMethod(method)) { + return false; + } + + return true; } @Override - public Streamable getQueryMethods() { + public List getQueryMethods() { return null; } @@ -141,4 +127,9 @@ class StubRepositoryInformation implements RepositoryInformation { public Method getTargetClassMethod(Method method) { return null; } + + @Override + public RepositoryComposition getRepositoryComposition() { + return baseComposition; + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/aot/generated/TestMongoAotRepositoryContext.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/aot/TestMongoAotRepositoryContext.java similarity index 77% rename from spring-data-mongodb/src/test/java/org/springframework/data/mongodb/aot/generated/TestMongoAotRepositoryContext.java rename to spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/aot/TestMongoAotRepositoryContext.java index 814710e51..2349524fa 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/aot/generated/TestMongoAotRepositoryContext.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/aot/TestMongoAotRepositoryContext.java @@ -1,19 +1,3 @@ -/* - * 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. * @@ -21,7 +5,7 @@ * 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 + * 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, @@ -29,13 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.mongodb.aot.generated; +package org.springframework.data.mongodb.repository.aot; import java.io.IOException; import java.lang.annotation.Annotation; import java.util.List; import java.util.Set; +import org.jspecify.annotations.Nullable; import org.springframework.core.env.Environment; import org.springframework.core.env.StandardEnvironment; import org.springframework.core.test.tools.ClassFile; @@ -46,18 +31,16 @@ import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.repository.config.AotRepositoryContext; import org.springframework.data.repository.core.RepositoryInformation; import org.springframework.data.repository.core.support.RepositoryComposition; -import org.springframework.lang.Nullable; /** * @author Christoph Strobl - * @since 2025/01 */ -public class TestMongoAotRepositoryContext implements AotRepositoryContext { +class TestMongoAotRepositoryContext implements AotRepositoryContext { private final StubRepositoryInformation repositoryInformation; private final Environment environment = new StandardEnvironment(); - public TestMongoAotRepositoryContext(Class repositoryInterface, @Nullable RepositoryComposition composition) { + TestMongoAotRepositoryContext(Class repositoryInterface, @Nullable RepositoryComposition composition) { this.repositoryInformation = new StubRepositoryInformation(repositoryInterface, composition); } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/AbstractMongoQueryUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/AbstractMongoQueryUnitTests.java index ea3c9ad02..dad28ae5a 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/AbstractMongoQueryUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/AbstractMongoQueryUnitTests.java @@ -51,6 +51,7 @@ import org.springframework.data.expression.ValueExpressionParser; import org.springframework.data.mongodb.MongoDatabaseFactory; import org.springframework.data.mongodb.core.ExecutableFindOperation.ExecutableFind; import org.springframework.data.mongodb.core.ExecutableFindOperation.FindWithQuery; +import org.springframework.data.mongodb.core.ExecutableRemoveOperation.ExecutableRemove; import org.springframework.data.mongodb.core.ExecutableUpdateOperation.ExecutableUpdate; import org.springframework.data.mongodb.core.ExecutableUpdateOperation.TerminatingUpdate; import org.springframework.data.mongodb.core.ExecutableUpdateOperation.UpdateWithQuery; @@ -104,6 +105,7 @@ class AbstractMongoQueryUnitTests { @Mock UpdateWithQuery updateWithQuery; @Mock UpdateWithUpdate updateWithUpdate; @Mock TerminatingUpdate terminatingUpdate; + @Mock ExecutableRemove executableRemove; @Mock BasicMongoPersistentEntity persitentEntityMock; @Mock MongoMappingContext mappingContextMock; @Mock DeleteResult deleteResultMock; @@ -130,8 +132,9 @@ class AbstractMongoQueryUnitTests { doReturn(executableUpdate).when(mongoOperationsMock).update(any()); doReturn(updateWithQuery).when(executableUpdate).matching(any(Query.class)); doReturn(terminatingUpdate).when(updateWithQuery).apply(any(UpdateDefinition.class)); - - when(mongoOperationsMock.remove(any(), any(), anyString())).thenReturn(deleteResultMock); + doReturn(executableRemove).when(mongoOperationsMock).remove(any()); + doReturn(executableRemove).when(executableRemove).matching(any(Query.class)); + when(executableRemove.all()).thenReturn(deleteResultMock); when(mongoOperationsMock.updateMulti(any(), any(), any(), anyString())).thenReturn(updateResultMock); } @@ -140,8 +143,7 @@ class AbstractMongoQueryUnitTests { createQueryForMethod("deletePersonByLastname", String.class).setDeleteQuery(true).execute(new Object[] { "booh" }); - verify(mongoOperationsMock, times(1)).remove(any(), eq(Person.class), eq("persons")); - verify(mongoOperationsMock, times(0)).find(any(), any(), any()); + verify(executableRemove, times(1)).all(); } @Test // DATAMONGO-566, DATAMONGO-1040 @@ -149,7 +151,7 @@ class AbstractMongoQueryUnitTests { createQueryForMethod("deleteByLastname", String.class).setDeleteQuery(true).execute(new Object[] { "booh" }); - verify(mongoOperationsMock, times(1)).findAllAndRemove(any(), eq(Person.class), eq("persons")); + verify(executableRemove, times(1)).findAndRemove(); } @Test // DATAMONGO-566 @@ -171,7 +173,7 @@ class AbstractMongoQueryUnitTests { query.setDeleteQuery(true); assertThat(query.execute(new Object[] { "fake" })).isEqualTo(100L); - verify(mongoOperationsMock, times(1)).remove(any(), eq(Person.class), eq("persons")); + verify(executableRemove, times(1)).all(); } @Test // DATAMONGO-957 diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/MongoQueryExecutionUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/MongoQueryExecutionUnitTests.java index 326ccf5f3..dbd17aa80 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/MongoQueryExecutionUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/MongoQueryExecutionUnitTests.java @@ -15,20 +15,23 @@ */ package org.springframework.data.mongodb.repository.query; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.lang.reflect.Method; import java.util.Arrays; import java.util.Collections; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; - import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.geo.Distance; @@ -41,6 +44,7 @@ import org.springframework.data.mongodb.core.ExecutableFindOperation.ExecutableF import org.springframework.data.mongodb.core.ExecutableFindOperation.FindWithQuery; import org.springframework.data.mongodb.core.ExecutableFindOperation.TerminatingFind; import org.springframework.data.mongodb.core.ExecutableFindOperation.TerminatingFindNear; +import org.springframework.data.mongodb.core.ExecutableRemoveOperation.ExecutableRemove; import org.springframework.data.mongodb.core.MongoOperations; import org.springframework.data.mongodb.core.convert.DbRefResolver; import org.springframework.data.mongodb.core.convert.MappingMongoConverter; @@ -78,6 +82,7 @@ class MongoQueryExecutionUnitTests { @Mock FindWithQuery operationMock; @Mock TerminatingFind terminatingMock; @Mock TerminatingFindNear terminatingGeoMock; + @Mock ExecutableRemove removeMock; @Mock DbRefResolver dbRefResolver; private Point POINT = new Point(10, 20); @@ -183,38 +188,36 @@ class MongoQueryExecutionUnitTests { @Test // DATAMONGO-2351 void acknowledgedDeleteReturnsDeletedCount() { + doReturn(removeMock).when(removeMock).matching(any(Query.class)); + when(removeMock.all()).thenReturn(DeleteResult.acknowledged(10)); Method method = ReflectionUtils.findMethod(PersonRepository.class, "deleteAllByLastname", String.class); MongoQueryMethod queryMethod = new MongoQueryMethod(method, metadata, factory, context); - when(mongoOperationsMock.remove(any(Query.class), any(Class.class), anyString())) - .thenReturn(DeleteResult.acknowledged(10)); - - assertThat(new DeleteExecution(mongoOperationsMock, queryMethod).execute(new Query())).isEqualTo(10L); + assertThat(new DeleteExecution(removeMock, queryMethod).execute(new Query())).isEqualTo(10L); } @Test // DATAMONGO-2351 void unacknowledgedDeleteReturnsZeroDeletedCount() { + doReturn(removeMock).when(removeMock).matching(any(Query.class)); + when(removeMock.all()).thenReturn(DeleteResult.unacknowledged()); Method method = ReflectionUtils.findMethod(PersonRepository.class, "deleteAllByLastname", String.class); MongoQueryMethod queryMethod = new MongoQueryMethod(method, metadata, factory, context); - when(mongoOperationsMock.remove(any(Query.class), any(Class.class), anyString())) - .thenReturn(DeleteResult.unacknowledged()); - - assertThat(new DeleteExecution(mongoOperationsMock, queryMethod).execute(new Query())).isEqualTo(0L); + assertThat(new DeleteExecution(removeMock, queryMethod).execute(new Query())).isEqualTo(0L); } @Test // DATAMONGO-1997 void deleteExecutionWithEntityReturnTypeTriggersFindAndRemove() { - Method method = ReflectionUtils.findMethod(PersonRepository.class, "deleteByLastname", String.class); - MongoQueryMethod queryMethod = new MongoQueryMethod(method, metadata, factory, context); - Person person = new Person(); + doReturn(removeMock).when(removeMock).matching(any(Query.class)); + when(removeMock.findAndRemove()).thenReturn(List.of(person)); - when(mongoOperationsMock.findAndRemove(any(Query.class), any(Class.class), anyString())).thenReturn(person); + Method method = ReflectionUtils.findMethod(PersonRepository.class, "deleteByLastname", String.class); + MongoQueryMethod queryMethod = new MongoQueryMethod(method, metadata, factory, context); - assertThat(new DeleteExecution(mongoOperationsMock, queryMethod).execute(new Query())).isEqualTo(person); + assertThat(new DeleteExecution(removeMock, queryMethod).execute(new Query())).isEqualTo(person); } interface PersonRepository extends Repository { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/util/json/SpringJsonWriterUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/util/SpringJsonWriterUnitTests.java similarity index 96% rename from spring-data-mongodb/src/test/java/org/springframework/data/mongodb/util/json/SpringJsonWriterUnitTests.java rename to spring-data-mongodb/src/test/java/org/springframework/data/mongodb/util/SpringJsonWriterUnitTests.java index 57b8df548..878623944 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/util/json/SpringJsonWriterUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/util/SpringJsonWriterUnitTests.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.mongodb.util.json; +package org.springframework.data.mongodb.util; import static org.assertj.core.api.Assertions.assertThat; @@ -25,10 +25,12 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** + * Unit tests for {@link SpringJsonWriter}. + * * @author Christoph Strobl - * @since 2025/01 + * @since 5.0 */ -public class SpringJsonWriterUnitTests { +class SpringJsonWriterUnitTests { StringBuffer buffer; SpringJsonWriter writer; diff --git a/spring-data-mongodb/src/test/resources/logback.xml b/spring-data-mongodb/src/test/resources/logback.xml index 9a65ce79b..6ad8c163e 100644 --- a/spring-data-mongodb/src/test/resources/logback.xml +++ b/spring-data-mongodb/src/test/resources/logback.xml @@ -18,7 +18,9 @@ - + + + diff --git a/src/main/antora/modules/ROOT/nav.adoc b/src/main/antora/modules/ROOT/nav.adoc index 221f47c01..a7401fb11 100644 --- a/src/main/antora/modules/ROOT/nav.adoc +++ b/src/main/antora/modules/ROOT/nav.adoc @@ -53,6 +53,7 @@ ** xref:mongodb/repositories/cdi-integration.adoc[] ** xref:repositories/query-keywords-reference.adoc[] ** xref:repositories/query-return-types-reference.adoc[] +** xref:mongodb/aot.adoc[] // Observability * xref:observability/observability.adoc[] diff --git a/src/main/antora/modules/ROOT/pages/mongodb/aot.adoc b/src/main/antora/modules/ROOT/pages/mongodb/aot.adoc new file mode 100644 index 000000000..345b24cb7 --- /dev/null +++ b/src/main/antora/modules/ROOT/pages/mongodb/aot.adoc @@ -0,0 +1,88 @@ += Ahead of Time Optimizations + +This chapter covers Spring Data's Ahead of Time (AOT) optimizations that build upon {spring-framework-docs}/core/aot.html[Spring's Ahead of Time Optimizations]. + +[[aot.hints]] +== Runtime Hints + +Running an application as a native image requires additional information compared to a regular JVM runtime. +Spring Data contributes {spring-framework-docs}/core/aot.html#aot.hints[Runtime Hints] during AOT processing for native image usage. +These are in particular hints for: + +* Auditing +* `ManagedTypes` to capture the outcome of class-path scans +* Repositories +** Reflection hints for entities, return types, and Spring Data annotations +** Repository fragments +** Querydsl `Q` classes +** Kotlin Coroutine support +* Web support (Jackson Hints for `PagedModel`) + +[[aot.repositories]] +== Ahead of Time Repositories + +AOT Repositories are an extension to AOT processing by pre-generating eligible query method implementations. +Query methods are opaque to developers regarding their underlying queries being executed in a query method call. +AOT repositories contribute query method implementations based on derived or annotated queries, updates or aggregations that are known at build-time. +This optimization moves query method processing from runtime to build-time, which can lead to a significant bootstrap performance improvement as query methods do not need to be analyzed reflectively upon each application start. + +The resulting AOT repository fragment follows the naming scheme of `Impl__Aot` and is placed in the same package as the repository interface. +You can find all queries in their MQL form for generated repository query methods. + +[TIP] +==== +`spring.aot.repositories.enabled` property needs to be set to `true` for repository fragment code generation. +==== + +[NOTE] +==== +Consider AOT repository classes an internal optimization. +Do not use them directly in your code as generation and implementation details may change in future releases. +==== + +=== Running with AOT Repositories + +AOT is a mandatory step to transform a Spring application to a native executable, so it is automatically enabled when running in this mode. +It is also possible to use those optimizations on the JVM by setting the `spring.aot.enabled` and `spring.aot.repositories.enabled` System properties to `true`. + +AOT repositories contribute configuration changes to the actual repository bean registration to register the generated repository fragment. + +[NOTE] +==== +When AOT optimizations are included, some decisions that have been taken at build-time are hard-coded in the application setup. +For instance, profiles that have been enabled at build-time are automatically enabled at runtime as well. +Also, the Spring Data module implementing a repository is fixed. +Changing the implementation requires AOT re-processing. +==== + +=== Eligible Methods in Data MongoDB + +AOT repositories filter methods that are eligible for AOT processing. +These are typically all query methods that are not backed by an xref:repositories/custom-implementations.adoc[implementation fragment]. + +**Supported Features** + +* Derived `find`, `count`, `exists` and `delete` methods +* Query methods annotated with `@Query` (excluding those containing SpEL) +* Methods annotated with `@Aggregation` +* Methods using `@Update` +* `@Hint` & `@ReadPreference` support +* `Page`, `Slice`, and `Optional` return types +* DTO Projections + +**Limitations** + +* `@Meta` annotations are not evaluated. +* Queries / Aggregations / Updates containing `SpEL` cannot be generated. +* Limited `Collation` detection. +* Reserved parameter names (must not be used in method signature) `finder`, `filterQuery`, `countQuery`, `deleteQuery`, `remover` `updateDefinition`, `aggregation`, `aggregationPipeline`, `aggregationUpdate`, `aggregationOptions`, `updater`, `results`, `fields`. + +**Excluded methods** + +* `CrudRepository` and other base interface methods +* Querydsl and Query by Example methods +* Methods whose implementation would be overly complex +* Query Methods obtaining MQL from a file +** Methods accepting `ScrollPosition` (e.g. `Keyset` pagination) +** Dynamic projections +** Geospatial Queries