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..eb16ba4ef 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 @@ -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} @@ -198,6 +210,13 @@ test + + net.javacrumbs.json-unit + json-unit-assertj + 4.1.0 + test + + edu.umd.cs.mtc multithreadedtc @@ -227,11 +246,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/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 select = getStatementFactory().select(query, entity, tableName); + SimpleStatement statement = select.build(); + + return queryForResultSet(statement); + } + + ResultSet queryForResultSet(Statement> statement) { + + if (PreparedStatementDelegate.canPrepare(isUsePreparedStatements(), statement, log)) { + + PreparedStatementHandler statementHandler = createPreparedStatementHandler(statement); + return getCqlOperations().query(statementHandler, statementHandler, resultSet -> resultSet); + } + + return getCqlOperations().queryForResultSet(statement); + } + @Override public @Nullable T selectOne(Query query, Class entityClass) throws DataAccessException { @@ -970,6 +992,11 @@ private Function getMapper(EntityProjection projection, CqlIde Class targetType = projection.getMappedType().getType(); + if (Map.class.isAssignableFrom(targetType)) { + ColumnMapRowMapper columnMapRowMapper = new ColumnMapRowMapper(); + return row -> (T) columnMapRowMapper.mapRow(row, 0); + } + return row -> { maybeEmitEvent(() -> new AfterLoadEvent<>(row, targetType, tableName)); diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ExecutableSelectOperation.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ExecutableSelectOperation.java index 664159548..8d5e7ca40 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ExecutableSelectOperation.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ExecutableSelectOperation.java @@ -103,7 +103,7 @@ public interface ExecutableSelectOperation { * * @since 5.0 */ - interface UntypedSelect { + interface UntypedSelect extends TerminatingProjections { /** * Define the {@link Class result target type} that the Cassandra Row fields should be mapped to. diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ExecutableSelectOperationSupport.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ExecutableSelectOperationSupport.java index 679c5d2b9..40f8bf932 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ExecutableSelectOperationSupport.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ExecutableSelectOperationSupport.java @@ -29,6 +29,8 @@ import org.springframework.util.ObjectUtils; import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; import com.datastax.oss.driver.api.core.cql.SimpleStatement; import com.datastax.oss.driver.api.core.cql.Statement; @@ -91,6 +93,21 @@ public TerminatingResults map(RowMapper mapper) { return new TerminatingSelectResultSupport<>(template, statement, mapper); } + @Override + public long count() { + + List rows = template.select(statement, Row.class); + + if (rows.size() == 1) { + + Object object = rows.get(0).getObject(0); + + return ((Number) object).longValue(); + } + + return 0; + } + } static class TypedSelectSupport extends TerminatingSelectResultSupport implements TerminatingResults { @@ -99,7 +116,9 @@ static class TypedSelectSupport extends TerminatingSelectResultSupport TypedSelectSupport(CassandraTemplate template, Statement> statement, Class domainType) { super(template, statement, - template.getRowMapper(domainType, EntityQueryUtils.getTableName(statement), QueryResultConverter.entity())); + ResultSet.class.isAssignableFrom(domainType) ? null + : template.getRowMapper(domainType, EntityQueryUtils.getTableName(statement), + QueryResultConverter.entity())); this.domainType = domainType; } @@ -120,9 +139,10 @@ static class TerminatingSelectResultSupport implements TerminatingResults< final Statement> statement; - final RowMapper rowMapper; + final @Nullable RowMapper rowMapper; - TerminatingSelectResultSupport(CassandraTemplate template, Statement> statement, RowMapper rowMapper) { + TerminatingSelectResultSupport(CassandraTemplate template, Statement> statement, + @Nullable RowMapper rowMapper) { this.template = template; this.statement = statement; this.rowMapper = rowMapper; @@ -156,6 +176,10 @@ public TerminatingResults map(QueryResultConverter super T, ? extends R @Override public @Nullable T oneValue() { + if (this.rowMapper == null) { + return (T) this.template.queryForResultSet(this.statement); + } + List result = this.template.getCqlOperations().query(this.statement, this.rowMapper); if (ObjectUtils.isEmpty(result)) { @@ -271,6 +295,10 @@ public boolean exists() { @Override public @Nullable T oneValue() { + if (this.returnType.equals(ResultSet.class)) { + return (T) this.template.doSelectResultSet(this.query.limit(2), this.domainType, getTableName()); + } + List result = this.template.doSelect(this.query.limit(2), this.domainType, getTableName(), this.returnType, this.mappingFunction); @@ -279,8 +307,8 @@ public boolean exists() { } if (result.size() > 1) { - throw new IncorrectResultSizeDataAccessException( - String.format("Query [%s] returned non unique result", this.query), 1); + throw new IncorrectResultSizeDataAccessException(1, result.size()); + } return result.iterator().next(); diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/StatementFactory.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/StatementFactory.java index cab05be15..6367fbd70 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/StatementFactory.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/StatementFactory.java @@ -29,6 +29,7 @@ import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; + import org.springframework.data.cassandra.core.convert.CassandraConverter; import org.springframework.data.cassandra.core.convert.QueryMapper; import org.springframework.data.cassandra.core.convert.UpdateMapper; @@ -73,6 +74,7 @@ import org.springframework.util.ClassUtils; import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.cql.ResultSet; import com.datastax.oss.driver.api.core.data.CqlVector; import com.datastax.oss.driver.api.core.metadata.schema.ClusteringOrder; import com.datastax.oss.driver.api.querybuilder.BindMarker; @@ -615,7 +617,8 @@ public StatementBuilder delete(Object entity, QueryOptions options, Enti Columns computeColumnsForProjection(EntityProjection, ?> projection, Columns columns, CassandraPersistentEntity> domainType, Class> returnType) { - if (!columns.isEmpty() || ClassUtils.isAssignable(domainType.getType(), returnType)) { + if (!columns.isEmpty() || ClassUtils.isAssignable(domainType.getType(), returnType) + || ClassUtils.isAssignable(Map.class, returnType) || ClassUtils.isAssignable(ResultSet.class, returnType)) { return columns; } 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/core/query/Query.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/query/Query.java index 63d92087c..22eb4574a 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/query/Query.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/query/Query.java @@ -229,6 +229,10 @@ public Query pageRequest(Pageable pageable) { CassandraPageRequest.validatePageable(pageable); + if (pageable.isUnpaged()) { + return this; + } + CassandraScrollPosition scrollPosition = getScrollPosition(); if (pageable instanceof CassandraPageRequest cpr) { 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 new file mode 100644 index 000000000..846a994cc --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotQueryCreator.java @@ -0,0 +1,103 @@ +/* + * 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.jspecify.annotations.Nullable; + +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.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; + +/** + * Query creator to create queries during AOT processing using {@link ParameterBinding} placeholders. + * + * @author Chris Bono + * @author Mark Paluch + * @since 5.0 + */ +class AotQueryCreator extends CassandraQueryCreator { + + public AotQueryCreator(PartTree tree, CassandraParameters parameters, + MappingContext, CassandraPersistentProperty> mappingContext, List parameterBindings) { + 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; + + public AotPlaceholderParameterAccessor(CassandraParameters parameters, List parameterBindings) { + super(parameters, new Object[parameters.getNumberOfParameters()]); + this.parameterBindings = parameterBindings; + } + + @Override + public @Nullable Object getValue(int parameterIndex) { + + ParameterBinding binding = ParameterBinding.indexed(parameterIndex); + parameterBindings.add(binding); + + return binding; + } + + @Override + public CassandraScrollPosition getScrollPosition() { + return null; + } + + @Override + public @Nullable ScoringFunction getScoringFunction() { + return null; + } + + @Override + public @Nullable QueryOptions getQueryOptions() { + return null; + } + + @Override + public @Nullable Object getBindableValue(int index) { + return getValue(getParameters().getBindableParameter(index).getIndex()); + } + + @Override + public boolean hasBindableNullValue() { + return false; + } + + } + +} 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..5ce99bc7a --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/AotRepositoryFragmentSupport.java @@ -0,0 +1,171 @@ +/* + * 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.Limit; +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; + } + + if (bindableValue instanceof Limit limit) { + return limit.max(); + } + + 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, 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, Class> projection) { + + if (result == null) { + return null; + } + + if (projection.isInstance(result)) { + return result; + } + + if (result instanceof Stream> stream) { + return stream.map(it -> convertOne(it, projection)); + } + + if (result instanceof Slice> slice) { + return slice.map(it -> convertOne(it, projection)); + } + + if (result instanceof Collection> collection) { + + Collection<@Nullable Object> target = CollectionFactory.createCollection(collection.getClass(), + collection.size()); + for (Object o : collection) { + target.add(convertOne(o, 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/CassandraCodeBlocks.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java new file mode 100644 index 000000000..1ba4a2646 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraCodeBlocks.java @@ -0,0 +1,752 @@ +/* + * 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.nio.ByteBuffer; +import java.util.Map; +import java.util.Optional; + +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.core.cql.QueryOptions; +import org.springframework.data.cassandra.core.query.CassandraPageRequest; +import org.springframework.data.cassandra.core.query.CassandraScrollPosition; +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.Query; +import org.springframework.data.cassandra.repository.query.CassandraQueryMethod; +import org.springframework.data.cassandra.repository.query.ParameterBinding; +import org.springframework.data.cassandra.repository.query.WindowUtil; +import org.springframework.data.convert.CustomConversions; +import org.springframework.data.domain.Limit; +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.ConsistencyLevel; +import com.datastax.oss.driver.api.core.cql.ResultSet; +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 { + + /** + * Builder for generating query parsing {@link CodeBlock}. + * + * @param context + * @param queryMethod + * @return new instance of {@link QueryBlockBuilder}. + */ + static QueryBlockBuilder queryBuilder(AotQueryMethodGenerationContext context, CassandraQueryMethod queryMethod) { + return new QueryBlockBuilder(context, queryMethod); + } + + /** + * Builder for generating finder query execution {@link CodeBlock}. + * + * @param context + * @param customConversions + * @param queryMethod + * @return + */ + static QueryExecutionBlockBuilder executionBuilder(AotQueryMethodGenerationContext context, + CustomConversions customConversions, CassandraQueryMethod queryMethod) { + + return new QueryExecutionBlockBuilder(context, customConversions, queryMethod); + } + + @NullUnmarked + static class QueryBlockBuilder { + + private final AotQueryMethodGenerationContext context; + private final CassandraQueryMethod queryMethod; + private final String parameterNames; + + private @Nullable AotQuery query; + private String queryVariableName; + private MergedAnnotation queryAnnotation = MergedAnnotation.missing(); + + QueryBlockBuilder(AotQueryMethodGenerationContext context, CassandraQueryMethod queryMethod) { + + this.context = context; + this.queryMethod = queryMethod; + + String parameterNames = StringUtils.collectionToDelimitedString(context.getAllParameterNames(), ", "); + + if (StringUtils.hasText(parameterNames)) { + this.parameterNames = ", " + parameterNames; + } else { + this.parameterNames = ""; + } + } + + QueryBlockBuilder query(AotQuery query) { + this.query = query; + return this; + } + + QueryBlockBuilder usingQueryVariableName(String queryVariableName) { + this.queryVariableName = queryVariableName; + return this; + } + + QueryBlockBuilder query(MergedAnnotation query) { + this.queryAnnotation = query; + return this; + } + + CodeBlock build() { + + if (query instanceof StringAotQuery sq) { + return buildStringQuery(sq); + } else if (query instanceof DerivedAotQuery derived) { + return buildDerivedQuery(derived); + } + + throw new UnsupportedOperationException("Unsupported query: " + query); + } + + private CodeBlock buildStringQuery(StringAotQuery query) { + + Builder builder = CodeBlock.builder(); + String pagingState = null; + + if (queryMethod.isSliceQuery() && StringUtils.hasText(context.getPageableParameterName())) { + + pagingState = context.localVariable("pagingState"); + builder.addStatement("$1T $2L = $3L instanceof $4T $5L ? $5L.getPagingState() : null", ByteBuffer.class, + pagingState, context.getPageableParameterName(), CassandraPageRequest.class, + context.localVariable("pageRequest")); + + } else if (queryMethod.isScrollQuery() && StringUtils.hasText(context.getScrollPositionParameterName())) { + + pagingState = context.localVariable("pagingState"); + builder.addStatement("$1T $2L = $3L instanceof $4T $5L && !$5L.isInitial() ? $5L.getPagingState() : null", + ByteBuffer.class, pagingState, context.getScrollPositionParameterName(), CassandraScrollPosition.class, + context.localVariable("cassandraScrollPosition")); + } + + builder.add(buildQuery(query)); + builder.add(buildOptions(pagingState)); + + return builder.build(); + } + + private CodeBlock buildQuery(StringAotQuery query) { + + CodeBlock.Builder builder = CodeBlock.builder(); + + if (query.getParameterBindings().isEmpty()) { + builder.addStatement("$1T $2L = $1T.newInstance($3S)", SimpleStatement.class, queryVariableName, + query.getQueryString()); + + return builder.build(); + } + + builder.addStatement("Object[] $L = new Object[$L]", context.localVariable("args"), + query.getParameterBindings().size()); + + int index = 0; + for (ParameterBinding binding : query.getParameterBindings()) { + + // TODO:Conversion, Data type + builder.addStatement("$1L[$2L] = $3L", context.localVariable("args"), index++, + getParameter(binding.getOrigin())); + } + + builder.addStatement("$1T $2L = $1T.newInstance($3S, $4L)", SimpleStatement.class, queryVariableName, + query.getQueryString(), context.localVariable("args")); + + return builder.build(); + } + + private CodeBlock buildOptions(@Nullable String pagingState) { + + CodeBlock.Builder builder = CodeBlock.builder(); + + if (queryAnnotation.isPresent()) { + + Query.Idempotency idempotent = queryAnnotation.getEnum("idempotent", Query.Idempotency.class); + + if (idempotent != Query.Idempotency.UNDEFINED) { + builder.addStatement("$1L = $1L.setIdempotent($2L)", queryVariableName, + idempotent == Query.Idempotency.IDEMPOTENT); + } + } + + if (StringUtils.hasText(pagingState)) { + builder.addStatement("$1L = $1L.setPagingState($2L)", queryVariableName, pagingState); + } + + if (StringUtils.hasText(context.getPageableParameterName())) { + + builder.beginControlFlow("if ($1L.isPaged())", context.getPageableParameterName()); + builder.addStatement("$1L = $1L.setPageSize($2L.getPageSize())", queryVariableName, + context.getPageableParameterName()); + builder.endControlFlow(); + } + + if (StringUtils.hasText(context.getLimitParameterName())) { + + builder.beginControlFlow("if ($1L.isLimited())", context.getLimitParameterName()); + builder.addStatement("$1L = $1L.setPageSize($2L.max())", queryVariableName, context.getLimitParameterName()); + builder.endControlFlow(); + } + + if (queryMethod.hasConsistencyLevel()) { + + ConsistencyLevel consistencyLevel = queryMethod.getRequiredAnnotatedConsistencyLevel(); + + builder.addStatement("$1L = $1L.setConsistencyLevel($2T.$3L)", queryVariableName, ConsistencyLevel.class, + consistencyLevel.name()); + } + + return builder.build(); + } + + private CodeBlock buildDerivedQuery(DerivedAotQuery derived) { + + org.springframework.data.cassandra.core.query.Query query = derived.getQuery(); + + Builder builder = CodeBlock.builder(); + + builder.add(buildQuery(query)); + builder.add(buildColumns(query.getColumns())); + builder.add(buildSortScrollLimit(derived, query)); + + builder.add(buildQueryOptions(derived)); + + return builder.build(); + } + + private CodeBlock buildQuery(org.springframework.data.cassandra.core.query.Query query) { + + Builder queryBuilder = CodeBlock.builder(); + + if (query.isEmpty()) { + + queryBuilder.addStatement("$1T $2L = $1T.empty()", org.springframework.data.cassandra.core.query.Query.class, + queryVariableName); + + return queryBuilder.build(); + } + + boolean first = true; + queryBuilder.add("$["); + queryBuilder.add("$1T $2L = $1T.query(", org.springframework.data.cassandra.core.query.Query.class, + queryVariableName); + + for (CriteriaDefinition criteriaDefinition : query) { + + if (first) { + first = false; + } else { + queryBuilder.add(".and("); + } + + queryBuilder.add("$1T.where($2S)", Criteria.class, criteriaDefinition.getColumnName().toCql()); + appendPredicate(criteriaDefinition, queryBuilder); + + queryBuilder.add(")"); + } + + queryBuilder.add(";\n$]"); + + return queryBuilder.build(); + } + + private CodeBlock buildColumns(Columns columns) { + + if (columns.isEmpty()) { + return CodeBlock.builder().build(); + } + + 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$]"); + + columnBuilder.addStatement("$1L = $1L.columns($2L)", queryVariableName, context.localVariable("columns")); + + return columnBuilder.build(); + } + + private CodeBlock buildSortScrollLimit(DerivedAotQuery derived, + org.springframework.data.cassandra.core.query.Query query) { + + Builder builder = CodeBlock.builder(); + + // Slice and Window require an indication for hasNext() so we increase the query limit and leave the page size set + // to the limit. + boolean increaseLimitByOne = queryMethod.isSliceQuery() || queryMethod.isScrollQuery(); + + if (query.getSort().isSorted()) { + builder.addStatement("$1L = $1L.sort($2L)", queryVariableName, buildSort(query.getSort())); + } + + if (derived.isLimited()) { + builder.addStatement("$1L = $1L.limit($2L)", queryVariableName, + derived.getLimit().max() + (increaseLimitByOne ? 1 : 0)); + } + + if (StringUtils.hasText(context.getLimitParameterName())) { + + if (increaseLimitByOne) { + builder.beginControlFlow("if ($1L.isLimited())", context.getLimitParameterName()); + builder.addStatement("$1L = $1L.limit($2L.max() + 1)", queryVariableName, context.getLimitParameterName()); + builder.endControlFlow(); + } else { + builder.addStatement("$1L = $1L.limit($2L)", queryVariableName, context.getLimitParameterName()); + } + } + + if (StringUtils.hasText(context.getSortParameterName())) { + builder.addStatement("$1L = $1L.sort($2L)", queryVariableName, context.getSortParameterName()); + } else if (StringUtils.hasText(context.getPageableParameterName())) { + builder.addStatement("$1L = $1L.pageRequest($2L)", queryVariableName, context.getPageableParameterName()); + } + + if (StringUtils.hasText(context.getScrollPositionParameterName())) { + builder.beginControlFlow("if (!$1L.isInitial())", context.getScrollPositionParameterName()); + builder.addStatement("$1L = $1L.pagingState(($2T) $3L)", queryVariableName, CassandraScrollPosition.class, + context.getScrollPositionParameterName()); + builder.endControlFlow(); + } + + if (queryAnnotation.isPresent()) { + + boolean allowFiltering = queryAnnotation.getBoolean("allowFiltering"); + + if (allowFiltering) { + builder.addStatement("$1L = $1L.withAllowFiltering()", queryVariableName); + } + } + + return builder.build(); + } + + 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 CodeBlock buildQueryOptions(DerivedAotQuery derived) { + + CodeBlock.Builder builder = CodeBlock.builder(); + + boolean requiresOptions = StringUtils.hasText(context.getLimitParameterName()) + || StringUtils.hasText(context.getPageableParameterName()) || derived.isLimited() + || queryMethod.hasConsistencyLevel() || queryMethod.isSliceQuery() | queryMethod.isScrollQuery(); + + int queryOptionsIndex = queryMethod.getParameters().getQueryOptionsIndex(); + + if (requiresOptions) { + + if (queryOptionsIndex != -1) { + + String queryOptions = context.getParameterName(queryOptionsIndex); + builder.addStatement("$1T $2L = $3L.mutate()", QueryOptions.QueryOptionsBuilder.class, + context.localVariable("optionsBuilder"), queryOptions); + } else { + builder.addStatement("$1T $2L = $3T.builder()", QueryOptions.QueryOptionsBuilder.class, + context.localVariable("optionsBuilder"), QueryOptions.class); + } + + applyOptions(derived.getLimit(), builder); + + builder.addStatement("$1L = $1L.queryOptions($2L.build())", queryVariableName, + context.localVariable("optionsBuilder")); + + } else if (queryOptionsIndex != -1) { + + String queryOptions = context.getParameterName(queryOptionsIndex); + builder.addStatement("$1L = $1L.queryOptions($2L)", queryVariableName, queryOptions); + } + + return builder.build(); + } + + private void applyOptions(Limit limit, Builder builder) { + + if (limit.isLimited()) { + builder.addStatement("$1L.pageSize($2L)", context.localVariable("optionsBuilder"), limit.max()); + } + + if (StringUtils.hasText(context.getPageableParameterName())) { + + builder.beginControlFlow("if ($1L.isPaged())", context.getPageableParameterName()); + builder.addStatement("$1L.pageSize($2L.getPageSize())", context.localVariable("optionsBuilder"), + context.getPageableParameterName()); + builder.endControlFlow(); + } + + if (StringUtils.hasText(context.getLimitParameterName())) { + + builder.beginControlFlow("if ($1L.isLimited())", context.getLimitParameterName()); + builder.addStatement("$1L.pageSize($2L.max())", context.localVariable("optionsBuilder"), + context.getLimitParameterName()); + builder.endControlFlow(); + } + + if (queryMethod.hasConsistencyLevel()) { + ConsistencyLevel consistencyLevel = queryMethod.getRequiredAnnotatedConsistencyLevel(); + builder.addStatement("$1L.consistencyLevel($2T.$3L)", context.localVariable("optionsBuilder"), + ConsistencyLevel.class, consistencyLevel.name()); + } + } + + 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 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) { + + if (mia.identifier().hasPosition()) { + return "potentiallyConvertBindingValue(" + + context.getRequiredBindableParameterName(mia.identifier().getPosition()) + ")"; + } + + if (mia.identifier().hasName()) { + return "potentiallyConvertBindingValue(" + + context.getMethodParameter(mia.identifier().getName()).getParameterName() + ")"; + } + } + + 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); + + return builder.build(); + } + + throw new UnsupportedOperationException("Not supported yet for: " + origin); + } + } + + @NullUnmarked + static class QueryExecutionBlockBuilder { + + private final AotQueryMethodGenerationContext context; + private final CustomConversions customConversions; + private final CassandraQueryMethod queryMethod; + private @Nullable AotQuery query; + private @Nullable String queryVariableName; + + QueryExecutionBlockBuilder(AotQueryMethodGenerationContext context, CustomConversions customConversions, + CassandraQueryMethod queryMethod) { + + this.context = context; + this.customConversions = customConversions; + this.queryMethod = queryMethod; + } + + QueryExecutionBlockBuilder query(AotQuery query) { + + this.query = query; + return this; + } + + QueryExecutionBlockBuilder usingQueryVariableName(String queryVariableName) { + this.queryVariableName = queryVariableName; + return this; + } + + CodeBlock build() { + + Builder builder = CodeBlock.builder(); + + boolean isProjecting = !query.isCount() && !query.isExists() + && (context.getReturnedType().isProjecting() + && !customConversions.isSimpleType(context.getReturnedType().getReturnedType())) + || StringUtils.hasText(context.getDynamicProjectionParameterName()); + Class> domainType = context.getRepositoryInformation().getDomainType(); + + builder.add("\n"); + + if (query.isDelete()) { + + if (query instanceof StringAotQuery) { + + builder.addStatement("boolean $1L = $2L.getCqlOperations().execute($3L)", context.localVariable("result"), + context.fieldNameOf(CassandraOperations.class), queryVariableName); + } else { + builder.addStatement("boolean $1L = $2L.delete($3L, $4T.class)", context.localVariable("result"), + context.fieldNameOf(CassandraOperations.class), queryVariableName, domainType); + } + + if (context.getReturnType().isAssignableFrom(Boolean.class) + || context.getReturnType().isAssignableFrom(Boolean.TYPE)) { + builder.addStatement("return $1L", context.localVariable("result")); + } + + return builder.build(); + } + + boolean isInterfaceProjection = context.getActualReturnType().toClass().isInterface(); + boolean requiresConversion = false; + + boolean isMapProjection = Map.class.isAssignableFrom(context.getActualReturnType().toClass()); + boolean rawProjection = isMapProjection || ResultSet.class.isAssignableFrom(context.getReturnType().toClass()); + + TypeName actualReturnType = isMapProjection + ? TypeName.get(Map.class) + : context.getActualReturnTypeName(); + Object asDynamicTypeNameOrProjectionTypeParameter = actualReturnType; + + if (StringUtils.hasText(context.getDynamicProjectionParameterName())) { + asDynamicTypeNameOrProjectionTypeParameter = context.getDynamicProjectionParameterName(); + } + + if (query instanceof StringAotQuery) { + + if (StringUtils.hasText(context.getDynamicProjectionParameterName())) { + + builder.addStatement("$1T<$2T> $3L = $4L.query($5L).as($6L)", + ExecutableSelectOperation.TerminatingResults.class, actualReturnType, context.localVariable("select"), + context.fieldNameOf(CassandraOperations.class), queryVariableName, + context.getDynamicProjectionParameterName()); + + } else if (isProjecting && !rawProjection) { + + requiresConversion = isInterfaceProjection; + + builder.addStatement("$1T<$2T> $3L = $4L.query($5L).as($2T.class)", + ExecutableSelectOperation.TerminatingResults.class, + requiresConversion ? context.getDomainType() : actualReturnType, context.localVariable("select"), + context.fieldNameOf(CassandraOperations.class), queryVariableName); + + } else { + + if (query.isExists() || query.isCount()) { + builder.addStatement("$1T $2L = $3L.query($4L)", ExecutableSelectOperation.TerminatingProjections.class, + context.localVariable("select"), context.fieldNameOf(CassandraOperations.class), queryVariableName); + } else { + builder.addStatement("$1T<$2T> $3L = $4L.query($5L).as($2T.class)", + ExecutableSelectOperation.TerminatingResults.class, actualReturnType, 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.TerminatingSelect.class, actualReturnType, context.localVariable("select"), + context.fieldNameOf(CassandraOperations.class), domainType, asDynamicTypeNameOrProjectionTypeParameter, + queryVariableName); + } else { + + builder.addStatement("$1T<$2T> $3L = $4L.query($2T.class).matching($5L)", + ExecutableSelectOperation.TerminatingSelect.class, domainType, context.localVariable("select"), + context.fieldNameOf(CassandraOperations.class), queryVariableName); + } + } + + String terminatingMethod; + + if (queryMethod.isScrollQuery() || queryMethod.isSliceQuery() || queryMethod.isPageQuery()) { + terminatingMethod = "slice()"; + } else if (queryMethod.isCollectionQuery()) { + terminatingMethod = "all()"; + } else if (queryMethod.isStreamQuery()) { + terminatingMethod = "stream()"; + } else if (query.isCount()) { + terminatingMethod = "count()"; + } else if (query.isExists()) { + terminatingMethod = "count() > 0"; + } else if (query.isLimited()) { + terminatingMethod = "firstValue()"; + } else { + terminatingMethod = Optional.class.isAssignableFrom(context.getReturnType().toClass()) ? "one()" : "oneValue()"; + } + + Builder execution = CodeBlock.builder(); + + if (queryMethod.isScrollQuery()) { + execution.add("$T.of($L.$L)", WindowUtil.class, context.localVariable("select"), terminatingMethod); + } else if (context.getReturnType().isArray()) { + execution.add("$L.$L.toArray(new $T[0])", context.localVariable("select"), terminatingMethod, + context.getActualReturnTypeName()); + } else { + + if (rawProjection && isMapProjection) { + execution.add("($T) $L.$L", context.getReturnType().toClass(), context.localVariable("select"), + terminatingMethod); + } else { + execution.add("$L.$L", context.localVariable("select"), terminatingMethod); + } + } + + if (requiresConversion) { + + if (Optional.class.isAssignableFrom(context.getReturnType().toClass())) { + builder.addStatement("return ($T) $T.ofNullable(convertOne($L, $T.class))", context.getReturnTypeName(), + Optional.class, execution.build(), actualReturnType); + } else { + + String conversionMethod; + + if (queryMethod.isCollectionQuery() || queryMethod.isPageQuery() || queryMethod.isSliceQuery() + || queryMethod.isStreamQuery()) { + conversionMethod = "convertMany"; + } else { + conversionMethod = "convertOne"; + } + + builder.addStatement("return ($T) $L($L, $T.class)", context.getReturnTypeName(), conversionMethod, + execution.build(), actualReturnType); + } + + } else { + + if (query.isCount() && (context.getReturnType().isAssignableFrom(Integer.class) + || context.getReturnType().isAssignableFrom(int.class))) { + builder.addStatement("return (int) $L", execution.build()); + } else { + builder.addStatement("return $L", execution.build()); + } + } + + return builder.build(); + } + } + +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraQueryMetadata.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraQueryMetadata.java new file mode 100644 index 000000000..d2735e9f8 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraQueryMetadata.java @@ -0,0 +1,48 @@ +/* + * 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; + +/** + * Metadata for an AOT Cassandra query. + * + * @author Mark Paluch + * @since 5.0 + */ +record CassandraQueryMetadata(AotQuery result) implements QueryMetadata { + + @Override + public Map serialize() { + + Map serialized = new LinkedHashMap<>(); + + if (result() instanceof StringAotQuery sq) { + serialized.put("query", sq.getQueryString()); + } else if (result() instanceof DerivedAotQuery dq) { + serialized.put("query", dq.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/CassandraRepositoryContributor.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributor.java new file mode 100644 index 000000000..a63337e3a --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributor.java @@ -0,0 +1,125 @@ +/* + * 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.List; + +import org.jspecify.annotations.Nullable; + +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.convert.CassandraCustomConversions; +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.support.RepositoryFactoryBeanSupport; +import org.springframework.data.repository.query.QueryMethod; +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.StringUtils; + +/** + * Cassandra specific {@link RepositoryContributor}. + * + * @author Chris Bono + * @author Mark Paluch + * @since 5.0 + */ +public class CassandraRepositoryContributor extends RepositoryContributor { + + private final AotRepositoryContext context; + private final CassandraMappingContext mappingContext; + private final CassandraCustomConversions customConversions; + private final QueriesFactory queryFactory; + + public CassandraRepositoryContributor(AotRepositoryContext context) { + + super(context); + this.context = context; + this.mappingContext = new CassandraMappingContext(); + this.customConversions = new CassandraCustomConversions(List.of()); + this.queryFactory = new QueriesFactory(context.getConfigurationSource(), context.getRequiredClassLoader(), + ValueExpressionDelegate.create(), mappingContext); + } + + @Override + protected void customizeClass(AotRepositoryClassBuilder builder) { + builder.customize(b -> b.superclass(TypeName.get(AotRepositoryFragmentSupport.class))); + } + + @Override + protected void customizeConstructor(AotRepositoryConstructorBuilder constructorBuilder) { + + constructorBuilder.addParameter("operations", CassandraOperations.class, customizer -> { + + String cassandraTemplateRef = getCassandraTemplateRef(); + customizer.bindToField() + .origin(StringUtils.hasText(cassandraTemplateRef) + ? new RuntimeBeanReference(cassandraTemplateRef, CassandraOperations.class) + : new RuntimeBeanReference(CassandraOperations.class)); + }); + + constructorBuilder.addParameter("context", RepositoryFactoryBeanSupport.FragmentCreationContext.class, false); + } + + private @Nullable String getCassandraTemplateRef() { + return context.getConfigurationSource().getAttribute("cassandraTemplateRef").orElse(null); + } + + @Override + @SuppressWarnings("NullAway") + protected @Nullable MethodContributor extends QueryMethod> contributeQueryMethod(Method method) { + + CassandraQueryMethod queryMethod = new CassandraQueryMethod(method, getRepositoryInformation(), + getProjectionFactory(), mappingContext); + + ReturnedType returnedType = queryMethod.getResultProcessor().getReturnedType(); + MergedAnnotation query = MergedAnnotations.from(method).get(Query.class); + + if (queryMethod.isSearchQuery()) { + return null; + } + + AotQuery aotQuery = queryFactory.createQuery(getRepositoryInformation(), returnedType, query, queryMethod); + + return MethodContributor.forQueryMethod(queryMethod).withMetadata(new CassandraQueryMetadata(aotQuery)) + .contribute(context -> { + + CodeBlock.Builder body = CodeBlock.builder(); + + String queryVariableName = context.localVariable("query"); + + body.add(CassandraCodeBlocks.queryBuilder(context, queryMethod).usingQueryVariableName(queryVariableName) + .query(aotQuery).query(query).build()); + + body.add(CassandraCodeBlocks.executionBuilder(context, customConversions, queryMethod) + .usingQueryVariableName(queryVariableName).query(aotQuery).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 new file mode 100644 index 000000000..a51895fe6 --- /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 + * @since 5.0 + */ +class DerivedAotQuery extends AotQuery { + + 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, PartTree partTree) { + + this(queryString, bindings, query, partTree.getSort(), partTree.getResultLimit(), partTree.isDelete(), + partTree.isCountProjection(), partTree.isExistsProjection()); + } + + private 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; + } + + 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/LikeParameterBinding.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/LikeParameterBinding.java new file mode 100644 index 000000000..1e8948830 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/LikeParameterBinding.java @@ -0,0 +1,40 @@ +/* + * 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 org.springframework.data.cassandra.repository.query.ParameterBinding; +import org.springframework.data.repository.query.parser.Part; + +/** + * Extension to {@link ParameterBinding} to capture {@link Part.Type} for LIKE predicates. + * + * @author Mark Paluch + * @since 5.0 + */ +class LikeParameterBinding extends ParameterBinding { + + private final Part.Type type; + + protected LikeParameterBinding(ParameterBinding binding, Part.Type type) { + super(binding.getIdentifier(), binding.getOrigin()); + this.type = type; + } + + 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 new file mode 100644 index 000000000..c0dfb104a --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/QueriesFactory.java @@ -0,0 +1,181 @@ +/* + * 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.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.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; +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.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 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) { + + 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); + } + + private NamedQueries getNamedQueries(RepositoryConfigurationSource configSource, ClassLoader classLoader) { + + String location = configSource.getNamedQueryLocation().orElse(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 AotQuery createQuery(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 (namedQueries.hasQuery(queryName)) { + return buildNamedQuery(queryName, queryMethod, count, exists); + } + + return buildPartTreeQuery(repositoryInformation, returnedType, queryMethod); + } + + private AotQuery buildStringQuery(String queryString, CassandraQueryMethod queryMethod, boolean count, + boolean exists) { + + StringBasedQuery query = parseQuery(queryMethod, queryString); + List bindings = query.getQueryParameterBindings(); + + return StringAotQuery.of(query.getPostProcessedQuery(), bindings, count, exists); + } + + private AotQuery buildNamedQuery(String queryName, CassandraQueryMethod queryMethod, boolean count, + boolean exists) { + + String queryString = namedQueries.getQuery(queryName); + StringBasedQuery query = parseQuery(queryMethod, queryString); + List bindings = query.getQueryParameterBindings(); + + return StringAotQuery.named(queryName, query.getPostProcessedQuery(), bindings, count, exists); + } + + private StringBasedQuery parseQuery(CassandraQueryMethod queryMethod, String queryString) { + return new StringBasedQuery(queryString, queryMethod.getParameters(), delegate); + } + + private AotQuery 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); + + BasicCassandraPersistentEntity> entity = mappingContext + .getRequiredPersistentEntity(repositoryInformation.getDomainType()); + + org.springframework.data.cassandra.core.query.Query query = queryCreator.createQuery(Sort.unsorted()); + + 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(); + } + + return new DerivedAotQuery(queryString, parameterBindings, query, partTree); + } + +} 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..936cfb705 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/aot/StringAotQuery.java @@ -0,0 +1,128 @@ +/* + * 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 { + + private StringAotQuery(List parameterBindings) { + super(parameterBindings); + } + + /** + * Creates a new {@link StringAotQuery}. + * + * @param query + * @param parameterBindings + * @param count whether to apply count projection. + * @param exists whether to apply exists projection. + * @return + */ + static StringAotQuery of(String query, List parameterBindings, boolean count, boolean exists) { + return new DeclaredAotQuery(query, parameterBindings, count, exists); + } + + /** + * Creates a new {@link NamedStringAotQuery named string query}. + * + * @param queryName + * @param query + * @param parameterBindings + * @param count whether to apply count projection. + * @param exists whether to apply exists projection. + * @return + */ + static StringAotQuery named(String queryName, String query, List parameterBindings, boolean count, + boolean exists) { + return new NamedStringAotQuery(queryName, query, parameterBindings, count, exists); + } + + /** + * @return the query string used to execute the query. + */ + public abstract String getQueryString(); + + @Override + public String toString() { + return getQueryString(); + } + + /** + * Declared Cassandra query. + */ + 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; + } + + } + + /** + * Named Cassandra 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/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..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 @@ -20,18 +20,27 @@ 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.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; /** @@ -67,6 +76,11 @@ public String getRepositoryFactoryBeanClassName() { return CassandraRepositoryFactoryBean.class.getName(); } + @Override + public Class extends BeanRegistrationAotProcessor> getRepositoryAotProcessor() { + return CassandraRepositoryRegistrationAotProcessor.class; + } + @Override public void postProcess(BeanDefinitionBuilder builder, XmlRepositoryConfigurationSource config) { @@ -103,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 eebb31904..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 @@ -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); @@ -283,20 +286,17 @@ 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: - 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/main/java/org/springframework/data/cassandra/repository/query/ContextualValueExpressionEvaluator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ContextualValueExpressionEvaluator.java index 4e02c4ee5..47785ebc7 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ContextualValueExpressionEvaluator.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ContextualValueExpressionEvaluator.java @@ -39,7 +39,12 @@ public ContextualValueExpressionEvaluator(ValueExpressionParser parser, ValueEva @SuppressWarnings("unchecked") @Override - public @Nullable T evaluate(String expressionString) { + public @Nullable T evaluate(String rawExpressionString) { + + String expressionString = rawExpressionString.contains("#{") || rawExpressionString.contains("${") + ? rawExpressionString + : "#{" + rawExpressionString + "}"; + ValueExpression expression = parser.parse(expressionString); return (T) expression.evaluate(evaluationContext); } 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; 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..60af1dbc1 --- /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) + */ + protected 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 super String, ? extends String> 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 super String, ? extends String> 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 super String, ? extends String> 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 super String, ? extends String> 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 super String, ? extends String> 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/QueryStatementCreator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/QueryStatementCreator.java index 0b868264a..363bcdc46 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/QueryStatementCreator.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/QueryStatementCreator.java @@ -316,9 +316,7 @@ SimpleStatement select(StringBasedQuery stringBasedQuery, CassandraParameterAcce ValueExpressionEvaluator evaluator) { try { - SimpleStatement boundQuery = stringBasedQuery.bindQuery(parameterAccessor, evaluator); - Optional queryOptions = Optional.ofNullable(parameterAccessor.getQueryOptions()); SimpleStatement queryToUse = boundQuery; 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/main/java/org/springframework/data/cassandra/repository/query/WindowUtil.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/WindowUtil.java index 1ee8b8668..b9f90256f 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/WindowUtil.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/WindowUtil.java @@ -26,9 +26,9 @@ * * @author Mark Paluch */ -class WindowUtil { +public class WindowUtil { - static Window of(Slice slice) { + public static Window of(Slice slice) { List content = slice.getContent(); CassandraPageRequest pageable = (CassandraPageRequest) slice.getPageable(); diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotNamedQueryIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotNamedQueryIntegrationTests.java new file mode 100644 index 000000000..4a384dff9 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotNamedQueryIntegrationTests.java @@ -0,0 +1,99 @@ +/* + * 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; + +import java.io.IOException; +import java.util.Collections; +import java.util.Set; + +import org.springframework.beans.factory.config.PropertiesFactoryBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.cassandra.config.SchemaAction; +import org.springframework.data.cassandra.core.CassandraTemplate; +import org.springframework.data.cassandra.core.mapping.MapId; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.aot.AotFragmentTestConfigurationSupport; +import org.springframework.data.cassandra.repository.support.CassandraRepositoryFactoryBean; +import org.springframework.data.cassandra.repository.support.IntegrationTestConfig; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.support.PropertiesBasedNamedQueries; +import org.springframework.data.repository.core.support.RepositoryComposition; +import org.springframework.data.spel.ExtensionAwareEvaluationContextProvider; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Integration test for {@link PersonRepositoryWithNamedQueries} using JavaConfig with mounted AOT-generated repository + * methods. + * + * @author Mark Paluch + */ +@SpringJUnitConfig(classes = AotNamedQueryIntegrationTests.AotConfiguration.class) +public class AotNamedQueryIntegrationTests extends NamedQueryIntegrationTests { + + @Configuration + public static class AotConfiguration extends IntegrationTestConfig { + + @Override + protected Set> getInitialEntitySet() { + return Collections.singleton(Person.class); + } + + @Override + public SchemaAction getSchemaAction() { + return SchemaAction.RECREATE_DROP_UNUSED; + } + + @Bean + static AotFragmentTestConfigurationSupport aot() { + return new AotFragmentTestConfigurationSupport(PersonRepositoryWithNamedQueries.class, + NamedQueryIntegrationTests.Config.class, false); + } + + @Bean + public NamedQueryIntegrationTests.PersonRepositoryWithNamedQueries personRepository( + ApplicationContext applicationContext, CassandraTemplate template) throws Exception { + + ExtensionAwareEvaluationContextProvider evaluationContextProvider = new ExtensionAwareEvaluationContextProvider( + applicationContext); + + CassandraRepositoryFactoryBean factory = new CassandraRepositoryFactoryBean<>( + NamedQueryIntegrationTests.PersonRepositoryWithNamedQueries.class); + factory.setCassandraTemplate(template); + factory.setBeanFactory(applicationContext); + + factory.setRepositoryFragments( + RepositoryComposition.RepositoryFragments.just(applicationContext.getBean("fragment"))); + + factory.setNamedQueries(namedQueries()); + factory.setEvaluationContextProvider(evaluationContextProvider); + factory.afterPropertiesSet(); + + return factory.getObject(); + } + + private NamedQueries namedQueries() throws IOException { + + PropertiesFactoryBean factory = new PropertiesFactoryBean(); + factory.setLocation(new ClassPathResource("META-INF/PersonRepositoryWithNamedQueries.properties")); + factory.afterPropertiesSet(); + + return new PropertiesBasedNamedQueries(factory.getObject()); + } + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotQueryDerivationIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotQueryDerivationIntegrationTests.java new file mode 100644 index 000000000..c0d1c67ad --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotQueryDerivationIntegrationTests.java @@ -0,0 +1,103 @@ +/* + * 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; + +import java.io.IOException; +import java.util.Set; + +import org.springframework.beans.factory.config.PropertiesFactoryBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.cassandra.config.SchemaAction; +import org.springframework.data.cassandra.core.CassandraTemplate; +import org.springframework.data.cassandra.core.mapping.MapId; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.aot.AotFragmentTestConfigurationSupport; +import org.springframework.data.cassandra.repository.config.EnableCassandraRepositories; +import org.springframework.data.cassandra.repository.support.CassandraRepositoryFactoryBean; +import org.springframework.data.cassandra.repository.support.IntegrationTestConfig; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.support.PropertiesBasedNamedQueries; +import org.springframework.data.repository.core.support.RepositoryComposition; +import org.springframework.data.spel.ExtensionAwareEvaluationContextProvider; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Integration test for {@link PersonRepository} using JavaConfig with mounted AOT-generated repository methods. + * + * @author Mark Paluch + */ +@SpringJUnitConfig(classes = AotQueryDerivationIntegrationTests.AotConfiguration.class) +class AotQueryDerivationIntegrationTests extends QueryDerivationIntegrationTests { + + @Configuration(proxyBeanMethods = false) + @EnableCassandraRepositories(considerNestedRepositories = true, + includeFilters = @ComponentScan.Filter(classes = { EmbeddedPersonRepository.class }, + type = FilterType.ASSIGNABLE_TYPE)) + static class AotConfiguration extends IntegrationTestConfig { + + @Override + protected Set> getInitialEntitySet() { + return Set.of(Person.class, PersonWithEmbedded.class); + } + + @Override + public SchemaAction getSchemaAction() { + return SchemaAction.CREATE; + } + + @Bean + static AotFragmentTestConfigurationSupport aot() { + return new AotFragmentTestConfigurationSupport(PersonRepository.class, + QueryDerivationIntegrationTests.Config.class, false); + } + + @Bean + public QueryDerivationIntegrationTests.PersonRepository personRepository(ApplicationContext applicationContext, + CassandraTemplate template) throws Exception { + + ExtensionAwareEvaluationContextProvider evaluationContextProvider = new ExtensionAwareEvaluationContextProvider( + applicationContext); + + CassandraRepositoryFactoryBean factory = new CassandraRepositoryFactoryBean<>( + QueryDerivationIntegrationTests.PersonRepository.class); + factory.setCassandraTemplate(template); + factory.setBeanFactory(applicationContext); + + factory.setRepositoryFragments( + RepositoryComposition.RepositoryFragments.just(applicationContext.getBean("fragment"))); + + factory.setNamedQueries(namedQueries()); + factory.setEvaluationContextProvider(evaluationContextProvider); + factory.afterPropertiesSet(); + + return factory.getObject(); + } + + private NamedQueries namedQueries() throws IOException { + + PropertiesFactoryBean factory = new PropertiesFactoryBean(); + factory.setLocation(new ClassPathResource("META-INF/cassandra-named-queries.properties")); + factory.afterPropertiesSet(); + + return new PropertiesBasedNamedQueries(factory.getObject()); + } + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/QueryDerivationIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/QueryDerivationIntegrationTests.java index d3894e2ad..feaf1e0d8 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/QueryDerivationIntegrationTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/QueryDerivationIntegrationTests.java @@ -447,7 +447,7 @@ public void derivedQueryOnPropertyOfEmbeddedEntity() { /** * @author Mark Paluch */ - static interface PersonRepository extends MapIdCassandraRepository { + public static interface PersonRepository extends MapIdCassandraRepository { List findByLastname(String lastname); @@ -504,14 +504,14 @@ enum NumberOfChildren { ZERO, ONE, TWO, } - interface PersonProjection { + public interface PersonProjection { String getFirstname(); String getLastname(); } - class PersonDto { + public class PersonDto { public String firstname, lastname; @@ -525,14 +525,14 @@ public PersonDto(String firstname, String lastname) { /** * @author Christoph Strobl */ - static interface EmbeddedPersonRepository extends CassandraRepository { + public static interface EmbeddedPersonRepository extends CassandraRepository { PersonWithEmbedded findByName_Firstname(String firstname); } @Table - static class PersonWithEmbedded { + public static class PersonWithEmbedded { @Id String id; @Embedded.Nullable Name name; 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..eff15d4b8 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/AotFragmentTestConfigurationSupport.java @@ -0,0 +1,174 @@ +/* + * 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.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 { + + private final Class> repositoryInterface; + private final boolean registerFragmentFacade; + private final TestCassandraAotRepositoryContext> repositoryContext; + + 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 repositoryContributor = new CassandraRepositoryContributor(repositoryContext); + repositoryContributor.contribute(generationContext); + + AbstractBeanDefinition aotGeneratedRepository = BeanDefinitionBuilder + .genericBeanDefinition( + repositoryInterface.getPackageName() + "." + repositoryInterface.getSimpleName() + "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; + } + +} 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..cbff00f0d --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributorIntegrationTests.java @@ -0,0 +1,509 @@ +/* + * 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.lang.reflect.UndeclaredThrowableException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +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.cql.QueryOptions; +import org.springframework.data.cassandra.core.cql.SessionCallback; +import org.springframework.data.cassandra.core.query.CassandraScrollPosition; +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.Limit; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScoringFunction; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Vector; +import org.springframework.data.domain.Window; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.cql.ResultSet; +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}. + * + * @author Mark Paluch + */ +@SpringJUnitConfig( + classes = CassandraRepositoryContributorIntegrationTests.CassandraRepositoryContributorConfiguration.class) +class CassandraRepositoryContributorIntegrationTests extends AbstractSpringDataEmbeddedCassandraIntegrationTest { + + @Autowired PersonRepository fragment; + private static boolean indexExists = false; + + @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() { + + template.delete(Person.class); + + if (!indexExists) { + + template.getCqlOperations().execute( + "CREATE CUSTOM INDEX IF NOT EXISTS person_firstname ON person (firstname) USING 'org.apache.cassandra.index.sasi.SASIIndex' WITH OPTIONS = {'mode': 'CONTAINS' };"); + template.getCqlOperations() + .execute("CREATE INDEX IF NOT EXISTS person_numberofchildren ON person (numberofchildren)"); + + template.getCqlOperations().execute((SessionCallback extends Object>) 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; + }); + indexExists = true; + } + + 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 = template.insert(person); + skyler = template.insert(new Person("Skyler", "White")); + flynn = template.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 shouldFindByFirstnameWithQueryOptions() { + + Person walter = fragment.findByFirstname("Walter", QueryOptions.builder().build()); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldQuerySlice() { + + Slice first = fragment.findTop2SliceByLastname("White", Pageable.unpaged()); + Slice second = fragment.findTop2SliceByLastname("White", first.nextPageable()); + + assertThat(first).hasSize(2); + assertThat(first.hasNext()).isTrue(); + assertThat(second).hasSize(1); + assertThat(second.hasNext()).isFalse(); + } + + @Test // GH-1566 + void shouldQueryWindow() { + + Window first = fragment.findWindowByLastname("White", CassandraScrollPosition.initial(), Limit.of(2)); + Window second = fragment.findWindowByLastname("White", first.positionAt(1), Limit.of(2)); + + assertThat(first).hasSize(2); + assertThat(first.hasNext()).isTrue(); + assertThat(second).hasSize(1); + assertThat(second.hasNext()).isFalse(); + } + + @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() { + + 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 shouldApplyExistsCountProjection() { + + assertThat(fragment.existsByLastname("White")).isTrue(); + assertThat(fragment.countByLastname("White")).isEqualTo(3); + } + + @Test // GH-1566 + void shouldFindByDeclaredFirstname() { + + Person walter = fragment.findDeclaredByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindByDeclaredPositionalFirstname() { + + Person walter = fragment.findDeclaredByPositionalFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindByDeclaredExpression() { + + Person walter = fragment.findDeclaredByExpression("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindByDeclaredPropertyPlaceholderExpression() { + + Person walter = fragment.findDeclaredByExpression(); + + assertThat(walter).isNull(); + } + + @Test // GH-1566 + void shouldApplyDeclaredExistsCountProjection() { + + assertThat(fragment.existsDeclaredByLastname("White")).isTrue(); + assertThat(fragment.countDeclaredByLastname("White")).isEqualTo(3); + } + + @Test // GH-1566 + void shouldQueryDeclaredSlice() { + + Slice first = fragment.findDeclaredSliceByLastname("White", Pageable.ofSize(2)); + Slice second = fragment.findDeclaredSliceByLastname("White", first.nextPageable()); + + assertThat(first).hasSize(2); + assertThat(first.hasNext()).isTrue(); + assertThat(second).hasSize(1); + assertThat(second.hasNext()).isFalse(); + } + + @Test // GH-1566 + void shouldQueryDeclaredWindow() { + + Window first = fragment.findDeclaredWindowByLastname("White", CassandraScrollPosition.initial(), 3, + Limit.of(2)); + Window second = fragment.findDeclaredWindowByLastname("White", first.positionAt(1), 3, Limit.of(2)); + + assertThat(first).hasSize(2); + assertThat(first.hasNext()).isTrue(); + assertThat(second).hasSize(1); + assertThat(second.hasNext()).isFalse(); + } + + @Test // GH-1566 + void shouldFindNamedQuery() { + + Person walter = fragment.findNamedByFirstname("Walter"); + + assertThat(walter).isNotNull(); + } + + @Test // GH-1566 + void shouldReturnResultSet() { + + ResultSet resultSet = fragment.findResultSetByFirstname("Walter"); + + assertThat(resultSet).isNotNull(); + assertThat(resultSet.one().getString("firstname")).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldReturnDeclaredResultSet() { + + ResultSet resultSet = fragment.findDeclaredResultSetByFirstname("Walter"); + + assertThat(resultSet).isNotNull(); + assertThat(resultSet.one().getString("firstname")).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldReturnMap() { + + Map map = fragment.findMapByFirstname("Walter"); + + assertThat(map).isNotNull(); + assertThat(map.get("firstname")).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldReturnDeclaredMap() { + + Map map = fragment.findDeclaredMapByFirstname("Walter"); + + assertThat(map).isNotNull(); + assertThat(map.get("firstname")).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectToDto() { + + PersonRepository.PersonDto walter = fragment.findOneDtoProjectionByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.firstname).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectManyToDto() { + + List walter = fragment.findDtoProjectionByFirstname("Walter"); + + assertThat(walter).hasSize(1); + assertThat(walter).extracting(PersonRepository.PersonDto::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectStreamToDto() { + + Stream walter = fragment.streamDtoProjectionByFirstname("Walter"); + + assertThat(walter).hasSize(1).extracting(PersonRepository.PersonDto::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectDeclaredToDto() { + + PersonRepository.PersonDto walter = fragment.findOneDeclaredDtoProjectionByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectDeclaredSimpleType() { + + assertThat(fragment.findDeclaredNumberOfChildrenByFirstname("Walter")).isEqualTo(2); + assertThat(fragment.findDeclaredNumberOfChildrenByFirstname("Flynn (Walter Jr.)")).isEqualTo(0); + assertThat(fragment.findDeclaredListNumberOfChildrenByFirstname("Walter")).containsOnly(2); + } + + @Test // GH-1566 + void shouldProjectToInterface() { + + PersonRepository.PersonProjection walter = fragment.findOneInterfaceProjectionByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectManyToInterface() { + + List walter = fragment.findInterfaceProjectionByFirstname("Walter"); + + assertThat(walter).hasSize(1); + assertThat(walter).extracting(PersonRepository.PersonProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectStreamToInterface() { + + Stream walter = fragment.streamInterfaceProjectionByFirstname("Walter"); + + assertThat(walter).hasSize(1).extracting(PersonRepository.PersonProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectDeclaredToInterface() { + + PersonRepository.PersonProjection walter = fragment.findOneDeclaredInterfaceProjectionByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectDynamically() { + + PersonRepository.PersonProjection projection = fragment.findOneProjectionByFirstname("Walter", + PersonRepository.PersonProjection.class); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + + List projections = fragment.findProjectionByFirstname("Walter", + PersonRepository.PersonProjection.class); + + assertThat(projections).hasSize(1); + assertThat(projections).extracting(PersonRepository.PersonProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + @Disabled("not supported") + void shouldProjectDeclaredDynamically() { + + PersonRepository.PersonProjection projection = fragment.findOneDeclaredProjectionByFirstname("Walter", + PersonRepository.PersonProjection.class); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + + List projections = fragment.findDeclaredProjectionByFirstname("Walter", + PersonRepository.PersonProjection.class); + + assertThat(projections).hasSize(1); + assertThat(projections).extracting(PersonRepository.PersonProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectToDtoDynamically() { + + PersonRepository.PersonDto projection = fragment.findOneProjectionByFirstname("Walter", + PersonRepository.PersonDto.class); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + + List projections = fragment.findProjectionByFirstname("Walter", + PersonRepository.PersonDto.class); + + assertThat(projections).hasSize(1); + assertThat(projections).extracting(PersonRepository.PersonDto::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectDeclaredToDtoDynamically() { + + PersonRepository.PersonDto projection = fragment.findOneDeclaredProjectionByFirstname("Walter", + PersonRepository.PersonDto.class); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + + List projections = fragment.findDeclaredProjectionByFirstname("Walter", + PersonRepository.PersonDto.class); + + assertThat(projections).hasSize(1); + assertThat(projections).extracting(PersonRepository.PersonDto::getFirstname).containsOnly("Walter"); + } + + // TODO: Vector Search + + @Test // GH-1566 + void vectorSearchNotSupportedYet() { + assertThatExceptionOfType(UndeclaredThrowableException.class) + .isThrownBy(() -> fragment.findAllByVector(Vector.of(1f), ScoringFunction.cosine())) + .withCauseInstanceOf(NoSuchMethodException.class); + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryMetadataIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryMetadataIntegrationTests.java new file mode 100644 index 000000000..54ee4fd9a --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryMetadataIntegrationTests.java @@ -0,0 +1,159 @@ +/* + * 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 net.javacrumbs.jsonunit.assertj.JsonAssertions.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.support.AbstractApplicationContext; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.data.cassandra.core.CassandraTemplate; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.config.EnableCassandraRepositories; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Integration tests for the {@link PersonRepository} JSON metadata via {@link CassandraRepositoryContributor}. + * + * @author Mark Paluch + */ +@SpringJUnitConfig( + classes = CassandraRepositoryMetadataIntegrationTests.CassandraRepositoryContributorConfiguration.class) +class CassandraRepositoryMetadataIntegrationTests { + + @Autowired AbstractApplicationContext context; + + @Configuration + @EnableCassandraRepositories(considerNestedRepositories = true, + includeFilters = { @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = Person.class) }) + static class CassandraRepositoryContributorConfiguration extends AotFragmentTestConfigurationSupport { + + public CassandraRepositoryContributorConfiguration() { + super(PersonRepository.class, CassandraRepositoryContributorConfiguration.class); + } + + @Bean + public CassandraTemplate cassandraTemplate() { + return mock(CassandraTemplate.class); + } + } + + @Test // GH-1566 + void shouldDocumentBase() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).isObject() // + .containsEntry("name", PersonRepository.class.getName()) // + .containsEntry("module", "Cassandra") // + .containsEntry("type", "IMPERATIVE"); + } + + @Test // GH-1566 + void shouldDocumentDerivedQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'countByLastname')].query").isArray().first().isObject() + .containsEntry("query", "SELECT count(1) FROM person WHERE lastname=?"); + } + + @Test // GH-1566 + void shouldDocumentDeclaredQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findDeclaredByFirstname')].query").isArray().first().isObject() + .containsEntry("query", "select * from person where firstname = ?"); + } + + @Test // GH-1566 + void shouldDocumentNamedQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findNamedByFirstname')].query").isArray().first().isObject() + .containsEntry("query", "SELECT * FROM person WHERE firstname=? ALLOW FILTERING") + .containsEntry("name", "Person.findNamedByFirstname"); + } + + @Test // GH-1566 + void shouldNotIncludeVectorSearch() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findAllByVector')].query").isArray().isEmpty(); + } + + @Test // GH-1566 + void shouldDocumentBaseFragment() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'existsById')].fragment").isArray().first().isObject() + .containsEntry("fragment", "org.springframework.data.cassandra.repository.support.SimpleCassandraRepository"); + } + + private Resource getResource() { + + String location = PersonRepository.class.getPackageName().replace('.', '/') + "/" + + PersonRepository.class.getSimpleName() + ".json"; + return new UrlResource(context.getBeanFactory().getBeanClassLoader().getResource(location)); + } + +} 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..e365cec35 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/PersonRepository.java @@ -0,0 +1,238 @@ +/* + * 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.Map; +import java.util.Optional; +import java.util.stream.Stream; + +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.CountQuery; +import org.springframework.data.cassandra.repository.ExistsQuery; +import org.springframework.data.cassandra.repository.Query; +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScoringFunction; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.SearchResults; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Vector; +import org.springframework.data.domain.Window; +import org.springframework.data.repository.CrudRepository; + +import com.datastax.oss.driver.api.core.DefaultConsistencyLevel; +import com.datastax.oss.driver.api.core.cql.ResultSet; + +/** + * AOT repository interface for {@link Person} entities. + * + * @author Mark Paluch + */ +public interface PersonRepository extends CrudRepository { + + @Query(idempotent = Query.Idempotency.IDEMPOTENT) + Person findByFirstname(String firstname); + + @Consistency(DefaultConsistencyLevel.ONE) + Person findByFirstname(String firstname, QueryOptions queryOptions); + + Slice findTop2SliceByLastname(String lastname, Pageable pageable); + + Window findWindowByLastname(String lastname, ScrollPosition scrollPosition, Limit limit); + + Optional findOptionalByFirstname(String firstname); + + List findByLastname(String lastname, Sort sort); + + List findByLastnameOrderByFirstnameAsc(String lastname); + + 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); + + int countByLastname(String lastname); + + boolean existsByLastname(String lastname); + + // ------------------------------------------------------------------------- + // Declared Queries + // ------------------------------------------------------------------------- + + @Query(value = "select * from person where firstname = :firstname", idempotent = Query.Idempotency.IDEMPOTENT) + Person findDeclaredByFirstname(String firstname); + + @Query(value = "select * from person where firstname = ?0") + Person findDeclaredByPositionalFirstname(String firstname); + + @CountQuery(value = "select COUNT(*) from person where lastname = ?0") + int countDeclaredByLastname(String lastname); + + @ExistsQuery(value = "select COUNT(*) from person where lastname = ?0") + boolean existsDeclaredByLastname(String lastname); + + @Query(value = "select * from person where lastname = ?0 LIMIT 3") + Slice findDeclaredSliceByLastname(String lastname, Pageable pageable); + + @Query(value = "select * from person where lastname = :lastname LIMIT :sliceLimit") + Window findDeclaredWindowByLastname(String lastname, ScrollPosition scrollPosition, int sliceLimit, + Limit pageSize); + + // ------------------------------------------------------------------------- + // Value Expressions + // ------------------------------------------------------------------------- + + @Query(value = "select * from person where firstname = :#{#firstname}") + Person findDeclaredByExpression(String firstname); + + @Query(value = "select * from person where firstname = :${user.dir}") + Person findDeclaredByExpression(); + + // ------------------------------------------------------------------------- + // Named Queries + // ------------------------------------------------------------------------- + + Person findNamedByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: ResultSet, Map + // ------------------------------------------------------------------------- + + ResultSet findResultSetByFirstname(String firstname); + + @Query(value = "select * from person where firstname = ?0") + ResultSet findDeclaredResultSetByFirstname(String firstname); + + Map findMapByFirstname(String firstname); + + @Query(value = "select * from person where firstname = ?0") + Map findDeclaredMapByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: DTO + // ------------------------------------------------------------------------- + + PersonDto findOneDtoProjectionByFirstname(String firstname); + + List findDtoProjectionByFirstname(String firstname); + + Stream streamDtoProjectionByFirstname(String firstname); + + @Query(value = "select * from person where firstname = :#{#firstname}") + PersonDto findOneDeclaredDtoProjectionByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: Single-field + // ------------------------------------------------------------------------- + + @Query("select numberOfChildren from person where firstname = :firstname") + int findDeclaredNumberOfChildrenByFirstname(String firstname); + + @Query("select numberOfChildren from person where firstname = :firstname") + List findDeclaredListNumberOfChildrenByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: Interface + // ------------------------------------------------------------------------- + + PersonProjection findOneInterfaceProjectionByFirstname(String firstname); + + List findInterfaceProjectionByFirstname(String firstname); + + Stream streamInterfaceProjectionByFirstname(String firstname); + + @Query(value = "select * from person where firstname = :#{#firstname}") + PersonProjection findOneDeclaredInterfaceProjectionByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: Dynamic + // ------------------------------------------------------------------------- + + T findOneProjectionByFirstname(String firstname, Class projectionType); + + @Query(value = "select * from person where firstname = :firstname") + T findOneDeclaredProjectionByFirstname(String firstname, Class projectionType); + + List findProjectionByFirstname(String firstname, Class projectionType); + + @Query(value = "select * from person where firstname = :firstname") + List findDeclaredProjectionByFirstname(String firstname, Class projectionType); + + // ------------------------------------------------------------------------- + // Excluded + // ------------------------------------------------------------------------- + + SearchResults findAllByVector(Vector vector, ScoringFunction scoringFunction); + + interface PersonProjection { + + String getFirstname(); + + String getLastname(); + } + + class PersonDto { + + public String firstname, lastname; + + public PersonDto(String firstname, String lastname) { + this.firstname = firstname; + this.lastname = lastname; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } + } +} 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) 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( 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}. diff --git a/spring-data-cassandra/src/test/resources/META-INF/cassandra-named-queries.properties b/spring-data-cassandra/src/test/resources/META-INF/cassandra-named-queries.properties index c596c8305..c1e18ab2a 100644 --- a/spring-data-cassandra/src/test/resources/META-INF/cassandra-named-queries.properties +++ b/spring-data-cassandra/src/test/resources/META-INF/cassandra-named-queries.properties @@ -1 +1,2 @@ User.findByNamedQuery=SELECT firstname FROM users WHERE username=?0 +Person.findNamedByFirstname=SELECT * FROM person WHERE firstname=?0 ALLOW FILTERING diff --git a/spring-data-cassandra/src/test/resources/logback.xml b/spring-data-cassandra/src/test/resources/logback.xml index 10ee2a2c7..086cd2362 100644 --- a/spring-data-cassandra/src/test/resources/logback.xml +++ b/spring-data-cassandra/src/test/resources/logback.xml @@ -11,6 +11,7 @@ + diff --git a/src/main/antora/modules/ROOT/nav.adoc b/src/main/antora/modules/ROOT/nav.adoc index dac20da62..63fc8df2f 100644 --- a/src/main/antora/modules/ROOT/nav.adoc +++ b/src/main/antora/modules/ROOT/nav.adoc @@ -36,6 +36,7 @@ ** xref:cassandra/repositories/cdi-integration.adoc[] ** xref:repositories/query-keywords-reference.adoc[] ** xref:repositories/query-return-types-reference.adoc[] +** xref:cassandra/repositories/aot.adoc[] * xref:observability.adoc[] diff --git a/src/main/antora/modules/ROOT/pages/cassandra/repositories/aot.adoc b/src/main/antora/modules/ROOT/pages/cassandra/repositories/aot.adoc new file mode 100644 index 000000000..0fa00fcc5 --- /dev/null +++ b/src/main/antora/modules/ROOT/pages/cassandra/repositories/aot.adoc @@ -0,0 +1,179 @@ += Ahead of Time Optimizations + +This chapter covers Spring Data's Ahead of Time (AOT) optimizations that build upon {spring-framework-docs}/core/aot.html[Spring's Ahead of Time Optimizations]. + +[[aot.bestpractices]] +== Best Practices + +=== Annotate your Domain Types + +During application startup, Spring scans the classpath for domain classes for early processing of entities. +By annotating your domain types with Spring Data-specific `@Table`, `@Document` or `@Entity` annotations you can aid initial entity scanning and ensure that those types are registered with `ManagedTypes` for Runtime Hints. +Classpath scanning is not possible in native image arrangements and so Spring has to use `ManagedTypes` for the initial entity set. + +[[aot.hints]] +== Runtime Hints + +Running an application as a native image requires additional information compared to a regular JVM runtime. +Spring Data contributes {spring-framework-docs}/core/aot.html#aot.hints[Runtime Hints] during AOT processing for native image usage. +These are in particular hints for: + +* Auditing +* `ManagedTypes` to capture the outcome of class-path scans +* Repositories +** Reflection hints for entities, return types, and Spring Data annotations +** Repository fragments +** Querydsl `Q` classes +** Kotlin Coroutine support +* Web support (Jackson Hints for `PagedModel`) + +[[aot.repositories]] +== Ahead of Time Repositories + +AOT Repositories are an extension to AOT processing by pre-generating eligible query method implementations. +Query methods are opaque to developers regarding their underlying queries being executed in a query method call. +AOT repositories contribute query method implementations based on derived, annotated, and named queries that are known at build-time. +This optimization moves query method processing from runtime to build-time, which can lead to a significant performance improvement as query methods do not need to be analyzed reflectively upon each application start. + +The resulting AOT repository fragment follows the naming scheme of `Impl__Aot` and is placed in the same package as the repository interface. +You can find all queries in their String form for generated repository query methods. + +NOTE: Consider AOT repository classes an internal optimization. +Do not use them directly in your code as generation and implementation details may change in future releases. + +=== Running with AOT Repositories + +AOT is a mandatory step to transform a Spring application to a native executable, so it is automatically enabled when running in this mode. +When AOT is enabled (either for native compilation or by setting `spring.aot.enabled=true`), AOT repositories are automatically enabled by default. + +You can disable AOT repository generation entirely or only disable Cassandra AOT repositories: + +* Set the `spring.aot.repositories.enabled=false` property to disable generated repositories for all Spring Data modules. +* Set the `spring.aot.cassandra.repositories.enabled=false` property to disable only C AOT repositories. + +AOT repositories contribute configuration changes to the actual repository bean registration to register the generated repository fragment. + +NOTE: When AOT optimizations are included, some decisions that have been taken at build-time are hard-coded in the application setup. +For instance, profiles that have been enabled at build-time are automatically enabled at runtime as well. +Also, the Spring Data module implementing a repository is fixed. +Changing the implementation requires AOT re-processing. + +=== Eligible Methods + +AOT repositories filter methods that are eligible for AOT processing. +These are typically all query methods that are not backed by an xref:repositories/custom-implementations.adoc[implementation fragment]. + +**Supported Features** + +* Derived query methods, `@Query` and named query methods. +* `Window`, `Slice`, `Stream`, and `Optional` return types +* Sort query rewriting +* Interface and DTO Projections +* Value Expressions (Those require a bit of reflective information. +Mind that using Value Expressions requires expression parsing and contextual information to evaluate the expression) + +**Limitation** + +* Vector Search not yet supported + +**Excluded methods** + +* `CrudRepository` and other base interface methods as their implementation is provided by the base class respective fragments +* Vector Search Methods + +[[aot.repositories.json]] +== Repository Metadata + +AOT processing introspects query methods and collects metadata about repository queries. +Spring Data Cassandra stores this metadata in JSON files that are named like the repository interface and stored next to it (i.e. within the same package). +Repository JSON Metadata contains details about queries and fragments. +An example for the following repository is shown below: + +==== +[source,java] +---- +interface UserRepository extends CrudRepository { + + List findUserNoArgumentsBy(); <1> + + Slice findSliceOfUsersByLastnameStartingWith(String lastname, Pageable page); <2> + + @Query("select * from User where emailAddress = :emailAddress") + User findAnnotatedQueryByEmailAddress(String emailAddress); <3> + + User findByEmailAddress(String emailAddress); <4> +} +---- + +<1> Derived query without arguments. +<2> Derived query using pagination. +<3> Annotated query. +<4> Named query. +==== + +[source,json] +---- +{ + "name": "com.acme.UserRepository", + "module": "Cassandra", + "type": "IMPERATIVE", + "methods": [ + { + "name": "findUserNoArgumentsBy", + "signature": "public abstract java.util.List com.acme.UserRepository.findUserNoArgumentsBy()", + "query": { + "query": "select * from user" + } + }, + { + "name": "findSliceOfUsersByLastnameStartingWith", + "signature": "public abstract org.springframework.data.domain.Slice com.acme.UserRepository.findSliceOfUsersByLastnameStartingWith(java.lang.String,org.springframework.data.domain.Pageable)", + "query": { + "query": "select * from user where lastname like ?" + } + }, + { + "name": "findAnnotatedQueryByEmailAddress", + "signature": "public abstract com.acme.User com.acme.UserRepository.findAnnotatedQueryByEmailAddress(java.lang.String)", + "query": { + "query": "select * from user where emailaddress = ?" + } + }, + { + "name": "findByEmailAddress", + "signature": "public abstract com.acme.User com.acme.UserRepository.findByEmailAddress(java.lang.String)", + "query": { + "name": "User.findByEmailAddress", + "query": "select * from user where emailaddress = ?" + } + }, + { + "name": "count", + "signature": "public abstract long org.springframework.data.repository.CrudRepository.count()", + "fragment": { + "fragment": "org.springframework.data.cassandra.repository.support.SimpleCassandraRepository" + } + } + ] +} +---- + +Queries may contain the following fields: + +* `query`: Query descriptor if the method is a query method. +** `query` the query used to obtain the query method results. +** `name`: Name of the named query if the query is a named one. +* `fragment`: Target fragment if the method call is delegated to a store (repository base class, functional fragment such as Querydsl) or user fragment. +Fragments are either described with just `fragment` if there is no further interface or as `interface` and `fragment` tuple in case there is an interface (such as Querydsl or user-declared fragment interface). + +[NOTE] +.Normalized Query Form +==== +Static analysis of queries allows only a limited representation of runtime query behavior. +Queries are represented in their normalized (pre-parsed and rewritten) form: + +* Value Expressions are replaced with bind markers. +* Query Metadata does not reflect bind-value processing. +`StartingWith`/`EndingWith` queries prepend/append the wildcard character `%` to the actual bind value. +* Runtime Sort information cannot be incorporated in the query string itself as that detail is not known at build-time. +====
+ * 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); @@ -283,20 +286,17 @@ 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: - 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/main/java/org/springframework/data/cassandra/repository/query/ContextualValueExpressionEvaluator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ContextualValueExpressionEvaluator.java index 4e02c4ee5..47785ebc7 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ContextualValueExpressionEvaluator.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ContextualValueExpressionEvaluator.java @@ -39,7 +39,12 @@ public ContextualValueExpressionEvaluator(ValueExpressionParser parser, ValueEva @SuppressWarnings("unchecked") @Override - public @Nullable T evaluate(String expressionString) { + public @Nullable T evaluate(String rawExpressionString) { + + String expressionString = rawExpressionString.contains("#{") || rawExpressionString.contains("${") + ? rawExpressionString + : "#{" + rawExpressionString + "}"; + ValueExpression expression = parser.parse(expressionString); return (T) expression.evaluate(evaluationContext); } 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; 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..60af1dbc1 --- /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) + */ + protected 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 super String, ? extends String> 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 super String, ? extends String> 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 super String, ? extends String> 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 super String, ? extends String> 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 super String, ? extends String> 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/QueryStatementCreator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/QueryStatementCreator.java index 0b868264a..363bcdc46 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/QueryStatementCreator.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/QueryStatementCreator.java @@ -316,9 +316,7 @@ SimpleStatement select(StringBasedQuery stringBasedQuery, CassandraParameterAcce ValueExpressionEvaluator evaluator) { try { - SimpleStatement boundQuery = stringBasedQuery.bindQuery(parameterAccessor, evaluator); - Optional queryOptions = Optional.ofNullable(parameterAccessor.getQueryOptions()); SimpleStatement queryToUse = boundQuery; 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/main/java/org/springframework/data/cassandra/repository/query/WindowUtil.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/WindowUtil.java index 1ee8b8668..b9f90256f 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/WindowUtil.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/WindowUtil.java @@ -26,9 +26,9 @@ * * @author Mark Paluch */ -class WindowUtil { +public class WindowUtil { - static Window of(Slice slice) { + public static Window of(Slice slice) { List content = slice.getContent(); CassandraPageRequest pageable = (CassandraPageRequest) slice.getPageable(); diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotNamedQueryIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotNamedQueryIntegrationTests.java new file mode 100644 index 000000000..4a384dff9 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotNamedQueryIntegrationTests.java @@ -0,0 +1,99 @@ +/* + * 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; + +import java.io.IOException; +import java.util.Collections; +import java.util.Set; + +import org.springframework.beans.factory.config.PropertiesFactoryBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.cassandra.config.SchemaAction; +import org.springframework.data.cassandra.core.CassandraTemplate; +import org.springframework.data.cassandra.core.mapping.MapId; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.aot.AotFragmentTestConfigurationSupport; +import org.springframework.data.cassandra.repository.support.CassandraRepositoryFactoryBean; +import org.springframework.data.cassandra.repository.support.IntegrationTestConfig; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.support.PropertiesBasedNamedQueries; +import org.springframework.data.repository.core.support.RepositoryComposition; +import org.springframework.data.spel.ExtensionAwareEvaluationContextProvider; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Integration test for {@link PersonRepositoryWithNamedQueries} using JavaConfig with mounted AOT-generated repository + * methods. + * + * @author Mark Paluch + */ +@SpringJUnitConfig(classes = AotNamedQueryIntegrationTests.AotConfiguration.class) +public class AotNamedQueryIntegrationTests extends NamedQueryIntegrationTests { + + @Configuration + public static class AotConfiguration extends IntegrationTestConfig { + + @Override + protected Set> getInitialEntitySet() { + return Collections.singleton(Person.class); + } + + @Override + public SchemaAction getSchemaAction() { + return SchemaAction.RECREATE_DROP_UNUSED; + } + + @Bean + static AotFragmentTestConfigurationSupport aot() { + return new AotFragmentTestConfigurationSupport(PersonRepositoryWithNamedQueries.class, + NamedQueryIntegrationTests.Config.class, false); + } + + @Bean + public NamedQueryIntegrationTests.PersonRepositoryWithNamedQueries personRepository( + ApplicationContext applicationContext, CassandraTemplate template) throws Exception { + + ExtensionAwareEvaluationContextProvider evaluationContextProvider = new ExtensionAwareEvaluationContextProvider( + applicationContext); + + CassandraRepositoryFactoryBean factory = new CassandraRepositoryFactoryBean<>( + NamedQueryIntegrationTests.PersonRepositoryWithNamedQueries.class); + factory.setCassandraTemplate(template); + factory.setBeanFactory(applicationContext); + + factory.setRepositoryFragments( + RepositoryComposition.RepositoryFragments.just(applicationContext.getBean("fragment"))); + + factory.setNamedQueries(namedQueries()); + factory.setEvaluationContextProvider(evaluationContextProvider); + factory.afterPropertiesSet(); + + return factory.getObject(); + } + + private NamedQueries namedQueries() throws IOException { + + PropertiesFactoryBean factory = new PropertiesFactoryBean(); + factory.setLocation(new ClassPathResource("META-INF/PersonRepositoryWithNamedQueries.properties")); + factory.afterPropertiesSet(); + + return new PropertiesBasedNamedQueries(factory.getObject()); + } + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotQueryDerivationIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotQueryDerivationIntegrationTests.java new file mode 100644 index 000000000..c0d1c67ad --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotQueryDerivationIntegrationTests.java @@ -0,0 +1,103 @@ +/* + * 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; + +import java.io.IOException; +import java.util.Set; + +import org.springframework.beans.factory.config.PropertiesFactoryBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.cassandra.config.SchemaAction; +import org.springframework.data.cassandra.core.CassandraTemplate; +import org.springframework.data.cassandra.core.mapping.MapId; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.aot.AotFragmentTestConfigurationSupport; +import org.springframework.data.cassandra.repository.config.EnableCassandraRepositories; +import org.springframework.data.cassandra.repository.support.CassandraRepositoryFactoryBean; +import org.springframework.data.cassandra.repository.support.IntegrationTestConfig; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.support.PropertiesBasedNamedQueries; +import org.springframework.data.repository.core.support.RepositoryComposition; +import org.springframework.data.spel.ExtensionAwareEvaluationContextProvider; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Integration test for {@link PersonRepository} using JavaConfig with mounted AOT-generated repository methods. + * + * @author Mark Paluch + */ +@SpringJUnitConfig(classes = AotQueryDerivationIntegrationTests.AotConfiguration.class) +class AotQueryDerivationIntegrationTests extends QueryDerivationIntegrationTests { + + @Configuration(proxyBeanMethods = false) + @EnableCassandraRepositories(considerNestedRepositories = true, + includeFilters = @ComponentScan.Filter(classes = { EmbeddedPersonRepository.class }, + type = FilterType.ASSIGNABLE_TYPE)) + static class AotConfiguration extends IntegrationTestConfig { + + @Override + protected Set> getInitialEntitySet() { + return Set.of(Person.class, PersonWithEmbedded.class); + } + + @Override + public SchemaAction getSchemaAction() { + return SchemaAction.CREATE; + } + + @Bean + static AotFragmentTestConfigurationSupport aot() { + return new AotFragmentTestConfigurationSupport(PersonRepository.class, + QueryDerivationIntegrationTests.Config.class, false); + } + + @Bean + public QueryDerivationIntegrationTests.PersonRepository personRepository(ApplicationContext applicationContext, + CassandraTemplate template) throws Exception { + + ExtensionAwareEvaluationContextProvider evaluationContextProvider = new ExtensionAwareEvaluationContextProvider( + applicationContext); + + CassandraRepositoryFactoryBean factory = new CassandraRepositoryFactoryBean<>( + QueryDerivationIntegrationTests.PersonRepository.class); + factory.setCassandraTemplate(template); + factory.setBeanFactory(applicationContext); + + factory.setRepositoryFragments( + RepositoryComposition.RepositoryFragments.just(applicationContext.getBean("fragment"))); + + factory.setNamedQueries(namedQueries()); + factory.setEvaluationContextProvider(evaluationContextProvider); + factory.afterPropertiesSet(); + + return factory.getObject(); + } + + private NamedQueries namedQueries() throws IOException { + + PropertiesFactoryBean factory = new PropertiesFactoryBean(); + factory.setLocation(new ClassPathResource("META-INF/cassandra-named-queries.properties")); + factory.afterPropertiesSet(); + + return new PropertiesBasedNamedQueries(factory.getObject()); + } + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/QueryDerivationIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/QueryDerivationIntegrationTests.java index d3894e2ad..feaf1e0d8 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/QueryDerivationIntegrationTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/QueryDerivationIntegrationTests.java @@ -447,7 +447,7 @@ public void derivedQueryOnPropertyOfEmbeddedEntity() { /** * @author Mark Paluch */ - static interface PersonRepository extends MapIdCassandraRepository { + public static interface PersonRepository extends MapIdCassandraRepository { List findByLastname(String lastname); @@ -504,14 +504,14 @@ enum NumberOfChildren { ZERO, ONE, TWO, } - interface PersonProjection { + public interface PersonProjection { String getFirstname(); String getLastname(); } - class PersonDto { + public class PersonDto { public String firstname, lastname; @@ -525,14 +525,14 @@ public PersonDto(String firstname, String lastname) { /** * @author Christoph Strobl */ - static interface EmbeddedPersonRepository extends CassandraRepository { + public static interface EmbeddedPersonRepository extends CassandraRepository { PersonWithEmbedded findByName_Firstname(String firstname); } @Table - static class PersonWithEmbedded { + public static class PersonWithEmbedded { @Id String id; @Embedded.Nullable Name name; 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..eff15d4b8 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/AotFragmentTestConfigurationSupport.java @@ -0,0 +1,174 @@ +/* + * 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.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 { + + private final Class> repositoryInterface; + private final boolean registerFragmentFacade; + private final TestCassandraAotRepositoryContext> repositoryContext; + + 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 repositoryContributor = new CassandraRepositoryContributor(repositoryContext); + repositoryContributor.contribute(generationContext); + + AbstractBeanDefinition aotGeneratedRepository = BeanDefinitionBuilder + .genericBeanDefinition( + repositoryInterface.getPackageName() + "." + repositoryInterface.getSimpleName() + "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; + } + +} 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..cbff00f0d --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributorIntegrationTests.java @@ -0,0 +1,509 @@ +/* + * 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.lang.reflect.UndeclaredThrowableException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +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.cql.QueryOptions; +import org.springframework.data.cassandra.core.cql.SessionCallback; +import org.springframework.data.cassandra.core.query.CassandraScrollPosition; +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.Limit; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScoringFunction; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Vector; +import org.springframework.data.domain.Window; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.cql.ResultSet; +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}. + * + * @author Mark Paluch + */ +@SpringJUnitConfig( + classes = CassandraRepositoryContributorIntegrationTests.CassandraRepositoryContributorConfiguration.class) +class CassandraRepositoryContributorIntegrationTests extends AbstractSpringDataEmbeddedCassandraIntegrationTest { + + @Autowired PersonRepository fragment; + private static boolean indexExists = false; + + @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() { + + template.delete(Person.class); + + if (!indexExists) { + + template.getCqlOperations().execute( + "CREATE CUSTOM INDEX IF NOT EXISTS person_firstname ON person (firstname) USING 'org.apache.cassandra.index.sasi.SASIIndex' WITH OPTIONS = {'mode': 'CONTAINS' };"); + template.getCqlOperations() + .execute("CREATE INDEX IF NOT EXISTS person_numberofchildren ON person (numberofchildren)"); + + template.getCqlOperations().execute((SessionCallback extends Object>) 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; + }); + indexExists = true; + } + + 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 = template.insert(person); + skyler = template.insert(new Person("Skyler", "White")); + flynn = template.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 shouldFindByFirstnameWithQueryOptions() { + + Person walter = fragment.findByFirstname("Walter", QueryOptions.builder().build()); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldQuerySlice() { + + Slice first = fragment.findTop2SliceByLastname("White", Pageable.unpaged()); + Slice second = fragment.findTop2SliceByLastname("White", first.nextPageable()); + + assertThat(first).hasSize(2); + assertThat(first.hasNext()).isTrue(); + assertThat(second).hasSize(1); + assertThat(second.hasNext()).isFalse(); + } + + @Test // GH-1566 + void shouldQueryWindow() { + + Window first = fragment.findWindowByLastname("White", CassandraScrollPosition.initial(), Limit.of(2)); + Window second = fragment.findWindowByLastname("White", first.positionAt(1), Limit.of(2)); + + assertThat(first).hasSize(2); + assertThat(first.hasNext()).isTrue(); + assertThat(second).hasSize(1); + assertThat(second.hasNext()).isFalse(); + } + + @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() { + + 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 shouldApplyExistsCountProjection() { + + assertThat(fragment.existsByLastname("White")).isTrue(); + assertThat(fragment.countByLastname("White")).isEqualTo(3); + } + + @Test // GH-1566 + void shouldFindByDeclaredFirstname() { + + Person walter = fragment.findDeclaredByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindByDeclaredPositionalFirstname() { + + Person walter = fragment.findDeclaredByPositionalFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindByDeclaredExpression() { + + Person walter = fragment.findDeclaredByExpression("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindByDeclaredPropertyPlaceholderExpression() { + + Person walter = fragment.findDeclaredByExpression(); + + assertThat(walter).isNull(); + } + + @Test // GH-1566 + void shouldApplyDeclaredExistsCountProjection() { + + assertThat(fragment.existsDeclaredByLastname("White")).isTrue(); + assertThat(fragment.countDeclaredByLastname("White")).isEqualTo(3); + } + + @Test // GH-1566 + void shouldQueryDeclaredSlice() { + + Slice first = fragment.findDeclaredSliceByLastname("White", Pageable.ofSize(2)); + Slice second = fragment.findDeclaredSliceByLastname("White", first.nextPageable()); + + assertThat(first).hasSize(2); + assertThat(first.hasNext()).isTrue(); + assertThat(second).hasSize(1); + assertThat(second.hasNext()).isFalse(); + } + + @Test // GH-1566 + void shouldQueryDeclaredWindow() { + + Window first = fragment.findDeclaredWindowByLastname("White", CassandraScrollPosition.initial(), 3, + Limit.of(2)); + Window second = fragment.findDeclaredWindowByLastname("White", first.positionAt(1), 3, Limit.of(2)); + + assertThat(first).hasSize(2); + assertThat(first.hasNext()).isTrue(); + assertThat(second).hasSize(1); + assertThat(second.hasNext()).isFalse(); + } + + @Test // GH-1566 + void shouldFindNamedQuery() { + + Person walter = fragment.findNamedByFirstname("Walter"); + + assertThat(walter).isNotNull(); + } + + @Test // GH-1566 + void shouldReturnResultSet() { + + ResultSet resultSet = fragment.findResultSetByFirstname("Walter"); + + assertThat(resultSet).isNotNull(); + assertThat(resultSet.one().getString("firstname")).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldReturnDeclaredResultSet() { + + ResultSet resultSet = fragment.findDeclaredResultSetByFirstname("Walter"); + + assertThat(resultSet).isNotNull(); + assertThat(resultSet.one().getString("firstname")).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldReturnMap() { + + Map map = fragment.findMapByFirstname("Walter"); + + assertThat(map).isNotNull(); + assertThat(map.get("firstname")).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldReturnDeclaredMap() { + + Map map = fragment.findDeclaredMapByFirstname("Walter"); + + assertThat(map).isNotNull(); + assertThat(map.get("firstname")).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectToDto() { + + PersonRepository.PersonDto walter = fragment.findOneDtoProjectionByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.firstname).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectManyToDto() { + + List walter = fragment.findDtoProjectionByFirstname("Walter"); + + assertThat(walter).hasSize(1); + assertThat(walter).extracting(PersonRepository.PersonDto::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectStreamToDto() { + + Stream walter = fragment.streamDtoProjectionByFirstname("Walter"); + + assertThat(walter).hasSize(1).extracting(PersonRepository.PersonDto::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectDeclaredToDto() { + + PersonRepository.PersonDto walter = fragment.findOneDeclaredDtoProjectionByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectDeclaredSimpleType() { + + assertThat(fragment.findDeclaredNumberOfChildrenByFirstname("Walter")).isEqualTo(2); + assertThat(fragment.findDeclaredNumberOfChildrenByFirstname("Flynn (Walter Jr.)")).isEqualTo(0); + assertThat(fragment.findDeclaredListNumberOfChildrenByFirstname("Walter")).containsOnly(2); + } + + @Test // GH-1566 + void shouldProjectToInterface() { + + PersonRepository.PersonProjection walter = fragment.findOneInterfaceProjectionByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectManyToInterface() { + + List walter = fragment.findInterfaceProjectionByFirstname("Walter"); + + assertThat(walter).hasSize(1); + assertThat(walter).extracting(PersonRepository.PersonProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectStreamToInterface() { + + Stream walter = fragment.streamInterfaceProjectionByFirstname("Walter"); + + assertThat(walter).hasSize(1).extracting(PersonRepository.PersonProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectDeclaredToInterface() { + + PersonRepository.PersonProjection walter = fragment.findOneDeclaredInterfaceProjectionByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectDynamically() { + + PersonRepository.PersonProjection projection = fragment.findOneProjectionByFirstname("Walter", + PersonRepository.PersonProjection.class); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + + List projections = fragment.findProjectionByFirstname("Walter", + PersonRepository.PersonProjection.class); + + assertThat(projections).hasSize(1); + assertThat(projections).extracting(PersonRepository.PersonProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + @Disabled("not supported") + void shouldProjectDeclaredDynamically() { + + PersonRepository.PersonProjection projection = fragment.findOneDeclaredProjectionByFirstname("Walter", + PersonRepository.PersonProjection.class); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + + List projections = fragment.findDeclaredProjectionByFirstname("Walter", + PersonRepository.PersonProjection.class); + + assertThat(projections).hasSize(1); + assertThat(projections).extracting(PersonRepository.PersonProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectToDtoDynamically() { + + PersonRepository.PersonDto projection = fragment.findOneProjectionByFirstname("Walter", + PersonRepository.PersonDto.class); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + + List projections = fragment.findProjectionByFirstname("Walter", + PersonRepository.PersonDto.class); + + assertThat(projections).hasSize(1); + assertThat(projections).extracting(PersonRepository.PersonDto::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectDeclaredToDtoDynamically() { + + PersonRepository.PersonDto projection = fragment.findOneDeclaredProjectionByFirstname("Walter", + PersonRepository.PersonDto.class); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + + List projections = fragment.findDeclaredProjectionByFirstname("Walter", + PersonRepository.PersonDto.class); + + assertThat(projections).hasSize(1); + assertThat(projections).extracting(PersonRepository.PersonDto::getFirstname).containsOnly("Walter"); + } + + // TODO: Vector Search + + @Test // GH-1566 + void vectorSearchNotSupportedYet() { + assertThatExceptionOfType(UndeclaredThrowableException.class) + .isThrownBy(() -> fragment.findAllByVector(Vector.of(1f), ScoringFunction.cosine())) + .withCauseInstanceOf(NoSuchMethodException.class); + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryMetadataIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryMetadataIntegrationTests.java new file mode 100644 index 000000000..54ee4fd9a --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryMetadataIntegrationTests.java @@ -0,0 +1,159 @@ +/* + * 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 net.javacrumbs.jsonunit.assertj.JsonAssertions.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.support.AbstractApplicationContext; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.data.cassandra.core.CassandraTemplate; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.config.EnableCassandraRepositories; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Integration tests for the {@link PersonRepository} JSON metadata via {@link CassandraRepositoryContributor}. + * + * @author Mark Paluch + */ +@SpringJUnitConfig( + classes = CassandraRepositoryMetadataIntegrationTests.CassandraRepositoryContributorConfiguration.class) +class CassandraRepositoryMetadataIntegrationTests { + + @Autowired AbstractApplicationContext context; + + @Configuration + @EnableCassandraRepositories(considerNestedRepositories = true, + includeFilters = { @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = Person.class) }) + static class CassandraRepositoryContributorConfiguration extends AotFragmentTestConfigurationSupport { + + public CassandraRepositoryContributorConfiguration() { + super(PersonRepository.class, CassandraRepositoryContributorConfiguration.class); + } + + @Bean + public CassandraTemplate cassandraTemplate() { + return mock(CassandraTemplate.class); + } + } + + @Test // GH-1566 + void shouldDocumentBase() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).isObject() // + .containsEntry("name", PersonRepository.class.getName()) // + .containsEntry("module", "Cassandra") // + .containsEntry("type", "IMPERATIVE"); + } + + @Test // GH-1566 + void shouldDocumentDerivedQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'countByLastname')].query").isArray().first().isObject() + .containsEntry("query", "SELECT count(1) FROM person WHERE lastname=?"); + } + + @Test // GH-1566 + void shouldDocumentDeclaredQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findDeclaredByFirstname')].query").isArray().first().isObject() + .containsEntry("query", "select * from person where firstname = ?"); + } + + @Test // GH-1566 + void shouldDocumentNamedQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findNamedByFirstname')].query").isArray().first().isObject() + .containsEntry("query", "SELECT * FROM person WHERE firstname=? ALLOW FILTERING") + .containsEntry("name", "Person.findNamedByFirstname"); + } + + @Test // GH-1566 + void shouldNotIncludeVectorSearch() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findAllByVector')].query").isArray().isEmpty(); + } + + @Test // GH-1566 + void shouldDocumentBaseFragment() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'existsById')].fragment").isArray().first().isObject() + .containsEntry("fragment", "org.springframework.data.cassandra.repository.support.SimpleCassandraRepository"); + } + + private Resource getResource() { + + String location = PersonRepository.class.getPackageName().replace('.', '/') + "/" + + PersonRepository.class.getSimpleName() + ".json"; + return new UrlResource(context.getBeanFactory().getBeanClassLoader().getResource(location)); + } + +} 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..e365cec35 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/PersonRepository.java @@ -0,0 +1,238 @@ +/* + * 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.Map; +import java.util.Optional; +import java.util.stream.Stream; + +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.CountQuery; +import org.springframework.data.cassandra.repository.ExistsQuery; +import org.springframework.data.cassandra.repository.Query; +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScoringFunction; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.SearchResults; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Vector; +import org.springframework.data.domain.Window; +import org.springframework.data.repository.CrudRepository; + +import com.datastax.oss.driver.api.core.DefaultConsistencyLevel; +import com.datastax.oss.driver.api.core.cql.ResultSet; + +/** + * AOT repository interface for {@link Person} entities. + * + * @author Mark Paluch + */ +public interface PersonRepository extends CrudRepository { + + @Query(idempotent = Query.Idempotency.IDEMPOTENT) + Person findByFirstname(String firstname); + + @Consistency(DefaultConsistencyLevel.ONE) + Person findByFirstname(String firstname, QueryOptions queryOptions); + + Slice findTop2SliceByLastname(String lastname, Pageable pageable); + + Window findWindowByLastname(String lastname, ScrollPosition scrollPosition, Limit limit); + + Optional findOptionalByFirstname(String firstname); + + List findByLastname(String lastname, Sort sort); + + List findByLastnameOrderByFirstnameAsc(String lastname); + + 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); + + int countByLastname(String lastname); + + boolean existsByLastname(String lastname); + + // ------------------------------------------------------------------------- + // Declared Queries + // ------------------------------------------------------------------------- + + @Query(value = "select * from person where firstname = :firstname", idempotent = Query.Idempotency.IDEMPOTENT) + Person findDeclaredByFirstname(String firstname); + + @Query(value = "select * from person where firstname = ?0") + Person findDeclaredByPositionalFirstname(String firstname); + + @CountQuery(value = "select COUNT(*) from person where lastname = ?0") + int countDeclaredByLastname(String lastname); + + @ExistsQuery(value = "select COUNT(*) from person where lastname = ?0") + boolean existsDeclaredByLastname(String lastname); + + @Query(value = "select * from person where lastname = ?0 LIMIT 3") + Slice findDeclaredSliceByLastname(String lastname, Pageable pageable); + + @Query(value = "select * from person where lastname = :lastname LIMIT :sliceLimit") + Window findDeclaredWindowByLastname(String lastname, ScrollPosition scrollPosition, int sliceLimit, + Limit pageSize); + + // ------------------------------------------------------------------------- + // Value Expressions + // ------------------------------------------------------------------------- + + @Query(value = "select * from person where firstname = :#{#firstname}") + Person findDeclaredByExpression(String firstname); + + @Query(value = "select * from person where firstname = :${user.dir}") + Person findDeclaredByExpression(); + + // ------------------------------------------------------------------------- + // Named Queries + // ------------------------------------------------------------------------- + + Person findNamedByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: ResultSet, Map + // ------------------------------------------------------------------------- + + ResultSet findResultSetByFirstname(String firstname); + + @Query(value = "select * from person where firstname = ?0") + ResultSet findDeclaredResultSetByFirstname(String firstname); + + Map findMapByFirstname(String firstname); + + @Query(value = "select * from person where firstname = ?0") + Map findDeclaredMapByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: DTO + // ------------------------------------------------------------------------- + + PersonDto findOneDtoProjectionByFirstname(String firstname); + + List findDtoProjectionByFirstname(String firstname); + + Stream streamDtoProjectionByFirstname(String firstname); + + @Query(value = "select * from person where firstname = :#{#firstname}") + PersonDto findOneDeclaredDtoProjectionByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: Single-field + // ------------------------------------------------------------------------- + + @Query("select numberOfChildren from person where firstname = :firstname") + int findDeclaredNumberOfChildrenByFirstname(String firstname); + + @Query("select numberOfChildren from person where firstname = :firstname") + List findDeclaredListNumberOfChildrenByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: Interface + // ------------------------------------------------------------------------- + + PersonProjection findOneInterfaceProjectionByFirstname(String firstname); + + List findInterfaceProjectionByFirstname(String firstname); + + Stream streamInterfaceProjectionByFirstname(String firstname); + + @Query(value = "select * from person where firstname = :#{#firstname}") + PersonProjection findOneDeclaredInterfaceProjectionByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: Dynamic + // ------------------------------------------------------------------------- + + T findOneProjectionByFirstname(String firstname, Class projectionType); + + @Query(value = "select * from person where firstname = :firstname") + T findOneDeclaredProjectionByFirstname(String firstname, Class projectionType); + + List findProjectionByFirstname(String firstname, Class projectionType); + + @Query(value = "select * from person where firstname = :firstname") + List findDeclaredProjectionByFirstname(String firstname, Class projectionType); + + // ------------------------------------------------------------------------- + // Excluded + // ------------------------------------------------------------------------- + + SearchResults findAllByVector(Vector vector, ScoringFunction scoringFunction); + + interface PersonProjection { + + String getFirstname(); + + String getLastname(); + } + + class PersonDto { + + public String firstname, lastname; + + public PersonDto(String firstname, String lastname) { + this.firstname = firstname; + this.lastname = lastname; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } + } +} 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) 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( 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}. diff --git a/spring-data-cassandra/src/test/resources/META-INF/cassandra-named-queries.properties b/spring-data-cassandra/src/test/resources/META-INF/cassandra-named-queries.properties index c596c8305..c1e18ab2a 100644 --- a/spring-data-cassandra/src/test/resources/META-INF/cassandra-named-queries.properties +++ b/spring-data-cassandra/src/test/resources/META-INF/cassandra-named-queries.properties @@ -1 +1,2 @@ User.findByNamedQuery=SELECT firstname FROM users WHERE username=?0 +Person.findNamedByFirstname=SELECT * FROM person WHERE firstname=?0 ALLOW FILTERING diff --git a/spring-data-cassandra/src/test/resources/logback.xml b/spring-data-cassandra/src/test/resources/logback.xml index 10ee2a2c7..086cd2362 100644 --- a/spring-data-cassandra/src/test/resources/logback.xml +++ b/spring-data-cassandra/src/test/resources/logback.xml @@ -11,6 +11,7 @@ + diff --git a/src/main/antora/modules/ROOT/nav.adoc b/src/main/antora/modules/ROOT/nav.adoc index dac20da62..63fc8df2f 100644 --- a/src/main/antora/modules/ROOT/nav.adoc +++ b/src/main/antora/modules/ROOT/nav.adoc @@ -36,6 +36,7 @@ ** xref:cassandra/repositories/cdi-integration.adoc[] ** xref:repositories/query-keywords-reference.adoc[] ** xref:repositories/query-return-types-reference.adoc[] +** xref:cassandra/repositories/aot.adoc[] * xref:observability.adoc[] diff --git a/src/main/antora/modules/ROOT/pages/cassandra/repositories/aot.adoc b/src/main/antora/modules/ROOT/pages/cassandra/repositories/aot.adoc new file mode 100644 index 000000000..0fa00fcc5 --- /dev/null +++ b/src/main/antora/modules/ROOT/pages/cassandra/repositories/aot.adoc @@ -0,0 +1,179 @@ += Ahead of Time Optimizations + +This chapter covers Spring Data's Ahead of Time (AOT) optimizations that build upon {spring-framework-docs}/core/aot.html[Spring's Ahead of Time Optimizations]. + +[[aot.bestpractices]] +== Best Practices + +=== Annotate your Domain Types + +During application startup, Spring scans the classpath for domain classes for early processing of entities. +By annotating your domain types with Spring Data-specific `@Table`, `@Document` or `@Entity` annotations you can aid initial entity scanning and ensure that those types are registered with `ManagedTypes` for Runtime Hints. +Classpath scanning is not possible in native image arrangements and so Spring has to use `ManagedTypes` for the initial entity set. + +[[aot.hints]] +== Runtime Hints + +Running an application as a native image requires additional information compared to a regular JVM runtime. +Spring Data contributes {spring-framework-docs}/core/aot.html#aot.hints[Runtime Hints] during AOT processing for native image usage. +These are in particular hints for: + +* Auditing +* `ManagedTypes` to capture the outcome of class-path scans +* Repositories +** Reflection hints for entities, return types, and Spring Data annotations +** Repository fragments +** Querydsl `Q` classes +** Kotlin Coroutine support +* Web support (Jackson Hints for `PagedModel`) + +[[aot.repositories]] +== Ahead of Time Repositories + +AOT Repositories are an extension to AOT processing by pre-generating eligible query method implementations. +Query methods are opaque to developers regarding their underlying queries being executed in a query method call. +AOT repositories contribute query method implementations based on derived, annotated, and named queries that are known at build-time. +This optimization moves query method processing from runtime to build-time, which can lead to a significant performance improvement as query methods do not need to be analyzed reflectively upon each application start. + +The resulting AOT repository fragment follows the naming scheme of `Impl__Aot` and is placed in the same package as the repository interface. +You can find all queries in their String form for generated repository query methods. + +NOTE: Consider AOT repository classes an internal optimization. +Do not use them directly in your code as generation and implementation details may change in future releases. + +=== Running with AOT Repositories + +AOT is a mandatory step to transform a Spring application to a native executable, so it is automatically enabled when running in this mode. +When AOT is enabled (either for native compilation or by setting `spring.aot.enabled=true`), AOT repositories are automatically enabled by default. + +You can disable AOT repository generation entirely or only disable Cassandra AOT repositories: + +* Set the `spring.aot.repositories.enabled=false` property to disable generated repositories for all Spring Data modules. +* Set the `spring.aot.cassandra.repositories.enabled=false` property to disable only C AOT repositories. + +AOT repositories contribute configuration changes to the actual repository bean registration to register the generated repository fragment. + +NOTE: When AOT optimizations are included, some decisions that have been taken at build-time are hard-coded in the application setup. +For instance, profiles that have been enabled at build-time are automatically enabled at runtime as well. +Also, the Spring Data module implementing a repository is fixed. +Changing the implementation requires AOT re-processing. + +=== Eligible Methods + +AOT repositories filter methods that are eligible for AOT processing. +These are typically all query methods that are not backed by an xref:repositories/custom-implementations.adoc[implementation fragment]. + +**Supported Features** + +* Derived query methods, `@Query` and named query methods. +* `Window`, `Slice`, `Stream`, and `Optional` return types +* Sort query rewriting +* Interface and DTO Projections +* Value Expressions (Those require a bit of reflective information. +Mind that using Value Expressions requires expression parsing and contextual information to evaluate the expression) + +**Limitation** + +* Vector Search not yet supported + +**Excluded methods** + +* `CrudRepository` and other base interface methods as their implementation is provided by the base class respective fragments +* Vector Search Methods + +[[aot.repositories.json]] +== Repository Metadata + +AOT processing introspects query methods and collects metadata about repository queries. +Spring Data Cassandra stores this metadata in JSON files that are named like the repository interface and stored next to it (i.e. within the same package). +Repository JSON Metadata contains details about queries and fragments. +An example for the following repository is shown below: + +==== +[source,java] +---- +interface UserRepository extends CrudRepository { + + List findUserNoArgumentsBy(); <1> + + Slice findSliceOfUsersByLastnameStartingWith(String lastname, Pageable page); <2> + + @Query("select * from User where emailAddress = :emailAddress") + User findAnnotatedQueryByEmailAddress(String emailAddress); <3> + + User findByEmailAddress(String emailAddress); <4> +} +---- + +<1> Derived query without arguments. +<2> Derived query using pagination. +<3> Annotated query. +<4> Named query. +==== + +[source,json] +---- +{ + "name": "com.acme.UserRepository", + "module": "Cassandra", + "type": "IMPERATIVE", + "methods": [ + { + "name": "findUserNoArgumentsBy", + "signature": "public abstract java.util.List com.acme.UserRepository.findUserNoArgumentsBy()", + "query": { + "query": "select * from user" + } + }, + { + "name": "findSliceOfUsersByLastnameStartingWith", + "signature": "public abstract org.springframework.data.domain.Slice com.acme.UserRepository.findSliceOfUsersByLastnameStartingWith(java.lang.String,org.springframework.data.domain.Pageable)", + "query": { + "query": "select * from user where lastname like ?" + } + }, + { + "name": "findAnnotatedQueryByEmailAddress", + "signature": "public abstract com.acme.User com.acme.UserRepository.findAnnotatedQueryByEmailAddress(java.lang.String)", + "query": { + "query": "select * from user where emailaddress = ?" + } + }, + { + "name": "findByEmailAddress", + "signature": "public abstract com.acme.User com.acme.UserRepository.findByEmailAddress(java.lang.String)", + "query": { + "name": "User.findByEmailAddress", + "query": "select * from user where emailaddress = ?" + } + }, + { + "name": "count", + "signature": "public abstract long org.springframework.data.repository.CrudRepository.count()", + "fragment": { + "fragment": "org.springframework.data.cassandra.repository.support.SimpleCassandraRepository" + } + } + ] +} +---- + +Queries may contain the following fields: + +* `query`: Query descriptor if the method is a query method. +** `query` the query used to obtain the query method results. +** `name`: Name of the named query if the query is a named one. +* `fragment`: Target fragment if the method call is delegated to a store (repository base class, functional fragment such as Querydsl) or user fragment. +Fragments are either described with just `fragment` if there is no further interface or as `interface` and `fragment` tuple in case there is an interface (such as Querydsl or user-declared fragment interface). + +[NOTE] +.Normalized Query Form +==== +Static analysis of queries allows only a limited representation of runtime query behavior. +Queries are represented in their normalized (pre-parsed and rewritten) form: + +* Value Expressions are replaced with bind markers. +* Query Metadata does not reflect bind-value processing. +`StartingWith`/`EndingWith` queries prepend/append the wildcard character `%` to the actual bind value. +* Runtime Sort information cannot be incorporated in the query string itself as that detail is not known at build-time. +====
+ * 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; 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..60af1dbc1 --- /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) + */ + protected 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 super String, ? extends String> 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 super String, ? extends String> 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 super String, ? extends String> 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 super String, ? extends String> 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 super String, ? extends String> 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/QueryStatementCreator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/QueryStatementCreator.java index 0b868264a..363bcdc46 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/QueryStatementCreator.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/QueryStatementCreator.java @@ -316,9 +316,7 @@ SimpleStatement select(StringBasedQuery stringBasedQuery, CassandraParameterAcce ValueExpressionEvaluator evaluator) { try { - SimpleStatement boundQuery = stringBasedQuery.bindQuery(parameterAccessor, evaluator); - Optional queryOptions = Optional.ofNullable(parameterAccessor.getQueryOptions()); SimpleStatement queryToUse = boundQuery; 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/main/java/org/springframework/data/cassandra/repository/query/WindowUtil.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/WindowUtil.java index 1ee8b8668..b9f90256f 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/WindowUtil.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/WindowUtil.java @@ -26,9 +26,9 @@ * * @author Mark Paluch */ -class WindowUtil { +public class WindowUtil { - static Window of(Slice slice) { + public static Window of(Slice slice) { List content = slice.getContent(); CassandraPageRequest pageable = (CassandraPageRequest) slice.getPageable(); diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotNamedQueryIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotNamedQueryIntegrationTests.java new file mode 100644 index 000000000..4a384dff9 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotNamedQueryIntegrationTests.java @@ -0,0 +1,99 @@ +/* + * 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; + +import java.io.IOException; +import java.util.Collections; +import java.util.Set; + +import org.springframework.beans.factory.config.PropertiesFactoryBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.cassandra.config.SchemaAction; +import org.springframework.data.cassandra.core.CassandraTemplate; +import org.springframework.data.cassandra.core.mapping.MapId; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.aot.AotFragmentTestConfigurationSupport; +import org.springframework.data.cassandra.repository.support.CassandraRepositoryFactoryBean; +import org.springframework.data.cassandra.repository.support.IntegrationTestConfig; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.support.PropertiesBasedNamedQueries; +import org.springframework.data.repository.core.support.RepositoryComposition; +import org.springframework.data.spel.ExtensionAwareEvaluationContextProvider; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Integration test for {@link PersonRepositoryWithNamedQueries} using JavaConfig with mounted AOT-generated repository + * methods. + * + * @author Mark Paluch + */ +@SpringJUnitConfig(classes = AotNamedQueryIntegrationTests.AotConfiguration.class) +public class AotNamedQueryIntegrationTests extends NamedQueryIntegrationTests { + + @Configuration + public static class AotConfiguration extends IntegrationTestConfig { + + @Override + protected Set> getInitialEntitySet() { + return Collections.singleton(Person.class); + } + + @Override + public SchemaAction getSchemaAction() { + return SchemaAction.RECREATE_DROP_UNUSED; + } + + @Bean + static AotFragmentTestConfigurationSupport aot() { + return new AotFragmentTestConfigurationSupport(PersonRepositoryWithNamedQueries.class, + NamedQueryIntegrationTests.Config.class, false); + } + + @Bean + public NamedQueryIntegrationTests.PersonRepositoryWithNamedQueries personRepository( + ApplicationContext applicationContext, CassandraTemplate template) throws Exception { + + ExtensionAwareEvaluationContextProvider evaluationContextProvider = new ExtensionAwareEvaluationContextProvider( + applicationContext); + + CassandraRepositoryFactoryBean factory = new CassandraRepositoryFactoryBean<>( + NamedQueryIntegrationTests.PersonRepositoryWithNamedQueries.class); + factory.setCassandraTemplate(template); + factory.setBeanFactory(applicationContext); + + factory.setRepositoryFragments( + RepositoryComposition.RepositoryFragments.just(applicationContext.getBean("fragment"))); + + factory.setNamedQueries(namedQueries()); + factory.setEvaluationContextProvider(evaluationContextProvider); + factory.afterPropertiesSet(); + + return factory.getObject(); + } + + private NamedQueries namedQueries() throws IOException { + + PropertiesFactoryBean factory = new PropertiesFactoryBean(); + factory.setLocation(new ClassPathResource("META-INF/PersonRepositoryWithNamedQueries.properties")); + factory.afterPropertiesSet(); + + return new PropertiesBasedNamedQueries(factory.getObject()); + } + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotQueryDerivationIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotQueryDerivationIntegrationTests.java new file mode 100644 index 000000000..c0d1c67ad --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/AotQueryDerivationIntegrationTests.java @@ -0,0 +1,103 @@ +/* + * 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; + +import java.io.IOException; +import java.util.Set; + +import org.springframework.beans.factory.config.PropertiesFactoryBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.cassandra.config.SchemaAction; +import org.springframework.data.cassandra.core.CassandraTemplate; +import org.springframework.data.cassandra.core.mapping.MapId; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.aot.AotFragmentTestConfigurationSupport; +import org.springframework.data.cassandra.repository.config.EnableCassandraRepositories; +import org.springframework.data.cassandra.repository.support.CassandraRepositoryFactoryBean; +import org.springframework.data.cassandra.repository.support.IntegrationTestConfig; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.support.PropertiesBasedNamedQueries; +import org.springframework.data.repository.core.support.RepositoryComposition; +import org.springframework.data.spel.ExtensionAwareEvaluationContextProvider; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Integration test for {@link PersonRepository} using JavaConfig with mounted AOT-generated repository methods. + * + * @author Mark Paluch + */ +@SpringJUnitConfig(classes = AotQueryDerivationIntegrationTests.AotConfiguration.class) +class AotQueryDerivationIntegrationTests extends QueryDerivationIntegrationTests { + + @Configuration(proxyBeanMethods = false) + @EnableCassandraRepositories(considerNestedRepositories = true, + includeFilters = @ComponentScan.Filter(classes = { EmbeddedPersonRepository.class }, + type = FilterType.ASSIGNABLE_TYPE)) + static class AotConfiguration extends IntegrationTestConfig { + + @Override + protected Set> getInitialEntitySet() { + return Set.of(Person.class, PersonWithEmbedded.class); + } + + @Override + public SchemaAction getSchemaAction() { + return SchemaAction.CREATE; + } + + @Bean + static AotFragmentTestConfigurationSupport aot() { + return new AotFragmentTestConfigurationSupport(PersonRepository.class, + QueryDerivationIntegrationTests.Config.class, false); + } + + @Bean + public QueryDerivationIntegrationTests.PersonRepository personRepository(ApplicationContext applicationContext, + CassandraTemplate template) throws Exception { + + ExtensionAwareEvaluationContextProvider evaluationContextProvider = new ExtensionAwareEvaluationContextProvider( + applicationContext); + + CassandraRepositoryFactoryBean factory = new CassandraRepositoryFactoryBean<>( + QueryDerivationIntegrationTests.PersonRepository.class); + factory.setCassandraTemplate(template); + factory.setBeanFactory(applicationContext); + + factory.setRepositoryFragments( + RepositoryComposition.RepositoryFragments.just(applicationContext.getBean("fragment"))); + + factory.setNamedQueries(namedQueries()); + factory.setEvaluationContextProvider(evaluationContextProvider); + factory.afterPropertiesSet(); + + return factory.getObject(); + } + + private NamedQueries namedQueries() throws IOException { + + PropertiesFactoryBean factory = new PropertiesFactoryBean(); + factory.setLocation(new ClassPathResource("META-INF/cassandra-named-queries.properties")); + factory.afterPropertiesSet(); + + return new PropertiesBasedNamedQueries(factory.getObject()); + } + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/QueryDerivationIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/QueryDerivationIntegrationTests.java index d3894e2ad..feaf1e0d8 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/QueryDerivationIntegrationTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/QueryDerivationIntegrationTests.java @@ -447,7 +447,7 @@ public void derivedQueryOnPropertyOfEmbeddedEntity() { /** * @author Mark Paluch */ - static interface PersonRepository extends MapIdCassandraRepository { + public static interface PersonRepository extends MapIdCassandraRepository { List findByLastname(String lastname); @@ -504,14 +504,14 @@ enum NumberOfChildren { ZERO, ONE, TWO, } - interface PersonProjection { + public interface PersonProjection { String getFirstname(); String getLastname(); } - class PersonDto { + public class PersonDto { public String firstname, lastname; @@ -525,14 +525,14 @@ public PersonDto(String firstname, String lastname) { /** * @author Christoph Strobl */ - static interface EmbeddedPersonRepository extends CassandraRepository { + public static interface EmbeddedPersonRepository extends CassandraRepository { PersonWithEmbedded findByName_Firstname(String firstname); } @Table - static class PersonWithEmbedded { + public static class PersonWithEmbedded { @Id String id; @Embedded.Nullable Name name; 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..eff15d4b8 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/AotFragmentTestConfigurationSupport.java @@ -0,0 +1,174 @@ +/* + * 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.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 { + + private final Class> repositoryInterface; + private final boolean registerFragmentFacade; + private final TestCassandraAotRepositoryContext> repositoryContext; + + 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 repositoryContributor = new CassandraRepositoryContributor(repositoryContext); + repositoryContributor.contribute(generationContext); + + AbstractBeanDefinition aotGeneratedRepository = BeanDefinitionBuilder + .genericBeanDefinition( + repositoryInterface.getPackageName() + "." + repositoryInterface.getSimpleName() + "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; + } + +} 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..cbff00f0d --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributorIntegrationTests.java @@ -0,0 +1,509 @@ +/* + * 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.lang.reflect.UndeclaredThrowableException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +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.cql.QueryOptions; +import org.springframework.data.cassandra.core.cql.SessionCallback; +import org.springframework.data.cassandra.core.query.CassandraScrollPosition; +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.Limit; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScoringFunction; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Vector; +import org.springframework.data.domain.Window; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.cql.ResultSet; +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}. + * + * @author Mark Paluch + */ +@SpringJUnitConfig( + classes = CassandraRepositoryContributorIntegrationTests.CassandraRepositoryContributorConfiguration.class) +class CassandraRepositoryContributorIntegrationTests extends AbstractSpringDataEmbeddedCassandraIntegrationTest { + + @Autowired PersonRepository fragment; + private static boolean indexExists = false; + + @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() { + + template.delete(Person.class); + + if (!indexExists) { + + template.getCqlOperations().execute( + "CREATE CUSTOM INDEX IF NOT EXISTS person_firstname ON person (firstname) USING 'org.apache.cassandra.index.sasi.SASIIndex' WITH OPTIONS = {'mode': 'CONTAINS' };"); + template.getCqlOperations() + .execute("CREATE INDEX IF NOT EXISTS person_numberofchildren ON person (numberofchildren)"); + + template.getCqlOperations().execute((SessionCallback extends Object>) 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; + }); + indexExists = true; + } + + 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 = template.insert(person); + skyler = template.insert(new Person("Skyler", "White")); + flynn = template.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 shouldFindByFirstnameWithQueryOptions() { + + Person walter = fragment.findByFirstname("Walter", QueryOptions.builder().build()); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldQuerySlice() { + + Slice first = fragment.findTop2SliceByLastname("White", Pageable.unpaged()); + Slice second = fragment.findTop2SliceByLastname("White", first.nextPageable()); + + assertThat(first).hasSize(2); + assertThat(first.hasNext()).isTrue(); + assertThat(second).hasSize(1); + assertThat(second.hasNext()).isFalse(); + } + + @Test // GH-1566 + void shouldQueryWindow() { + + Window first = fragment.findWindowByLastname("White", CassandraScrollPosition.initial(), Limit.of(2)); + Window second = fragment.findWindowByLastname("White", first.positionAt(1), Limit.of(2)); + + assertThat(first).hasSize(2); + assertThat(first.hasNext()).isTrue(); + assertThat(second).hasSize(1); + assertThat(second.hasNext()).isFalse(); + } + + @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() { + + 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 shouldApplyExistsCountProjection() { + + assertThat(fragment.existsByLastname("White")).isTrue(); + assertThat(fragment.countByLastname("White")).isEqualTo(3); + } + + @Test // GH-1566 + void shouldFindByDeclaredFirstname() { + + Person walter = fragment.findDeclaredByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindByDeclaredPositionalFirstname() { + + Person walter = fragment.findDeclaredByPositionalFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindByDeclaredExpression() { + + Person walter = fragment.findDeclaredByExpression("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindByDeclaredPropertyPlaceholderExpression() { + + Person walter = fragment.findDeclaredByExpression(); + + assertThat(walter).isNull(); + } + + @Test // GH-1566 + void shouldApplyDeclaredExistsCountProjection() { + + assertThat(fragment.existsDeclaredByLastname("White")).isTrue(); + assertThat(fragment.countDeclaredByLastname("White")).isEqualTo(3); + } + + @Test // GH-1566 + void shouldQueryDeclaredSlice() { + + Slice first = fragment.findDeclaredSliceByLastname("White", Pageable.ofSize(2)); + Slice second = fragment.findDeclaredSliceByLastname("White", first.nextPageable()); + + assertThat(first).hasSize(2); + assertThat(first.hasNext()).isTrue(); + assertThat(second).hasSize(1); + assertThat(second.hasNext()).isFalse(); + } + + @Test // GH-1566 + void shouldQueryDeclaredWindow() { + + Window first = fragment.findDeclaredWindowByLastname("White", CassandraScrollPosition.initial(), 3, + Limit.of(2)); + Window second = fragment.findDeclaredWindowByLastname("White", first.positionAt(1), 3, Limit.of(2)); + + assertThat(first).hasSize(2); + assertThat(first.hasNext()).isTrue(); + assertThat(second).hasSize(1); + assertThat(second.hasNext()).isFalse(); + } + + @Test // GH-1566 + void shouldFindNamedQuery() { + + Person walter = fragment.findNamedByFirstname("Walter"); + + assertThat(walter).isNotNull(); + } + + @Test // GH-1566 + void shouldReturnResultSet() { + + ResultSet resultSet = fragment.findResultSetByFirstname("Walter"); + + assertThat(resultSet).isNotNull(); + assertThat(resultSet.one().getString("firstname")).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldReturnDeclaredResultSet() { + + ResultSet resultSet = fragment.findDeclaredResultSetByFirstname("Walter"); + + assertThat(resultSet).isNotNull(); + assertThat(resultSet.one().getString("firstname")).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldReturnMap() { + + Map map = fragment.findMapByFirstname("Walter"); + + assertThat(map).isNotNull(); + assertThat(map.get("firstname")).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldReturnDeclaredMap() { + + Map map = fragment.findDeclaredMapByFirstname("Walter"); + + assertThat(map).isNotNull(); + assertThat(map.get("firstname")).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectToDto() { + + PersonRepository.PersonDto walter = fragment.findOneDtoProjectionByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.firstname).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectManyToDto() { + + List walter = fragment.findDtoProjectionByFirstname("Walter"); + + assertThat(walter).hasSize(1); + assertThat(walter).extracting(PersonRepository.PersonDto::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectStreamToDto() { + + Stream walter = fragment.streamDtoProjectionByFirstname("Walter"); + + assertThat(walter).hasSize(1).extracting(PersonRepository.PersonDto::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectDeclaredToDto() { + + PersonRepository.PersonDto walter = fragment.findOneDeclaredDtoProjectionByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectDeclaredSimpleType() { + + assertThat(fragment.findDeclaredNumberOfChildrenByFirstname("Walter")).isEqualTo(2); + assertThat(fragment.findDeclaredNumberOfChildrenByFirstname("Flynn (Walter Jr.)")).isEqualTo(0); + assertThat(fragment.findDeclaredListNumberOfChildrenByFirstname("Walter")).containsOnly(2); + } + + @Test // GH-1566 + void shouldProjectToInterface() { + + PersonRepository.PersonProjection walter = fragment.findOneInterfaceProjectionByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectManyToInterface() { + + List walter = fragment.findInterfaceProjectionByFirstname("Walter"); + + assertThat(walter).hasSize(1); + assertThat(walter).extracting(PersonRepository.PersonProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectStreamToInterface() { + + Stream walter = fragment.streamInterfaceProjectionByFirstname("Walter"); + + assertThat(walter).hasSize(1).extracting(PersonRepository.PersonProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectDeclaredToInterface() { + + PersonRepository.PersonProjection walter = fragment.findOneDeclaredInterfaceProjectionByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectDynamically() { + + PersonRepository.PersonProjection projection = fragment.findOneProjectionByFirstname("Walter", + PersonRepository.PersonProjection.class); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + + List projections = fragment.findProjectionByFirstname("Walter", + PersonRepository.PersonProjection.class); + + assertThat(projections).hasSize(1); + assertThat(projections).extracting(PersonRepository.PersonProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + @Disabled("not supported") + void shouldProjectDeclaredDynamically() { + + PersonRepository.PersonProjection projection = fragment.findOneDeclaredProjectionByFirstname("Walter", + PersonRepository.PersonProjection.class); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + + List projections = fragment.findDeclaredProjectionByFirstname("Walter", + PersonRepository.PersonProjection.class); + + assertThat(projections).hasSize(1); + assertThat(projections).extracting(PersonRepository.PersonProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectToDtoDynamically() { + + PersonRepository.PersonDto projection = fragment.findOneProjectionByFirstname("Walter", + PersonRepository.PersonDto.class); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + + List projections = fragment.findProjectionByFirstname("Walter", + PersonRepository.PersonDto.class); + + assertThat(projections).hasSize(1); + assertThat(projections).extracting(PersonRepository.PersonDto::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectDeclaredToDtoDynamically() { + + PersonRepository.PersonDto projection = fragment.findOneDeclaredProjectionByFirstname("Walter", + PersonRepository.PersonDto.class); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + + List projections = fragment.findDeclaredProjectionByFirstname("Walter", + PersonRepository.PersonDto.class); + + assertThat(projections).hasSize(1); + assertThat(projections).extracting(PersonRepository.PersonDto::getFirstname).containsOnly("Walter"); + } + + // TODO: Vector Search + + @Test // GH-1566 + void vectorSearchNotSupportedYet() { + assertThatExceptionOfType(UndeclaredThrowableException.class) + .isThrownBy(() -> fragment.findAllByVector(Vector.of(1f), ScoringFunction.cosine())) + .withCauseInstanceOf(NoSuchMethodException.class); + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryMetadataIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryMetadataIntegrationTests.java new file mode 100644 index 000000000..54ee4fd9a --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryMetadataIntegrationTests.java @@ -0,0 +1,159 @@ +/* + * 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 net.javacrumbs.jsonunit.assertj.JsonAssertions.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.support.AbstractApplicationContext; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.data.cassandra.core.CassandraTemplate; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.config.EnableCassandraRepositories; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Integration tests for the {@link PersonRepository} JSON metadata via {@link CassandraRepositoryContributor}. + * + * @author Mark Paluch + */ +@SpringJUnitConfig( + classes = CassandraRepositoryMetadataIntegrationTests.CassandraRepositoryContributorConfiguration.class) +class CassandraRepositoryMetadataIntegrationTests { + + @Autowired AbstractApplicationContext context; + + @Configuration + @EnableCassandraRepositories(considerNestedRepositories = true, + includeFilters = { @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = Person.class) }) + static class CassandraRepositoryContributorConfiguration extends AotFragmentTestConfigurationSupport { + + public CassandraRepositoryContributorConfiguration() { + super(PersonRepository.class, CassandraRepositoryContributorConfiguration.class); + } + + @Bean + public CassandraTemplate cassandraTemplate() { + return mock(CassandraTemplate.class); + } + } + + @Test // GH-1566 + void shouldDocumentBase() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).isObject() // + .containsEntry("name", PersonRepository.class.getName()) // + .containsEntry("module", "Cassandra") // + .containsEntry("type", "IMPERATIVE"); + } + + @Test // GH-1566 + void shouldDocumentDerivedQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'countByLastname')].query").isArray().first().isObject() + .containsEntry("query", "SELECT count(1) FROM person WHERE lastname=?"); + } + + @Test // GH-1566 + void shouldDocumentDeclaredQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findDeclaredByFirstname')].query").isArray().first().isObject() + .containsEntry("query", "select * from person where firstname = ?"); + } + + @Test // GH-1566 + void shouldDocumentNamedQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findNamedByFirstname')].query").isArray().first().isObject() + .containsEntry("query", "SELECT * FROM person WHERE firstname=? ALLOW FILTERING") + .containsEntry("name", "Person.findNamedByFirstname"); + } + + @Test // GH-1566 + void shouldNotIncludeVectorSearch() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findAllByVector')].query").isArray().isEmpty(); + } + + @Test // GH-1566 + void shouldDocumentBaseFragment() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'existsById')].fragment").isArray().first().isObject() + .containsEntry("fragment", "org.springframework.data.cassandra.repository.support.SimpleCassandraRepository"); + } + + private Resource getResource() { + + String location = PersonRepository.class.getPackageName().replace('.', '/') + "/" + + PersonRepository.class.getSimpleName() + ".json"; + return new UrlResource(context.getBeanFactory().getBeanClassLoader().getResource(location)); + } + +} 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..e365cec35 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/PersonRepository.java @@ -0,0 +1,238 @@ +/* + * 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.Map; +import java.util.Optional; +import java.util.stream.Stream; + +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.CountQuery; +import org.springframework.data.cassandra.repository.ExistsQuery; +import org.springframework.data.cassandra.repository.Query; +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScoringFunction; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.SearchResults; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Vector; +import org.springframework.data.domain.Window; +import org.springframework.data.repository.CrudRepository; + +import com.datastax.oss.driver.api.core.DefaultConsistencyLevel; +import com.datastax.oss.driver.api.core.cql.ResultSet; + +/** + * AOT repository interface for {@link Person} entities. + * + * @author Mark Paluch + */ +public interface PersonRepository extends CrudRepository { + + @Query(idempotent = Query.Idempotency.IDEMPOTENT) + Person findByFirstname(String firstname); + + @Consistency(DefaultConsistencyLevel.ONE) + Person findByFirstname(String firstname, QueryOptions queryOptions); + + Slice findTop2SliceByLastname(String lastname, Pageable pageable); + + Window findWindowByLastname(String lastname, ScrollPosition scrollPosition, Limit limit); + + Optional findOptionalByFirstname(String firstname); + + List findByLastname(String lastname, Sort sort); + + List findByLastnameOrderByFirstnameAsc(String lastname); + + 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); + + int countByLastname(String lastname); + + boolean existsByLastname(String lastname); + + // ------------------------------------------------------------------------- + // Declared Queries + // ------------------------------------------------------------------------- + + @Query(value = "select * from person where firstname = :firstname", idempotent = Query.Idempotency.IDEMPOTENT) + Person findDeclaredByFirstname(String firstname); + + @Query(value = "select * from person where firstname = ?0") + Person findDeclaredByPositionalFirstname(String firstname); + + @CountQuery(value = "select COUNT(*) from person where lastname = ?0") + int countDeclaredByLastname(String lastname); + + @ExistsQuery(value = "select COUNT(*) from person where lastname = ?0") + boolean existsDeclaredByLastname(String lastname); + + @Query(value = "select * from person where lastname = ?0 LIMIT 3") + Slice findDeclaredSliceByLastname(String lastname, Pageable pageable); + + @Query(value = "select * from person where lastname = :lastname LIMIT :sliceLimit") + Window findDeclaredWindowByLastname(String lastname, ScrollPosition scrollPosition, int sliceLimit, + Limit pageSize); + + // ------------------------------------------------------------------------- + // Value Expressions + // ------------------------------------------------------------------------- + + @Query(value = "select * from person where firstname = :#{#firstname}") + Person findDeclaredByExpression(String firstname); + + @Query(value = "select * from person where firstname = :${user.dir}") + Person findDeclaredByExpression(); + + // ------------------------------------------------------------------------- + // Named Queries + // ------------------------------------------------------------------------- + + Person findNamedByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: ResultSet, Map + // ------------------------------------------------------------------------- + + ResultSet findResultSetByFirstname(String firstname); + + @Query(value = "select * from person where firstname = ?0") + ResultSet findDeclaredResultSetByFirstname(String firstname); + + Map findMapByFirstname(String firstname); + + @Query(value = "select * from person where firstname = ?0") + Map findDeclaredMapByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: DTO + // ------------------------------------------------------------------------- + + PersonDto findOneDtoProjectionByFirstname(String firstname); + + List findDtoProjectionByFirstname(String firstname); + + Stream streamDtoProjectionByFirstname(String firstname); + + @Query(value = "select * from person where firstname = :#{#firstname}") + PersonDto findOneDeclaredDtoProjectionByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: Single-field + // ------------------------------------------------------------------------- + + @Query("select numberOfChildren from person where firstname = :firstname") + int findDeclaredNumberOfChildrenByFirstname(String firstname); + + @Query("select numberOfChildren from person where firstname = :firstname") + List findDeclaredListNumberOfChildrenByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: Interface + // ------------------------------------------------------------------------- + + PersonProjection findOneInterfaceProjectionByFirstname(String firstname); + + List findInterfaceProjectionByFirstname(String firstname); + + Stream streamInterfaceProjectionByFirstname(String firstname); + + @Query(value = "select * from person where firstname = :#{#firstname}") + PersonProjection findOneDeclaredInterfaceProjectionByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: Dynamic + // ------------------------------------------------------------------------- + + T findOneProjectionByFirstname(String firstname, Class projectionType); + + @Query(value = "select * from person where firstname = :firstname") + T findOneDeclaredProjectionByFirstname(String firstname, Class projectionType); + + List findProjectionByFirstname(String firstname, Class projectionType); + + @Query(value = "select * from person where firstname = :firstname") + List findDeclaredProjectionByFirstname(String firstname, Class projectionType); + + // ------------------------------------------------------------------------- + // Excluded + // ------------------------------------------------------------------------- + + SearchResults findAllByVector(Vector vector, ScoringFunction scoringFunction); + + interface PersonProjection { + + String getFirstname(); + + String getLastname(); + } + + class PersonDto { + + public String firstname, lastname; + + public PersonDto(String firstname, String lastname) { + this.firstname = firstname; + this.lastname = lastname; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } + } +} 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) 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( 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}. diff --git a/spring-data-cassandra/src/test/resources/META-INF/cassandra-named-queries.properties b/spring-data-cassandra/src/test/resources/META-INF/cassandra-named-queries.properties index c596c8305..c1e18ab2a 100644 --- a/spring-data-cassandra/src/test/resources/META-INF/cassandra-named-queries.properties +++ b/spring-data-cassandra/src/test/resources/META-INF/cassandra-named-queries.properties @@ -1 +1,2 @@ User.findByNamedQuery=SELECT firstname FROM users WHERE username=?0 +Person.findNamedByFirstname=SELECT * FROM person WHERE firstname=?0 ALLOW FILTERING diff --git a/spring-data-cassandra/src/test/resources/logback.xml b/spring-data-cassandra/src/test/resources/logback.xml index 10ee2a2c7..086cd2362 100644 --- a/spring-data-cassandra/src/test/resources/logback.xml +++ b/spring-data-cassandra/src/test/resources/logback.xml @@ -11,6 +11,7 @@ + diff --git a/src/main/antora/modules/ROOT/nav.adoc b/src/main/antora/modules/ROOT/nav.adoc index dac20da62..63fc8df2f 100644 --- a/src/main/antora/modules/ROOT/nav.adoc +++ b/src/main/antora/modules/ROOT/nav.adoc @@ -36,6 +36,7 @@ ** xref:cassandra/repositories/cdi-integration.adoc[] ** xref:repositories/query-keywords-reference.adoc[] ** xref:repositories/query-return-types-reference.adoc[] +** xref:cassandra/repositories/aot.adoc[] * xref:observability.adoc[] diff --git a/src/main/antora/modules/ROOT/pages/cassandra/repositories/aot.adoc b/src/main/antora/modules/ROOT/pages/cassandra/repositories/aot.adoc new file mode 100644 index 000000000..0fa00fcc5 --- /dev/null +++ b/src/main/antora/modules/ROOT/pages/cassandra/repositories/aot.adoc @@ -0,0 +1,179 @@ += Ahead of Time Optimizations + +This chapter covers Spring Data's Ahead of Time (AOT) optimizations that build upon {spring-framework-docs}/core/aot.html[Spring's Ahead of Time Optimizations]. + +[[aot.bestpractices]] +== Best Practices + +=== Annotate your Domain Types + +During application startup, Spring scans the classpath for domain classes for early processing of entities. +By annotating your domain types with Spring Data-specific `@Table`, `@Document` or `@Entity` annotations you can aid initial entity scanning and ensure that those types are registered with `ManagedTypes` for Runtime Hints. +Classpath scanning is not possible in native image arrangements and so Spring has to use `ManagedTypes` for the initial entity set. + +[[aot.hints]] +== Runtime Hints + +Running an application as a native image requires additional information compared to a regular JVM runtime. +Spring Data contributes {spring-framework-docs}/core/aot.html#aot.hints[Runtime Hints] during AOT processing for native image usage. +These are in particular hints for: + +* Auditing +* `ManagedTypes` to capture the outcome of class-path scans +* Repositories +** Reflection hints for entities, return types, and Spring Data annotations +** Repository fragments +** Querydsl `Q` classes +** Kotlin Coroutine support +* Web support (Jackson Hints for `PagedModel`) + +[[aot.repositories]] +== Ahead of Time Repositories + +AOT Repositories are an extension to AOT processing by pre-generating eligible query method implementations. +Query methods are opaque to developers regarding their underlying queries being executed in a query method call. +AOT repositories contribute query method implementations based on derived, annotated, and named queries that are known at build-time. +This optimization moves query method processing from runtime to build-time, which can lead to a significant performance improvement as query methods do not need to be analyzed reflectively upon each application start. + +The resulting AOT repository fragment follows the naming scheme of `Impl__Aot` and is placed in the same package as the repository interface. +You can find all queries in their String form for generated repository query methods. + +NOTE: Consider AOT repository classes an internal optimization. +Do not use them directly in your code as generation and implementation details may change in future releases. + +=== Running with AOT Repositories + +AOT is a mandatory step to transform a Spring application to a native executable, so it is automatically enabled when running in this mode. +When AOT is enabled (either for native compilation or by setting `spring.aot.enabled=true`), AOT repositories are automatically enabled by default. + +You can disable AOT repository generation entirely or only disable Cassandra AOT repositories: + +* Set the `spring.aot.repositories.enabled=false` property to disable generated repositories for all Spring Data modules. +* Set the `spring.aot.cassandra.repositories.enabled=false` property to disable only C AOT repositories. + +AOT repositories contribute configuration changes to the actual repository bean registration to register the generated repository fragment. + +NOTE: When AOT optimizations are included, some decisions that have been taken at build-time are hard-coded in the application setup. +For instance, profiles that have been enabled at build-time are automatically enabled at runtime as well. +Also, the Spring Data module implementing a repository is fixed. +Changing the implementation requires AOT re-processing. + +=== Eligible Methods + +AOT repositories filter methods that are eligible for AOT processing. +These are typically all query methods that are not backed by an xref:repositories/custom-implementations.adoc[implementation fragment]. + +**Supported Features** + +* Derived query methods, `@Query` and named query methods. +* `Window`, `Slice`, `Stream`, and `Optional` return types +* Sort query rewriting +* Interface and DTO Projections +* Value Expressions (Those require a bit of reflective information. +Mind that using Value Expressions requires expression parsing and contextual information to evaluate the expression) + +**Limitation** + +* Vector Search not yet supported + +**Excluded methods** + +* `CrudRepository` and other base interface methods as their implementation is provided by the base class respective fragments +* Vector Search Methods + +[[aot.repositories.json]] +== Repository Metadata + +AOT processing introspects query methods and collects metadata about repository queries. +Spring Data Cassandra stores this metadata in JSON files that are named like the repository interface and stored next to it (i.e. within the same package). +Repository JSON Metadata contains details about queries and fragments. +An example for the following repository is shown below: + +==== +[source,java] +---- +interface UserRepository extends CrudRepository { + + List findUserNoArgumentsBy(); <1> + + Slice findSliceOfUsersByLastnameStartingWith(String lastname, Pageable page); <2> + + @Query("select * from User where emailAddress = :emailAddress") + User findAnnotatedQueryByEmailAddress(String emailAddress); <3> + + User findByEmailAddress(String emailAddress); <4> +} +---- + +<1> Derived query without arguments. +<2> Derived query using pagination. +<3> Annotated query. +<4> Named query. +==== + +[source,json] +---- +{ + "name": "com.acme.UserRepository", + "module": "Cassandra", + "type": "IMPERATIVE", + "methods": [ + { + "name": "findUserNoArgumentsBy", + "signature": "public abstract java.util.List com.acme.UserRepository.findUserNoArgumentsBy()", + "query": { + "query": "select * from user" + } + }, + { + "name": "findSliceOfUsersByLastnameStartingWith", + "signature": "public abstract org.springframework.data.domain.Slice com.acme.UserRepository.findSliceOfUsersByLastnameStartingWith(java.lang.String,org.springframework.data.domain.Pageable)", + "query": { + "query": "select * from user where lastname like ?" + } + }, + { + "name": "findAnnotatedQueryByEmailAddress", + "signature": "public abstract com.acme.User com.acme.UserRepository.findAnnotatedQueryByEmailAddress(java.lang.String)", + "query": { + "query": "select * from user where emailaddress = ?" + } + }, + { + "name": "findByEmailAddress", + "signature": "public abstract com.acme.User com.acme.UserRepository.findByEmailAddress(java.lang.String)", + "query": { + "name": "User.findByEmailAddress", + "query": "select * from user where emailaddress = ?" + } + }, + { + "name": "count", + "signature": "public abstract long org.springframework.data.repository.CrudRepository.count()", + "fragment": { + "fragment": "org.springframework.data.cassandra.repository.support.SimpleCassandraRepository" + } + } + ] +} +---- + +Queries may contain the following fields: + +* `query`: Query descriptor if the method is a query method. +** `query` the query used to obtain the query method results. +** `name`: Name of the named query if the query is a named one. +* `fragment`: Target fragment if the method call is delegated to a store (repository base class, functional fragment such as Querydsl) or user fragment. +Fragments are either described with just `fragment` if there is no further interface or as `interface` and `fragment` tuple in case there is an interface (such as Querydsl or user-declared fragment interface). + +[NOTE] +.Normalized Query Form +==== +Static analysis of queries allows only a limited representation of runtime query behavior. +Queries are represented in their normalized (pre-parsed and rewritten) form: + +* Value Expressions are replaced with bind markers. +* Query Metadata does not reflect bind-value processing. +`StartingWith`/`EndingWith` queries prepend/append the wildcard character `%` to the actual bind value. +* Runtime Sort information cannot be incorporated in the query string itself as that detail is not known at build-time. +====
+ * 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 { + + private final Class> repositoryInterface; + private final boolean registerFragmentFacade; + private final TestCassandraAotRepositoryContext> repositoryContext; + + 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 repositoryContributor = new CassandraRepositoryContributor(repositoryContext); + repositoryContributor.contribute(generationContext); + + AbstractBeanDefinition aotGeneratedRepository = BeanDefinitionBuilder + .genericBeanDefinition( + repositoryInterface.getPackageName() + "." + repositoryInterface.getSimpleName() + "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; + } + +} 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..cbff00f0d --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryContributorIntegrationTests.java @@ -0,0 +1,509 @@ +/* + * 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.lang.reflect.UndeclaredThrowableException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +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.cql.QueryOptions; +import org.springframework.data.cassandra.core.cql.SessionCallback; +import org.springframework.data.cassandra.core.query.CassandraScrollPosition; +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.Limit; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScoringFunction; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Vector; +import org.springframework.data.domain.Window; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.cql.ResultSet; +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}. + * + * @author Mark Paluch + */ +@SpringJUnitConfig( + classes = CassandraRepositoryContributorIntegrationTests.CassandraRepositoryContributorConfiguration.class) +class CassandraRepositoryContributorIntegrationTests extends AbstractSpringDataEmbeddedCassandraIntegrationTest { + + @Autowired PersonRepository fragment; + private static boolean indexExists = false; + + @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() { + + template.delete(Person.class); + + if (!indexExists) { + + template.getCqlOperations().execute( + "CREATE CUSTOM INDEX IF NOT EXISTS person_firstname ON person (firstname) USING 'org.apache.cassandra.index.sasi.SASIIndex' WITH OPTIONS = {'mode': 'CONTAINS' };"); + template.getCqlOperations() + .execute("CREATE INDEX IF NOT EXISTS person_numberofchildren ON person (numberofchildren)"); + + template.getCqlOperations().execute((SessionCallback extends Object>) 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; + }); + indexExists = true; + } + + 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 = template.insert(person); + skyler = template.insert(new Person("Skyler", "White")); + flynn = template.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 shouldFindByFirstnameWithQueryOptions() { + + Person walter = fragment.findByFirstname("Walter", QueryOptions.builder().build()); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldQuerySlice() { + + Slice first = fragment.findTop2SliceByLastname("White", Pageable.unpaged()); + Slice second = fragment.findTop2SliceByLastname("White", first.nextPageable()); + + assertThat(first).hasSize(2); + assertThat(first.hasNext()).isTrue(); + assertThat(second).hasSize(1); + assertThat(second.hasNext()).isFalse(); + } + + @Test // GH-1566 + void shouldQueryWindow() { + + Window first = fragment.findWindowByLastname("White", CassandraScrollPosition.initial(), Limit.of(2)); + Window second = fragment.findWindowByLastname("White", first.positionAt(1), Limit.of(2)); + + assertThat(first).hasSize(2); + assertThat(first.hasNext()).isTrue(); + assertThat(second).hasSize(1); + assertThat(second.hasNext()).isFalse(); + } + + @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() { + + 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 shouldApplyExistsCountProjection() { + + assertThat(fragment.existsByLastname("White")).isTrue(); + assertThat(fragment.countByLastname("White")).isEqualTo(3); + } + + @Test // GH-1566 + void shouldFindByDeclaredFirstname() { + + Person walter = fragment.findDeclaredByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindByDeclaredPositionalFirstname() { + + Person walter = fragment.findDeclaredByPositionalFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindByDeclaredExpression() { + + Person walter = fragment.findDeclaredByExpression("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldFindByDeclaredPropertyPlaceholderExpression() { + + Person walter = fragment.findDeclaredByExpression(); + + assertThat(walter).isNull(); + } + + @Test // GH-1566 + void shouldApplyDeclaredExistsCountProjection() { + + assertThat(fragment.existsDeclaredByLastname("White")).isTrue(); + assertThat(fragment.countDeclaredByLastname("White")).isEqualTo(3); + } + + @Test // GH-1566 + void shouldQueryDeclaredSlice() { + + Slice first = fragment.findDeclaredSliceByLastname("White", Pageable.ofSize(2)); + Slice second = fragment.findDeclaredSliceByLastname("White", first.nextPageable()); + + assertThat(first).hasSize(2); + assertThat(first.hasNext()).isTrue(); + assertThat(second).hasSize(1); + assertThat(second.hasNext()).isFalse(); + } + + @Test // GH-1566 + void shouldQueryDeclaredWindow() { + + Window first = fragment.findDeclaredWindowByLastname("White", CassandraScrollPosition.initial(), 3, + Limit.of(2)); + Window second = fragment.findDeclaredWindowByLastname("White", first.positionAt(1), 3, Limit.of(2)); + + assertThat(first).hasSize(2); + assertThat(first.hasNext()).isTrue(); + assertThat(second).hasSize(1); + assertThat(second.hasNext()).isFalse(); + } + + @Test // GH-1566 + void shouldFindNamedQuery() { + + Person walter = fragment.findNamedByFirstname("Walter"); + + assertThat(walter).isNotNull(); + } + + @Test // GH-1566 + void shouldReturnResultSet() { + + ResultSet resultSet = fragment.findResultSetByFirstname("Walter"); + + assertThat(resultSet).isNotNull(); + assertThat(resultSet.one().getString("firstname")).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldReturnDeclaredResultSet() { + + ResultSet resultSet = fragment.findDeclaredResultSetByFirstname("Walter"); + + assertThat(resultSet).isNotNull(); + assertThat(resultSet.one().getString("firstname")).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldReturnMap() { + + Map map = fragment.findMapByFirstname("Walter"); + + assertThat(map).isNotNull(); + assertThat(map.get("firstname")).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldReturnDeclaredMap() { + + Map map = fragment.findDeclaredMapByFirstname("Walter"); + + assertThat(map).isNotNull(); + assertThat(map.get("firstname")).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectToDto() { + + PersonRepository.PersonDto walter = fragment.findOneDtoProjectionByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.firstname).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectManyToDto() { + + List walter = fragment.findDtoProjectionByFirstname("Walter"); + + assertThat(walter).hasSize(1); + assertThat(walter).extracting(PersonRepository.PersonDto::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectStreamToDto() { + + Stream walter = fragment.streamDtoProjectionByFirstname("Walter"); + + assertThat(walter).hasSize(1).extracting(PersonRepository.PersonDto::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectDeclaredToDto() { + + PersonRepository.PersonDto walter = fragment.findOneDeclaredDtoProjectionByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectDeclaredSimpleType() { + + assertThat(fragment.findDeclaredNumberOfChildrenByFirstname("Walter")).isEqualTo(2); + assertThat(fragment.findDeclaredNumberOfChildrenByFirstname("Flynn (Walter Jr.)")).isEqualTo(0); + assertThat(fragment.findDeclaredListNumberOfChildrenByFirstname("Walter")).containsOnly(2); + } + + @Test // GH-1566 + void shouldProjectToInterface() { + + PersonRepository.PersonProjection walter = fragment.findOneInterfaceProjectionByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectManyToInterface() { + + List walter = fragment.findInterfaceProjectionByFirstname("Walter"); + + assertThat(walter).hasSize(1); + assertThat(walter).extracting(PersonRepository.PersonProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectStreamToInterface() { + + Stream walter = fragment.streamInterfaceProjectionByFirstname("Walter"); + + assertThat(walter).hasSize(1).extracting(PersonRepository.PersonProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectDeclaredToInterface() { + + PersonRepository.PersonProjection walter = fragment.findOneDeclaredInterfaceProjectionByFirstname("Walter"); + + assertThat(walter).isNotNull(); + assertThat(walter.getFirstname()).isEqualTo("Walter"); + } + + @Test // GH-1566 + void shouldProjectDynamically() { + + PersonRepository.PersonProjection projection = fragment.findOneProjectionByFirstname("Walter", + PersonRepository.PersonProjection.class); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + + List projections = fragment.findProjectionByFirstname("Walter", + PersonRepository.PersonProjection.class); + + assertThat(projections).hasSize(1); + assertThat(projections).extracting(PersonRepository.PersonProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + @Disabled("not supported") + void shouldProjectDeclaredDynamically() { + + PersonRepository.PersonProjection projection = fragment.findOneDeclaredProjectionByFirstname("Walter", + PersonRepository.PersonProjection.class); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + + List projections = fragment.findDeclaredProjectionByFirstname("Walter", + PersonRepository.PersonProjection.class); + + assertThat(projections).hasSize(1); + assertThat(projections).extracting(PersonRepository.PersonProjection::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectToDtoDynamically() { + + PersonRepository.PersonDto projection = fragment.findOneProjectionByFirstname("Walter", + PersonRepository.PersonDto.class); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + + List projections = fragment.findProjectionByFirstname("Walter", + PersonRepository.PersonDto.class); + + assertThat(projections).hasSize(1); + assertThat(projections).extracting(PersonRepository.PersonDto::getFirstname).containsOnly("Walter"); + } + + @Test // GH-1566 + void shouldProjectDeclaredToDtoDynamically() { + + PersonRepository.PersonDto projection = fragment.findOneDeclaredProjectionByFirstname("Walter", + PersonRepository.PersonDto.class); + + assertThat(projection).isNotNull(); + assertThat(projection.getFirstname()).isEqualTo("Walter"); + + List projections = fragment.findDeclaredProjectionByFirstname("Walter", + PersonRepository.PersonDto.class); + + assertThat(projections).hasSize(1); + assertThat(projections).extracting(PersonRepository.PersonDto::getFirstname).containsOnly("Walter"); + } + + // TODO: Vector Search + + @Test // GH-1566 + void vectorSearchNotSupportedYet() { + assertThatExceptionOfType(UndeclaredThrowableException.class) + .isThrownBy(() -> fragment.findAllByVector(Vector.of(1f), ScoringFunction.cosine())) + .withCauseInstanceOf(NoSuchMethodException.class); + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryMetadataIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryMetadataIntegrationTests.java new file mode 100644 index 000000000..54ee4fd9a --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/CassandraRepositoryMetadataIntegrationTests.java @@ -0,0 +1,159 @@ +/* + * 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 net.javacrumbs.jsonunit.assertj.JsonAssertions.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.support.AbstractApplicationContext; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.data.cassandra.core.CassandraTemplate; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.config.EnableCassandraRepositories; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Integration tests for the {@link PersonRepository} JSON metadata via {@link CassandraRepositoryContributor}. + * + * @author Mark Paluch + */ +@SpringJUnitConfig( + classes = CassandraRepositoryMetadataIntegrationTests.CassandraRepositoryContributorConfiguration.class) +class CassandraRepositoryMetadataIntegrationTests { + + @Autowired AbstractApplicationContext context; + + @Configuration + @EnableCassandraRepositories(considerNestedRepositories = true, + includeFilters = { @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = Person.class) }) + static class CassandraRepositoryContributorConfiguration extends AotFragmentTestConfigurationSupport { + + public CassandraRepositoryContributorConfiguration() { + super(PersonRepository.class, CassandraRepositoryContributorConfiguration.class); + } + + @Bean + public CassandraTemplate cassandraTemplate() { + return mock(CassandraTemplate.class); + } + } + + @Test // GH-1566 + void shouldDocumentBase() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).isObject() // + .containsEntry("name", PersonRepository.class.getName()) // + .containsEntry("module", "Cassandra") // + .containsEntry("type", "IMPERATIVE"); + } + + @Test // GH-1566 + void shouldDocumentDerivedQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'countByLastname')].query").isArray().first().isObject() + .containsEntry("query", "SELECT count(1) FROM person WHERE lastname=?"); + } + + @Test // GH-1566 + void shouldDocumentDeclaredQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findDeclaredByFirstname')].query").isArray().first().isObject() + .containsEntry("query", "select * from person where firstname = ?"); + } + + @Test // GH-1566 + void shouldDocumentNamedQuery() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findNamedByFirstname')].query").isArray().first().isObject() + .containsEntry("query", "SELECT * FROM person WHERE firstname=? ALLOW FILTERING") + .containsEntry("name", "Person.findNamedByFirstname"); + } + + @Test // GH-1566 + void shouldNotIncludeVectorSearch() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'findAllByVector')].query").isArray().isEmpty(); + } + + @Test // GH-1566 + void shouldDocumentBaseFragment() throws IOException { + + Resource resource = getResource(); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'existsById')].fragment").isArray().first().isObject() + .containsEntry("fragment", "org.springframework.data.cassandra.repository.support.SimpleCassandraRepository"); + } + + private Resource getResource() { + + String location = PersonRepository.class.getPackageName().replace('.', '/') + "/" + + PersonRepository.class.getSimpleName() + ".json"; + return new UrlResource(context.getBeanFactory().getBeanClassLoader().getResource(location)); + } + +} 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..e365cec35 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/aot/PersonRepository.java @@ -0,0 +1,238 @@ +/* + * 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.Map; +import java.util.Optional; +import java.util.stream.Stream; + +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.CountQuery; +import org.springframework.data.cassandra.repository.ExistsQuery; +import org.springframework.data.cassandra.repository.Query; +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScoringFunction; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.SearchResults; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Vector; +import org.springframework.data.domain.Window; +import org.springframework.data.repository.CrudRepository; + +import com.datastax.oss.driver.api.core.DefaultConsistencyLevel; +import com.datastax.oss.driver.api.core.cql.ResultSet; + +/** + * AOT repository interface for {@link Person} entities. + * + * @author Mark Paluch + */ +public interface PersonRepository extends CrudRepository { + + @Query(idempotent = Query.Idempotency.IDEMPOTENT) + Person findByFirstname(String firstname); + + @Consistency(DefaultConsistencyLevel.ONE) + Person findByFirstname(String firstname, QueryOptions queryOptions); + + Slice findTop2SliceByLastname(String lastname, Pageable pageable); + + Window findWindowByLastname(String lastname, ScrollPosition scrollPosition, Limit limit); + + Optional findOptionalByFirstname(String firstname); + + List findByLastname(String lastname, Sort sort); + + List findByLastnameOrderByFirstnameAsc(String lastname); + + 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); + + int countByLastname(String lastname); + + boolean existsByLastname(String lastname); + + // ------------------------------------------------------------------------- + // Declared Queries + // ------------------------------------------------------------------------- + + @Query(value = "select * from person where firstname = :firstname", idempotent = Query.Idempotency.IDEMPOTENT) + Person findDeclaredByFirstname(String firstname); + + @Query(value = "select * from person where firstname = ?0") + Person findDeclaredByPositionalFirstname(String firstname); + + @CountQuery(value = "select COUNT(*) from person where lastname = ?0") + int countDeclaredByLastname(String lastname); + + @ExistsQuery(value = "select COUNT(*) from person where lastname = ?0") + boolean existsDeclaredByLastname(String lastname); + + @Query(value = "select * from person where lastname = ?0 LIMIT 3") + Slice findDeclaredSliceByLastname(String lastname, Pageable pageable); + + @Query(value = "select * from person where lastname = :lastname LIMIT :sliceLimit") + Window findDeclaredWindowByLastname(String lastname, ScrollPosition scrollPosition, int sliceLimit, + Limit pageSize); + + // ------------------------------------------------------------------------- + // Value Expressions + // ------------------------------------------------------------------------- + + @Query(value = "select * from person where firstname = :#{#firstname}") + Person findDeclaredByExpression(String firstname); + + @Query(value = "select * from person where firstname = :${user.dir}") + Person findDeclaredByExpression(); + + // ------------------------------------------------------------------------- + // Named Queries + // ------------------------------------------------------------------------- + + Person findNamedByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: ResultSet, Map + // ------------------------------------------------------------------------- + + ResultSet findResultSetByFirstname(String firstname); + + @Query(value = "select * from person where firstname = ?0") + ResultSet findDeclaredResultSetByFirstname(String firstname); + + Map findMapByFirstname(String firstname); + + @Query(value = "select * from person where firstname = ?0") + Map findDeclaredMapByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: DTO + // ------------------------------------------------------------------------- + + PersonDto findOneDtoProjectionByFirstname(String firstname); + + List findDtoProjectionByFirstname(String firstname); + + Stream streamDtoProjectionByFirstname(String firstname); + + @Query(value = "select * from person where firstname = :#{#firstname}") + PersonDto findOneDeclaredDtoProjectionByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: Single-field + // ------------------------------------------------------------------------- + + @Query("select numberOfChildren from person where firstname = :firstname") + int findDeclaredNumberOfChildrenByFirstname(String firstname); + + @Query("select numberOfChildren from person where firstname = :firstname") + List findDeclaredListNumberOfChildrenByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: Interface + // ------------------------------------------------------------------------- + + PersonProjection findOneInterfaceProjectionByFirstname(String firstname); + + List findInterfaceProjectionByFirstname(String firstname); + + Stream streamInterfaceProjectionByFirstname(String firstname); + + @Query(value = "select * from person where firstname = :#{#firstname}") + PersonProjection findOneDeclaredInterfaceProjectionByFirstname(String firstname); + + // ------------------------------------------------------------------------- + // Projections: Dynamic + // ------------------------------------------------------------------------- + + T findOneProjectionByFirstname(String firstname, Class projectionType); + + @Query(value = "select * from person where firstname = :firstname") + T findOneDeclaredProjectionByFirstname(String firstname, Class projectionType); + + List findProjectionByFirstname(String firstname, Class projectionType); + + @Query(value = "select * from person where firstname = :firstname") + List findDeclaredProjectionByFirstname(String firstname, Class projectionType); + + // ------------------------------------------------------------------------- + // Excluded + // ------------------------------------------------------------------------- + + SearchResults findAllByVector(Vector vector, ScoringFunction scoringFunction); + + interface PersonProjection { + + String getFirstname(); + + String getLastname(); + } + + class PersonDto { + + public String firstname, lastname; + + public PersonDto(String firstname, String lastname) { + this.firstname = firstname; + this.lastname = lastname; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } + } +} 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) 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( 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}. diff --git a/spring-data-cassandra/src/test/resources/META-INF/cassandra-named-queries.properties b/spring-data-cassandra/src/test/resources/META-INF/cassandra-named-queries.properties index c596c8305..c1e18ab2a 100644 --- a/spring-data-cassandra/src/test/resources/META-INF/cassandra-named-queries.properties +++ b/spring-data-cassandra/src/test/resources/META-INF/cassandra-named-queries.properties @@ -1 +1,2 @@ User.findByNamedQuery=SELECT firstname FROM users WHERE username=?0 +Person.findNamedByFirstname=SELECT * FROM person WHERE firstname=?0 ALLOW FILTERING diff --git a/spring-data-cassandra/src/test/resources/logback.xml b/spring-data-cassandra/src/test/resources/logback.xml index 10ee2a2c7..086cd2362 100644 --- a/spring-data-cassandra/src/test/resources/logback.xml +++ b/spring-data-cassandra/src/test/resources/logback.xml @@ -11,6 +11,7 @@ + diff --git a/src/main/antora/modules/ROOT/nav.adoc b/src/main/antora/modules/ROOT/nav.adoc index dac20da62..63fc8df2f 100644 --- a/src/main/antora/modules/ROOT/nav.adoc +++ b/src/main/antora/modules/ROOT/nav.adoc @@ -36,6 +36,7 @@ ** xref:cassandra/repositories/cdi-integration.adoc[] ** xref:repositories/query-keywords-reference.adoc[] ** xref:repositories/query-return-types-reference.adoc[] +** xref:cassandra/repositories/aot.adoc[] * xref:observability.adoc[] diff --git a/src/main/antora/modules/ROOT/pages/cassandra/repositories/aot.adoc b/src/main/antora/modules/ROOT/pages/cassandra/repositories/aot.adoc new file mode 100644 index 000000000..0fa00fcc5 --- /dev/null +++ b/src/main/antora/modules/ROOT/pages/cassandra/repositories/aot.adoc @@ -0,0 +1,179 @@ += Ahead of Time Optimizations + +This chapter covers Spring Data's Ahead of Time (AOT) optimizations that build upon {spring-framework-docs}/core/aot.html[Spring's Ahead of Time Optimizations]. + +[[aot.bestpractices]] +== Best Practices + +=== Annotate your Domain Types + +During application startup, Spring scans the classpath for domain classes for early processing of entities. +By annotating your domain types with Spring Data-specific `@Table`, `@Document` or `@Entity` annotations you can aid initial entity scanning and ensure that those types are registered with `ManagedTypes` for Runtime Hints. +Classpath scanning is not possible in native image arrangements and so Spring has to use `ManagedTypes` for the initial entity set. + +[[aot.hints]] +== Runtime Hints + +Running an application as a native image requires additional information compared to a regular JVM runtime. +Spring Data contributes {spring-framework-docs}/core/aot.html#aot.hints[Runtime Hints] during AOT processing for native image usage. +These are in particular hints for: + +* Auditing +* `ManagedTypes` to capture the outcome of class-path scans +* Repositories +** Reflection hints for entities, return types, and Spring Data annotations +** Repository fragments +** Querydsl `Q` classes +** Kotlin Coroutine support +* Web support (Jackson Hints for `PagedModel`) + +[[aot.repositories]] +== Ahead of Time Repositories + +AOT Repositories are an extension to AOT processing by pre-generating eligible query method implementations. +Query methods are opaque to developers regarding their underlying queries being executed in a query method call. +AOT repositories contribute query method implementations based on derived, annotated, and named queries that are known at build-time. +This optimization moves query method processing from runtime to build-time, which can lead to a significant performance improvement as query methods do not need to be analyzed reflectively upon each application start. + +The resulting AOT repository fragment follows the naming scheme of `Impl__Aot` and is placed in the same package as the repository interface. +You can find all queries in their String form for generated repository query methods. + +NOTE: Consider AOT repository classes an internal optimization. +Do not use them directly in your code as generation and implementation details may change in future releases. + +=== Running with AOT Repositories + +AOT is a mandatory step to transform a Spring application to a native executable, so it is automatically enabled when running in this mode. +When AOT is enabled (either for native compilation or by setting `spring.aot.enabled=true`), AOT repositories are automatically enabled by default. + +You can disable AOT repository generation entirely or only disable Cassandra AOT repositories: + +* Set the `spring.aot.repositories.enabled=false` property to disable generated repositories for all Spring Data modules. +* Set the `spring.aot.cassandra.repositories.enabled=false` property to disable only C AOT repositories. + +AOT repositories contribute configuration changes to the actual repository bean registration to register the generated repository fragment. + +NOTE: When AOT optimizations are included, some decisions that have been taken at build-time are hard-coded in the application setup. +For instance, profiles that have been enabled at build-time are automatically enabled at runtime as well. +Also, the Spring Data module implementing a repository is fixed. +Changing the implementation requires AOT re-processing. + +=== Eligible Methods + +AOT repositories filter methods that are eligible for AOT processing. +These are typically all query methods that are not backed by an xref:repositories/custom-implementations.adoc[implementation fragment]. + +**Supported Features** + +* Derived query methods, `@Query` and named query methods. +* `Window`, `Slice`, `Stream`, and `Optional` return types +* Sort query rewriting +* Interface and DTO Projections +* Value Expressions (Those require a bit of reflective information. +Mind that using Value Expressions requires expression parsing and contextual information to evaluate the expression) + +**Limitation** + +* Vector Search not yet supported + +**Excluded methods** + +* `CrudRepository` and other base interface methods as their implementation is provided by the base class respective fragments +* Vector Search Methods + +[[aot.repositories.json]] +== Repository Metadata + +AOT processing introspects query methods and collects metadata about repository queries. +Spring Data Cassandra stores this metadata in JSON files that are named like the repository interface and stored next to it (i.e. within the same package). +Repository JSON Metadata contains details about queries and fragments. +An example for the following repository is shown below: + +==== +[source,java] +---- +interface UserRepository extends CrudRepository { + + List findUserNoArgumentsBy(); <1> + + Slice findSliceOfUsersByLastnameStartingWith(String lastname, Pageable page); <2> + + @Query("select * from User where emailAddress = :emailAddress") + User findAnnotatedQueryByEmailAddress(String emailAddress); <3> + + User findByEmailAddress(String emailAddress); <4> +} +---- + +<1> Derived query without arguments. +<2> Derived query using pagination. +<3> Annotated query. +<4> Named query. +==== + +[source,json] +---- +{ + "name": "com.acme.UserRepository", + "module": "Cassandra", + "type": "IMPERATIVE", + "methods": [ + { + "name": "findUserNoArgumentsBy", + "signature": "public abstract java.util.List com.acme.UserRepository.findUserNoArgumentsBy()", + "query": { + "query": "select * from user" + } + }, + { + "name": "findSliceOfUsersByLastnameStartingWith", + "signature": "public abstract org.springframework.data.domain.Slice com.acme.UserRepository.findSliceOfUsersByLastnameStartingWith(java.lang.String,org.springframework.data.domain.Pageable)", + "query": { + "query": "select * from user where lastname like ?" + } + }, + { + "name": "findAnnotatedQueryByEmailAddress", + "signature": "public abstract com.acme.User com.acme.UserRepository.findAnnotatedQueryByEmailAddress(java.lang.String)", + "query": { + "query": "select * from user where emailaddress = ?" + } + }, + { + "name": "findByEmailAddress", + "signature": "public abstract com.acme.User com.acme.UserRepository.findByEmailAddress(java.lang.String)", + "query": { + "name": "User.findByEmailAddress", + "query": "select * from user where emailaddress = ?" + } + }, + { + "name": "count", + "signature": "public abstract long org.springframework.data.repository.CrudRepository.count()", + "fragment": { + "fragment": "org.springframework.data.cassandra.repository.support.SimpleCassandraRepository" + } + } + ] +} +---- + +Queries may contain the following fields: + +* `query`: Query descriptor if the method is a query method. +** `query` the query used to obtain the query method results. +** `name`: Name of the named query if the query is a named one. +* `fragment`: Target fragment if the method call is delegated to a store (repository base class, functional fragment such as Querydsl) or user fragment. +Fragments are either described with just `fragment` if there is no further interface or as `interface` and `fragment` tuple in case there is an interface (such as Querydsl or user-declared fragment interface). + +[NOTE] +.Normalized Query Form +==== +Static analysis of queries allows only a limited representation of runtime query behavior. +Queries are represented in their normalized (pre-parsed and rewritten) form: + +* Value Expressions are replaced with bind markers. +* Query Metadata does not reflect bind-value processing. +`StartingWith`/`EndingWith` queries prepend/append the wildcard character `%` to the actual bind value. +* Runtime Sort information cannot be incorporated in the query string itself as that detail is not known at build-time. +====