From 889134b2ca3f2b960186802aa77ad63864718880 Mon Sep 17 00:00:00 2001 From: Chris Bono Date: Wed, 18 Jun 2025 14:35:38 -0500 Subject: [PATCH 01/12] GH-1566 - Prepare branch --- pom.xml | 2 +- spring-data-cassandra-distribution/pom.xml | 2 +- spring-data-cassandra/pom.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index ff38b2c9f..da26daee1 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ org.springframework.data spring-data-cassandra-parent - 5.0.0-SNAPSHOT + 5.0.0-GH-1566-SNAPSHOT pom Spring Data for Apache Cassandra diff --git a/spring-data-cassandra-distribution/pom.xml b/spring-data-cassandra-distribution/pom.xml index 86394f673..986e4c27b 100644 --- a/spring-data-cassandra-distribution/pom.xml +++ b/spring-data-cassandra-distribution/pom.xml @@ -8,7 +8,7 @@ org.springframework.data spring-data-cassandra-parent - 5.0.0-SNAPSHOT + 5.0.0-GH-1566-SNAPSHOT ../pom.xml diff --git a/spring-data-cassandra/pom.xml b/spring-data-cassandra/pom.xml index 4df9e91f6..a6166162b 100644 --- a/spring-data-cassandra/pom.xml +++ b/spring-data-cassandra/pom.xml @@ -8,7 +8,7 @@ org.springframework.data spring-data-cassandra-parent - 5.0.0-SNAPSHOT + 5.0.0-GH-1566-SNAPSHOT ../pom.xml From 93315c6f5b7e0ded7fbc0665c0dfef654178a2d2 Mon Sep 17 00:00:00 2001 From: Chris Bono Date: Mon, 30 Jun 2025 16:03:56 -0500 Subject: [PATCH 02/12] WIP: Very very rough first pass at AOT repo support The configuration/extension and entry point of CassandraRepositoryContributor are in somewhat good shape but the latter breaks down when getting to the "ok lets generated some code" stage. Namely, there are still large helpings of "MongoDB-AOT-repo-copy-pasta" laying around and this is where I was struggling to bridge the gap between Spring Data MongoDB and Spring Data Cassandra, Signed-off-by: Chris Bono --- .../repository/aot/AotQueryCreator.java | 182 +++++++++++ ...CassandraAotRepositoryFragmentSupport.java | 143 +++++++++ .../repository/aot/CassandraCodeBlocks.java | 296 ++++++++++++++++++ .../repository/aot/CassandraInteraction.java | 61 ++++ .../aot/CassandraRepositoryContributor.java | 211 +++++++++++++ ...draRepositoryRegistrationAotProcessor.java | 64 ++++ .../repository/aot/QueryInteraction.java | 81 +++++ .../cassandra/repository/aot/StringQuery.java | 101 ++++++ .../repository/aot/StringUpdate.java | 27 ++ .../repository/aot/UpdateInteraction.java | 57 ++++ .../repository/aot/package-info.java | 5 + ...andraRepositoryConfigurationExtension.java | 8 +- .../query/CassandraQueryCreator.java | 5 +- .../query/ConvertingParameterAccessor.java | 7 +- 14 files changed, 1244 insertions(+), 4 deletions(-) create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueryCreator.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraAotRepositoryFragmentSupport.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraInteraction.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributor.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryRegistrationAotProcessor.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueryInteraction.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringQuery.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringUpdate.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/UpdateInteraction.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/package-info.java diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueryCreator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueryCreator.java new file mode 100644 index 000000000..08b92e102 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueryCreator.java @@ -0,0 +1,182 @@ +/* + * 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.cassandra.repository.aot; + +import kotlinx.coroutines.internal.LockFreeTaskQueueCore.Placeholder; + +import java.util.Iterator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.jspecify.annotations.NullUnmarked; +import org.jspecify.annotations.Nullable; +import org.springframework.data.cassandra.core.convert.CassandraConverter; +import org.springframework.data.cassandra.core.convert.CassandraCustomConversions; +import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; +import org.springframework.data.cassandra.core.query.Query; +import org.springframework.data.cassandra.repository.query.CassandraParameterAccessor; +import org.springframework.data.cassandra.repository.query.CassandraQueryCreator; +import org.springframework.data.cassandra.repository.query.ConvertingParameterAccessor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Range; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Sort; +import org.springframework.data.geo.Distance; +import org.springframework.data.geo.Point; +import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.data.util.TypeInformation; + +/** + * @author Chris Bono + */ +class AotQueryCreator { + + private CassandraMappingContext mappingContext; + + public AotQueryCreator() { + + CassandraMappingContext cassandraMappingContext = new CassandraMappingContext(); + cassandraMappingContext.afterPropertiesSet(); + this.mappingContext = cassandraMappingContext; + } + + @SuppressWarnings("NullAway") + StringQuery createQuery(PartTree partTree, int parameterCount) { + + Query query = new CassandraQueryCreator(partTree, + new PlaceholderConvertingParameterAccessor(new PlaceholderParameterAccessor(parameterCount)), mappingContext) + .createQuery(); + + if (partTree.isLimiting()) { + query.limit(partTree.getMaxResults()); + } + return new StringQuery(query); + } + + static class PlaceholderConvertingParameterAccessor extends ConvertingParameterAccessor { + + /** + * Creates a new {@link ConvertingParameterAccessor} with the given {@link CassandraConverter} and delegate. + * + * @param delegate must not be {@literal null}. + */ + public PlaceholderConvertingParameterAccessor(PlaceholderParameterAccessor delegate) { + super(PlaceholderConverter.INSTANCE, delegate); + } + } + + @NullUnmarked + enum PlaceholderConverter implements CassandraConverter { + + INSTANCE; + + @Override + public @Nullable Object convertToCassandraType(@Nullable Object obj, @Nullable TypeInformation typeInformation) { + return obj instanceof Placeholder p ? p.getValue() : obj; + } + + @Override + public DBRef toDBRef(Object object, @Nullable CassandraPersistentProperty referringProperty) { + return null; + } + + @Override + public void write(Object source, Bson sink) { + + } + } + + @NullUnmarked + static class PlaceholderParameterAccessor implements CassandraParameterAccessor { + + private final List placeholders; + /* + public PlaceholderParameterAccessor(int parameterCount) { + if (parameterCount == 0) { + placeholders = List.of(); + } else { + placeholders = IntStream.range(0, parameterCount).mapToObj(Placeholder::indexed).collect(Collectors.toList()); + } + } + + @Override + public Range getDistanceRange() { + return null; + } + + @Override + public @Nullable Point getGeoNearLocation() { + return null; + } + + @Override + public @Nullable TextCriteria getFullText() { + return null; + } + + @Override + public @Nullable Collation getCollation() { + return null; + } + + @Override + public Object[] getValues() { + return placeholders.toArray(); + } + + @Override + public @Nullable UpdateDefinition getUpdate() { + return null; + } + + @Override + public @Nullable ScrollPosition getScrollPosition() { + return null; + } + + @Override + public Pageable getPageable() { + return null; + } + + @Override + public Sort getSort() { + return null; + } + + @Override + public @Nullable Class findDynamicProjection() { + return null; + } + + @Override + public @Nullable Object getBindableValue(int index) { + return placeholders.get(index).getValue(); + } + + @Override + public boolean hasBindableNullValue() { + return false; + } + + @Override + @SuppressWarnings({ "unchecked", "rawtypes" }) + public Iterator iterator() { + return ((List) placeholders).iterator(); + } + */ + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraAotRepositoryFragmentSupport.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraAotRepositoryFragmentSupport.java new file mode 100644 index 000000000..340a0f731 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraAotRepositoryFragmentSupport.java @@ -0,0 +1,143 @@ +/* + * 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.cassandra.repository.aot; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.springframework.data.cassandra.core.CassandraOperations; +import org.springframework.data.cassandra.core.convert.CassandraConverter; +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; + +/** + * Support class for Cassandra AOT repository fragments. + * + * @author Chris Bono + * @since 5.0 + */ +public class CassandraAotRepositoryFragmentSupport { + + private final RepositoryMetadata repositoryMetadata; + private final CassandraOperations cassandraOperations; + private final CassandraConverter cassandraConverter; + private final ProjectionFactory projectionFactory; + + protected CassandraAotRepositoryFragmentSupport(CassandraOperations cassandraOperations, + RepositoryFactoryBeanSupport.FragmentCreationContext context) { + this(cassandraOperations, context.getRepositoryMetadata(), context.getProjectionFactory()); + } + + protected CassandraAotRepositoryFragmentSupport(CassandraOperations cassandraOperations, RepositoryMetadata repositoryMetadata, + ProjectionFactory projectionFactory) { + + this.cassandraOperations = cassandraOperations; + this.cassandraConverter = cassandraOperations.getConverter(); + this.repositoryMetadata = repositoryMetadata; + this.projectionFactory = projectionFactory; + } + + protected Document bindParameters(String source, Object[] parameters) { + return new BindableCassandraExpression(source, this.cassandraConverter, 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, cassandraConverter)); + } + return list; + } + + private static @Nullable T extractSimpleTypeResult(@Nullable Document source, Class targetType, + CassandraConverter 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(CassandraConverter 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-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java new file mode 100644 index 000000000..40a119a80 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java @@ -0,0 +1,296 @@ +/* + * 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.cassandra.repository.aot; + +import java.util.regex.Pattern; + +import org.jspecify.annotations.NullUnmarked; + +import org.springframework.data.cassandra.core.CassandraOperations; +import org.springframework.data.cassandra.repository.query.CassandraQueryMethod; +import org.springframework.data.repository.aot.generate.AotQueryMethodGenerationContext; +import org.springframework.javapoet.CodeBlock; +import org.springframework.javapoet.CodeBlock.Builder; +import org.springframework.util.StringUtils; + +/** + * {@link CodeBlock} generator for common Cassandra tasks. + * + * @author Chris Bono + */ +class CassandraCodeBlocks { + + 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, + CassandraQueryMethod queryMethod) { + return new QueryCodeBlockBuilder(context, queryMethod); + } + + /** + * Builder for generating finder query execution {@link CodeBlock}. + * + * @param context + * @param queryMethod + * @return + */ + static QueryExecutionCodeBlockBuilder queryExecutionBlockBuilder(AotQueryMethodGenerationContext context, + CassandraQueryMethod queryMethod) { + + return new QueryExecutionCodeBlockBuilder(context, queryMethod); + } + + @NullUnmarked + static class QueryExecutionCodeBlockBuilder { + + private final AotQueryMethodGenerationContext context; + private final CassandraQueryMethod queryMethod; + private QueryInteraction query; + + QueryExecutionCodeBlockBuilder(AotQueryMethodGenerationContext context, CassandraQueryMethod queryMethod) { + + this.context = context; + this.queryMethod = queryMethod; + } + + QueryExecutionCodeBlockBuilder forQuery(QueryInteraction query) { + + this.query = query; + return this; + } + + CodeBlock build() { + + String cassandraOpsRef = context.fieldNameOf(CassandraOperations.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, + cassandraOpsRef, context.getRepositoryInformation().getDomainType(), actualReturnType); + } else { + + builder.addStatement("$T<$T> finder = $L.query($T.class)", FindWithQuery.class, actualReturnType, cassandraOpsRef, + 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 QueryCodeBlockBuilder { + + private final AotQueryMethodGenerationContext context; + private final CassandraQueryMethod queryMethod; + + private QueryInteraction source; + private List arguments; + private String queryVariableName; + + QueryCodeBlockBuilder(AotQueryMethodGenerationContext context, CassandraQueryMethod 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() { + + 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.Cassandra.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, CassandraQueryMethod 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() { + + 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-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraInteraction.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraInteraction.java new file mode 100644 index 000000000..98cac4ab0 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraInteraction.java @@ -0,0 +1,61 @@ +/* + * 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.cassandra.repository.aot; + +/** + * Base abstraction for interactions with Cassandra. + * + * @author Chris Bono + */ +abstract class CassandraInteraction { + + 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-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributor.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributor.java new file mode 100644 index 000000000..b3d8fe05f --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributor.java @@ -0,0 +1,211 @@ +/* + * 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.cassandra.repository.aot; + +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.cassandra.core.CassandraOperations; +import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; +import org.springframework.data.cassandra.repository.Query; +import org.springframework.data.cassandra.repository.query.CassandraQueryMethod; +import org.springframework.data.repository.aot.generate.AotRepositoryClassBuilder; +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.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.util.ObjectUtils; +import org.springframework.util.StringUtils; + +import static org.springframework.data.cassandra.repository.aot.CassandraCodeBlocks.*; + +/** + * Cassandra specific {@link RepositoryContributor}. + * + * @author Chris Bono + * @since 5.0 + */ +public class CassandraRepositoryContributor extends RepositoryContributor { + + private static final Log logger = LogFactory.getLog(RepositoryContributor.class); + + private final AotQueryCreator queryCreator; + private final CassandraMappingContext mappingContext; + + public CassandraRepositoryContributor(AotRepositoryContext repositoryContext) { + + super(repositoryContext); + this.queryCreator = new AotQueryCreator(); + this.mappingContext = new CassandraMappingContext(); + } + + @Override + protected void customizeClass(AotRepositoryClassBuilder builder) { + builder.customize(b -> b.superclass(TypeName.get(CassandraAotRepositoryFragmentSupport.class))); + } + + @Override + protected void customizeConstructor(AotRepositoryConstructorBuilder constructorBuilder) { + + constructorBuilder.addParameter("operations", TypeName.get(CassandraOperations.class)); + constructorBuilder.addParameter("context", TypeName.get(RepositoryFactoryBeanSupport.FragmentCreationContext.class), + false); + + constructorBuilder.customize((builder) -> { + builder.addStatement("super(operations, context)"); + }); + } + + @Override + @SuppressWarnings("NullAway") + protected @Nullable MethodContributor contributeQueryMethod(Method method) { + + var queryMethod = new CassandraQueryMethod(method, getRepositoryInformation(), getProjectionFactory(), + mappingContext); + + QueryInteraction query = createStringQuery(getRepositoryInformation(), 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 (backoff(queryMethod)) { + return null; + } + + 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); +// } +// } + + return queryMethodContributor(queryMethod, query); + } + + @SuppressWarnings("NullAway") + private QueryInteraction createStringQuery(RepositoryInformation repositoryInformation, CassandraQueryMethod queryMethod, + @Nullable Query queryAnnotation, int parameterCount) { + + QueryInteraction query; + if (queryMethod.hasAnnotatedQuery() && queryAnnotation != null) { + query = new QueryInteraction(new StringQuery(queryMethod.getAnnotatedQuery()), queryAnnotation.count(), + false, 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(CassandraQueryMethod method) { + + boolean skip = method.isScrollQuery() || method.isStreamQuery(); + + if (skip && logger.isDebugEnabled()) { + logger.debug("Skipping AOT generation for [%s]. Method is either streaming or scrolling query" + .formatted(method.getName())); + } + return skip; + } + +// private static MethodContributor updateMethodContributor(CassandraQueryMethod 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 deleteMethodContributor(CassandraQueryMethod queryMethod, + QueryInteraction query) { + + return MethodContributor.forQueryMethod(queryMethod).withMetadata(query).contribute(context -> { + + CodeBlock.Builder builder = CodeBlock.builder(); + QueryCodeBlockBuilder queryCodeBlockBuilder = queryBlockBuilder(context, queryMethod).filter(query); + + String queryVariableName = context.localVariable(query.name()); + builder.add(queryCodeBlockBuilder.usingQueryVariableName(queryVariableName).build()); + builder.add(deleteExecutionBlockBuilder(context, queryMethod).referencing(queryVariableName).build()); + return builder.build(); + }); + } + + private static MethodContributor queryMethodContributor(CassandraQueryMethod queryMethod, + QueryInteraction query) { + + return MethodContributor.forQueryMethod(queryMethod).withMetadata(query).contribute(context -> { + + CodeBlock.Builder builder = CodeBlock.builder(); + QueryCodeBlockBuilder queryCodeBlockBuilder = queryBlockBuilder(context, queryMethod).filter(query); + + builder.add(queryCodeBlockBuilder.usingQueryVariableName(context.localVariable(query.name())).build()); + builder.add(queryExecutionBlockBuilder(context, queryMethod).forQuery(query).build()); + return builder.build(); + }); + } + +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryRegistrationAotProcessor.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryRegistrationAotProcessor.java new file mode 100644 index 000000000..2cdb57bc3 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryRegistrationAotProcessor.java @@ -0,0 +1,64 @@ +/* + * Copyright 2022-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.cassandra.repository.aot; + +import java.util.function.Predicate; +import org.jspecify.annotations.Nullable; +import org.springframework.aot.generate.GenerationContext; +import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor; +import org.springframework.data.aot.AotContext; +import org.springframework.data.cassandra.core.mapping.CassandraSimpleTypeHolder; +import org.springframework.data.repository.aot.generate.RepositoryContributor; +import org.springframework.data.repository.config.AotRepositoryContext; +import org.springframework.data.repository.config.RepositoryRegistrationAotProcessor; +import org.springframework.data.util.TypeContributor; +import org.springframework.data.util.TypeUtils; + +/** + * Cassandra specific {@link BeanRegistrationAotProcessor AOT processor}. + * + * @author Chris Bono + * @since 5.0 + */ +public class CassandraRepositoryRegistrationAotProcessor extends RepositoryRegistrationAotProcessor { + + private static final Predicate> IS_SIMPLE_TYPE = (type) -> CassandraSimpleTypeHolder.HOLDER.isSimpleType(type); + + @Override + protected @Nullable RepositoryContributor contribute(AotRepositoryContext repositoryContext, GenerationContext generationContext) { + + super.contribute(repositoryContext, generationContext); + + repositoryContext.getResolvedTypes().stream() + .filter(IS_SIMPLE_TYPE.negate()) + .forEach(type -> TypeContributor.contribute(type, (__) -> true, generationContext)); + + boolean enabled = Boolean.parseBoolean( + repositoryContext.getEnvironment().getProperty(AotContext.GENERATED_REPOSITORIES_ENABLED, "false")); + + return enabled ? new CassandraRepositoryContributor(repositoryContext) : null; + } + + @Override + protected void contributeType(Class type, GenerationContext generationContext) { + + if (TypeUtils.type(type).isPartOf("org.springframework.data.cassandra", "org.apache.cassandra", "com.datastax")) { + return; + } + + super.contributeType(type, generationContext); + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueryInteraction.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueryInteraction.java new file mode 100644 index 000000000..290ffe1a4 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueryInteraction.java @@ -0,0 +1,81 @@ +/* + * 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.cassandra.repository.aot; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.data.repository.aot.generate.QueryMetadata; +import org.springframework.util.StringUtils; + +/** + * A {@link CassandraInteraction} to execute a query. + * + * @author Chris Bono + */ +class QueryInteraction extends CassandraInteraction 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-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringQuery.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringQuery.java new file mode 100644 index 000000000..a79b27aa7 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringQuery.java @@ -0,0 +1,101 @@ +/* + * 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.cassandra.repository.aot; + +import java.util.Optional; +import java.util.Set; +import org.jspecify.annotations.Nullable; +import org.springframework.data.cassandra.core.query.Query; +import org.springframework.data.domain.KeysetScrollPosition; +import org.springframework.util.StringUtils; + +/** + * Helper to capture setting for AOT queries. + * + * @author Chris Bono + * @since 4.0 + */ +class StringQuery extends Query { + + private Query delegate; + private @Nullable String raw; + private @Nullable String sort; + private @Nullable String fields; + + public StringQuery(Query query) { + this.delegate = query; + } + + public StringQuery(String query) { + this.delegate = new Query(); + this.raw = query; + } + + @Nullable + String getQueryString() { + + if (StringUtils.hasText(raw)) { + return raw; + } + + Document queryObj = getQueryObject(); + if (queryObj.isEmpty()) { + return null; + } + return toJson(queryObj); + } + + public Query sort(String sort) { + this.sort = sort; + return this; + } + + + +// @Nullable +// String getSortString() { +// if (StringUtils.hasText(sort)) { +// return sort; +// } +// Document sort = getSortObject(); +// if (sort.isEmpty()) { +// return null; +// } +// return toJson(sort); +// } +// +// @Nullable +// String getFieldsString() { +// if (StringUtils.hasText(fields)) { +// return fields; +// } +// +// Document fields = getFieldsObject(); +// if (fields.isEmpty()) { +// return null; +// } +// return toJson(fields); +// } +// +// StringQuery fields(String fields) { +// this.fields = fields; +// return this; +// } + +// String toJson(Document source) { +// return BsonUtils.writeJson(source).toJsonString(); +// } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringUpdate.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringUpdate.java new file mode 100644 index 000000000..ef6bc1e2f --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/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.cassandra.repository.aot; + +/** + * @author Chris Bono + * @since 4.0 + */ +record StringUpdate(String raw) { + + String getUpdateString() { + return raw; + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/UpdateInteraction.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/UpdateInteraction.java new file mode 100644 index 000000000..f21a337d3 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/UpdateInteraction.java @@ -0,0 +1,57 @@ +/* + * 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.cassandra.repository.aot; + +import java.util.Map; +import org.springframework.data.repository.aot.generate.QueryMetadata; + +/** + * A {@link CassandraInteraction} to execute an update. + * + * @author Chris Bono + * @since 4.0 + */ +class UpdateInteraction extends CassandraInteraction 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-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/package-info.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/package-info.java new file mode 100644 index 000000000..0a104c321 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/package-info.java @@ -0,0 +1,5 @@ +/** + * Ahead-Of-Time processors for Cassandra repositories. + */ +@org.jspecify.annotations.NullMarked +package org.springframework.data.cassandra.repository.aot; diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/CassandraRepositoryConfigurationExtension.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/CassandraRepositoryConfigurationExtension.java index 87c8bee44..9558c9810 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/CassandraRepositoryConfigurationExtension.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/CassandraRepositoryConfigurationExtension.java @@ -19,11 +19,12 @@ import java.util.Collection; import java.util.Collections; import java.util.Optional; - +import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor; import org.springframework.beans.factory.support.BeanDefinitionBuilder; import org.springframework.data.cassandra.config.DefaultBeanNames; import org.springframework.data.cassandra.core.mapping.Table; import org.springframework.data.cassandra.repository.CassandraRepository; +import org.springframework.data.cassandra.repository.aot.CassandraRepositoryRegistrationAotProcessor; import org.springframework.data.cassandra.repository.support.CassandraRepositoryFactoryBean; import org.springframework.data.cassandra.repository.support.SimpleCassandraRepository; import org.springframework.data.repository.config.AnnotationRepositoryConfigurationSource; @@ -67,6 +68,11 @@ public String getRepositoryFactoryBeanClassName() { return CassandraRepositoryFactoryBean.class.getName(); } + @Override + public Class getRepositoryAotProcessor() { + return CassandraRepositoryRegistrationAotProcessor.class; + } + @Override public void postProcess(BeanDefinitionBuilder builder, XmlRepositoryConfigurationSource config) { diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreator.java index eebb31904..07f8eea25 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreator.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreator.java @@ -47,12 +47,15 @@ /** * Custom query creator to create Cassandra criteria. + *

+ * Only intended for internal use. * * @author Matthew Adams * @author Mark Paluch * @author John Blum + * @author Chris Bono */ -class CassandraQueryCreator extends AbstractQueryCreator { +public class CassandraQueryCreator extends AbstractQueryCreator { private static final Log LOG = LogFactory.getLog(CassandraQueryCreator.class); diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ConvertingParameterAccessor.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ConvertingParameterAccessor.java index 53f539826..423b1d2ff 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ConvertingParameterAccessor.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ConvertingParameterAccessor.java @@ -36,18 +36,21 @@ /** * Custom {@link org.springframework.data.repository.query.ParameterAccessor} that uses a {@link CassandraConverter} to * convert parameters. + *

+ * Only intended for internal use. * * @author Mark Paluch + * @author Chris Bono * @see org.springframework.data.cassandra.repository.query.ConvertingParameterAccessor * @since 1.5 */ -class ConvertingParameterAccessor implements CassandraParameterAccessor { +public class ConvertingParameterAccessor implements CassandraParameterAccessor { private final CassandraConverter converter; private final CassandraParameterAccessor delegate; - ConvertingParameterAccessor(CassandraConverter converter, CassandraParameterAccessor delegate) { + public ConvertingParameterAccessor(CassandraConverter converter, CassandraParameterAccessor delegate) { this.converter = converter; this.delegate = delegate; From b0a39d4de2f907faff9ff8133dc775fc64eb5489 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 26 Aug 2025 15:55:37 +0200 Subject: [PATCH 03/12] Hacking. --- .../cassandra/repository/aot/AotQueries.java | 68 ++ .../cassandra/repository/aot/AotQuery.java | 79 +++ .../repository/aot/AotQueryCreator.java | 153 +---- .../aot/AotRepositoryFragmentSupport.java | 166 +++++ ...CassandraAotRepositoryFragmentSupport.java | 143 ---- .../repository/aot/CassandraCodeBlocks.java | 353 +++++----- .../aot/CassandraRepositoryContributor.java | 180 ++--- ...draRepositoryRegistrationAotProcessor.java | 64 -- .../repository/aot/DerivedAotQuery.java | 78 +++ .../repository/aot/QueriesFactory.java | 195 ++++++ .../repository/aot/QueryInteraction.java | 81 --- .../repository/aot/StringAotQuery.java | 100 +++ .../cassandra/repository/aot/StringQuery.java | 101 --- .../repository/aot/UpdateInteraction.java | 57 -- ...andraRepositoryConfigurationExtension.java | 44 +- .../repository/query/BindingContext.java | 98 +-- .../repository/query/CassandraParameters.java | 2 +- .../CassandraParametersParameterAccessor.java | 12 +- .../query/CassandraQueryCreator.java | 2 +- .../repository/query/ParameterBinding.java | 629 ++++++++++++++++++ .../repository/query/StringBasedQuery.java | 62 +- .../ParameterBindingParserUnitTests.java | 27 +- 22 files changed, 1706 insertions(+), 988 deletions(-) create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueries.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQuery.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotRepositoryFragmentSupport.java delete mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraAotRepositoryFragmentSupport.java delete mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryRegistrationAotProcessor.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/DerivedAotQuery.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueriesFactory.java delete mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueryInteraction.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringAotQuery.java delete mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringQuery.java delete mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/UpdateInteraction.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ParameterBinding.java diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueries.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueries.java new file mode 100644 index 000000000..fa416bb84 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueries.java @@ -0,0 +1,68 @@ +/* + * 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.cassandra.repository.aot; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.data.repository.aot.generate.QueryMetadata; + +/** + * Value object capturing queries used for repository query methods. + * + * @author Mark Paluch + * @since 5.0 + */ +record AotQueries(AotQuery result) { + + /** + * Factory method to create an {@link AotQueries} instance with a single query. + * + * @param query + * @return + */ + public static AotQueries create(AotQuery query) { + return new AotQueries(query); + } + + public QueryMetadata toMetadata() { + return new AotQueryMetadata(); + } + + /** + * String and Named Query-based {@link QueryMetadata}. + */ + private class AotQueryMetadata implements QueryMetadata { + + @Override + public Map serialize() { + + Map serialized = new LinkedHashMap<>(); + + if (result() instanceof StringAotQuery sq) { + serialized.put("query", sq.getQueryString()); + } + + if (result() instanceof StringAotQuery.NamedStringAotQuery nsq) { + serialized.put("name", nsq.getQueryName()); + } + + return serialized; + } + + } + +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQuery.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQuery.java new file mode 100644 index 000000000..5dc451602 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQuery.java @@ -0,0 +1,79 @@ +/* + * 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.cassandra.repository.aot; + +import java.util.List; + +import org.springframework.data.cassandra.repository.query.ParameterBinding; +import org.springframework.data.domain.Limit; + +/** + * AOT query value object along with its parameter bindings. + * + * @author Mark Paluch + * @since 5.0 + */ +abstract class AotQuery { + + private final List parameterBindings; + + AotQuery(List parameterBindings) { + this.parameterBindings = parameterBindings; + } + + /** + * @return the list of parameter bindings. + */ + public List getParameterBindings() { + return parameterBindings; + } + + /** + * @return the preliminary query limit. + */ + public Limit getLimit() { + return Limit.unlimited(); + } + + /** + * @return whether the query is limited (e.g. {@code findTop10By}). + */ + public boolean isLimited() { + return getLimit().isLimited(); + } + + /** + * @return whether the query a delete query. + */ + public boolean isDelete() { + return false; + } + + /** + * @return whether the query is a count query. + */ + public boolean isCount() { + return false; + } + + /** + * @return whether the query is an exists query. + */ + public boolean isExists() { + return false; + } + +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueryCreator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueryCreator.java index 08b92e102..2be640656 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueryCreator.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueryCreator.java @@ -15,168 +15,75 @@ */ package org.springframework.data.cassandra.repository.aot; -import kotlinx.coroutines.internal.LockFreeTaskQueueCore.Placeholder; - -import java.util.Iterator; import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.IntStream; -import org.jspecify.annotations.NullUnmarked; + import org.jspecify.annotations.Nullable; -import org.springframework.data.cassandra.core.convert.CassandraConverter; -import org.springframework.data.cassandra.core.convert.CassandraCustomConversions; -import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; -import org.springframework.data.cassandra.core.query.Query; -import org.springframework.data.cassandra.repository.query.CassandraParameterAccessor; + +import org.springframework.data.cassandra.core.cql.QueryOptions; +import org.springframework.data.cassandra.core.mapping.CassandraPersistentProperty; +import org.springframework.data.cassandra.core.query.CassandraScrollPosition; +import org.springframework.data.cassandra.repository.query.CassandraParameters; +import org.springframework.data.cassandra.repository.query.CassandraParametersParameterAccessor; import org.springframework.data.cassandra.repository.query.CassandraQueryCreator; -import org.springframework.data.cassandra.repository.query.ConvertingParameterAccessor; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Range; -import org.springframework.data.domain.ScrollPosition; -import org.springframework.data.domain.Sort; -import org.springframework.data.geo.Distance; -import org.springframework.data.geo.Point; +import org.springframework.data.cassandra.repository.query.ParameterBinding; +import org.springframework.data.domain.ScoringFunction; +import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.repository.query.parser.PartTree; -import org.springframework.data.util.TypeInformation; /** * @author Chris Bono + * @author Mark Paluch + * @since 5.0 */ -class AotQueryCreator { - - private CassandraMappingContext mappingContext; - - public AotQueryCreator() { - - CassandraMappingContext cassandraMappingContext = new CassandraMappingContext(); - cassandraMappingContext.afterPropertiesSet(); - this.mappingContext = cassandraMappingContext; - } - - @SuppressWarnings("NullAway") - StringQuery createQuery(PartTree partTree, int parameterCount) { - - Query query = new CassandraQueryCreator(partTree, - new PlaceholderConvertingParameterAccessor(new PlaceholderParameterAccessor(parameterCount)), mappingContext) - .createQuery(); - - if (partTree.isLimiting()) { - query.limit(partTree.getMaxResults()); - } - return new StringQuery(query); - } - - static class PlaceholderConvertingParameterAccessor extends ConvertingParameterAccessor { - - /** - * Creates a new {@link ConvertingParameterAccessor} with the given {@link CassandraConverter} and delegate. - * - * @param delegate must not be {@literal null}. - */ - public PlaceholderConvertingParameterAccessor(PlaceholderParameterAccessor delegate) { - super(PlaceholderConverter.INSTANCE, delegate); - } - } - - @NullUnmarked - enum PlaceholderConverter implements CassandraConverter { - - INSTANCE; - - @Override - public @Nullable Object convertToCassandraType(@Nullable Object obj, @Nullable TypeInformation typeInformation) { - return obj instanceof Placeholder p ? p.getValue() : obj; - } - - @Override - public DBRef toDBRef(Object object, @Nullable CassandraPersistentProperty referringProperty) { - return null; - } - - @Override - public void write(Object source, Bson sink) { +class AotQueryCreator extends CassandraQueryCreator { - } + public AotQueryCreator(PartTree tree, CassandraParameters parameters, + MappingContext mappingContext, List parameterBindings) { + super(tree, new AotPlaceholderParameterAccessor(parameters, parameterBindings), mappingContext); } - @NullUnmarked - static class PlaceholderParameterAccessor implements CassandraParameterAccessor { - - private final List placeholders; - /* - public PlaceholderParameterAccessor(int parameterCount) { - if (parameterCount == 0) { - placeholders = List.of(); - } else { - placeholders = IntStream.range(0, parameterCount).mapToObj(Placeholder::indexed).collect(Collectors.toList()); - } - } - - @Override - public Range getDistanceRange() { - return null; - } + static class AotPlaceholderParameterAccessor extends CassandraParametersParameterAccessor { - @Override - public @Nullable Point getGeoNearLocation() { - return null; - } - - @Override - public @Nullable TextCriteria getFullText() { - return null; - } + private final List parameterBindings; - @Override - public @Nullable Collation getCollation() { - return null; + public AotPlaceholderParameterAccessor(CassandraParameters parameters, List parameterBindings) { + super(parameters, new Object[parameters.getNumberOfParameters()]); + this.parameterBindings = parameterBindings; } @Override - public Object[] getValues() { - return placeholders.toArray(); - } + public @Nullable Object getValue(int parameterIndex) { - @Override - public @Nullable UpdateDefinition getUpdate() { - return null; - } + ParameterBinding binding = ParameterBinding.indexed(parameterIndex); + parameterBindings.add(binding); - @Override - public @Nullable ScrollPosition getScrollPosition() { - return null; + return binding; } @Override - public Pageable getPageable() { + public CassandraScrollPosition getScrollPosition() { return null; } @Override - public Sort getSort() { + public @Nullable ScoringFunction getScoringFunction() { return null; } @Override - public @Nullable Class findDynamicProjection() { + public @Nullable QueryOptions getQueryOptions() { return null; } @Override public @Nullable Object getBindableValue(int index) { - return placeholders.get(index).getValue(); + return getValue(getParameters().getBindableParameter(index).getIndex()); } @Override public boolean hasBindableNullValue() { return false; } - - @Override - @SuppressWarnings({ "unchecked", "rawtypes" }) - public Iterator iterator() { - return ((List) placeholders).iterator(); - } - */ } + } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotRepositoryFragmentSupport.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotRepositoryFragmentSupport.java new file mode 100644 index 000000000..6d7814f9c --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotRepositoryFragmentSupport.java @@ -0,0 +1,166 @@ +/* + * 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.cassandra.repository.aot; + +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.Optional; +import java.util.stream.Stream; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.CollectionFactory; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.ConfigurableConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.data.cassandra.core.CassandraOperations; +import org.springframework.data.cassandra.core.convert.CassandraConverter; +import org.springframework.data.cassandra.repository.query.CassandraParameters; +import org.springframework.data.domain.Slice; +import org.springframework.data.expression.ValueEvaluationContextProvider; +import org.springframework.data.expression.ValueExpression; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport; +import org.springframework.data.repository.query.ParametersSource; +import org.springframework.data.repository.query.ValueExpressionDelegate; +import org.springframework.data.util.Lazy; +import org.springframework.util.ConcurrentLruCache; + +/** + * Support class for Cassandra AOT repository fragments. + * + * @author Chris Bono + * @since 5.0 + */ +public class AotRepositoryFragmentSupport { + + private static final ConversionService CONVERSION_SERVICE; + + static { + + ConfigurableConversionService conversionService = new DefaultConversionService(); + + conversionService.removeConvertible(Collection.class, Object.class); + conversionService.removeConvertible(Object.class, Optional.class); + + CONVERSION_SERVICE = conversionService; + } + + private final RepositoryMetadata repositoryMetadata; + private final CassandraOperations cassandraOperations; + private final CassandraConverter converter; + private final ProjectionFactory projectionFactory; + + private final Lazy> expressions; + + private final Lazy> contextProviders; + + protected AotRepositoryFragmentSupport(CassandraOperations cassandraOperations, + RepositoryFactoryBeanSupport.FragmentCreationContext context) { + this(cassandraOperations, context.getRepositoryMetadata(), context.getValueExpressionDelegate(), + context.getProjectionFactory()); + } + + protected AotRepositoryFragmentSupport(CassandraOperations cassandraOperations, RepositoryMetadata repositoryMetadata, + ValueExpressionDelegate valueExpressions, ProjectionFactory projectionFactory) { + + this.cassandraOperations = cassandraOperations; + this.converter = cassandraOperations.getConverter(); + this.repositoryMetadata = repositoryMetadata; + this.projectionFactory = projectionFactory; + + this.expressions = Lazy.of(() -> new ConcurrentLruCache<>(32, valueExpressions::parse)); + this.contextProviders = Lazy.of(() -> new ConcurrentLruCache<>(32, it -> valueExpressions + .createValueContextProvider(new CassandraParameters(ParametersSource.of(repositoryMetadata, it))))); + } + + protected @Nullable Object potentiallyConvertBindingValue(@Nullable Object bindableValue) { + + if (bindableValue == null) { + return null; + } + + return this.converter.convertToColumnType(bindableValue, converter.getColumnTypeResolver().resolve(bindableValue)); + } + + /** + * Evaluate a Value Expression. + * + * @param method + * @param expressionString + * @param args + * @return + */ + protected @Nullable Object evaluateExpression(Method method, String expressionString, Object... args) { + + ValueExpression expression = this.expressions.get().get(expressionString); + ValueEvaluationContextProvider contextProvider = this.contextProviders.get().get(method); + + return potentiallyConvertBindingValue( + expression.evaluate(contextProvider.getEvaluationContext(args, expression.getExpressionDependencies()))); + } + + protected @Nullable T convertOne(@Nullable Object result, boolean nativeQuery, Class projection) { + + if (result == null) { + return null; + } + + if (projection.isInstance(result)) { + return projection.cast(result); + } + + if (CONVERSION_SERVICE.canConvert(result.getClass(), projection)) { + return CONVERSION_SERVICE.convert(result, projection); + } + + return projectionFactory.createProjection(projection, result); + } + + protected @Nullable Object convertMany(@Nullable Object result, boolean nativeQuery, Class projection) { + + if (result == null) { + return null; + } + + if (projection.isInstance(result)) { + return result; + } + + if (result instanceof Stream stream) { + return stream.map(it -> convertOne(it, nativeQuery, projection)); + } + + if (result instanceof Slice slice) { + return slice.map(it -> convertOne(it, nativeQuery, projection)); + } + + if (result instanceof Collection collection) { + + Collection<@Nullable Object> target = CollectionFactory.createCollection(collection.getClass(), + collection.size()); + for (Object o : collection) { + target.add(convertOne(o, nativeQuery, projection)); + } + + return target; + } + + throw new UnsupportedOperationException("Cannot create projection for %s".formatted(result)); + } + +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraAotRepositoryFragmentSupport.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraAotRepositoryFragmentSupport.java deleted file mode 100644 index 340a0f731..000000000 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraAotRepositoryFragmentSupport.java +++ /dev/null @@ -1,143 +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.cassandra.repository.aot; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import org.jspecify.annotations.Nullable; -import org.springframework.data.cassandra.core.CassandraOperations; -import org.springframework.data.cassandra.core.convert.CassandraConverter; -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; - -/** - * Support class for Cassandra AOT repository fragments. - * - * @author Chris Bono - * @since 5.0 - */ -public class CassandraAotRepositoryFragmentSupport { - - private final RepositoryMetadata repositoryMetadata; - private final CassandraOperations cassandraOperations; - private final CassandraConverter cassandraConverter; - private final ProjectionFactory projectionFactory; - - protected CassandraAotRepositoryFragmentSupport(CassandraOperations cassandraOperations, - RepositoryFactoryBeanSupport.FragmentCreationContext context) { - this(cassandraOperations, context.getRepositoryMetadata(), context.getProjectionFactory()); - } - - protected CassandraAotRepositoryFragmentSupport(CassandraOperations cassandraOperations, RepositoryMetadata repositoryMetadata, - ProjectionFactory projectionFactory) { - - this.cassandraOperations = cassandraOperations; - this.cassandraConverter = cassandraOperations.getConverter(); - this.repositoryMetadata = repositoryMetadata; - this.projectionFactory = projectionFactory; - } - - protected Document bindParameters(String source, Object[] parameters) { - return new BindableCassandraExpression(source, this.cassandraConverter, 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, cassandraConverter)); - } - return list; - } - - private static @Nullable T extractSimpleTypeResult(@Nullable Document source, Class targetType, - CassandraConverter 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(CassandraConverter 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-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java index 40a119a80..03b312033 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java @@ -15,21 +15,35 @@ */ package org.springframework.data.cassandra.repository.aot; +import java.util.List; +import java.util.Optional; import java.util.regex.Pattern; import org.jspecify.annotations.NullUnmarked; +import org.jspecify.annotations.Nullable; +import org.springframework.core.annotation.MergedAnnotation; import org.springframework.data.cassandra.core.CassandraOperations; +import org.springframework.data.cassandra.core.ExecutableSelectOperation; +import org.springframework.data.cassandra.repository.Consistency; +import org.springframework.data.cassandra.repository.Query; import org.springframework.data.cassandra.repository.query.CassandraQueryMethod; +import org.springframework.data.cassandra.repository.query.ParameterBinding; import org.springframework.data.repository.aot.generate.AotQueryMethodGenerationContext; import org.springframework.javapoet.CodeBlock; import org.springframework.javapoet.CodeBlock.Builder; +import org.springframework.javapoet.TypeName; import org.springframework.util.StringUtils; +import com.datastax.oss.driver.api.core.DefaultConsistencyLevel; +import com.datastax.oss.driver.api.core.cql.SimpleStatement; + /** * {@link CodeBlock} generator for common Cassandra tasks. * * @author Chris Bono + * @author Mark Paluch + * @since 5.0 */ class CassandraCodeBlocks { @@ -40,11 +54,10 @@ class CassandraCodeBlocks { * * @param context * @param queryMethod - * @return new instance of {@link QueryCodeBlockBuilder}. + * @return new instance of {@link QueryBlockBuilder}. */ - static QueryCodeBlockBuilder queryBlockBuilder(AotQueryMethodGenerationContext context, - CassandraQueryMethod queryMethod) { - return new QueryCodeBlockBuilder(context, queryMethod); + static QueryBlockBuilder queryBuilder(AotQueryMethodGenerationContext context, CassandraQueryMethod queryMethod) { + return new QueryBlockBuilder(context, queryMethod); } /** @@ -54,240 +67,256 @@ static QueryCodeBlockBuilder queryBlockBuilder(AotQueryMethodGenerationContext c * @param queryMethod * @return */ - static QueryExecutionCodeBlockBuilder queryExecutionBlockBuilder(AotQueryMethodGenerationContext context, + static QueryExecutionBlockBuilder executionBuilder(AotQueryMethodGenerationContext context, CassandraQueryMethod queryMethod) { - return new QueryExecutionCodeBlockBuilder(context, queryMethod); + return new QueryExecutionBlockBuilder(context, queryMethod); } - + @NullUnmarked - static class QueryExecutionCodeBlockBuilder { + static class QueryBlockBuilder { private final AotQueryMethodGenerationContext context; private final CassandraQueryMethod queryMethod; - private QueryInteraction query; + private final String parameterNames; - QueryExecutionCodeBlockBuilder(AotQueryMethodGenerationContext context, CassandraQueryMethod queryMethod) { + private @Nullable AotQuery query; + private String queryVariableName; + private @Nullable Class queryReturnType; + private MergedAnnotation queryAnnotation = MergedAnnotation.missing(); + private MergedAnnotation consistencyAnnotation = MergedAnnotation.missing(); + + QueryBlockBuilder(AotQueryMethodGenerationContext context, CassandraQueryMethod queryMethod) { this.context = context; this.queryMethod = queryMethod; - } - - QueryExecutionCodeBlockBuilder forQuery(QueryInteraction query) { - - this.query = query; - return this; - } - - CodeBlock build() { - - String cassandraOpsRef = context.fieldNameOf(CassandraOperations.class); - - Builder builder = CodeBlock.builder(); - boolean isProjecting = context.getReturnedType().isProjecting(); - Object actualReturnType = isProjecting ? context.getActualReturnType().getType() - : context.getRepositoryInformation().getDomainType(); + String parameterNames = StringUtils.collectionToDelimitedString(context.getAllParameterNames(), ", "); - builder.add("\n"); - - if (isProjecting) { - builder.addStatement("$T<$T> finder = $L.query($T.class).as($T.class)", FindWithQuery.class, actualReturnType, - cassandraOpsRef, context.getRepositoryInformation().getDomainType(), actualReturnType); + if (StringUtils.hasText(parameterNames)) { + this.parameterNames = ", " + parameterNames; } else { - - builder.addStatement("$T<$T> finder = $L.query($T.class)", FindWithQuery.class, actualReturnType, cassandraOpsRef, - context.getRepositoryInformation().getDomainType()); + this.parameterNames = ""; } - - 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 QueryCodeBlockBuilder { - - private final AotQueryMethodGenerationContext context; - private final CassandraQueryMethod queryMethod; - - private QueryInteraction source; - private List arguments; - private String queryVariableName; - - QueryCodeBlockBuilder(AotQueryMethodGenerationContext context, CassandraQueryMethod queryMethod) { + QueryBlockBuilder query(AotQuery query) { + this.query = query; + return this; + } - this.context = context; - this.arguments = context.getBindableParameterNames(); - this.queryMethod = queryMethod; + QueryBlockBuilder usingQueryVariableName(String queryVariableName) { + this.queryVariableName = queryVariableName; + return this; } - QueryCodeBlockBuilder filter(QueryInteraction query) { + QueryBlockBuilder queryReturnType(@Nullable Class queryReturnType) { + this.queryReturnType = queryReturnType; + return this; + } - this.source = query; + QueryBlockBuilder query(MergedAnnotation query) { + this.queryAnnotation = query; return this; } - QueryCodeBlockBuilder usingQueryVariableName(String queryVariableName) { - this.queryVariableName = queryVariableName; + QueryBlockBuilder consistency(MergedAnnotation consistency) { + this.consistencyAnnotation = consistency; return this; } CodeBlock build() { - Builder builder = CodeBlock.builder(); + if (query instanceof StringAotQuery sq) { - builder.add("\n"); - builder.add(renderExpressionToQuery(source.getQuery().getQueryString(), queryVariableName)); + Builder builder = CodeBlock.builder(); + List bindings = sq.getParameterBindings(); - if (StringUtils.hasText(source.getQuery().getFieldsString())) { + if (bindings.isEmpty()) { + builder.addStatement("$1T $2L = $1T.newInstance($3S)", SimpleStatement.class, queryVariableName, + sq.getQueryString()); + } else { - builder.add(renderExpressionToDocument(source.getQuery().getFieldsString(), "fields", arguments)); - builder.addStatement("$L.setFieldsObject(fields)", queryVariableName); - } + builder.addStatement("Object[] $L = new Object[$L]", context.localVariable("args"), bindings.size()); - String sortParameter = context.getSortParameterName(); - if (StringUtils.hasText(sortParameter)) { - builder.addStatement("$L.with($L)", queryVariableName, sortParameter); - } else if (StringUtils.hasText(source.getQuery().getSortString())) { + int index = 0; + for (ParameterBinding binding : bindings) { - builder.add(renderExpressionToDocument(source.getQuery().getSortString(), "sort", arguments)); - builder.addStatement("$L.setSortObject(sort)", queryVariableName); - } + // TODO:Conversion, Data type + builder.addStatement("$1L[$2L] = $3L", context.localVariable("args"), index++, + getParameter(binding.getOrigin())); + } - 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()); - } + builder.addStatement("$1T $2L = $1T.newInstance($3L)", SimpleStatement.class, queryVariableName, + context.localVariable("args")); + } - String pageableParameter = context.getPageableParameterName(); - if (StringUtils.hasText(pageableParameter) && !queryMethod.isPageQuery() && !queryMethod.isSliceQuery()) { - builder.addStatement("$L.with($L)", queryVariableName, pageableParameter); - } + if (queryAnnotation.isPresent()) { - MergedAnnotation hintAnnotation = context.getAnnotation(Hint.class); - String hint = hintAnnotation.isPresent() ? hintAnnotation.getString("value") : null; + Query.Idempotency idempotent = queryAnnotation.getEnum("idempotent", Query.Idempotency.class); - if (StringUtils.hasText(hint)) { - builder.addStatement("$L.withHint($S)", queryVariableName, hint); - } + if (idempotent != Query.Idempotency.UNDEFINED) { + builder.addStatement("$1L = $1L.setIdempotent($2L)", queryVariableName, + idempotent == Query.Idempotency.IDEMPOTENT); + } + } - MergedAnnotation readPreferenceAnnotation = context.getAnnotation(ReadPreference.class); - String readPreference = readPreferenceAnnotation.isPresent() ? readPreferenceAnnotation.getString("value") : null; + if (consistencyAnnotation.isPresent()) { - if (StringUtils.hasText(readPreference)) { - builder.addStatement("$L.withReadPreference($T.valueOf($S))", queryVariableName, - com.Cassandra.ReadPreference.class, readPreference); - } + DefaultConsistencyLevel consistencyLevel = consistencyAnnotation.getEnum("value", + DefaultConsistencyLevel.class); - // TODO: Meta annotation + builder.addStatement("$1L = $1L.setConsistencyLevel($2T.$3L)", queryVariableName, + DefaultConsistencyLevel.class, consistencyLevel.name()); + } - return builder.build(); + return builder.build(); + } + + throw new UnsupportedOperationException("Unsupported query: " + query); } - private CodeBlock renderExpressionToQuery(@Nullable String source, String variableName) { + private Object getParameter(ParameterBinding.ParameterOrigin origin) { - Builder builder = CodeBlock.builder(); - if (!StringUtils.hasText(source)) { + if (origin.isMethodArgument() && origin instanceof ParameterBinding.MethodInvocationArgument mia) { - builder.addStatement("$T $L = new $T(new $T())", BasicQuery.class, variableName, BasicQuery.class, - Document.class); - } else if (!containsPlaceholder(source)) { + if (mia.identifier().hasPosition()) { + return "potentiallyConvertBindingValue(" + + context.getRequiredBindableParameterName(mia.identifier().getPosition()) + ")"; + } - String tmpVarName = "%sString".formatted(variableName); - builder.addStatement("String $L = $S", tmpVarName, source); + if (mia.identifier().hasName()) { + return "potentiallyConvertBindingValue(" + + context.getRequiredBindableParameterName(mia.identifier().getName()) + ")"; + } + } - builder.addStatement("$T $L = new $T($T.parse($L))", BasicQuery.class, variableName, BasicQuery.class, - Document.class, tmpVarName); - } else { + if (origin.isExpression() && origin instanceof ParameterBinding.Expression expr) { + + Builder builder = CodeBlock.builder(); + + String expressionString = expr.expression().getExpressionString(); + // re-wrap expression + if (!expressionString.startsWith("$")) { + expressionString = "#{" + expressionString + "}"; + } + + builder.add("evaluateExpression($L, $S$L)", context.getExpressionMarker().enclosingMethod(), expressionString, + parameterNames); - 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(); } - return builder.build(); + throw new UnsupportedOperationException("Not supported yet for: " + origin); } } @NullUnmarked - static class UpdateCodeBlockBuilder { + static class QueryExecutionBlockBuilder { - private UpdateInteraction source; - private List arguments; - private String updateVariableName; + private final AotQueryMethodGenerationContext context; + private final CassandraQueryMethod queryMethod; + private @Nullable AotQuery query; + private @Nullable String queryVariableName; - public UpdateCodeBlockBuilder(AotQueryMethodGenerationContext context, CassandraQueryMethod queryMethod) { - this.arguments = context.getBindableParameterNames(); + QueryExecutionBlockBuilder(AotQueryMethodGenerationContext context, CassandraQueryMethod queryMethod) { + + this.context = context; + this.queryMethod = queryMethod; } - public UpdateCodeBlockBuilder update(UpdateInteraction update) { - this.source = update; + QueryExecutionBlockBuilder query(AotQuery query) { + + this.query = query; return this; } - public UpdateCodeBlockBuilder usingUpdateVariableName(String updateVariableName) { - this.updateVariableName = updateVariableName; + QueryExecutionBlockBuilder usingQueryVariableName(String queryVariableName) { + this.queryVariableName = queryVariableName; return this; } CodeBlock build() { + String cassandraOpsRef = context.fieldNameOf(CassandraOperations.class); + Builder builder = CodeBlock.builder(); + /*if (getQueryMethod().isSliceQuery()) { + return new SlicedExecution(getOperations(), parameterAccessor.getPageable()); + } else if (getQueryMethod().isScrollQuery()) { + return new WindowExecution(getOperations(), parameterAccessor.getScrollPosition(), parameterAccessor.getLimit()); + } else if (getQueryMethod().isSearchQuery()) { + return new SearchExecution(getOperations(), parameterAccessor); + } else if (getQueryMethod().isCollectionQuery()) { + return new CollectionExecution(getOperations()); + } else if (getQueryMethod().isResultSetQuery()) { + return new ResultSetQuery(getOperations()); + } else if (getQueryMethod().isStreamQuery()) { + return new StreamExecution(getOperations(), resultProcessing); + } else if (isCountQuery()) { + return ((statement, type) -> new SingleEntityExecution(getOperations(), false).execute(statement, Long.class)); + } else if (isExistsQuery()) { + return new ExistsExecution(getOperations()); + } else if (isModifyingQuery()) { + return ((statement, type) -> getOperations().execute(statement).wasApplied()); + } else { + return new SingleEntityExecution(getOperations(), isLimiting()); + } */ + + boolean isProjecting = false; + TypeName queryResultType = TypeName.get(context.getActualReturnType().toClass()); + 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(); - } - } + if (query instanceof StringAotQuery) { + + isProjecting = context.getReturnedType().isProjecting(); - private static CodeBlock renderExpressionToDocument(@Nullable String source, String variableName, - List arguments) { + if (isProjecting) { - Builder builder = CodeBlock.builder(); - if (!StringUtils.hasText(source)) { - builder.addStatement("$T $L = new $T()", Document.class, variableName, Document.class); - } else if (!containsPlaceholder(source)) { + builder.addStatement("$1T<$2T> $3L = $L.query($4L).as($2T.class)", + ExecutableSelectOperation.TerminatingResults.class, + TypeName.get(context.getRepositoryInformation().getDomainType()), context.localVariable("select"), + context.fieldNameOf(CassandraOperations.class), queryVariableName); + } else { + builder.addStatement("$1T<$2T> $3L = $L.query($4L).as($2T.class)", + ExecutableSelectOperation.TerminatingResults.class, queryResultType, context.localVariable("select"), + context.fieldNameOf(CassandraOperations.class), queryVariableName); + } + } - 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 terminatingMethod; - 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, ", ")); + if (queryMethod.isCollectionQuery() || queryMethod.isPageQuery() || queryMethod.isSliceQuery()) { + terminatingMethod = "all()"; + } else if (queryMethod.isStreamQuery()) { + terminatingMethod = "stream()"; + } else if (query.isCount()) { + terminatingMethod = "count()"; + } else if (query.isExists()) { + terminatingMethod = "exists()"; + } else { + terminatingMethod = Optional.class.isAssignableFrom(context.getReturnType().toClass()) ? "one()" : "oneValue()"; + } + + if (isProjecting) { + + if (queryMethod.isCollectionQuery() || queryMethod.isPageQuery() || queryMethod.isSliceQuery() + || queryMethod.isStreamQuery()) { + builder.addStatement("return ($T) convertMany($L.$L, $T.class)", context.getReturnTypeName(), + context.localVariable("select"), terminatingMethod, queryResultType); + } else { + builder.addStatement("return ($T) convertOne($L.$L, $T.class)", context.getReturnTypeName(), + context.localVariable("select"), terminatingMethod, queryResultType); + } + } + else { + builder.addStatement("return $L.$L", context.localVariable("select"), terminatingMethod); + } + + return builder.build(); } - return builder.build(); } private static boolean containsPlaceholder(String source) { diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributor.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributor.java index b3d8fe05f..73cf596e6 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributor.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributor.java @@ -16,31 +16,31 @@ package org.springframework.data.cassandra.repository.aot; 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.beans.factory.config.RuntimeBeanReference; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; import org.springframework.data.cassandra.core.CassandraOperations; import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; +import org.springframework.data.cassandra.repository.Consistency; import org.springframework.data.cassandra.repository.Query; +import org.springframework.data.cassandra.repository.query.CassandraParameters; import org.springframework.data.cassandra.repository.query.CassandraQueryMethod; import org.springframework.data.repository.aot.generate.AotRepositoryClassBuilder; 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.core.support.RepositoryFactoryBeanSupport; import org.springframework.data.repository.query.QueryMethod; -import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.data.repository.query.ReturnedType; +import org.springframework.data.repository.query.ValueExpressionDelegate; import org.springframework.javapoet.CodeBlock; import org.springframework.javapoet.TypeName; -import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; -import static org.springframework.data.cassandra.repository.aot.CassandraCodeBlocks.*; - /** * Cassandra specific {@link RepositoryContributor}. * @@ -49,163 +49,75 @@ */ public class CassandraRepositoryContributor extends RepositoryContributor { - private static final Log logger = LogFactory.getLog(RepositoryContributor.class); - - private final AotQueryCreator queryCreator; + private final AotRepositoryContext context; + private final QueriesFactory queriesFactory; private final CassandraMappingContext mappingContext; - public CassandraRepositoryContributor(AotRepositoryContext repositoryContext) { + public CassandraRepositoryContributor(AotRepositoryContext context) { - super(repositoryContext); - this.queryCreator = new AotQueryCreator(); + super(context); + this.context = context; this.mappingContext = new CassandraMappingContext(); + this.queriesFactory = new QueriesFactory(context.getConfigurationSource(), context.getClassLoader(), + ValueExpressionDelegate.create(), mappingContext); } @Override protected void customizeClass(AotRepositoryClassBuilder builder) { - builder.customize(b -> b.superclass(TypeName.get(CassandraAotRepositoryFragmentSupport.class))); + builder.customize(b -> b.superclass(TypeName.get(AotRepositoryFragmentSupport.class))); } @Override protected void customizeConstructor(AotRepositoryConstructorBuilder constructorBuilder) { - constructorBuilder.addParameter("operations", TypeName.get(CassandraOperations.class)); - constructorBuilder.addParameter("context", TypeName.get(RepositoryFactoryBeanSupport.FragmentCreationContext.class), - false); + constructorBuilder.addParameter("operations", CassandraOperations.class, customizer -> { - constructorBuilder.customize((builder) -> { - builder.addStatement("super(operations, context)"); + String cassandraTemplateRef = getCassandraTemplateRef(); + customizer.origin(StringUtils.hasText(cassandraTemplateRef) + ? new RuntimeBeanReference(cassandraTemplateRef, CassandraOperations.class) + : new RuntimeBeanReference(CassandraOperations.class)); }); + + constructorBuilder.addParameter("context", RepositoryFactoryBeanSupport.FragmentCreationContext.class, + false); + } + + private String getCassandraTemplateRef() { + return context.getConfigurationSource().getAttribute("cassandraTemplateRef").orElse(null); } @Override @SuppressWarnings("NullAway") protected @Nullable MethodContributor contributeQueryMethod(Method method) { - var queryMethod = new CassandraQueryMethod(method, getRepositoryInformation(), getProjectionFactory(), + CassandraQueryMethod queryMethod = new CassandraQueryMethod(method, getRepositoryInformation(), + getProjectionFactory(), mappingContext); - QueryInteraction query = createStringQuery(getRepositoryInformation(), 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 (backoff(queryMethod)) { - return null; - } - - 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); -// } -// } - - return queryMethodContributor(queryMethod, query); - } - - @SuppressWarnings("NullAway") - private QueryInteraction createStringQuery(RepositoryInformation repositoryInformation, CassandraQueryMethod queryMethod, - @Nullable Query queryAnnotation, int parameterCount) { - - QueryInteraction query; - if (queryMethod.hasAnnotatedQuery() && queryAnnotation != null) { - query = new QueryInteraction(new StringQuery(queryMethod.getAnnotatedQuery()), queryAnnotation.count(), - false, 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; - } + ReturnedType returnedType = queryMethod.getResultProcessor().getReturnedType(); + CassandraParameters parameters = queryMethod.getParameters(); - private static boolean backoff(CassandraQueryMethod method) { + MergedAnnotation query = MergedAnnotations.from(method).get(Query.class); + MergedAnnotation consistency = MergedAnnotations.from(method).get(Consistency.class); - boolean skip = method.isScrollQuery() || method.isStreamQuery(); + AotQueries aotQueries = queriesFactory.createQueries(getRepositoryInformation(), returnedType, query, queryMethod); - if (skip && logger.isDebugEnabled()) { - logger.debug("Skipping AOT generation for [%s]. Method is either streaming or scrolling query" - .formatted(method.getName())); - } - return skip; - } + return MethodContributor.forQueryMethod(queryMethod).withMetadata(aotQueries.toMetadata()).contribute(context -> { -// private static MethodContributor updateMethodContributor(CassandraQueryMethod 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 deleteMethodContributor(CassandraQueryMethod queryMethod, - QueryInteraction query) { - - return MethodContributor.forQueryMethod(queryMethod).withMetadata(query).contribute(context -> { - - CodeBlock.Builder builder = CodeBlock.builder(); - QueryCodeBlockBuilder queryCodeBlockBuilder = queryBlockBuilder(context, queryMethod).filter(query); - - String queryVariableName = context.localVariable(query.name()); - builder.add(queryCodeBlockBuilder.usingQueryVariableName(queryVariableName).build()); - builder.add(deleteExecutionBlockBuilder(context, queryMethod).referencing(queryVariableName).build()); - return builder.build(); - }); - } + CodeBlock.Builder body = CodeBlock.builder(); - private static MethodContributor queryMethodContributor(CassandraQueryMethod queryMethod, - QueryInteraction query) { + String queryVariableName = context.localVariable("query"); - return MethodContributor.forQueryMethod(queryMethod).withMetadata(query).contribute(context -> { + body.add(CassandraCodeBlocks.queryBuilder(context, queryMethod).usingQueryVariableName(queryVariableName) + .query(aotQueries.result()).query(query).consistency(consistency) + .queryReturnType(QueriesFactory.getQueryReturnType(aotQueries.result(), returnedType, context)).build()); - CodeBlock.Builder builder = CodeBlock.builder(); - QueryCodeBlockBuilder queryCodeBlockBuilder = queryBlockBuilder(context, queryMethod).filter(query); + /*body.add(CassandraCodeBlocks.executionBuilder(context, queryMethod).modifying(modifying).query(aotQueries.result()) + .build());*/ - builder.add(queryCodeBlockBuilder.usingQueryVariableName(context.localVariable(query.name())).build()); - builder.add(queryExecutionBlockBuilder(context, queryMethod).forQuery(query).build()); - return builder.build(); + return body.build(); }); } + } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryRegistrationAotProcessor.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryRegistrationAotProcessor.java deleted file mode 100644 index 2cdb57bc3..000000000 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryRegistrationAotProcessor.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2022-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.cassandra.repository.aot; - -import java.util.function.Predicate; -import org.jspecify.annotations.Nullable; -import org.springframework.aot.generate.GenerationContext; -import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor; -import org.springframework.data.aot.AotContext; -import org.springframework.data.cassandra.core.mapping.CassandraSimpleTypeHolder; -import org.springframework.data.repository.aot.generate.RepositoryContributor; -import org.springframework.data.repository.config.AotRepositoryContext; -import org.springframework.data.repository.config.RepositoryRegistrationAotProcessor; -import org.springframework.data.util.TypeContributor; -import org.springframework.data.util.TypeUtils; - -/** - * Cassandra specific {@link BeanRegistrationAotProcessor AOT processor}. - * - * @author Chris Bono - * @since 5.0 - */ -public class CassandraRepositoryRegistrationAotProcessor extends RepositoryRegistrationAotProcessor { - - private static final Predicate> IS_SIMPLE_TYPE = (type) -> CassandraSimpleTypeHolder.HOLDER.isSimpleType(type); - - @Override - protected @Nullable RepositoryContributor contribute(AotRepositoryContext repositoryContext, GenerationContext generationContext) { - - super.contribute(repositoryContext, generationContext); - - repositoryContext.getResolvedTypes().stream() - .filter(IS_SIMPLE_TYPE.negate()) - .forEach(type -> TypeContributor.contribute(type, (__) -> true, generationContext)); - - boolean enabled = Boolean.parseBoolean( - repositoryContext.getEnvironment().getProperty(AotContext.GENERATED_REPOSITORIES_ENABLED, "false")); - - return enabled ? new CassandraRepositoryContributor(repositoryContext) : null; - } - - @Override - protected void contributeType(Class type, GenerationContext generationContext) { - - if (TypeUtils.type(type).isPartOf("org.springframework.data.cassandra", "org.apache.cassandra", "com.datastax")) { - return; - } - - super.contributeType(type, generationContext); - } -} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/DerivedAotQuery.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/DerivedAotQuery.java new file mode 100644 index 000000000..7907a053f --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/DerivedAotQuery.java @@ -0,0 +1,78 @@ +package org.springframework.data.cassandra.repository.aot; + +import java.util.List; + +import org.springframework.data.cassandra.core.query.Query; +import org.springframework.data.cassandra.repository.query.ParameterBinding; +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.query.parser.PartTree; + +/** + * PartTree (derived) Query with a limit associated. + * + * @author Mark Paluch + */ +class DerivedAotQuery extends StringAotQuery { + + private final String queryString; + private final Query query; + private final Sort sort; + private final Limit limit; + private final boolean delete; + private final boolean count; + private final boolean exists; + + DerivedAotQuery(String queryString, List bindings, Query query, Sort sort, Limit limit, + boolean delete, boolean count, boolean exists) { + + super(bindings); + + this.queryString = queryString; + this.query = query; + this.sort = sort; + this.limit = limit; + this.delete = delete; + this.count = count; + this.exists = exists; + } + + DerivedAotQuery(String queryString, List bindings, Query query, PartTree partTree) { + + this(queryString, bindings, query, partTree.getSort(), partTree.getResultLimit(), partTree.isDelete(), + partTree.isCountProjection(), partTree.isExistsProjection()); + } + + @Override + public String getQueryString() { + return queryString; + } + + @Override + public Limit getLimit() { + return limit; + } + + @Override + public boolean isDelete() { + return delete; + } + + @Override + public boolean isCount() { + return count; + } + + @Override + public boolean isExists() { + return exists; + } + + public Query getQuery() { + return query; + } + + public Sort getSort() { + return sort; + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueriesFactory.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueriesFactory.java new file mode 100644 index 000000000..57853d200 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueriesFactory.java @@ -0,0 +1,195 @@ +/* + * 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.cassandra.repository.aot; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Properties; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; +import org.springframework.data.cassandra.repository.Query; +import org.springframework.data.cassandra.repository.config.CassandraRepositoryConfigurationExtension; +import org.springframework.data.cassandra.repository.query.CassandraQueryMethod; +import org.springframework.data.cassandra.repository.query.ParameterBinding; +import org.springframework.data.cassandra.repository.query.StringBasedQuery; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.aot.generate.AotQueryMethodGenerationContext; +import org.springframework.data.repository.config.PropertiesBasedNamedQueriesFactoryBean; +import org.springframework.data.repository.config.RepositoryConfigurationSource; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.support.PropertiesBasedNamedQueries; +import org.springframework.data.repository.query.ReturnedType; +import org.springframework.data.repository.query.ValueExpressionDelegate; +import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.util.StringUtils; + +/** + * Factory for {@link AotQueries}. + * + * @author Mark Paluch + * @since 4.0 + */ +class QueriesFactory { + + private final NamedQueries namedQueries; + private final ValueExpressionDelegate delegate; + private final CassandraMappingContext mappingContext; + + public QueriesFactory(RepositoryConfigurationSource configurationSource, ClassLoader classLoader, + ValueExpressionDelegate delegate, CassandraMappingContext mappingContext) { + + this.namedQueries = getNamedQueries(configurationSource, classLoader); + this.delegate = delegate; + this.mappingContext = mappingContext; + } + + public NamedQueries getNamedQueries() { + return namedQueries; + } + + private NamedQueries getNamedQueries(@Nullable RepositoryConfigurationSource configSource, ClassLoader classLoader) { + + String location = configSource != null ? configSource.getNamedQueryLocation().orElse(null) : null; + + if (location == null) { + location = new CassandraRepositoryConfigurationExtension().getDefaultNamedQueryLocation(); + } + + if (StringUtils.hasText(location)) { + + try { + + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(classLoader); + + PropertiesBasedNamedQueriesFactoryBean factoryBean = new PropertiesBasedNamedQueriesFactoryBean(); + factoryBean.setLocations(resolver.getResources(location)); + factoryBean.afterPropertiesSet(); + return Objects.requireNonNull(factoryBean.getObject()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + return new PropertiesBasedNamedQueries(new Properties()); + } + + /** + * Creates the {@link AotQueries} used within a specific {@link CassandraQueryMethod}. + * + * @param repositoryInformation + * @param returnedType + * @param query + * @param queryMethod + * @return + */ + public AotQueries createQueries(RepositoryInformation repositoryInformation, ReturnedType returnedType, + MergedAnnotation query, CassandraQueryMethod queryMethod) { + + boolean count = query.isPresent() && query.getBoolean("count"); + boolean exists = query.isPresent() && query.getBoolean("exists"); + + if (query.isPresent() && StringUtils.hasText(query.getString("value"))) { + return buildStringQuery(query.getString("value"), queryMethod, count, exists); + } + + String queryName = queryMethod.getNamedQueryName(); + if (hasNamedQuery(queryName)) { + return buildNamedQuery(queryName, queryMethod, count, exists); + } + + return buildPartTreeQuery(repositoryInformation, returnedType, queryMethod); + } + + private boolean hasNamedQuery(String queryName) { + return namedQueries.hasQuery(queryName); + } + + private AotQueries buildStringQuery(String queryString, CassandraQueryMethod queryMethod, boolean count, + boolean exists) { + + StringBasedQuery query = parseQuery(queryMethod, queryString); + + List bindings = query.getQueryParameterBindings(); + + StringAotQuery aotStringQuery = StringAotQuery.of(query.getPostProcessedQuery(), bindings, count, exists); + + return AotQueries.create(aotStringQuery); + } + + private AotQueries buildNamedQuery(String queryName, CassandraQueryMethod queryMethod, boolean count, + boolean exists) { + + String queryString = namedQueries.getQuery(queryName); + StringBasedQuery query = parseQuery(queryMethod, queryString); + + List bindings = query.getQueryParameterBindings(); + + return AotQueries.create(StringAotQuery.named(queryName, queryString, bindings, count, exists)); + } + + private StringBasedQuery parseQuery(CassandraQueryMethod queryMethod, String queryString) { + return new StringBasedQuery(queryString, queryMethod.getParameters(), delegate); + } + + private AotQueries buildPartTreeQuery(RepositoryInformation repositoryInformation, ReturnedType returnedType, + CassandraQueryMethod queryMethod) { + + PartTree partTree = new PartTree(queryMethod.getName(), repositoryInformation.getDomainType()); + + List parameterBindings = new ArrayList<>(); + AotQueryCreator queryCreator = new AotQueryCreator(partTree, queryMethod.getParameters(), mappingContext, + parameterBindings); + + // TODO: Render query string + org.springframework.data.cassandra.core.query.Query query = queryCreator.createQuery(Sort.unsorted()); + DerivedAotQuery aotQuery = new DerivedAotQuery("", parameterBindings, query, partTree); + + return AotQueries.create(aotQuery); + } + + public static @Nullable Class getQueryReturnType(AotQuery query, ReturnedType returnedType, + AotQueryMethodGenerationContext context) { + + Method method = context.getMethod(); + RepositoryInformation repositoryInformation = context.getRepositoryInformation(); + + Class methodReturnType = repositoryInformation.getReturnedDomainClass(method); + boolean queryForEntity = repositoryInformation.getDomainType().isAssignableFrom(methodReturnType); + + Class result = queryForEntity ? returnedType.getDomainType() : null; + + if (returnedType.isProjecting()) { + + if (returnedType.getReturnedType().isInterface()) { + + return result; + } + + return returnedType.getReturnedType(); + } + + return result; + } + +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueryInteraction.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueryInteraction.java deleted file mode 100644 index 290ffe1a4..000000000 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueryInteraction.java +++ /dev/null @@ -1,81 +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.cassandra.repository.aot; - -import java.util.LinkedHashMap; -import java.util.Map; -import org.springframework.data.repository.aot.generate.QueryMetadata; -import org.springframework.util.StringUtils; - -/** - * A {@link CassandraInteraction} to execute a query. - * - * @author Chris Bono - */ -class QueryInteraction extends CassandraInteraction 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-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringAotQuery.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringAotQuery.java new file mode 100644 index 000000000..f915378c4 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringAotQuery.java @@ -0,0 +1,100 @@ +/* + * 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.cassandra.repository.aot; + +import java.util.List; + +import org.springframework.data.cassandra.repository.query.ParameterBinding; + +/** + * An AOT query represented by a string. + * + * @author Mark Paluch + * @since 5.0 + */ +abstract class StringAotQuery extends AotQuery { + + StringAotQuery(List parameterBindings) { + super(parameterBindings); + } + + static StringAotQuery of(String query, List parameterBindings, boolean count, boolean exists) { + return new DeclaredAotQuery(query, parameterBindings, count, exists); + } + + static StringAotQuery named(String queryName, String query, List parameterBindings, boolean count, + boolean exists) { + return new NamedStringAotQuery(queryName, query, parameterBindings, count, exists); + } + + public abstract String getQueryString(); + + @Override + public String toString() { + return getQueryString(); + } + + static class DeclaredAotQuery extends StringAotQuery { + + private final String query; + private final boolean count; + private final boolean exists; + + DeclaredAotQuery(String query, List parameterBindings, boolean count, boolean exists) { + + super(parameterBindings); + + this.query = query; + this.count = count; + this.exists = exists; + } + + @Override + public boolean isCount() { + return count; + } + + @Override + public boolean isExists() { + return exists; + } + + @Override + public String getQueryString() { + return query; + } + + } + + static class NamedStringAotQuery extends DeclaredAotQuery { + + private final String queryName; + + NamedStringAotQuery(String queryName, String query, List parameterBindings, boolean count, + boolean exists) { + + super(query, parameterBindings, count, exists); + + this.queryName = queryName; + } + + public String getQueryName() { + return queryName; + } + + } + +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringQuery.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringQuery.java deleted file mode 100644 index a79b27aa7..000000000 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringQuery.java +++ /dev/null @@ -1,101 +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.cassandra.repository.aot; - -import java.util.Optional; -import java.util.Set; -import org.jspecify.annotations.Nullable; -import org.springframework.data.cassandra.core.query.Query; -import org.springframework.data.domain.KeysetScrollPosition; -import org.springframework.util.StringUtils; - -/** - * Helper to capture setting for AOT queries. - * - * @author Chris Bono - * @since 4.0 - */ -class StringQuery extends Query { - - private Query delegate; - private @Nullable String raw; - private @Nullable String sort; - private @Nullable String fields; - - public StringQuery(Query query) { - this.delegate = query; - } - - public StringQuery(String query) { - this.delegate = new Query(); - this.raw = query; - } - - @Nullable - String getQueryString() { - - if (StringUtils.hasText(raw)) { - return raw; - } - - Document queryObj = getQueryObject(); - if (queryObj.isEmpty()) { - return null; - } - return toJson(queryObj); - } - - public Query sort(String sort) { - this.sort = sort; - return this; - } - - - -// @Nullable -// String getSortString() { -// if (StringUtils.hasText(sort)) { -// return sort; -// } -// Document sort = getSortObject(); -// if (sort.isEmpty()) { -// return null; -// } -// return toJson(sort); -// } -// -// @Nullable -// String getFieldsString() { -// if (StringUtils.hasText(fields)) { -// return fields; -// } -// -// Document fields = getFieldsObject(); -// if (fields.isEmpty()) { -// return null; -// } -// return toJson(fields); -// } -// -// StringQuery fields(String fields) { -// this.fields = fields; -// return this; -// } - -// String toJson(Document source) { -// return BsonUtils.writeJson(source).toJsonString(); -// } -} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/UpdateInteraction.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/UpdateInteraction.java deleted file mode 100644 index f21a337d3..000000000 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/UpdateInteraction.java +++ /dev/null @@ -1,57 +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.cassandra.repository.aot; - -import java.util.Map; -import org.springframework.data.repository.aot.generate.QueryMetadata; - -/** - * A {@link CassandraInteraction} to execute an update. - * - * @author Chris Bono - * @since 4.0 - */ -class UpdateInteraction extends CassandraInteraction 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-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/CassandraRepositoryConfigurationExtension.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/CassandraRepositoryConfigurationExtension.java index 9558c9810..aa8dd8e8a 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/CassandraRepositoryConfigurationExtension.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/CassandraRepositoryConfigurationExtension.java @@ -19,20 +19,28 @@ import java.util.Collection; import java.util.Collections; import java.util.Optional; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.generate.GenerationContext; import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor; import org.springframework.beans.factory.support.BeanDefinitionBuilder; import org.springframework.data.cassandra.config.DefaultBeanNames; import org.springframework.data.cassandra.core.mapping.Table; import org.springframework.data.cassandra.repository.CassandraRepository; -import org.springframework.data.cassandra.repository.aot.CassandraRepositoryRegistrationAotProcessor; +import org.springframework.data.cassandra.repository.aot.CassandraRepositoryContributor; import org.springframework.data.cassandra.repository.support.CassandraRepositoryFactoryBean; import org.springframework.data.cassandra.repository.support.SimpleCassandraRepository; import org.springframework.data.repository.config.AnnotationRepositoryConfigurationSource; +import org.springframework.data.repository.config.AotRepositoryContext; import org.springframework.data.repository.config.RepositoryConfigurationExtension; import org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport; +import org.springframework.data.repository.config.RepositoryRegistrationAotProcessor; import org.springframework.data.repository.config.XmlRepositoryConfigurationSource; import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.util.TypeUtils; import org.springframework.util.StringUtils; + import org.w3c.dom.Element; /** @@ -109,4 +117,38 @@ protected Collection> getIdentifyingTypes() { protected boolean useRepositoryConfiguration(RepositoryMetadata metadata) { return !metadata.isReactiveRepository(); } + + /** + * Cassandra-specific {@link BeanRegistrationAotProcessor AOT processor}. + * + * @author Chris Bono + * @author Mark Paluch + * @since 5.0 + */ + public static class CassandraRepositoryRegistrationAotProcessor extends RepositoryRegistrationAotProcessor { + + private static final String MODULE_NAME = "cassandra"; + + protected @Nullable CassandraRepositoryContributor contribute(AotRepositoryContext repositoryContext, + GenerationContext generationContext) { + + if (!repositoryContext.isGeneratedRepositoriesEnabled(MODULE_NAME)) { + return null; + } + + return new CassandraRepositoryContributor(repositoryContext); + } + + @Override + protected void contributeType(Class type, GenerationContext generationContext) { + + if (TypeUtils.type(type).isPartOf("org.springframework.data.cassandra", "org.apache.cassandra", "com.datastax")) { + return; + } + + super.contributeType(type, generationContext); + } + + } + } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/BindingContext.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/BindingContext.java index 028f1bd0b..55267baa8 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/BindingContext.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/BindingContext.java @@ -21,9 +21,7 @@ import org.jspecify.annotations.Nullable; -import org.springframework.data.domain.Limit; import org.springframework.data.mapping.model.ValueExpressionEvaluator; -import org.springframework.util.Assert; /** * Value object capturing the binding context to provide {@link #getBindingValues() binding values} for queries. @@ -91,13 +89,21 @@ public List getBindingValues() { */ private @Nullable Object getParameterValueForBinding(ParameterBinding binding) { - if (binding.isExpression()) { - return evaluator.evaluate(binding.getRequiredExpression()); + ParameterBinding.ParameterOrigin origin = binding.getOrigin(); + + if (origin.isExpression() && origin instanceof ParameterBinding.Expression expression) { + return evaluator.evaluate(expression.expression().getExpressionString()); + } + + if (origin instanceof ParameterBinding.MethodInvocationArgument invocationArgument) { + + ParameterBinding.BindingIdentifier argument = invocationArgument.identifier(); + + return argument.hasName() ? parameterAccessor.getValue(getParameterIndex(parameters, argument.getName())) + : parameterAccessor.getBindableValue(argument.getPosition()); } - return binding.isNamed() - ? parameterAccessor.getValue(getParameterIndex(parameters, binding.getRequiredParameterName())) - : parameterAccessor.getBindableValue(binding.getParameterIndex()); + throw new UnsupportedOperationException("Unsupported parameter origin '%s'".formatted(origin)); } private int getParameterIndex(CassandraParameters parameters, String parameterName) { @@ -112,82 +118,4 @@ private int getParameterIndex(CassandraParameters parameters, String parameterNa String.format("Invalid parameter name; Cannot resolve parameter [%s]", parameterName)); } - /** - * A generic parameter binding with name or position information. - * - * @author Mark Paluch - */ - static class ParameterBinding { - - private final int parameterIndex; - private final @Nullable String expression; - private final @Nullable String parameterName; - - private ParameterBinding(int parameterIndex, @Nullable String expression, @Nullable String parameterName) { - - this.parameterIndex = parameterIndex; - this.expression = expression; - this.parameterName = parameterName; - } - - public static ParameterBinding expression(String expression, boolean quoted) { - return new ParameterBinding(-1, expression, null); - } - - public static ParameterBinding indexed(int parameterIndex) { - return new ParameterBinding(parameterIndex, null, null); - } - - public static ParameterBinding named(String name) { - return new ParameterBinding(-1, null, name); - } - - public boolean isNamed() { - return (parameterName != null); - } - - public int getParameterIndex() { - return parameterIndex; - } - - public String getParameter() { - return ("?" + (isExpression() ? "expr" : "") + parameterIndex); - } - - public String getRequiredExpression() { - - Assert.state(expression != null, "ParameterBinding is not an expression"); - return expression; - } - - boolean isExpression() { - return (this.expression != null); - } - - String getRequiredParameterName() { - - Assert.state(parameterName != null, "ParameterBinding is not named"); - - return parameterName; - } - - /** - * Prepare a value before binding it to the query. - * - * @param value - * @return - */ - public @Nullable Object prepareValue(@Nullable Object value) { - - if (value == null) { - return value; - } - - if (value instanceof Limit limit) { - return limit.max(); - } - - return value; - } - } } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraParameters.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraParameters.java index da24b3b10..04d08d411 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraParameters.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraParameters.java @@ -106,7 +106,7 @@ public int getScoringFunctionIndex() { * * @author Mark Paluch */ - static class CassandraParameter extends Parameter { + public static class CassandraParameter extends Parameter { private final @Nullable CassandraType cassandraType; private final Class parameterType; diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraParametersParameterAccessor.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraParametersParameterAccessor.java index c766be770..fa6a96adc 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraParametersParameterAccessor.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraParametersParameterAccessor.java @@ -49,8 +49,18 @@ public class CassandraParametersParameterAccessor extends ParametersParameterAcc * @param values must not be {@literal null}. */ public CassandraParametersParameterAccessor(CassandraQueryMethod method, @Nullable Object... values) { + this(method.getParameters(), values); + } - super(method.getParameters(), values); + /** + * Create a new {@link CassandraParametersParameterAccessor}. + * + * @param parameters must not be {@literal null}. + * @param values must not be {@literal null}. + * @since 5.0 + */ + public CassandraParametersParameterAccessor(CassandraParameters parameters, @Nullable Object... values) { + super(parameters, values); } @Override diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreator.java index 07f8eea25..ab9016606 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreator.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreator.java @@ -286,7 +286,7 @@ private CriteriaDefinition containing(Criteria where, CassandraPersistentPropert return where.like(like(Type.CONTAINING, bindableValue)); } - private Object like(Type type, Object value) { + protected Object like(Type type, Object value) { switch (type) { case LIKE: diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ParameterBinding.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ParameterBinding.java new file mode 100644 index 000000000..71228e7a3 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ParameterBinding.java @@ -0,0 +1,629 @@ +/* + * Copyright 2023-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.cassandra.repository.query; + +import static org.springframework.util.ObjectUtils.*; + +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; + +import org.springframework.data.domain.Limit; +import org.springframework.data.expression.ValueExpression; +import org.springframework.data.repository.query.Parameter; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * A generic parameter binding with name or position information. + * + * @author Mark Paluch + * @since 4.0 + */ +public class ParameterBinding { + + private final BindingIdentifier identifier; + private final ParameterOrigin origin; + + /** + * Creates a new {@link ParameterBinding} for the parameter with the given identifier and origin. + * + * @param identifier of the parameter, must not be {@literal null}. + * @param origin the origin of the parameter (expression or method argument) + */ + ParameterBinding(BindingIdentifier identifier, ParameterOrigin origin) { + + Assert.notNull(identifier, "BindingIdentifier must not be null"); + Assert.notNull(origin, "ParameterOrigin must not be null"); + + this.identifier = identifier; + this.origin = origin; + } + + /** + * Creates a new {@link ParameterBinding} for the parameter with the given name and origin. + * + * @param parameter + * @return + */ + public static ParameterBinding of(Parameter parameter) { + return named(parameter.getRequiredName(), ParameterOrigin.ofParameter(parameter)); + } + + /** + * Creates a new {@link ParameterBinding} for the named parameter with the given name and origin. + * + * @param name + * @param origin + * @return + */ + public static ParameterBinding named(String name, ParameterOrigin origin) { + return new ParameterBinding(BindingIdentifier.of(name), origin); + } + + /** + * Creates a new {@link ParameterBinding} for the named parameter using the bindable method invocation parameter as + * origin. + * + * @param name + * @return + */ + public static ParameterBinding named(String name) { + BindingIdentifier id = BindingIdentifier.of(name); + return new ParameterBinding(id, new MethodInvocationArgument(id)); + } + + /** + * Creates a new {@link ParameterBinding} for the indexed parameter using the bindable method invocation parameter as + * origin. + * + * @param position + * @return + */ + public static ParameterBinding indexed(int position) { + BindingIdentifier id = BindingIdentifier.of(position); + return new ParameterBinding(id, new MethodInvocationArgument(id)); + } + + /** + * Creates a new expression {@link ParameterBinding} for the anonymous parameter. + * + * @param expression + * @return + */ + public static ParameterBinding expression(ValueExpression expression) { + return new ParameterBinding(BindingIdentifier.anonymous(), new Expression(expression)); + } + + public BindingIdentifier getIdentifier() { + return identifier; + } + + public ParameterOrigin getOrigin() { + return origin; + } + + /** + * @return the name if available or {@literal null}. + */ + public @Nullable String getName() { + return identifier.hasName() ? identifier.getName() : null; + } + + /** + * @return {@literal true} if the binding identifier is associated with a name. + * @since 4.0 + */ + boolean hasName() { + return identifier.hasName(); + } + + /** + * @return the name + * @throws IllegalStateException if the name is not available. + * @since 2.0 + */ + String getRequiredName() throws IllegalStateException { + + String name = getName(); + + if (name != null) { + return name; + } + + throw new IllegalStateException(String.format("Required name for %s not available", this)); + } + + /** + * @return the position if available or {@literal null}. + */ + @Nullable + Integer getPosition() { + return identifier.hasPosition() ? identifier.getPosition() : null; + } + + /** + * @return the position + * @throws IllegalStateException if the position is not available. + */ + int getRequiredPosition() throws IllegalStateException { + + Integer position = getPosition(); + + if (position != null) { + return position; + } + + throw new IllegalStateException(String.format("Required position for %s not available", this)); + } + + /** + * Prepare a value before binding it to the query. + * + * @param value + * @return + */ + public @Nullable Object prepareValue(@Nullable Object value) { + + if (value == null) { + return value; + } + + if (value instanceof Limit limit) { + return limit.max(); + } + + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + ParameterBinding that = (ParameterBinding) o; + + if (!nullSafeEquals(identifier, that.identifier)) { + return false; + } + return nullSafeEquals(origin, that.origin); + } + + @Override + public int hashCode() { + int result = nullSafeHashCode(identifier); + result = 31 * result + nullSafeHashCode(origin); + return result; + } + + @Override + public String toString() { + return String.format("ParameterBinding [identifier: %s, origin: %s]", identifier, origin); + } + + /** + * Identifies a binding parameter by name, position or both. Used to bind parameters to a query or to describe a + * {@link MethodInvocationArgument} origin. + * + * @author Mark Paluch + */ + public sealed interface BindingIdentifier permits Anonymous, Named, Indexed, NamedAndIndexed { + + /** + * Creates an anonymous ({@code ?}) that binds by position. + * + * @return + */ + static BindingIdentifier anonymous() { + return Anonymous.ANONYMOUS; + } + + /** + * Creates an identifier for the given {@code name}. + * + * @param name + * @return + */ + static BindingIdentifier of(String name) { + + Assert.hasText(name, "Name must not be empty"); + + return new Named(name); + } + + /** + * Creates an identifier for the given {@code position}. + * + * @param position 1-based index. + * @return + */ + static BindingIdentifier of(int position) { + + Assert.isTrue(position > -1, "Index position must be greater zero"); + + return new Indexed(position); + } + + /** + * Creates an identifier for the given {@code name} and {@code position}. + * + * @param name + * @return + */ + static BindingIdentifier of(String name, int position) { + + Assert.hasText(name, "Name must not be empty"); + + return new NamedAndIndexed(name, position); + } + + /** + * @return {@code true} if the binding is associated with a name. + */ + default boolean hasName() { + return false; + } + + /** + * @return {@code true} if the binding is associated with a position index. + */ + default boolean hasPosition() { + return false; + } + + /** + * Returns the binding name {@link #hasName() if present} or throw {@link IllegalStateException} if no name + * associated. + * + * @return the binding name. + */ + default String getName() { + throw new IllegalStateException("No name associated"); + } + + /** + * Returns the binding name {@link #hasPosition() if present} or throw {@link IllegalStateException} if no position + * associated. + * + * @return the binding position. + */ + default int getPosition() { + throw new IllegalStateException("No position associated"); + } + + /** + * Map the name of the binding to a new name using the given {@link Function} if the binding has a name. If the + * binding is not associated with a name, then the binding is returned unchanged. + * + * @param nameMapper must not be {@literal null}. + * @return the transformed {@link BindingIdentifier} if the binding has a name, otherwise the binding itself. + * @since 4.0 + */ + BindingIdentifier mapName(Function nameMapper); + + /** + * Associate a position with the binding. + * + * @param position + * @return the new binding identifier with the position. + */ + BindingIdentifier withPosition(int position); + + } + + /** + * Anonymous binding identifier ({@code ?}). + */ + enum Anonymous implements BindingIdentifier { + + ANONYMOUS; + + @Override + public String toString() { + return "?"; + } + + @Override + public BindingIdentifier mapName(Function nameMapper) { + return this; + } + + @Override + public BindingIdentifier withPosition(int position) { + return new Indexed(position); + } + } + + private record Named(String name) implements BindingIdentifier { + + @Override + public boolean hasName() { + return true; + } + + @Override + public String getName() { + return name(); + } + + @Override + public String toString() { + return name(); + } + + @Override + public BindingIdentifier mapName(Function nameMapper) { + return new Named(nameMapper.apply(name())); + } + + @Override + public BindingIdentifier withPosition(int position) { + return new NamedAndIndexed(name, position); + } + } + + private record Indexed(int position) implements BindingIdentifier { + + @Override + public boolean hasPosition() { + return true; + } + + @Override + public int getPosition() { + return position(); + } + + @Override + public BindingIdentifier mapName(Function nameMapper) { + return this; + } + + @Override + public BindingIdentifier withPosition(int position) { + return new Indexed(position); + } + + @Override + public String toString() { + return "[" + position() + "]"; + } + } + + private record NamedAndIndexed(String name, int position) implements BindingIdentifier { + + @Override + public boolean hasName() { + return true; + } + + @Override + public String getName() { + return name(); + } + + @Override + public boolean hasPosition() { + return true; + } + + @Override + public int getPosition() { + return position(); + } + + @Override + public BindingIdentifier mapName(Function nameMapper) { + return new NamedAndIndexed(nameMapper.apply(name), position); + } + + @Override + public BindingIdentifier withPosition(int position) { + return new NamedAndIndexed(name, position); + } + + @Override + public String toString() { + return "[" + name() + ", " + position() + "]"; + } + } + + /** + * Value type hierarchy to describe where a binding parameter comes from, either method call or an expression. + * + * @author Mark Paluch + * @since 3.1.2 + */ + public sealed interface ParameterOrigin permits Expression, MethodInvocationArgument, Synthetic { + + /** + * Creates a {@link Expression} for the given {@code expression}. + * + * @param expression must not be {@literal null}. + * @return {@link Expression} for the given {@code expression}. + */ + static Expression ofExpression(ValueExpression expression) { + return new Expression(expression); + } + + /** + * Creates a {@link Expression} for the given {@code expression} string. + * + * @param value the captured value. + * @param source source from which this value is derived. + * @return {@link Synthetic} for the given {@code value}. + */ + static Synthetic synthetic(@Nullable Object value, Object source) { + return new Synthetic(value, source); + } + + /** + * Creates a {@link MethodInvocationArgument} object for {@code name} + * + * @param name the parameter name from the method invocation. + * @return {@link MethodInvocationArgument} object for {@code name}. + */ + static MethodInvocationArgument ofParameter(String name) { + return ofParameter(name, null); + } + + /** + * Creates a {@link MethodInvocationArgument} object for {@code name} and {@code position}. Either the name or the + * position must be given. + * + * @param name the parameter name from the method invocation, can be {@literal null}. + * @param position the parameter position (1-based) from the method invocation, can be {@literal null}. + * @return {@link MethodInvocationArgument} object for {@code name} and {@code position}. + */ + static MethodInvocationArgument ofParameter(@Nullable String name, @Nullable Integer position) { + + BindingIdentifier identifier; + if (!ObjectUtils.isEmpty(name) && position != null) { + identifier = BindingIdentifier.of(name, position); + } else if (!ObjectUtils.isEmpty(name)) { + identifier = BindingIdentifier.of(name); + } else if (position != null) { + identifier = BindingIdentifier.of(position); + } else { + throw new IllegalStateException("Neither name nor position available for binding"); + } + + return ofParameter(identifier); + } + + /** + * Creates a {@link MethodInvocationArgument} object for {@code position}. + * + * @param parameter the parameter from the method invocation. + * @return {@link MethodInvocationArgument} object for {@code position}. + */ + static MethodInvocationArgument ofParameter(Parameter parameter) { + return ofParameter(parameter.getIndex() + 1); + } + + /** + * Creates a {@link MethodInvocationArgument} object for {@code position}. + * + * @param position the parameter position (1-based) from the method invocation. + * @return {@link MethodInvocationArgument} object for {@code position}. + */ + static MethodInvocationArgument ofParameter(int position) { + return ofParameter(BindingIdentifier.of(position)); + } + + /** + * Creates a {@link MethodInvocationArgument} using {@link BindingIdentifier}. + * + * @param identifier must not be {@literal null}. + * @return {@link MethodInvocationArgument} for {@link BindingIdentifier}. + */ + static MethodInvocationArgument ofParameter(BindingIdentifier identifier) { + return new MethodInvocationArgument(identifier); + } + + /** + * @return {@code true} if the origin is a method argument reference. + */ + boolean isMethodArgument(); + + /** + * @return {@code true} if the origin is an expression. + */ + boolean isExpression(); + + /** + * @return {@code true} if the origin is synthetic (contributed by e.g. KeysetPagination) + */ + boolean isSynthetic(); + } + + /** + * Value object capturing the expression of which a binding parameter originates. + * + * @param expression + * @author Mark Paluch + * @since 3.1.2 + */ + public record Expression(ValueExpression expression) implements ParameterOrigin { + + @Override + public boolean isMethodArgument() { + return false; + } + + @Override + public boolean isExpression() { + return true; + } + + @Override + public boolean isSynthetic() { + return true; + } + } + + /** + * Value object capturing the expression of which a binding parameter originates. + * + * @param value + * @param source + * @author Mark Paluch + */ + public record Synthetic(@Nullable Object value, Object source) implements ParameterOrigin { + + @Override + public boolean isMethodArgument() { + return false; + } + + @Override + public boolean isExpression() { + return false; + } + + @Override + public boolean isSynthetic() { + return true; + } + } + + /** + * Value object capturing the method invocation parameter reference. + * + * @param identifier + * @author Mark Paluch + * @since 3.1.2 + */ + public record MethodInvocationArgument(BindingIdentifier identifier) implements ParameterOrigin { + + @Override + public boolean isMethodArgument() { + return true; + } + + @Override + public boolean isExpression() { + return false; + } + + @Override + public boolean isSynthetic() { + return false; + } + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/StringBasedQuery.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/StringBasedQuery.java index 7031fff69..c8e5234ce 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/StringBasedQuery.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/StringBasedQuery.java @@ -25,7 +25,8 @@ import org.jspecify.annotations.Nullable; -import org.springframework.data.cassandra.repository.query.BindingContext.ParameterBinding; +import org.springframework.data.expression.ValueExpression; +import org.springframework.data.expression.ValueExpressionParser; import org.springframework.data.mapping.model.ValueExpressionEvaluator; import org.springframework.data.repository.query.ValueExpressionDelegate; import org.springframework.data.spel.ExpressionDependencies; @@ -41,31 +42,30 @@ * @author Marcin Grzejszczak * @since 2.0 */ -class StringBasedQuery { +public class StringBasedQuery { private final String query; private final CassandraParameters parameters; - private final ValueExpressionDelegate expressionParser; - private final List queryParameterBindings = new ArrayList<>(); private final ExpressionDependencies expressionDependencies; /** - * Create a new {@link StringBasedQuery} given {@code query}, {@link CassandraParameters} and {@link ValueExpressionDelegate}. + * Create a new {@link StringBasedQuery} given {@code query}, {@link CassandraParameters} and + * {@link ValueExpressionDelegate}. * * @param query must not be empty. * @param parameters must not be {@literal null}. * @param expressionParser must not be {@literal null}. */ - StringBasedQuery(String query, CassandraParameters parameters, ValueExpressionDelegate expressionParser) { + public StringBasedQuery(String query, CassandraParameters parameters, ValueExpressionParser expressionParser) { - this.query = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, + this.query = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(expressionParser, + query, this.queryParameterBindings); this.parameters = parameters; - this.expressionParser = expressionParser; this.expressionDependencies = createExpressionDependencies(); } @@ -78,9 +78,8 @@ private ExpressionDependencies createExpressionDependencies() { List dependencies = new ArrayList<>(); for (ParameterBinding binding : queryParameterBindings) { - if (binding.isExpression()) { - dependencies - .add(expressionParser.parse(binding.getRequiredExpression()).getExpressionDependencies()); + if (binding.getOrigin().isExpression() && binding.getOrigin() instanceof ParameterBinding.Expression expr) { + dependencies.add(expr.expression().getExpressionDependencies()); } } @@ -96,6 +95,19 @@ public ExpressionDependencies getExpressionDependencies() { return expressionDependencies; } + /** + * Returns the query with parameter bindings replaced with anonymous {@code ?} placeholders. + * + * @return + */ + public String getPostProcessedQuery() { + return query.replaceAll(Pattern.quote(ParameterBinder.ARGUMENT_PLACEHOLDER), "?"); + } + + public List getQueryParameterBindings() { + return queryParameterBindings; + } + /** * Bind the query to actual parameters using {@link CassandraParameterAccessor}, * @@ -125,8 +137,8 @@ enum ParameterBinder { INSTANCE; - private static final String ARGUMENT_PLACEHOLDER = "?_param_?"; - private static final Pattern ARGUMENT_PLACEHOLDER_PATTERN = Pattern.compile(Pattern.quote(ARGUMENT_PLACEHOLDER)); + static final String ARGUMENT_PLACEHOLDER = "?_param_?"; + static final Pattern ARGUMENT_PLACEHOLDER_PATTERN = Pattern.compile(Pattern.quote(ARGUMENT_PLACEHOLDER)); public SimpleStatement bind(String input, List parameters) { @@ -182,7 +194,9 @@ enum ParameterBindingParser { private static final Pattern INDEX_BASED_PROPERTY_PLACEHOLDER_PATTERN = Pattern.compile("\\?\\$\\{"); private static final Pattern NAME_BASED_PROPERTY_PLACEHOLDER_PATTERN = Pattern.compile("\\:\\$\\{"); - private static final Set VALUE_EXPRESSION_PATTERNS = Set.of(INDEX_BASED_EXPRESSION_PATTERN, NAME_BASED_EXPRESSION_PATTERN, INDEX_BASED_PROPERTY_PLACEHOLDER_PATTERN, NAME_BASED_PROPERTY_PLACEHOLDER_PATTERN); + private static final Set VALUE_EXPRESSION_PATTERNS = Set.of(INDEX_BASED_EXPRESSION_PATTERN, + NAME_BASED_EXPRESSION_PATTERN, INDEX_BASED_PROPERTY_PLACEHOLDER_PATTERN, + NAME_BASED_PROPERTY_PLACEHOLDER_PATTERN); private static final String ARGUMENT_PLACEHOLDER = "?_param_?"; @@ -193,7 +207,8 @@ enum ParameterBindingParser { * @param bindings must not be {@literal null}. * @return a list of {@link ParameterBinding}s found in the given {@code input}. */ - public String parseAndCollectParameterBindingsFromQueryIntoBindings(String input, List bindings) { + public String parseAndCollectParameterBindingsFromQueryIntoBindings(ValueExpressionParser expressionParser, + String input, List bindings) { if (!StringUtils.hasText(input)) { return input; @@ -201,11 +216,11 @@ public String parseAndCollectParameterBindingsFromQueryIntoBindings(String input Assert.notNull(bindings, "Parameter bindings must not be null"); - return transformQueryAndCollectExpressionParametersIntoBindings(input, bindings); + return transformQueryAndCollectExpressionParametersIntoBindings(expressionParser, input, bindings); } - private static String transformQueryAndCollectExpressionParametersIntoBindings(String input, - List bindings) { + private static String transformQueryAndCollectExpressionParametersIntoBindings( + ValueExpressionParser expressionParser, String input, List bindings) { StringBuilder result = new StringBuilder(); @@ -248,15 +263,14 @@ private static String transformQueryAndCollectExpressionParametersIntoBindings(S result.append(ARGUMENT_PLACEHOLDER); if (isValueExpression(matcher)) { - bindings.add( - BindingContext.ParameterBinding - .expression(input.substring(exprStart + 1, currentPosition), true)); + + ValueExpression expression = expressionParser.parse(input.substring(exprStart + 1, currentPosition)); + bindings.add(ParameterBinding.expression(expression)); } else { if (matcher.pattern() == INDEX_PARAMETER_BINDING_PATTERN) { - bindings - .add(BindingContext.ParameterBinding.indexed(Integer.parseInt(matcher.group(1)))); + bindings.add(ParameterBinding.indexed(Integer.parseInt(matcher.group(1)))); } else { - bindings.add(BindingContext.ParameterBinding.named(matcher.group(1))); + bindings.add(ParameterBinding.named(matcher.group(1))); } currentPosition = matcher.end(); diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ParameterBindingParserUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ParameterBindingParserUnitTests.java index d185a994b..8260e300f 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ParameterBindingParserUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ParameterBindingParserUnitTests.java @@ -21,8 +21,8 @@ import java.util.List; import org.junit.jupiter.api.Test; -import org.springframework.data.cassandra.repository.query.BindingContext.ParameterBinding; import org.springframework.data.cassandra.repository.query.StringBasedQuery.ParameterBindingParser; +import org.springframework.data.expression.ValueExpressionParser; /** * Unit tests for {@link ParameterBindingParser}. @@ -37,7 +37,8 @@ void parseWithoutParameters() { String query = "SELECT * FROM hello_world"; List bindings = new ArrayList<>(); - String transformed = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, + String transformed = ParameterBindingParser.INSTANCE + .parseAndCollectParameterBindingsFromQueryIntoBindings(ValueExpressionParser.create(), query, bindings); assertThat(transformed).isEqualTo(query); @@ -50,7 +51,8 @@ void parseWithStaticParameters() { String query = "SELECT * FROM hello_world WHERE a = 1 AND b = {'list'} AND c = {'key':'value'}"; List bindings = new ArrayList<>(); - String transformed = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, + String transformed = ParameterBindingParser.INSTANCE + .parseAndCollectParameterBindingsFromQueryIntoBindings(ValueExpressionParser.create(), query, bindings); assertThat(transformed).isEqualTo(query); @@ -63,14 +65,15 @@ void parseWithPositionalParameters() { String query = "SELECT * FROM hello_world WHERE a = ?0 and b = ?13"; List bindings = new ArrayList<>(); - String transformed = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, + String transformed = ParameterBindingParser.INSTANCE + .parseAndCollectParameterBindingsFromQueryIntoBindings(ValueExpressionParser.create(), query, bindings); assertThat(transformed).isEqualTo("SELECT * FROM hello_world WHERE a = ?_param_? and b = ?_param_?"); assertThat(bindings).hasSize(2); - assertThat(bindings.get(0).getParameterIndex()).isEqualTo(0); - assertThat(bindings.get(1).getParameterIndex()).isEqualTo(13); + assertThat(bindings.get(0).getPosition()).isEqualTo(0); + assertThat(bindings.get(1).getPosition()).isEqualTo(13); } @Test // DATACASS-117 @@ -79,7 +82,8 @@ void parseWithNamedParameters() { String query = "SELECT * FROM hello_world WHERE a = :hello and b = :world"; List bindings = new ArrayList<>(); - String transformed = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, + String transformed = ParameterBindingParser.INSTANCE + .parseAndCollectParameterBindingsFromQueryIntoBindings(ValueExpressionParser.create(), query, bindings); assertThat(transformed).isEqualTo("SELECT * FROM hello_world WHERE a = ?_param_? and b = ?_param_?"); @@ -92,7 +96,8 @@ void parseWithIndexExpressionParameters() { String query = "SELECT * FROM hello_world WHERE a = ?#{[0]} and b = ?#{[2]}"; List bindings = new ArrayList<>(); - String transformed = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, + String transformed = ParameterBindingParser.INSTANCE + .parseAndCollectParameterBindingsFromQueryIntoBindings(ValueExpressionParser.create(), query, bindings); assertThat(transformed).isEqualTo("SELECT * FROM hello_world WHERE a = ?_param_? and b = ?_param_?"); @@ -105,7 +110,8 @@ void parseWithNameExpressionParameters() { String query = "SELECT * FROM hello_world WHERE a = :#{#a} and b = :#{#b}"; List bindings = new ArrayList<>(); - String transformed = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, + String transformed = ParameterBindingParser.INSTANCE + .parseAndCollectParameterBindingsFromQueryIntoBindings(ValueExpressionParser.create(), query, bindings); assertThat(transformed).isEqualTo("SELECT * FROM hello_world WHERE a = ?_param_? and b = ?_param_?"); @@ -118,7 +124,8 @@ void parseWithMixedParameters() { String query = "SELECT * FROM hello_world WHERE (a = ?1 and b = :name) and c = (:#{#a}) and (d = ?#{[1]})"; List bindings = new ArrayList<>(); - String transformed = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, + String transformed = ParameterBindingParser.INSTANCE + .parseAndCollectParameterBindingsFromQueryIntoBindings(ValueExpressionParser.create(), query, bindings); assertThat(transformed).isEqualTo( From 7b4e7e4b1500458a78c19a7d1aaa1b6de8ba1809 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 27 Aug 2025 11:19:55 +0200 Subject: [PATCH 04/12] Initial support for derived queries. --- spring-data-cassandra/pom.xml | 17 +- .../cassandra/core/convert/QueryMapper.java | 2 +- .../repository/aot/AotQueryCreator.java | 11 + .../repository/aot/CassandraCodeBlocks.java | 331 ++++++++++++++--- .../aot/CassandraRepositoryContributor.java | 6 +- .../repository/aot/DerivedAotQuery.java | 3 +- ...gUpdate.java => LikeParameterBinding.java} | 22 +- .../repository/aot/QueriesFactory.java | 34 +- .../repository/query/ParameterBinding.java | 2 +- .../AotFragmentTestConfigurationSupport.java | 180 ++++++++++ ...RepositoryContributorIntegrationTests.java | 338 ++++++++++++++++++ .../repository/aot/PersonRepository.java | 56 +++ .../TestCassandraAotRepositoryContext.java | 130 +++++++ .../repository/conversion/Contact.java | 2 +- .../ParameterConversionTestSupport.java | 2 +- 15 files changed, 1061 insertions(+), 75 deletions(-) rename spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/{StringUpdate.java => LikeParameterBinding.java} (55%) create mode 100644 spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/AotFragmentTestConfigurationSupport.java create mode 100644 spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributorIntegrationTests.java create mode 100644 spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/PersonRepository.java create mode 100644 spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/TestCassandraAotRepositoryContext.java diff --git a/spring-data-cassandra/pom.xml b/spring-data-cassandra/pom.xml index a6166162b..2e4c62bfe 100644 --- a/spring-data-cassandra/pom.xml +++ b/spring-data-cassandra/pom.xml @@ -24,6 +24,12 @@ + + org.jspecify + jspecify + 1.0.0 + + org.springframework @@ -50,6 +56,12 @@ spring-tx + + org.springframework + spring-core-test + test + + ${project.groupId} @@ -227,11 +239,6 @@ kotlinx-coroutines-reactor true - - org.jspecify - jspecify - 1.0.0 - io.mockk diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/QueryMapper.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/QueryMapper.java index b95c71bf7..db39af878 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/QueryMapper.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/QueryMapper.java @@ -145,7 +145,7 @@ public Filter getMappedObject(Filter filter, CassandraPersistentEntity entity return Filter.from(result); } - private @Nullable Object getMappedValue(Field field, CriteriaDefinition.Operator operator, Object value) { + protected @Nullable Object getMappedValue(Field field, CriteriaDefinition.Operator operator, Object value) { if (field.getProperty().isPresent() && field.getProperty().filter(it -> converter.getCustomConversions().hasValueConverter(it)).isPresent()) { diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueryCreator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueryCreator.java index 2be640656..e9d591d58 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueryCreator.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueryCreator.java @@ -28,6 +28,7 @@ import org.springframework.data.cassandra.repository.query.ParameterBinding; import org.springframework.data.domain.ScoringFunction; import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.repository.query.parser.Part; import org.springframework.data.repository.query.parser.PartTree; /** @@ -42,6 +43,16 @@ public AotQueryCreator(PartTree tree, CassandraParameters parameters, super(tree, new AotPlaceholderParameterAccessor(parameters, parameterBindings), mappingContext); } + @Override + protected Object like(Part.Type type, Object value) { + + if (value instanceof ParameterBinding pb) { + return new LikeParameterBinding(pb, type); + } + + return super.like(type, value); + } + static class AotPlaceholderParameterAccessor extends CassandraParametersParameterAccessor { private final List parameterBindings; diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java index 03b312033..5ca509f70 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java @@ -17,7 +17,6 @@ import java.util.List; import java.util.Optional; -import java.util.regex.Pattern; import org.jspecify.annotations.NullUnmarked; import org.jspecify.annotations.Nullable; @@ -25,17 +24,23 @@ import org.springframework.core.annotation.MergedAnnotation; import org.springframework.data.cassandra.core.CassandraOperations; import org.springframework.data.cassandra.core.ExecutableSelectOperation; +import org.springframework.data.cassandra.core.cql.QueryOptions; +import org.springframework.data.cassandra.core.query.ColumnName; +import org.springframework.data.cassandra.core.query.Columns; +import org.springframework.data.cassandra.core.query.Criteria; +import org.springframework.data.cassandra.core.query.CriteriaDefinition; import org.springframework.data.cassandra.repository.Consistency; import org.springframework.data.cassandra.repository.Query; import org.springframework.data.cassandra.repository.query.CassandraQueryMethod; import org.springframework.data.cassandra.repository.query.ParameterBinding; +import org.springframework.data.domain.Sort; import org.springframework.data.repository.aot.generate.AotQueryMethodGenerationContext; import org.springframework.javapoet.CodeBlock; import org.springframework.javapoet.CodeBlock.Builder; import org.springframework.javapoet.TypeName; import org.springframework.util.StringUtils; -import com.datastax.oss.driver.api.core.DefaultConsistencyLevel; +import com.datastax.oss.driver.api.core.ConsistencyLevel; import com.datastax.oss.driver.api.core.cql.SimpleStatement; /** @@ -47,8 +52,6 @@ */ class CassandraCodeBlocks { - private static final Pattern PARAMETER_BINDING_PATTERN = Pattern.compile("\\?(\\d+)"); - /** * Builder for generating query parsing {@link CodeBlock}. * @@ -147,8 +150,8 @@ CodeBlock build() { getParameter(binding.getOrigin())); } - builder.addStatement("$1T $2L = $1T.newInstance($3L)", SimpleStatement.class, queryVariableName, - context.localVariable("args")); + builder.addStatement("$1T $2L = $1T.newInstance($3S, $4L)", SimpleStatement.class, queryVariableName, + sq.getQueryString(), context.localVariable("args")); } if (queryAnnotation.isPresent()) { @@ -161,13 +164,138 @@ CodeBlock build() { } } - if (consistencyAnnotation.isPresent()) { + if (this.queryMethod.hasConsistencyLevel()) { - DefaultConsistencyLevel consistencyLevel = consistencyAnnotation.getEnum("value", - DefaultConsistencyLevel.class); + ConsistencyLevel consistencyLevel = this.queryMethod.getRequiredAnnotatedConsistencyLevel(); - builder.addStatement("$1L = $1L.setConsistencyLevel($2T.$3L)", queryVariableName, - DefaultConsistencyLevel.class, consistencyLevel.name()); + builder.addStatement("$1L = $1L.setConsistencyLevel($2T.$3L)", queryVariableName, ConsistencyLevel.class, + consistencyLevel.name()); + } + + return builder.build(); + } + + if (query instanceof DerivedAotQuery derived) { + + org.springframework.data.cassandra.core.query.Query query = derived.getQuery(); + + Builder builder = CodeBlock.builder(); + + if (!query.isEmpty()) { + + boolean first = true; + Builder criteriaBuilder = CodeBlock.builder(); + criteriaBuilder.add("$["); + + for (CriteriaDefinition criteriaDefinition : query) { + + if (first) { + first = false; + + criteriaBuilder.add("$1T $2L = $3T.where($4S)", CriteriaDefinition.class, + context.localVariable("criteria"), Criteria.class, criteriaDefinition.getColumnName().toCql()); + + } else { + criteriaBuilder.add(".and($1S)", criteriaDefinition.getColumnName().toCql()); + } + + appendPredicate(criteriaDefinition, criteriaBuilder); + } + + criteriaBuilder.add(";\n$]"); + + builder.add(criteriaBuilder.build()); + builder.addStatement("$1T $2L = $1T.query($3L)", org.springframework.data.cassandra.core.query.Query.class, + queryVariableName, context.localVariable("criteria")); + } else { + builder.addStatement("$1T $2L = $1T.empty()", org.springframework.data.cassandra.core.query.Query.class, + queryVariableName); + } + + Columns columns = query.getColumns(); + + if (!columns.isEmpty()) { + + boolean first = true; + Builder columnBuilder = CodeBlock.builder(); + columnBuilder.add("$["); + columnBuilder.add("$1T $2L = $1T.from(", Columns.class, context.localVariable("columns")); + + for (ColumnName column : columns) { + + columnBuilder.add("$S", column.toCql()); + + if (first) { + first = false; + } else { + columnBuilder.add(", "); + } + } + + columnBuilder.add(");\n$]"); + + builder.addStatement("$1L = $1L.columns($2L)", queryVariableName, context.localVariable("columns")); + } + + if (query.getSort().isSorted()) { + builder.addStatement("$1L = $1L.sort($2L)", queryVariableName, buildSort(query.getSort())); + } + + if (StringUtils.hasText(context.getSortParameterName())) { + builder.addStatement("$1L = $1L.sort($2L)", queryVariableName, context.getSortParameterName()); + } + + if (derived.isLimited()) { + builder.addStatement("$1L = $1L.limit($2L)", queryVariableName, derived.getLimit().max()); + } + + if (StringUtils.hasText(context.getLimitParameterName())) { + builder.addStatement("$1L = $1L.limit($2L)", queryVariableName, context.getLimitParameterName()); + } + + if (queryAnnotation.isPresent()) { + + boolean allowFiltering = queryAnnotation.getBoolean("allowFiltering"); + + if (allowFiltering) { + builder.addStatement("$1L = $1L.withAllowFiltering()", queryVariableName); + } + } + + if (queryMethod.getParameters().getQueryOptionsIndex() != -1) { + + String queryOptions = context.getParameterName(queryMethod.getParameters().getQueryOptionsIndex()); + + if (StringUtils.hasText(context.getLimitParameterName()) || derived.isLimited() + || this.queryMethod.hasConsistencyLevel()) { + builder.addStatement("$1T $2L = $3L.mutate()", QueryOptions.QueryOptionsBuilder.class, + context.localVariable("optionsBuilder"), queryOptions); + + if (derived.isLimited()) { + builder.addStatement("$1L.pageSize($2L)", context.localVariable("optionsBuilder"), + derived.getLimit().max()); + } + + if (StringUtils.hasText(context.getLimitParameterName())) { + builder.addStatement("$1L.pageSize($2L)", context.localVariable("optionsBuilder"), + context.getLimitParameterName()); + } + + if (this.queryMethod.hasConsistencyLevel()) { + ConsistencyLevel consistencyLevel = this.queryMethod.getRequiredAnnotatedConsistencyLevel(); + builder.addStatement("$1L.consistencyLevel($2T.$3L)", context.localVariable("optionsBuilder"), + ConsistencyLevel.class, consistencyLevel.name()); + } + } + + builder.addStatement("$1L = $1L.queryOptions($2L.build())", queryVariableName, + context.localVariable("optionsBuilder")); + + } else if (this.queryMethod.hasConsistencyLevel()) { + + ConsistencyLevel consistencyLevel = this.queryMethod.getRequiredAnnotatedConsistencyLevel(); + builder.addStatement("$1L = $1L.queryOptions($2T.consistencyLevel($3T.$4L).build())", queryVariableName, + QueryOptions.class, ConsistencyLevel.class, consistencyLevel.name()); } return builder.build(); @@ -176,6 +304,99 @@ CodeBlock build() { throw new UnsupportedOperationException("Unsupported query: " + query); } + private void appendPredicate(CriteriaDefinition criteriaDefinition, Builder criteriaBuilder) { + + CriteriaDefinition.Predicate predicate = criteriaDefinition.getPredicate(); + + if (predicate.getOperator() == CriteriaDefinition.Operators.EQ) { + criteriaBuilder.add(".is($L)", render(predicate.getValue())); + } else if (predicate.getOperator() == CriteriaDefinition.Operators.NE) { + criteriaBuilder.add(".ne($L)", render(predicate.getValue())); + } else if (predicate.getOperator() == CriteriaDefinition.Operators.GT) { + criteriaBuilder.add(".gt($L)", render(predicate.getValue())); + } else if (predicate.getOperator() == CriteriaDefinition.Operators.GTE) { + criteriaBuilder.add(".gte($L)", render(predicate.getValue())); + } else if (predicate.getOperator() == CriteriaDefinition.Operators.LT) { + criteriaBuilder.add(".lt($L)", render(predicate.getValue())); + } else if (predicate.getOperator() == CriteriaDefinition.Operators.LTE) { + criteriaBuilder.add(".lte($L)", render(predicate.getValue())); + } else if (predicate.getOperator() == CriteriaDefinition.Operators.IS_NOT_NULL) { + criteriaBuilder.add(".isNotNull()"); + } else if (predicate.getOperator() == CriteriaDefinition.Operators.LIKE) { + criteriaBuilder.add(".like($L)", render(predicate.getValue())); + } else if (predicate.getOperator() == CriteriaDefinition.Operators.CONTAINS) { + criteriaBuilder.add(".contains($L)", render(predicate.getValue())); + } else if (predicate.getOperator() == CriteriaDefinition.Operators.CONTAINS_KEY) { + criteriaBuilder.add(".containsKey($L)", render(predicate.getValue())); + } else if (predicate.getOperator() == CriteriaDefinition.Operators.IN) { + criteriaBuilder.add(".in($L)", render(predicate.getValue())); + } else { + throw new UnsupportedOperationException("Operator not supported yet: " + predicate.getOperator()); + } + } + + private static CodeBlock buildSort(Sort sort) { + + Builder sortBuilder = CodeBlock.builder(); + sortBuilder.add("$T.by(", Sort.class); + + boolean first = true; + for (Sort.Order order : sort) { + + sortBuilder.add("$T.$L($S)", Sort.Order.class, order.isAscending() ? "asc" : "desc", order.getProperty()); + if (order.isIgnoreCase()) { + sortBuilder.add(".ignoreCase()"); + } + + if (first) { + first = false; + } else { + sortBuilder.add(", "); + } + } + + sortBuilder.add(")"); + + return sortBuilder.build(); + } + + private Object render(@Nullable Object value) { + + if (value instanceof ParameterBinding binding) { + + String parameterName = getParameterName(binding); + + if (binding instanceof LikeParameterBinding like) { + + return switch (like.getType()) { + + case CONTAINING -> "\"%\" + " + parameterName + " + \"%\""; + case STARTING_WITH -> parameterName + " + \"%\""; + case ENDING_WITH -> "\"%\" + " + parameterName; + default -> parameterName; + }; + } + + return parameterName; + } + + return value != null ? value.toString() : null; + } + + String getParameterName(ParameterBinding binding) { + + if (binding.getOrigin() instanceof ParameterBinding.MethodInvocationArgument mia) { + + ParameterBinding.BindingIdentifier identifier = mia.identifier(); + if (identifier.hasPosition()) { + return context.getParameterName(identifier.getPosition()); + } + return identifier.getName(); + } + + throw new UnsupportedOperationException("Unsupported origin: " + binding.getOrigin()); + } + private Object getParameter(ParameterBinding.ParameterOrigin origin) { if (origin.isMethodArgument() && origin instanceof ParameterBinding.MethodInvocationArgument mia) { @@ -238,8 +459,6 @@ QueryExecutionBlockBuilder usingQueryVariableName(String queryVariableName) { CodeBlock build() { - String cassandraOpsRef = context.fieldNameOf(CassandraOperations.class); - Builder builder = CodeBlock.builder(); /*if (getQueryMethod().isSliceQuery()) { @@ -248,42 +467,70 @@ CodeBlock build() { return new WindowExecution(getOperations(), parameterAccessor.getScrollPosition(), parameterAccessor.getLimit()); } else if (getQueryMethod().isSearchQuery()) { return new SearchExecution(getOperations(), parameterAccessor); - } else if (getQueryMethod().isCollectionQuery()) { - return new CollectionExecution(getOperations()); } else if (getQueryMethod().isResultSetQuery()) { return new ResultSetQuery(getOperations()); - } else if (getQueryMethod().isStreamQuery()) { - return new StreamExecution(getOperations(), resultProcessing); - } else if (isCountQuery()) { - return ((statement, type) -> new SingleEntityExecution(getOperations(), false).execute(statement, Long.class)); - } else if (isExistsQuery()) { - return new ExistsExecution(getOperations()); - } else if (isModifyingQuery()) { - return ((statement, type) -> getOperations().execute(statement).wasApplied()); - } else { - return new SingleEntityExecution(getOperations(), isLimiting()); } */ - boolean isProjecting = false; + boolean isProjecting = context.getReturnedType().isProjecting() + || StringUtils.hasText(context.getDynamicProjectionParameterName()); TypeName queryResultType = TypeName.get(context.getActualReturnType().toClass()); builder.add("\n"); - if (query instanceof StringAotQuery) { + if (query.isDelete()) { + + if (query instanceof StringAotQuery) { - isProjecting = context.getReturnedType().isProjecting(); + builder.addStatement("boolean $1L = $L.getCqlOperations().execute($2L)", context.localVariable("result"), + queryVariableName); + } else { + builder.addStatement("boolean $1L = $L.delete($2L, $3T)", context.localVariable("result"), queryVariableName, + context.getRepositoryInformation().getDomainType()); + } + + if (context.getReturnType().isAssignableFrom(Boolean.class) + || context.getReturnType().isAssignableFrom(Boolean.TYPE)) { + builder.addStatement("return $1L", context.localVariable("result")); + } + + return builder.build(); + } + + Object asType = StringUtils.hasText(context.getDynamicProjectionParameterName()) + ? context.getDynamicProjectionParameterName() + : context.getReturnedType().getReturnedType(); + + if (query instanceof StringAotQuery) { if (isProjecting) { - builder.addStatement("$1T<$2T> $3L = $L.query($4L).as($2T.class)", - ExecutableSelectOperation.TerminatingResults.class, - TypeName.get(context.getRepositoryInformation().getDomainType()), context.localVariable("select"), - context.fieldNameOf(CassandraOperations.class), queryVariableName); + String as = StringUtils.hasText(context.getDynamicProjectionParameterName()) ? "$6L" : "$6T.class"; + + builder.addStatement("$1T<$2T> $3L = $4L.query($5T.class).as(%s)".formatted(as), + ExecutableSelectOperation.TerminatingResults.class, context.getRepositoryInformation().getDomainType(), + context.localVariable("select"), context.fieldNameOf(CassandraOperations.class), queryVariableName, + asType); } else { - builder.addStatement("$1T<$2T> $3L = $L.query($4L).as($2T.class)", + builder.addStatement("$1T<$2T> $3L = $4L.query($5T.class).as($2T.class)", ExecutableSelectOperation.TerminatingResults.class, queryResultType, context.localVariable("select"), context.fieldNameOf(CassandraOperations.class), queryVariableName); } + } else { + + if (isProjecting) { + + String as = StringUtils.hasText(context.getDynamicProjectionParameterName()) ? "$6L" : "$6T.class"; + + builder.addStatement("$1T<$2T> $3L = $4L.query($5T.class).as(%s).matching($7L)".formatted(as), + ExecutableSelectOperation.TerminatingResults.class, context.getActualReturnType().getType(), + context.localVariable("select"), context.fieldNameOf(CassandraOperations.class), + context.getRepositoryInformation().getDomainType(), asType, queryVariableName); + } else { + builder.addStatement("$1T<$2T> $3L = $4L.query($5T.class).matching($6L)", + ExecutableSelectOperation.TerminatingResults.class, context.getActualReturnType().getType(), + context.localVariable("select"), context.fieldNameOf(CassandraOperations.class), + context.getRepositoryInformation().getDomainType(), queryVariableName); + } } String terminatingMethod; @@ -300,26 +547,10 @@ CodeBlock build() { terminatingMethod = Optional.class.isAssignableFrom(context.getReturnType().toClass()) ? "one()" : "oneValue()"; } - if (isProjecting) { - - if (queryMethod.isCollectionQuery() || queryMethod.isPageQuery() || queryMethod.isSliceQuery() - || queryMethod.isStreamQuery()) { - builder.addStatement("return ($T) convertMany($L.$L, $T.class)", context.getReturnTypeName(), - context.localVariable("select"), terminatingMethod, queryResultType); - } else { - builder.addStatement("return ($T) convertOne($L.$L, $T.class)", context.getReturnTypeName(), - context.localVariable("select"), terminatingMethod, queryResultType); - } - } - else { - builder.addStatement("return $L.$L", context.localVariable("select"), terminatingMethod); - } + builder.addStatement("return $L.$L", context.localVariable("select"), terminatingMethod); return builder.build(); } } - private static boolean containsPlaceholder(String source) { - return PARAMETER_BINDING_PATTERN.matcher(source).find(); - } } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributor.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributor.java index 73cf596e6..b19481a45 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributor.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributor.java @@ -73,7 +73,7 @@ protected void customizeConstructor(AotRepositoryConstructorBuilder constructorB constructorBuilder.addParameter("operations", CassandraOperations.class, customizer -> { String cassandraTemplateRef = getCassandraTemplateRef(); - customizer.origin(StringUtils.hasText(cassandraTemplateRef) + customizer.bindToField().origin(StringUtils.hasText(cassandraTemplateRef) ? new RuntimeBeanReference(cassandraTemplateRef, CassandraOperations.class) : new RuntimeBeanReference(CassandraOperations.class)); }); @@ -112,8 +112,8 @@ private String getCassandraTemplateRef() { .query(aotQueries.result()).query(query).consistency(consistency) .queryReturnType(QueriesFactory.getQueryReturnType(aotQueries.result(), returnedType, context)).build()); - /*body.add(CassandraCodeBlocks.executionBuilder(context, queryMethod).modifying(modifying).query(aotQueries.result()) - .build());*/ + body.add(CassandraCodeBlocks.executionBuilder(context, queryMethod).usingQueryVariableName(queryVariableName) + .query(aotQueries.result()).build()); return body.build(); }); diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/DerivedAotQuery.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/DerivedAotQuery.java index 7907a053f..99c4ce1da 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/DerivedAotQuery.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/DerivedAotQuery.java @@ -13,7 +13,7 @@ * * @author Mark Paluch */ -class DerivedAotQuery extends StringAotQuery { +class DerivedAotQuery extends AotQuery { private final String queryString; private final Query query; @@ -43,7 +43,6 @@ class DerivedAotQuery extends StringAotQuery { partTree.isCountProjection(), partTree.isExistsProjection()); } - @Override public String getQueryString() { return queryString; } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringUpdate.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/LikeParameterBinding.java similarity index 55% rename from spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringUpdate.java rename to spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/LikeParameterBinding.java index ef6bc1e2f..d238caca3 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringUpdate.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/LikeParameterBinding.java @@ -15,13 +15,25 @@ */ package org.springframework.data.cassandra.repository.aot; +import org.springframework.data.cassandra.repository.query.ParameterBinding; +import org.springframework.data.repository.query.parser.Part; + /** - * @author Chris Bono - * @since 4.0 + * Extension to {@link ParameterBinding} to capture {@link Part.Type} for LIKE predicates. + * + * @author Mark Paluch + * @since 5.0 */ -record StringUpdate(String raw) { +class LikeParameterBinding extends ParameterBinding { + + private final Part.Type type; + + protected LikeParameterBinding(ParameterBinding binding, Part.Type type) { + super(binding.getIdentifier(), binding.getOrigin()); + this.type = type; + } - String getUpdateString() { - return raw; + public Part.Type getType() { + return type; } } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueriesFactory.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueriesFactory.java index 57853d200..b776a7036 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueriesFactory.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueriesFactory.java @@ -26,7 +26,12 @@ import org.springframework.core.annotation.MergedAnnotation; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.data.cassandra.core.StatementFactory; +import org.springframework.data.cassandra.core.convert.MappingCassandraConverter; +import org.springframework.data.cassandra.core.convert.UpdateMapper; +import org.springframework.data.cassandra.core.mapping.BasicCassandraPersistentEntity; import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; +import org.springframework.data.cassandra.core.query.CriteriaDefinition; import org.springframework.data.cassandra.repository.Query; import org.springframework.data.cassandra.repository.config.CassandraRepositoryConfigurationExtension; import org.springframework.data.cassandra.repository.query.CassandraQueryMethod; @@ -48,13 +53,14 @@ * Factory for {@link AotQueries}. * * @author Mark Paluch - * @since 4.0 + * @since 5.0 */ class QueriesFactory { private final NamedQueries namedQueries; private final ValueExpressionDelegate delegate; private final CassandraMappingContext mappingContext; + private final StatementFactory statementFactory; public QueriesFactory(RepositoryConfigurationSource configurationSource, ClassLoader classLoader, ValueExpressionDelegate delegate, CassandraMappingContext mappingContext) { @@ -62,6 +68,13 @@ public QueriesFactory(RepositoryConfigurationSource configurationSource, ClassLo this.namedQueries = getNamedQueries(configurationSource, classLoader); this.delegate = delegate; this.mappingContext = mappingContext; + UpdateMapper updateMapper = new UpdateMapper(new MappingCassandraConverter(mappingContext)) { + @Override + protected @Nullable Object getMappedValue(Field field, CriteriaDefinition.Operator operator, Object value) { + return value; + } + }; + this.statementFactory = new StatementFactory(updateMapper); } public NamedQueries getNamedQueries() { @@ -129,9 +142,7 @@ private AotQueries buildStringQuery(String queryString, CassandraQueryMethod que boolean exists) { StringBasedQuery query = parseQuery(queryMethod, queryString); - List bindings = query.getQueryParameterBindings(); - StringAotQuery aotStringQuery = StringAotQuery.of(query.getPostProcessedQuery(), bindings, count, exists); return AotQueries.create(aotStringQuery); @@ -142,7 +153,6 @@ private AotQueries buildNamedQuery(String queryName, CassandraQueryMethod queryM String queryString = namedQueries.getQuery(queryName); StringBasedQuery query = parseQuery(queryMethod, queryString); - List bindings = query.getQueryParameterBindings(); return AotQueries.create(StringAotQuery.named(queryName, queryString, bindings, count, exists)); @@ -161,9 +171,21 @@ private AotQueries buildPartTreeQuery(RepositoryInformation repositoryInformatio AotQueryCreator queryCreator = new AotQueryCreator(partTree, queryMethod.getParameters(), mappingContext, parameterBindings); - // TODO: Render query string + BasicCassandraPersistentEntity entity = mappingContext + .getRequiredPersistentEntity(repositoryInformation.getDomainType()); + org.springframework.data.cassandra.core.query.Query query = queryCreator.createQuery(Sort.unsorted()); - DerivedAotQuery aotQuery = new DerivedAotQuery("", parameterBindings, query, partTree); + + String queryString; + if (partTree.isDelete()) { + queryString = statementFactory.delete(query, entity).build().getQuery(); + } else if (partTree.isCountProjection() || partTree.isExistsProjection()) { + queryString = statementFactory.count(query, entity).build().getQuery(); + } else { + queryString = statementFactory.select(query, entity).build().getQuery(); + } + + DerivedAotQuery aotQuery = new DerivedAotQuery(queryString, parameterBindings, query, partTree); return AotQueries.create(aotQuery); } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ParameterBinding.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ParameterBinding.java index 71228e7a3..60af1dbc1 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ParameterBinding.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ParameterBinding.java @@ -44,7 +44,7 @@ public class ParameterBinding { * @param identifier of the parameter, must not be {@literal null}. * @param origin the origin of the parameter (expression or method argument) */ - ParameterBinding(BindingIdentifier identifier, ParameterOrigin origin) { + protected ParameterBinding(BindingIdentifier identifier, ParameterOrigin origin) { Assert.notNull(identifier, "BindingIdentifier must not be null"); Assert.notNull(origin, "ParameterOrigin must not be null"); diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/AotFragmentTestConfigurationSupport.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/AotFragmentTestConfigurationSupport.java new file mode 100644 index 000000000..7aede2741 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/AotFragmentTestConfigurationSupport.java @@ -0,0 +1,180 @@ +/* + * 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.cassandra.repository.aot; + +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Arrays; +import java.util.List; + +import org.mockito.Mockito; + +import org.springframework.aot.test.generate.TestGenerationContext; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.ListableBeanFactory; +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.config.RuntimeBeanReference; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.DefaultBeanNameGenerator; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.core.env.Environment; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.test.tools.TestCompiler; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.data.cassandra.core.CassandraOperations; +import org.springframework.data.cassandra.repository.config.EnableCassandraRepositories; +import org.springframework.data.expression.ValueExpressionParser; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.config.AnnotationRepositoryConfigurationSource; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryComposition; +import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport; +import org.springframework.data.repository.core.support.RepositoryFragment; +import org.springframework.data.repository.query.QueryMethodValueEvaluationContextAccessor; +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 Mark Paluch + */ +public class AotFragmentTestConfigurationSupport implements BeanFactoryPostProcessor, ApplicationContextAware { + + private final Class repositoryInterface; + private final boolean registerFragmentFacade; + private final TestCassandraAotRepositoryContext repositoryContext; + private ApplicationContext applicationContext; + + public AotFragmentTestConfigurationSupport(Class repositoryInterface, Class configClass) { + this(repositoryInterface, configClass, true); + } + + public AotFragmentTestConfigurationSupport(Class repositoryInterface, Class configClass, + boolean registerFragmentFacade, Class... additionalFragments) { + + this.repositoryInterface = repositoryInterface; + + RepositoryComposition composition = RepositoryComposition + .of((List) Arrays.stream(additionalFragments).map(RepositoryFragment::structural).toList()); + this.repositoryContext = new TestCassandraAotRepositoryContext<>(repositoryInterface, composition, + new AnnotationRepositoryConfigurationSource(AnnotationMetadata.introspect(configClass), + EnableCassandraRepositories.class, new DefaultResourceLoader(), new StandardEnvironment(), + Mockito.mock(BeanDefinitionRegistry.class), DefaultBeanNameGenerator.INSTANCE)); + this.registerFragmentFacade = registerFragmentFacade; + } + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { + + TestGenerationContext generationContext = new TestGenerationContext(repositoryInterface); + + repositoryContext.setBeanFactory(beanFactory); + + CassandraRepositoryContributor jdbcRepositoryContributor = new CassandraRepositoryContributor(repositoryContext); + jdbcRepositoryContributor.contribute(generationContext); + + AbstractBeanDefinition aotGeneratedRepository = BeanDefinitionBuilder + .genericBeanDefinition(repositoryInterface.getName() + "Impl__AotRepository") + .addConstructorArgValue(new RuntimeBeanReference(CassandraOperations.class)) + .addConstructorArgValue( + getCreationContext(repositoryContext, beanFactory.getBean(Environment.class), beanFactory)) + .getBeanDefinition(); + + generationContext.writeGeneratedContent(); + + TestCompiler.forSystem().withCompilerOptions("-parameters").with(generationContext).compile(compiled -> { + beanFactory.setBeanClassLoader(compiled.getClassLoader()); + ((BeanDefinitionRegistry) beanFactory).registerBeanDefinition("fragment", aotGeneratedRepository); + }); + + if (registerFragmentFacade) { + + 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( + TestCassandraAotRepositoryContext repositoryContext, Environment environment, + ListableBeanFactory beanFactory) { + + RepositoryFactoryBeanSupport.FragmentCreationContext creationContext = new RepositoryFactoryBeanSupport.FragmentCreationContext() { + @Override + public RepositoryMetadata getRepositoryMetadata() { + return repositoryContext.getRepositoryInformation(); + } + + @Override + public ValueExpressionDelegate getValueExpressionDelegate() { + + QueryMethodValueEvaluationContextAccessor accessor = new QueryMethodValueEvaluationContextAccessor(environment, + beanFactory); + return new ValueExpressionDelegate(accessor, ValueExpressionParser.create()); + } + + @Override + public ProjectionFactory getProjectionFactory() { + return new SpelAwareProxyProjectionFactory(); + } + }; + + return creationContext; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributorIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributorIntegrationTests.java new file mode 100644 index 000000000..f06794057 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributorIntegrationTests.java @@ -0,0 +1,338 @@ +/* + * 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.cassandra.repository.aot; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; +import org.springframework.data.cassandra.config.SchemaAction; +import org.springframework.data.cassandra.core.CassandraOperations; +import org.springframework.data.cassandra.domain.AddressType; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.config.EnableCassandraRepositories; +import org.springframework.data.cassandra.repository.support.AbstractSpringDataEmbeddedCassandraIntegrationTest; +import org.springframework.data.cassandra.repository.support.IntegrationTestConfig; +import org.springframework.data.domain.Sort; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Integration tests for AOT processing via {@link CassandraRepositoryContributor}. + * + * @author Mark Paluch + */ +@SpringJUnitConfig( + classes = CassandraRepositoryContributorIntegrationTests.CassandraRepositoryContributorConfiguration.class) +class CassandraRepositoryContributorIntegrationTests extends AbstractSpringDataEmbeddedCassandraIntegrationTest { + + @Autowired PersonRepository fragment; + @Autowired CassandraOperations operations; + + @Configuration + @Import(Config.class) + static class CassandraRepositoryContributorConfiguration extends AotFragmentTestConfigurationSupport { + + public CassandraRepositoryContributorConfiguration() { + super(PersonRepository.class, Config.class); + } + } + + @Configuration + @EnableCassandraRepositories(considerNestedRepositories = true, + includeFilters = { @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = Person.class) }) + public static class Config extends IntegrationTestConfig { + + @Override + protected Set> getInitialEntitySet() { + return new HashSet<>(Arrays.asList(Person.class)); + } + + @Override + public SchemaAction getSchemaAction() { + return SchemaAction.CREATE; + } + } + + private Person walter; + private Person skyler; + private Person flynn; + + @BeforeEach + void before() { + + operations.delete(Person.class); + + Person person = new Person("Walter", "White"); + person.setNumberOfChildren(2); + + person.setMainAddress(new AddressType("Albuquerque", "USA")); + + person.setAlternativeAddresses(Arrays.asList(new AddressType("Albuquerque", "USA"), + new AddressType("New Hampshire", "USA"), new AddressType("Grocery Store", "Mexico"))); + + walter = operations.insert(person); + skyler = operations.insert(new Person("Skyler", "White")); + flynn = operations.insert(new Person("Flynn (Walter Jr.)", "White")); + } + + @Test // GH-1566 + void shouldFindByFirstname() { + + Person walter = fragment.findByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindOptionalByFirstname() { + + assertThat(fragment.findOptionalByFirstname("Walter")).isPresent(); + assertThat(fragment.findOptionalByFirstname("Hank")).isEmpty(); + } + + @Test // GH-1566 + void shouldApplySorting() { + + assertThat(fragment.findByLastname("White", Sort.by("firstname"))).extracting(Person::getFirstname) + .containsSequence("Flynn (Walter Jr.)", "Skyler", "Walter"); + assertThat(fragment.findByLastnameOrderByFirstnameAsc("White")).extracting(Person::getFirstname) + .containsSequence("Flynn (Walter Jr.)", "Skyler", "Walter"); + } + + @Test // GH-1566 + void shouldFindByFirstnameContains() throws InterruptedException { + + operations.getCqlOperations().execute( + "CREATE CUSTOM INDEX person_firstname ON person (firstname) USING 'org.apache.cassandra.index.sasi.SASIIndex' WITH OPTIONS = {'mode': 'CONTAINS' };"); + + Thread.sleep(1000); // wait for index to come up + + Person walter = fragment.findByFirstnameContains("Walter Jr"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Flynn (Walter Jr.)"); + } + /* + + @Test // GH-1566 + void streamByAgeGreaterThan() { + assertThat(fragment.streamByAgeGreaterThan(20)).hasSize(5); + } + + @Test // GH-1566 + void shouldReturnSlice() { + + Slice slice = fragment.findSliceByAgeGreaterThan(Pageable.ofSize(4), 10); + + assertThat(slice).hasSize(4); + + assertThat(slice.hasNext()).isTrue(); + slice = fragment.findSliceByAgeGreaterThan(Pageable.ofSize(6), 10); + + assertThat(slice).hasSize(6); + assertThat(slice.hasNext()).isFalse(); + } + + @Test // GH-1566 + void shouldReturnPage() { + + Page page = fragment.findPageByAgeGreaterThan(PageRequest.of(0, 4, Sort.by("age")), 10); + + assertThat(page).hasSize(4); + + assertThat(page.hasNext()).isTrue(); + page = fragment.findPageByAgeGreaterThan(page.nextPageable(), 10); + + assertThat(page).hasSize(2); + assertThat(page.hasNext()).isFalse(); + } + + @Test // GH-1566 + void countByAgeLessThan() { + + long count = fragment.countByAgeLessThan(20); + + assertThat(count).isOne(); + } + + @Test // GH-1566 + void countShortByAgeLessThan() { + + short count = fragment.countShortByAgeLessThan(20); + + assertThat(count).isOne(); + } + + @Test // GH-1566 + void existsByAgeLessThan() { + + assertThat(fragment.existsByAgeLessThan(20)).isTrue(); + assertThat(fragment.existsByAgeLessThan(5)).isFalse(); + } + + @Test // GH-1566 + void listWithLimit() { + + List users = fragment.findTop5ByOrderByAge(); + + assertThat(users).hasSize(5).extracting(User::getFirstname).containsSequence("Flynn", "Skyler", "Gustavo", "Walter", + "Mike"); + } + + @Test // GH-1566 + void shouldFindAnnotatedByFirstname() { + + User walter = fragment.findByFirstnameAnnotated("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindAnnotatedByFirstnameExpression() { + + User walter = fragment.findByFirstnameExpression("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindUsingRowMapper() { + + User walter = fragment.findUsingRowMapper("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Row: 0"); + } + + @Test // GH-1566 + void shouldFindUsingRowMapperRef() { + + User walter = fragment.findUsingRowMapperRef("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Row: 0"); + } + + @Test // GH-1566 + void shouldFindUsingResultSetExtractor() { + + int result = fragment.findUsingAndResultSetExtractor("Walter"); + + assertThat(result).isOne(); + } + + @Test // GH-1566 + void shouldFindUsingResultSetExtractorRef() { + + int result = fragment.findUsingAndResultSetExtractorRef("Walter"); + + assertThat(result).isOne(); + } + + @Test // GH-1566 + void shouldProjectOneToDto() { + + UserDto dto = fragment.findOneDtoByFirstname("Walter"); + + assertThat(dto).isNotNull(); + assertThat(dto.firstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectListToDto() { + + List dtos = fragment.findDtoByFirstname("Walter"); + + assertThat(dtos).hasSize(1).extracting(UserDto::firstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectOneToInterface() { + + UserProjection projection = fragment.findOneInterfaceByFirstname("Walter"); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectListToInterface() { + + List projections = fragment.findInterfaceByFirstname("Walter"); + + assertThat(projections).hasSize(1).extracting(UserProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectDynamically() { + + List dtos = fragment.findDynamicProjectionByFirstname("Walter", UserDto.class); + assertThat(dtos).hasSize(1).extracting(UserDto::firstname).containsOnly("Walter"); + + List projections = fragment.findDynamicProjectionByFirstname("Walter", UserProjection.class); + assertThat(projections).hasSize(1).extracting(UserProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldDeleteByName() { + + assertThat(fragment.deleteByFirstname("Walter")).isTrue(); + assertThat(fragment.deleteByFirstname("Walter")).isFalse(); + } + + @Test // GH-1566 + void shouldDeleteCountByName() { + + assertThat(fragment.deleteCountByFirstname("Walter")).isOne(); + assertThat(fragment.deleteCountByFirstname("Walter")).isZero(); + } + + @Test // GH-1566 + void shouldDeleteAnnotated() { + + assertThat(fragment.deleteAnnotatedQuery("Walter")).isOne(); + assertThat(fragment.deleteAnnotatedQuery("Walter")).isZero(); + } + + @Test // GH-1566 + void shouldDeleteWithoutResult() { + + fragment.deleteWithoutResult("Walter"); + + assertThat(fragment.findByFirstname("Walter")).isNull(); + } + + @Test // GH-1566 + void shouldDeleteAndReturnByName() { + + assertThat(fragment.deleteOneByFirstname("Walter")).isNotNull(); + assertThat(fragment.deleteOneByFirstname("Walter")).isNull(); + } */ + +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/PersonRepository.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/PersonRepository.java new file mode 100644 index 000000000..6408c0748 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/PersonRepository.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.cassandra.repository.aot; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.cassandra.domain.AddressType; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.Query; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.CrudRepository; + +/** + * @author Mark Paluch + */ +public interface PersonRepository extends CrudRepository { + + @Query(allowFiltering = true) + Person findByFirstname(String firstname); + + @Query(allowFiltering = true) + Optional findOptionalByFirstname(String firstname); + + @Query(allowFiltering = true) + List findByLastname(String lastname, Sort sort); + + @Query(allowFiltering = true) + List findByLastnameOrderByFirstnameAsc(String lastname); + + Person findByMainAddress(AddressType address); + + Person findByFirstnameStartsWith(String prefix); + + Person findByFirstnameContains(String contains); + + // ------------------------------------------------------------------------- + // Declared Queries + // ------------------------------------------------------------------------- + + /*@Query("select * from person where mainaddress = ?0") + Person findByAddress(AddressType address);*/ +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/TestCassandraAotRepositoryContext.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/TestCassandraAotRepositoryContext.java new file mode 100644 index 000000000..3d4029f10 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/TestCassandraAotRepositoryContext.java @@ -0,0 +1,130 @@ +/* + * 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.cassandra.repository.aot; + +import java.lang.annotation.Annotation; +import java.util.Set; + +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.env.Environment; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.data.cassandra.core.Person; +import org.springframework.data.cassandra.core.mapping.Table; +import org.springframework.data.cassandra.repository.support.SimpleCassandraRepository; +import org.springframework.data.repository.config.AotRepositoryContext; +import org.springframework.data.repository.config.AotRepositoryInformation; +import org.springframework.data.repository.config.RepositoryConfigurationSource; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.AnnotationRepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryComposition; + +/** + * Test {@link AotRepositoryContext} implementation for Cassandra repositories. + * + * @author Mark Paluch + */ +public class TestCassandraAotRepositoryContext implements AotRepositoryContext { + + private final AotRepositoryInformation repositoryInformation; + private final Class repositoryInterface; + private final RepositoryConfigurationSource configurationSource; + private @Nullable ConfigurableListableBeanFactory beanFactory; + + public TestCassandraAotRepositoryContext(Class repositoryInterface, @Nullable RepositoryComposition composition, + RepositoryConfigurationSource configurationSource) { + this.repositoryInterface = repositoryInterface; + this.configurationSource = configurationSource; + + RepositoryMetadata metadata = AnnotationRepositoryMetadata.getMetadata(repositoryInterface); + + RepositoryComposition.RepositoryFragments fragments = RepositoryComposition.RepositoryFragments.empty(); + + this.repositoryInformation = new AotRepositoryInformation(metadata, SimpleCassandraRepository.class, + composition.append(fragments).getFragments().stream().toList()); + } + + public Class getRepositoryInterface() { + return repositoryInterface; + } + + @Override + public ConfigurableListableBeanFactory getBeanFactory() { + return beanFactory; + } + + @Override + public Environment getEnvironment() { + return new StandardEnvironment(); + } + + @Override + public TypeIntrospector introspectType(String typeName) { + return null; + } + + @Override + public IntrospectedBeanDefinition introspectBeanDefinition(String beanName) { + return null; + } + + @Override + public String getBeanName() { + return "dummyRepository"; + } + + @Override + public String getModuleName() { + return "Cassandra"; + } + + @Override + public RepositoryConfigurationSource getConfigurationSource() { + return configurationSource; + } + + @Override + public Set getBasePackages() { + return Set.of("org.springframework.data.dummy.repository.aot"); + } + + @Override + public Set> getIdentifyingAnnotations() { + return Set.of(Table.class); + } + + @Override + public RepositoryInformation getRepositoryInformation() { + return repositoryInformation; + } + + @Override + public Set> getResolvedAnnotations() { + return Set.of(); + } + + @Override + public Set> getResolvedTypes() { + return Set.of(Person.class); + } + + public void setBeanFactory(ConfigurableListableBeanFactory beanFactory) { + this.beanFactory = beanFactory; + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/conversion/Contact.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/conversion/Contact.java index faf8d74d8..789f00d61 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/conversion/Contact.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/conversion/Contact.java @@ -28,7 +28,7 @@ * @author Mark Paluch */ @Table -class Contact { +public class Contact { @Id String id; diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/conversion/ParameterConversionTestSupport.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/conversion/ParameterConversionTestSupport.java index b5df229ab..1784736de 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/conversion/ParameterConversionTestSupport.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/conversion/ParameterConversionTestSupport.java @@ -49,7 +49,7 @@ * * @author Mark Paluch */ -abstract class ParameterConversionTestSupport extends AbstractSpringDataEmbeddedCassandraIntegrationTest { +public abstract class ParameterConversionTestSupport extends AbstractSpringDataEmbeddedCassandraIntegrationTest { @Configuration @EnableCassandraRepositories(considerNestedRepositories = true) From 1a9e0fca57717df774d4c6b227d7eb4313bf40d9 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 28 Aug 2025 11:26:15 +0200 Subject: [PATCH 05/12] Moar. --- .../repository/aot/CassandraCodeBlocks.java | 4 +- .../query/CassandraQueryCreator.java | 19 ++- ...RepositoryContributorIntegrationTests.java | 119 ++++++++++++++++-- .../repository/aot/PersonRepository.java | 51 +++++++- ...gDataEmbeddedCassandraIntegrationTest.java | 2 +- 5 files changed, 164 insertions(+), 31 deletions(-) diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java index 5ca509f70..0cfeb3190 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java @@ -506,12 +506,12 @@ CodeBlock build() { String as = StringUtils.hasText(context.getDynamicProjectionParameterName()) ? "$6L" : "$6T.class"; - builder.addStatement("$1T<$2T> $3L = $4L.query($5T.class).as(%s)".formatted(as), + builder.addStatement("$1T<$2T> $3L = $4L.query($5L).as(%s)".formatted(as), ExecutableSelectOperation.TerminatingResults.class, context.getRepositoryInformation().getDomainType(), context.localVariable("select"), context.fieldNameOf(CassandraOperations.class), queryVariableName, asType); } else { - builder.addStatement("$1T<$2T> $3L = $4L.query($5T.class).as($2T.class)", + builder.addStatement("$1T<$2T> $3L = $4L.query($5L).as($2T.class)", ExecutableSelectOperation.TerminatingResults.class, queryResultType, context.localVariable("select"), context.fieldNameOf(CassandraOperations.class), queryVariableName); } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreator.java index ab9016606..de7e1ce24 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreator.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreator.java @@ -288,18 +288,15 @@ private CriteriaDefinition containing(Criteria where, CassandraPersistentPropert protected Object like(Type type, Object value) { - switch (type) { - case LIKE: - return value; - case CONTAINING: - return "%" + value + "%"; - case STARTING_WITH: - return value + "%"; - case ENDING_WITH: - return "%" + value; - } + return switch (type) { + case LIKE -> value; + case CONTAINING -> "%" + value + "%"; + case STARTING_WITH -> value + "%"; + case ENDING_WITH -> "%" + value; + default -> + throw new IllegalArgumentException(String.format("Part Type [%s] not supported with like queries", type)); + }; - throw new IllegalArgumentException(String.format("Part Type [%s] not supported with like queries", type)); } private Object[] nextAsArray(Iterator iterator) { diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributorIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributorIntegrationTests.java index f06794057..260737354 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributorIntegrationTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributorIntegrationTests.java @@ -19,8 +19,10 @@ import java.util.Arrays; import java.util.HashSet; +import java.util.Map; import java.util.Set; +import org.awaitility.Awaitility; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -30,7 +32,8 @@ import org.springframework.context.annotation.FilterType; import org.springframework.context.annotation.Import; import org.springframework.data.cassandra.config.SchemaAction; -import org.springframework.data.cassandra.core.CassandraOperations; +import org.springframework.data.cassandra.core.cql.QueryOptions; +import org.springframework.data.cassandra.core.cql.SessionCallback; import org.springframework.data.cassandra.domain.AddressType; import org.springframework.data.cassandra.domain.Person; import org.springframework.data.cassandra.repository.config.EnableCassandraRepositories; @@ -39,6 +42,10 @@ import org.springframework.data.domain.Sort; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.metadata.schema.IndexMetadata; +import com.datastax.oss.driver.api.core.metadata.schema.KeyspaceMetadata; + /** * Integration tests for AOT processing via {@link CassandraRepositoryContributor}. * @@ -49,7 +56,6 @@ class CassandraRepositoryContributorIntegrationTests extends AbstractSpringDataEmbeddedCassandraIntegrationTest { @Autowired PersonRepository fragment; - @Autowired CassandraOperations operations; @Configuration @Import(Config.class) @@ -83,7 +89,24 @@ public SchemaAction getSchemaAction() { @BeforeEach void before() { - operations.delete(Person.class); + template.delete(Person.class); + + template.getCqlOperations().execute( + "CREATE CUSTOM INDEX person_firstname ON person (firstname) USING 'org.apache.cassandra.index.sasi.SASIIndex' WITH OPTIONS = {'mode': 'CONTAINS' };"); + + template.getCqlOperations().execute("CREATE INDEX person_numberofchildren ON person (numberofchildren)"); + + template.getCqlOperations().execute((SessionCallback) session -> { + + Awaitility.await().until(() -> { + KeyspaceMetadata keyspace = session.getMetadata().getKeyspace(session.getKeyspace().get()).get(); + + Map indexes = keyspace.getTable("person").get().getIndexes(); + return indexes.size() >= 1; + }); + + return null; + }); Person person = new Person("Walter", "White"); person.setNumberOfChildren(2); @@ -93,9 +116,9 @@ void before() { person.setAlternativeAddresses(Arrays.asList(new AddressType("Albuquerque", "USA"), new AddressType("New Hampshire", "USA"), new AddressType("Grocery Store", "Mexico"))); - walter = operations.insert(person); - skyler = operations.insert(new Person("Skyler", "White")); - flynn = operations.insert(new Person("Flynn (Walter Jr.)", "White")); + walter = template.insert(person); + skyler = template.insert(new Person("Skyler", "White")); + flynn = template.insert(new Person("Flynn (Walter Jr.)", "White")); } @Test // GH-1566 @@ -107,6 +130,15 @@ void shouldFindByFirstname() { assertThat(walter.getFirstname()).isEqualTo("Walter"); } + @Test // GH-1566 + void shouldFindByFirstnameWithQueryOptions() { + + Person walter = fragment.findByFirstname("Walter", QueryOptions.builder().build()); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + @Test // GH-1566 void shouldFindOptionalByFirstname() { @@ -124,18 +156,81 @@ void shouldApplySorting() { } @Test // GH-1566 - void shouldFindByFirstnameContains() throws InterruptedException { - - operations.getCqlOperations().execute( - "CREATE CUSTOM INDEX person_firstname ON person (firstname) USING 'org.apache.cassandra.index.sasi.SASIIndex' WITH OPTIONS = {'mode': 'CONTAINS' };"); - - Thread.sleep(1000); // wait for index to come up + void shouldFindByFirstnameContains() { Person walter = fragment.findByFirstnameContains("Walter Jr"); assertThat(walter).isNotNull(); assertThat(walter.getFirstname()).isEqualTo("Flynn (Walter Jr.)"); } + + @Test // GH-1566 + void shouldFindByGteLte() { + + assertThat(fragment.findByNumberOfChildrenGreaterThan(1)).hasSize(1); + assertThat(fragment.findByNumberOfChildrenGreaterThan(2)).isEmpty(); + + assertThat(fragment.findByNumberOfChildrenGreaterThanEqual(2)).hasSize(1); + assertThat(fragment.findByNumberOfChildrenGreaterThanEqual(3)).isEmpty(); + + assertThat(fragment.findByNumberOfChildrenLessThan(3)).hasSize(3); + assertThat(fragment.findByNumberOfChildrenLessThan(1)).hasSize(2); + + assertThat(fragment.findByNumberOfChildrenLessThanEqual(2)).hasSize(3); + assertThat(fragment.findByNumberOfChildrenLessThanEqual(1)).hasSize(2); + } + + @Test // GH-1566 + void shouldFindTrueFalse() { + + assertThat(fragment.findByCoolIsTrue()).isEmpty(); + assertThat(fragment.findByCoolIsFalse()).hasSize(3); + } + + @Test // GH-1566 + void shouldFindContaining() { + + assertThat(fragment.findByAlternativeAddressesContaining(walter.getAlternativeAddresses().get(0))) + .containsOnly(walter); + + assertThat(fragment.findByAlternativeAddressesContaining(new AddressType())).isEmpty(); + } + + @Test // GH-1566 + void shouldFindByAnnotatedFirstname() { + + Person walter = fragment.findAnnotatedByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindByAnnotatedPositionalFirstname() { + + Person walter = fragment.findAnnotatedByPositionalFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindByAnnotatedExpression() { + + Person walter = fragment.findAnnotatedByExpression("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindByAnnotatedPropertyPlaceholderExpression() { + + Person walter = fragment.findAnnotatedByExpression(); + + assertThat(walter).isNull(); + } + /* @Test // GH-1566 diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/PersonRepository.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/PersonRepository.java index 6408c0748..beeea4727 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/PersonRepository.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/PersonRepository.java @@ -18,20 +18,29 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.cassandra.core.cql.QueryOptions; import org.springframework.data.cassandra.domain.AddressType; import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.AllowFiltering; +import org.springframework.data.cassandra.repository.Consistency; import org.springframework.data.cassandra.repository.Query; import org.springframework.data.domain.Sort; import org.springframework.data.repository.CrudRepository; +import com.datastax.oss.driver.api.core.DefaultConsistencyLevel; + /** * @author Mark Paluch */ public interface PersonRepository extends CrudRepository { - @Query(allowFiltering = true) + @Query(allowFiltering = true, idempotent = Query.Idempotency.IDEMPOTENT) Person findByFirstname(String firstname); + @Query(allowFiltering = true) + @Consistency(DefaultConsistencyLevel.ONE) + Person findByFirstname(String firstname, QueryOptions queryOptions); + @Query(allowFiltering = true) Optional findOptionalByFirstname(String firstname); @@ -41,16 +50,48 @@ public interface PersonRepository extends CrudRepository { @Query(allowFiltering = true) List findByLastnameOrderByFirstnameAsc(String lastname); - Person findByMainAddress(AddressType address); - Person findByFirstnameStartsWith(String prefix); Person findByFirstnameContains(String contains); + @AllowFiltering + List findByNumberOfChildrenGreaterThan(int ch); + + @AllowFiltering + List findByNumberOfChildrenGreaterThanEqual(int ch); + + @AllowFiltering + List findByNumberOfChildrenLessThan(int ch); + + @AllowFiltering + List findByNumberOfChildrenLessThanEqual(int ch); + + @AllowFiltering + List findByCoolIsTrue(); + + @AllowFiltering + List findByCoolIsFalse(); + + @AllowFiltering + List findByAlternativeAddressesContaining(AddressType addressType); + // ------------------------------------------------------------------------- // Declared Queries // ------------------------------------------------------------------------- - /*@Query("select * from person where mainaddress = ?0") - Person findByAddress(AddressType address);*/ + @Query(value = "select * from person where firstname = :firstname", idempotent = Query.Idempotency.IDEMPOTENT) + Person findAnnotatedByFirstname(String firstname); + + @Query(value = "select * from person where firstname = ?0") + Person findAnnotatedByPositionalFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Value Expressions + // ------------------------------------------------------------------------- + + @Query(value = "select * from person where firstname = :#{#firstname}") + Person findAnnotatedByExpression(String firstname); + + @Query(value = "select * from person where firstname = :${user.dir}") + Person findAnnotatedByExpression(); } diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/support/AbstractSpringDataEmbeddedCassandraIntegrationTest.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/support/AbstractSpringDataEmbeddedCassandraIntegrationTest.java index f03d33e3e..c420c436b 100755 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/support/AbstractSpringDataEmbeddedCassandraIntegrationTest.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/support/AbstractSpringDataEmbeddedCassandraIntegrationTest.java @@ -31,7 +31,7 @@ public abstract class AbstractSpringDataEmbeddedCassandraIntegrationTest extends IntegrationTestsSupport { @Autowired @SuppressWarnings("unused") - private CassandraOperations template; + protected CassandraOperations template; /** * Truncate tables for all known {@link org.springframework.data.mapping.PersistentEntity entities}. From 9f323631d186cb09544851a3fb46ca59c58d12b1 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 28 Aug 2025 14:43:48 +0200 Subject: [PATCH 06/12] Add support for dynamic projections. --- .../cassandra/core/CassandraTemplate.java | 27 +++ .../ExecutableSelectOperationSupport.java | 18 +- .../data/cassandra/core/StatementFactory.java | 5 +- .../aot/AotRepositoryFragmentSupport.java | 10 +- .../repository/aot/CassandraCodeBlocks.java | 105 ++++++--- .../aot/CassandraRepositoryContributor.java | 9 +- ...RepositoryContributorIntegrationTests.java | 222 ++++++++---------- .../repository/aot/PersonRepository.java | 104 +++++++- .../src/test/resources/logback.xml | 1 + 9 files changed, 326 insertions(+), 175 deletions(-) diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/CassandraTemplate.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/CassandraTemplate.java index 2003d3110..88e127e4c 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/CassandraTemplate.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/CassandraTemplate.java @@ -16,6 +16,7 @@ package org.springframework.data.cassandra.core; import java.util.List; +import java.util.Map; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; @@ -423,6 +424,27 @@ List doSelect(Query query, Class entityClass, CqlIdentifier tableNa return doQuery(select.build(), rowMapper); } + ResultSet doSelectResultSet(Query query, Class entityClass, CqlIdentifier tableName) { + + CassandraPersistentEntity entity = getRequiredPersistentEntity(entityClass); + + StatementBuilder