diff --git a/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplate.java b/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplate.java index c5a353813..f123e9d1e 100644 --- a/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplate.java +++ b/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplate.java @@ -49,6 +49,7 @@ * @author Michael Reiche * @author Jorge Rodriguez Martin * @author Tigran Babloyan + * @author Emilien Bevierre * @since 3.0 */ public class CouchbaseTemplate implements CouchbaseOperations, ApplicationContextAware { @@ -188,6 +189,11 @@ public ExecutableFindByAnalytics findByAnalytics(Class domainType) { return new ExecutableFindByAnalyticsOperationSupport(this).findByAnalytics(domainType); } + @Override + public ExecutableFindBySearch findBySearch(Class domainType) { + return new ExecutableFindBySearchOperationSupport(this).findBySearch(domainType); + } + @Override @Deprecated public ExecutableRemoveById removeById() { diff --git a/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByIdOperation.java b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByIdOperation.java index a02e658d0..2c906c28a 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByIdOperation.java +++ b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByIdOperation.java @@ -16,8 +16,10 @@ package org.springframework.data.couchbase.core; import java.time.Duration; +import java.util.Arrays; import java.util.Collection; +import org.springframework.data.core.TypedPropertyPath; import org.springframework.data.couchbase.core.support.OneAndAllId; import org.springframework.data.couchbase.core.support.InCollection; import org.springframework.data.couchbase.core.support.WithGetOptions; @@ -33,6 +35,7 @@ * * @author Christoph Strobl * @author Tigran Babloyan + * @author Emilien Bevierre * @since 2.0 */ public interface ExecutableFindByIdOperation { @@ -122,6 +125,17 @@ interface FindByIdWithProjection extends FindByIdInScope, WithProjectionId */ @Override FindByIdInScope project(String... fields); + + /** + * Type-safe variant of {@link #project(String...)} using property paths. + * + * @param fields the property paths to project. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default FindByIdInScope project(TypedPropertyPath... fields) { + return project(Arrays.stream(fields).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } } interface FindByIdWithExpiry extends FindByIdWithProjection, WithExpiry { diff --git a/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByIdOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByIdOperationSupport.java index e4f24cf42..2fb9c04e8 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByIdOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByIdOperationSupport.java @@ -15,6 +15,7 @@ */ package org.springframework.data.couchbase.core; +import org.springframework.data.core.TypedPropertyPath; import java.time.Duration; import java.util.Arrays; import java.util.Collection; @@ -98,6 +99,13 @@ public FindByIdInScope project(String... fields) { return new ExecutableFindByIdSupport<>(template, domainType, scope, collection, options, Arrays.asList(fields), expiry, lockDuration); } + @Override + @SafeVarargs + // maps property references to stored field names (honoring @Field aliases) via the converter + public final FindByIdInScope project(TypedPropertyPath... fields) { + return project(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), fields)); + } + @Override public FindByIdWithProjection withExpiry(final Duration expiry) { return new ExecutableFindByIdSupport<>(template, domainType, scope, collection, options, fields, diff --git a/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByQueryOperation.java b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByQueryOperation.java index 3fc32d06b..bd490a6c9 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByQueryOperation.java +++ b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByQueryOperation.java @@ -15,11 +15,14 @@ */ package org.springframework.data.couchbase.core; +import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.stream.Stream; import org.springframework.dao.IncorrectResultSizeDataAccessException; + +import org.springframework.data.core.TypedPropertyPath; import org.springframework.data.couchbase.core.query.Query; import org.springframework.data.couchbase.core.query.QueryCriteriaDefinition; import org.springframework.data.couchbase.core.support.InCollection; @@ -38,6 +41,7 @@ * Query Operations * * @author Christoph Strobl + * @author Emilien Bevierre * @since 2.0 */ public interface ExecutableFindByQueryOperation { @@ -270,6 +274,17 @@ interface FindByQueryWithProjecting extends FindByQueryWithProjection { * @throws IllegalArgumentException if returnType is {@literal null}. */ FindByQueryWithProjection project(String[] fields); + + /** + * Type-safe variant of {@link #project(String[])} using property paths. + * + * @param fields the property paths to project. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default FindByQueryWithProjection project(TypedPropertyPath... fields) { + return project(Arrays.stream(fields).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } } /** @@ -288,6 +303,17 @@ interface FindByQueryWithDistinct extends FindByQueryWithProjecting, WithD */ @Override FindByQueryWithProjection distinct(String[] distinctFields); + + /** + * Type-safe variant of {@link #distinct(String[])} using property paths. + * + * @param distinctFields the property paths for distinct fields. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default FindByQueryWithProjection distinct(TypedPropertyPath... distinctFields) { + return distinct(Arrays.stream(distinctFields).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } } /** diff --git a/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByQueryOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByQueryOperationSupport.java index b14f478a9..a9503d1b3 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByQueryOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByQueryOperationSupport.java @@ -15,6 +15,7 @@ */ package org.springframework.data.couchbase.core; +import org.springframework.data.core.TypedPropertyPath; import java.util.List; import java.util.stream.Stream; @@ -147,6 +148,20 @@ public FindByQueryWithProjection distinct(final String[] distinctFields) { collection, options, dFields, fields); } + @Override + @SafeVarargs + // maps property references to stored field names (honoring @Field aliases) via the converter + public final FindByQueryWithProjection project(TypedPropertyPath... fields) { + return project(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), fields)); + } + + @Override + @SafeVarargs + // maps property references to stored field names (honoring @Field aliases) via the converter + public final FindByQueryWithProjection distinct(TypedPropertyPath... distinctFields) { + return distinct(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), distinctFields)); + } + @Override public Stream stream() { return reactiveSupport.all().toStream(); diff --git a/src/main/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperation.java b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperation.java new file mode 100644 index 000000000..0342ab34c --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperation.java @@ -0,0 +1,215 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.core; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.data.core.TypedPropertyPath; +import org.springframework.data.couchbase.core.support.InCollection; +import org.springframework.data.couchbase.core.support.InScope; +import org.springframework.data.couchbase.core.support.WithSearchConsistency; +import org.springframework.data.couchbase.core.support.WithSearchOptions; +import org.springframework.data.couchbase.core.support.WithSearchQuery; +import org.jspecify.annotations.Nullable; + +import com.couchbase.client.java.search.HighlightStyle; +import com.couchbase.client.java.search.SearchOptions; +import com.couchbase.client.java.search.SearchRequest; +import com.couchbase.client.java.search.SearchScanConsistency; +import com.couchbase.client.java.search.facet.SearchFacet; +import com.couchbase.client.java.search.result.SearchRow; +import com.couchbase.client.java.search.sort.SearchSort; + +/** + * Full-Text Search (FTS) Operations (Blocking) + * + * @author Emilien Bevierre + * @since 6.2 + */ +public interface ExecutableFindBySearchOperation { + + /** + * Queries the Full-Text Search (FTS) service. + * + * @param domainType the entity type to use for the results. + */ + ExecutableFindBySearch findBySearch(Class domainType); + + interface TerminatingFindBySearch { + + /** + * Get exactly zero or one result. + * + * @return {@link Optional#empty()} if no match found. + * @throws IncorrectResultSizeDataAccessException if more than one match found. + */ + default Optional one() { + return Optional.ofNullable(oneValue()); + } + + /** + * Get exactly zero or one result. + * + * @return {@literal null} if no match found. + * @throws IncorrectResultSizeDataAccessException if more than one match found. + */ + @Nullable + T oneValue(); + + /** + * Get the first or no result. + * + * @return {@link Optional#empty()} if no match found. + */ + default Optional first() { + return Optional.ofNullable(firstValue()); + } + + /** + * Get the first or no result. + * + * @return {@literal null} if no match found. + */ + @Nullable + T firstValue(); + + /** + * Get all matching elements, hydrated as entities via KV GET. + *

+ * If no limit is specified via {@code withLimit(...)} or {@code withOptions(...)}, a default limit of 10,000 is + * applied (the FTS service would otherwise return only its default of 10 hits). + * + * @return never {@literal null}. + */ + List all(); + + /** + * Stream all matching elements. + * + * @return a {@link Stream} of results. Never {@literal null}. + */ + Stream stream(); + + /** + * Get the number of matching elements. + * + * @return total number of matching elements. + */ + long count(); + + /** + * Check for the presence of matching elements. + * + * @return {@literal true} if at least one matching element exists. + */ + boolean exists(); + + /** + * Get raw FTS search rows (without entity hydration). + * + * @return never {@literal null}. + */ + List rows(); + + /** + * Get a combined result including hydrated entities, raw rows, metadata, and facet results. + * + * @return a {@link SearchResult} containing the full response. + */ + SearchResult result(); + } + + interface FindBySearchWithQuery extends TerminatingFindBySearch, WithSearchQuery { + @Override + TerminatingFindBySearch matching(SearchRequest searchRequest); + } + + interface FindBySearchWithOptions extends FindBySearchWithQuery, WithSearchOptions { + @Override + FindBySearchWithQuery withOptions(SearchOptions options); + } + + interface FindBySearchInCollection extends FindBySearchWithOptions, InCollection { + @Override + FindBySearchWithOptions inCollection(String collection); + } + + interface FindBySearchInScope extends FindBySearchInCollection, InScope { + @Override + FindBySearchInCollection inScope(String scope); + } + + interface FindBySearchWithConsistency extends FindBySearchInScope, WithSearchConsistency { + @Override + FindBySearchInScope withConsistency(SearchScanConsistency scanConsistency); + } + + interface FindBySearchWithLimit extends FindBySearchWithConsistency { + FindBySearchWithConsistency withLimit(int limit); + } + + interface FindBySearchWithSkip extends FindBySearchWithLimit { + FindBySearchWithLimit withSkip(int skip); + } + + interface FindBySearchWithSort extends FindBySearchWithSkip { + FindBySearchWithSkip withSort(SearchSort... sort); + +

FindBySearchWithSkip withSort(TypedPropertyPath property, + TypedPropertyPath... additionalProperties); + } + + interface FindBySearchWithHighlight extends FindBySearchWithSort { + FindBySearchWithSort withHighlight(HighlightStyle style, String... fields); + + default FindBySearchWithSort withHighlight(String... fields) { + return withHighlight(HighlightStyle.SERVER_DEFAULT, fields); + } + +

FindBySearchWithSort withHighlight(HighlightStyle style, TypedPropertyPath field, + TypedPropertyPath... additionalFields); + + default

FindBySearchWithSort withHighlight(TypedPropertyPath field, + TypedPropertyPath... additionalFields) { + return withHighlight(HighlightStyle.SERVER_DEFAULT, field, additionalFields); + } + } + + interface FindBySearchWithFacets extends FindBySearchWithHighlight { + FindBySearchWithHighlight withFacets(Map facets); + } + + interface FindBySearchWithFields extends FindBySearchWithFacets { + FindBySearchWithFacets withFields(String... fields); + +

FindBySearchWithFacets withFields(TypedPropertyPath field, + TypedPropertyPath... additionalFields); + } + + interface FindBySearchWithProjection extends FindBySearchWithFields { + FindBySearchWithFields as(Class returnType); + } + + interface FindBySearchWithIndex extends FindBySearchWithProjection { + FindBySearchWithProjection withIndex(String indexName); + } + + interface ExecutableFindBySearch extends FindBySearchWithIndex {} +} diff --git a/src/main/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperationSupport.java new file mode 100644 index 000000000..88552a1a3 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperationSupport.java @@ -0,0 +1,262 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.core; + +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.springframework.data.core.TypedPropertyPath; +import org.springframework.data.couchbase.core.ReactiveFindBySearchOperationSupport.ReactiveFindBySearchSupport; +import org.springframework.data.couchbase.core.query.OptionsBuilder; +import org.springframework.util.Assert; + +import com.couchbase.client.java.search.HighlightStyle; +import com.couchbase.client.java.search.SearchOptions; +import com.couchbase.client.java.search.SearchRequest; +import com.couchbase.client.java.search.SearchScanConsistency; +import com.couchbase.client.java.search.facet.SearchFacet; +import com.couchbase.client.java.search.result.SearchRow; +import com.couchbase.client.java.search.sort.SearchSort; + +/** + * {@link ExecutableFindBySearchOperation} implementations for Couchbase. + * + * @author Emilien Bevierre + * @since 6.2 + */ +public class ExecutableFindBySearchOperationSupport implements ExecutableFindBySearchOperation { + + private final CouchbaseTemplate template; + + public ExecutableFindBySearchOperationSupport(final CouchbaseTemplate template) { + this.template = template; + } + + @Override + public ExecutableFindBySearch findBySearch(final Class domainType) { + return new ExecutableFindBySearchSupport<>(template, domainType, domainType, null, null, null, + OptionsBuilder.getScopeFrom(domainType), OptionsBuilder.getCollectionFrom(domainType), null, + null, null, null, null, null, null); + } + + static class ExecutableFindBySearchSupport implements ExecutableFindBySearch { + + private final CouchbaseTemplate template; + private final Class domainType; + private final Class returnType; + private final String indexName; + private final SearchRequest searchRequest; + private final SearchScanConsistency scanConsistency; + private final String scope; + private final String collection; + private final SearchOptions options; + private final SearchSort[] sort; + private final HighlightStyle highlightStyle; + private final String[] highlightFields; + private final Map facets; + private final String[] fields; + private final Integer[] limitSkip; + private final ReactiveFindBySearchSupport reactiveSupport; + + ExecutableFindBySearchSupport(final CouchbaseTemplate template, final Class domainType, + final Class returnType, final String indexName, final SearchRequest searchRequest, + final SearchScanConsistency scanConsistency, final String scope, final String collection, + final SearchOptions options, final SearchSort[] sort, final HighlightStyle highlightStyle, + final String[] highlightFields, final Map facets, final String[] fields, + final Integer[] limitSkip) { + this.template = template; + this.domainType = domainType; + this.returnType = returnType; + this.indexName = indexName; + this.searchRequest = searchRequest; + this.scanConsistency = scanConsistency; + this.scope = scope; + this.collection = collection; + this.options = options; + this.sort = sort; + this.highlightStyle = highlightStyle; + this.highlightFields = highlightFields; + this.facets = facets; + this.fields = fields; + this.limitSkip = limitSkip; + this.reactiveSupport = new ReactiveFindBySearchSupport<>(template.reactive(), domainType, returnType, + indexName, searchRequest, scanConsistency, scope, collection, options, sort, highlightStyle, + highlightFields, facets, fields, limitSkip, + new NonReactiveSupportWrapper(template.support())); + } + + @Override + public T oneValue() { + return reactiveSupport.one().block(); + } + + @Override + public T firstValue() { + return reactiveSupport.first().block(); + } + + @Override + public List all() { + return reactiveSupport.all().collectList().block(); + } + + @Override + public Stream stream() { + return reactiveSupport.all().toStream(); + } + + @Override + public long count() { + Long count = reactiveSupport.count().block(); + if (count == null) { + throw new CouchbaseQueryExecutionException("search count query did not return a count, index: " + indexName); + } + return count; + } + + @Override + public boolean exists() { + return count() > 0; + } + + @Override + public List rows() { + return reactiveSupport.rows().collectList().block(); + } + + @Override + public SearchResult result() { + return reactiveSupport.result().block(); + } + + @Override + public TerminatingFindBySearch matching(SearchRequest searchRequest) { + Assert.notNull(searchRequest, "SearchRequest must not be null!"); + return new ExecutableFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, + fields, limitSkip); + } + + @Override + public FindBySearchWithProjection withIndex(String indexName) { + Assert.notNull(indexName, "Index name must not be null!"); + return new ExecutableFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, + fields, limitSkip); + } + + @Override + public FindBySearchWithFields as(final Class returnType) { + Assert.notNull(returnType, "returnType must not be null!"); + return new ExecutableFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, + fields, limitSkip); + } + + @Override + public FindBySearchInScope withConsistency(SearchScanConsistency scanConsistency) { + return new ExecutableFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, + fields, limitSkip); + } + + @Override + public FindBySearchInCollection inScope(final String scope) { + return new ExecutableFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope != null ? scope : this.scope, collection, options, sort, highlightStyle, + highlightFields, facets, fields, limitSkip); + } + + @Override + public FindBySearchWithOptions inCollection(final String collection) { + return new ExecutableFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection != null ? collection : this.collection, options, sort, + highlightStyle, highlightFields, facets, fields, limitSkip); + } + + @Override + public FindBySearchWithQuery withOptions(final SearchOptions options) { + return new ExecutableFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options != null ? options : this.options, sort, highlightStyle, + highlightFields, facets, fields, limitSkip); + } + + @Override + public FindBySearchWithConsistency withLimit(int limit) { + Integer[] ls = limitSkip != null ? java.util.Arrays.copyOf(limitSkip, 2) : new Integer[2]; + ls[0] = limit; + return new ExecutableFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, + fields, ls); + } + + @Override + public FindBySearchWithLimit withSkip(int skip) { + Integer[] ls = limitSkip != null ? java.util.Arrays.copyOf(limitSkip, 2) : new Integer[2]; + ls[1] = skip; + return new ExecutableFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, + fields, ls); + } + + @Override + public FindBySearchWithSkip withSort(SearchSort... sort) { + return new ExecutableFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, + fields, limitSkip); + } + + @Override + public

FindBySearchWithSkip withSort(TypedPropertyPath property, + TypedPropertyPath... additionalProperties) { + return withSort(PropertyPathSupport.toSearchSorts(template.getConverter(), property, additionalProperties)); + } + + @Override + public FindBySearchWithSort withHighlight(HighlightStyle style, String... fields) { + return new ExecutableFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, style, fields, facets, this.fields, limitSkip); + } + + @Override + public

FindBySearchWithSort withHighlight(HighlightStyle style, TypedPropertyPath field, + TypedPropertyPath... additionalFields) { + return withHighlight(style, + PropertyPathSupport.getMappedFieldPaths(template.getConverter(), field, additionalFields)); + } + + @Override + public FindBySearchWithHighlight withFacets(Map facets) { + return new ExecutableFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, + fields, limitSkip); + } + + @Override + public FindBySearchWithFacets withFields(String... fields) { + return new ExecutableFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, + fields, limitSkip); + } + + @Override + public

FindBySearchWithFacets withFields(TypedPropertyPath field, + TypedPropertyPath... additionalFields) { + return withFields(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), field, additionalFields)); + } + } +} diff --git a/src/main/java/org/springframework/data/couchbase/core/ExecutableMutateInByIdOperation.java b/src/main/java/org/springframework/data/couchbase/core/ExecutableMutateInByIdOperation.java index a176a5b56..06a9c4344 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ExecutableMutateInByIdOperation.java +++ b/src/main/java/org/springframework/data/couchbase/core/ExecutableMutateInByIdOperation.java @@ -19,17 +19,20 @@ import com.couchbase.client.java.kv.MutateInOptions; import com.couchbase.client.java.kv.PersistTo; import com.couchbase.client.java.kv.ReplicateTo; +import org.springframework.data.core.TypedPropertyPath; import org.springframework.data.couchbase.core.support.*; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.time.Duration; +import java.util.Arrays; import java.util.Collection; /** * Mutate In Operations * * @author Tigran Babloyan + * @author Emilien Bevierre * @since 5.1 */ public interface ExecutableMutateInByIdOperation { @@ -98,6 +101,42 @@ interface MutateInByIdWithPaths extends TerminatingMutateInById, WithMutat * By default the CAS value is not provided. */ MutateInByIdWithPaths withCasProvided(); + + /** + * Type-safe variant of {@link #withRemovePaths(String...)} using property paths. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default MutateInByIdWithPaths withRemovePaths(TypedPropertyPath... removePaths) { + return withRemovePaths(Arrays.stream(removePaths).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } + + /** + * Type-safe variant of {@link #withInsertPaths(String...)} using property paths. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default MutateInByIdWithPaths withInsertPaths(TypedPropertyPath... insertPaths) { + return withInsertPaths(Arrays.stream(insertPaths).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } + + /** + * Type-safe variant of {@link #withUpsertPaths(String...)} using property paths. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default MutateInByIdWithPaths withUpsertPaths(TypedPropertyPath... upsertPaths) { + return withUpsertPaths(Arrays.stream(upsertPaths).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } + + /** + * Type-safe variant of {@link #withReplacePaths(String...)} using property paths. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default MutateInByIdWithPaths withReplacePaths(TypedPropertyPath... replacePaths) { + return withReplacePaths(Arrays.stream(replacePaths).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } } /** diff --git a/src/main/java/org/springframework/data/couchbase/core/ExecutableMutateInByIdOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ExecutableMutateInByIdOperationSupport.java index 8b44b85fe..fe36709d4 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ExecutableMutateInByIdOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ExecutableMutateInByIdOperationSupport.java @@ -15,6 +15,7 @@ */ package org.springframework.data.couchbase.core; +import org.springframework.data.core.TypedPropertyPath; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; @@ -178,6 +179,35 @@ public MutateInByIdWithDurability withReplacePaths(final String... replacePat durabilityLevel, expiry, removePaths, upsertPaths, insertPaths, Arrays.asList(replacePaths), provideCas); } + + @Override + @SafeVarargs + // maps property references to stored field names (honoring @Field aliases) via the converter + public final MutateInByIdWithPaths withRemovePaths(TypedPropertyPath... removePaths) { + return withRemovePaths(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), removePaths)); + } + + @Override + @SafeVarargs + // maps property references to stored field names (honoring @Field aliases) via the converter + public final MutateInByIdWithPaths withUpsertPaths(TypedPropertyPath... upsertPaths) { + return withUpsertPaths(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), upsertPaths)); + } + + @Override + @SafeVarargs + // maps property references to stored field names (honoring @Field aliases) via the converter + public final MutateInByIdWithPaths withInsertPaths(TypedPropertyPath... insertPaths) { + return withInsertPaths(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), insertPaths)); + } + + @Override + @SafeVarargs + // maps property references to stored field names (honoring @Field aliases) via the converter + public final MutateInByIdWithPaths withReplacePaths(TypedPropertyPath... replacePaths) { + return withReplacePaths(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), replacePaths)); + } + @Override public MutateInByIdWithPaths withCasProvided() { return new ExecutableMutateInByIdSupport<>(template, domainType, scope, collection, options, persistTo, replicateTo, diff --git a/src/main/java/org/springframework/data/couchbase/core/FluentCouchbaseOperations.java b/src/main/java/org/springframework/data/couchbase/core/FluentCouchbaseOperations.java index 93cfe0257..94e45e5c7 100644 --- a/src/main/java/org/springframework/data/couchbase/core/FluentCouchbaseOperations.java +++ b/src/main/java/org/springframework/data/couchbase/core/FluentCouchbaseOperations.java @@ -21,6 +21,6 @@ */ public interface FluentCouchbaseOperations extends ExecutableUpsertByIdOperation, ExecutableInsertByIdOperation, ExecutableReplaceByIdOperation, ExecutableFindByIdOperation, ExecutableFindFromReplicasByIdOperation, - ExecutableFindByQueryOperation, ExecutableFindByAnalyticsOperation, ExecutableExistsByIdOperation, - ExecutableRemoveByIdOperation, ExecutableRemoveByQueryOperation, ExecutableMutateInByIdOperation, - ExecutableRangeScanOperation {} + ExecutableFindByQueryOperation, ExecutableFindByAnalyticsOperation, ExecutableFindBySearchOperation, + ExecutableExistsByIdOperation, ExecutableRemoveByIdOperation, ExecutableRemoveByQueryOperation, + ExecutableMutateInByIdOperation, ExecutableRangeScanOperation {} diff --git a/src/main/java/org/springframework/data/couchbase/core/PropertyPathSupport.java b/src/main/java/org/springframework/data/couchbase/core/PropertyPathSupport.java new file mode 100644 index 000000000..1090962c6 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/PropertyPathSupport.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.core; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.data.core.TypedPropertyPath; +import org.springframework.data.couchbase.core.convert.CouchbaseConverter; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; +import org.springframework.data.mapping.PersistentPropertyPath; + +import com.couchbase.client.java.search.sort.SearchSort; + +/** + * Maps {@link TypedPropertyPath} property references to the stored field names, honoring {@code @Field} aliases on + * every segment of the path. + * + * @author Emilien Bevierre + * @since 6.2 + */ +final class PropertyPathSupport { + + private PropertyPathSupport() { + } + + static

String[] getMappedFieldPaths(CouchbaseConverter converter, TypedPropertyPath[] properties) { + + String[] fields = new String[properties.length]; + for (int i = 0; i < properties.length; i++) { + fields[i] = getMappedFieldPath(converter, properties[i]); + } + return fields; + } + + static

String getMappedFieldPath(CouchbaseConverter converter, TypedPropertyPath property) { + PersistentPropertyPath path = converter.getMappingContext() + .getPersistentPropertyPath(property); + return path.toDotPath(CouchbasePersistentProperty::getFieldName); + } + + @SafeVarargs + static

String[] getMappedFieldPaths(CouchbaseConverter converter, TypedPropertyPath property, + TypedPropertyPath... additionalProperties) { + + List fields = new ArrayList<>(additionalProperties.length + 1); + fields.add(getMappedFieldPath(converter, property)); + + for (TypedPropertyPath additionalProperty : additionalProperties) { + fields.add(getMappedFieldPath(converter, additionalProperty)); + } + + return fields.toArray(String[]::new); + } + + @SafeVarargs + static

SearchSort[] toSearchSorts(CouchbaseConverter converter, TypedPropertyPath property, + TypedPropertyPath... additionalProperties) { + + String[] fields = getMappedFieldPaths(converter, property, additionalProperties); + SearchSort[] result = new SearchSort[fields.length]; + + for (int i = 0; i < fields.length; i++) { + result[i] = SearchSort.byField(fields[i]); + } + + return result; + } +} diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplate.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplate.java index e81cf8d06..6e5026154 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplate.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplate.java @@ -145,6 +145,11 @@ public ReactiveFindByAnalytics findByAnalytics(Class domainType) { return new ReactiveFindByAnalyticsOperationSupport(this).findByAnalytics(domainType); } + @Override + public ReactiveFindBySearch findBySearch(Class domainType) { + return new ReactiveFindBySearchOperationSupport(this).findBySearch(domainType); + } + @Override public ReactiveFindByQuery findByQuery(Class domainType) { return new ReactiveFindByQueryOperationSupport(this).findByQuery(domainType); diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByIdOperation.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByIdOperation.java index fb3ab1629..0902aab67 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByIdOperation.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByIdOperation.java @@ -19,8 +19,10 @@ import reactor.core.publisher.Mono; import java.time.Duration; +import java.util.Arrays; import java.util.Collection; +import org.springframework.data.core.TypedPropertyPath; import org.springframework.data.couchbase.core.support.InCollection; import org.springframework.data.couchbase.core.support.InScope; import org.springframework.data.couchbase.core.support.OneAndAllIdReactive; @@ -36,6 +38,7 @@ * * @author Christoph Strobl * @author Tigran Babloyan + * @author Emilien Bevierre * @since 2.0 */ public interface ReactiveFindByIdOperation { @@ -126,6 +129,17 @@ interface FindByIdWithProjection extends FindByIdInScope, WithProjectionId */ FindByIdInCollection project(String... fields); + /** + * Type-safe variant of {@link #project(String...)} using property paths. + * + * @param fields the property paths to project. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default FindByIdInCollection project(TypedPropertyPath... fields) { + return project(Arrays.stream(fields).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } + } interface FindByIdWithExpiry extends FindByIdWithProjection, WithExpiry { diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByIdOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByIdOperationSupport.java index 5b00a95fc..f57759256 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByIdOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByIdOperationSupport.java @@ -15,6 +15,7 @@ */ package org.springframework.data.couchbase.core; +import org.springframework.data.core.TypedPropertyPath; import static com.couchbase.client.java.kv.GetAndLockOptions.getAndLockOptions; import static com.couchbase.client.java.kv.GetAndTouchOptions.getAndTouchOptions; import static com.couchbase.client.java.transactions.internal.ConverterUtil.makeCollectionIdentifier; @@ -210,6 +211,13 @@ public FindByIdInCollection project(String... fields) { expiry, lockDuration, support); } + @Override + @SafeVarargs + // maps property references to stored field names (honoring @Field aliases) via the converter + public final FindByIdInCollection project(TypedPropertyPath... fields) { + return project(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), fields)); + } + @Override public FindByIdWithProjection withExpiry(final Duration expiry) { return new ReactiveFindByIdSupport<>(template, domainType, scope, collection, options, fields, expiry, diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByQueryOperation.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByQueryOperation.java index 5ef871193..b270877fe 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByQueryOperation.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByQueryOperation.java @@ -18,7 +18,10 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.util.Arrays; + import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.data.core.TypedPropertyPath; import org.springframework.data.couchbase.core.query.Query; import org.springframework.data.couchbase.core.query.QueryCriteriaDefinition; import org.springframework.data.couchbase.core.support.InCollection; @@ -38,6 +41,7 @@ * * @author Michael Nitschinger * @author Michael Reiche + * @author Emilien Bevierre */ public interface ReactiveFindByQueryOperation { @@ -217,6 +221,17 @@ interface FindByQueryWithProjecting extends FindByQueryWithProjection { * @throws IllegalArgumentException if returnType is {@literal null}. */ FindByQueryWithProjection project(String[] fields); + + /** + * Type-safe variant of {@link #project(String[])} using property paths. + * + * @param fields the property paths to project. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default FindByQueryWithProjection project(TypedPropertyPath... fields) { + return project(Arrays.stream(fields).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } } /** @@ -234,6 +249,17 @@ interface FindByQueryWithDistinct extends FindByQueryWithProjecting, WithD * @throws IllegalArgumentException if field is {@literal null}. */ FindByQueryWithProjection distinct(String[] distinctFields); + + /** + * Type-safe variant of {@link #distinct(String[])} using property paths. + * + * @param distinctFields the property paths for distinct fields. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default FindByQueryWithProjection distinct(TypedPropertyPath... distinctFields) { + return distinct(Arrays.stream(distinctFields).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } } /** diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByQueryOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByQueryOperationSupport.java index aec2fa691..87dcc45c4 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByQueryOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByQueryOperationSupport.java @@ -15,6 +15,7 @@ */ package org.springframework.data.couchbase.core; +import org.springframework.data.core.TypedPropertyPath; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -164,6 +165,20 @@ public FindByQueryWithDistinct distinct(final String[] distinctFields) { collection, options, dFields, fields, support); } + @Override + @SafeVarargs + // maps property references to stored field names (honoring @Field aliases) via the converter + public final FindByQueryWithProjection project(TypedPropertyPath... fields) { + return project(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), fields)); + } + + @Override + @SafeVarargs + // maps property references to stored field names (honoring @Field aliases) via the converter + public final FindByQueryWithProjection distinct(TypedPropertyPath... distinctFields) { + return distinct(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), distinctFields)); + } + @Override public Mono one() { return all().singleOrEmpty(); diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindBySearchOperation.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindBySearchOperation.java new file mode 100644 index 000000000..4df68cd27 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindBySearchOperation.java @@ -0,0 +1,293 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.core; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Map; + +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.data.core.TypedPropertyPath; +import org.springframework.data.couchbase.core.support.InCollection; +import org.springframework.data.couchbase.core.support.InScope; +import org.springframework.data.couchbase.core.support.WithSearchConsistency; +import org.springframework.data.couchbase.core.support.WithSearchOptions; +import org.springframework.data.couchbase.core.support.WithSearchQuery; + +import com.couchbase.client.java.search.HighlightStyle; +import com.couchbase.client.java.search.SearchOptions; +import com.couchbase.client.java.search.SearchRequest; +import com.couchbase.client.java.search.SearchScanConsistency; +import com.couchbase.client.java.search.facet.SearchFacet; +import com.couchbase.client.java.search.result.SearchRow; +import com.couchbase.client.java.search.sort.SearchSort; + +/** + * Full-Text Search (FTS) Operations (Reactive) + * + * @author Emilien Bevierre + * @since 6.2 + */ +public interface ReactiveFindBySearchOperation { + + /** + * Queries the Full-Text Search (FTS) service. + * + * @param domainType the entity type to use for the results. + */ + ReactiveFindBySearch findBySearch(Class domainType); + + /** + * Terminating operations invoking the actual execution. + */ + interface TerminatingFindBySearch { + + /** + * Get exactly zero or one result. + * + * @return a mono with the match if found (an empty one otherwise). + * @throws IncorrectResultSizeDataAccessException if more than one match found. + */ + Mono one(); + + /** + * Get the first or no result. + * + * @return the first or an empty mono if none found. + */ + Mono first(); + + /** + * Get all matching elements, hydrated as entities via KV GET. + *

+ * If no limit is specified via {@code withLimit(...)} or {@code withOptions(...)}, a default limit of 10,000 is + * applied (the FTS service would otherwise return only its default of 10 hits). + * + * @return never {@literal null}. + */ + Flux all(); + + /** + * Get the number of matching elements. + * + * @return total number of matching elements. + */ + Mono count(); + + /** + * Check for the presence of matching elements. + * + * @return {@literal true} if at least one matching element exists. + */ + Mono exists(); + + /** + * Get raw FTS search rows (without entity hydration). Useful for accessing scores, fragments, locations, etc. + * + * @return a {@link Flux} of {@link SearchRow} results. + */ + Flux rows(); + + /** + * Get a combined result including hydrated entities, raw rows, metadata, and facet results. + * + * @return a {@link Mono} of {@link SearchResult} containing the full response. + */ + Mono> result(); + } + + interface FindBySearchWithQuery extends TerminatingFindBySearch, WithSearchQuery { + + /** + * Set the search request to be used. + * + * @param searchRequest must not be {@literal null}. + * @throws IllegalArgumentException if searchRequest is {@literal null}. + */ + @Override + TerminatingFindBySearch matching(SearchRequest searchRequest); + } + + /** + * Fluent method to specify options. + *

+ * The given {@link SearchOptions} are passed to the SDK as provided (aside from collection routing) and must not be + * combined with the individual {@code with*} configuration methods of this fluent API. + * + * @param the entity type to use. + */ + interface FindBySearchWithOptions extends FindBySearchWithQuery, WithSearchOptions { + @Override + FindBySearchWithQuery withOptions(SearchOptions options); + } + + /** + * Fluent method to specify the collection. + * + * @param the entity type to use for the results. + */ + interface FindBySearchInCollection extends FindBySearchWithOptions, InCollection { + @Override + FindBySearchWithOptions inCollection(String collection); + } + + /** + * Fluent method to specify the scope. + * + * @param the entity type to use for the results. + */ + interface FindBySearchInScope extends FindBySearchInCollection, InScope { + @Override + FindBySearchInCollection inScope(String scope); + } + + /** + * Fluent method to specify scan consistency. + * + * @param the entity type to use for the results. + */ + interface FindBySearchWithConsistency extends FindBySearchInScope, WithSearchConsistency { + @Override + FindBySearchInScope withConsistency(SearchScanConsistency scanConsistency); + } + + /** + * Fluent method to specify a limit on results. + */ + interface FindBySearchWithLimit extends FindBySearchWithConsistency { + /** + * Limit the number of results returned. + * + * @param limit the maximum number of results. + */ + FindBySearchWithConsistency withLimit(int limit); + } + + /** + * Fluent method to specify a skip/offset. + */ + interface FindBySearchWithSkip extends FindBySearchWithLimit { + /** + * Skip the given number of results (for pagination). + * + * @param skip the number of results to skip. + */ + FindBySearchWithLimit withSkip(int skip); + } + + /** + * Fluent method to specify sorting. + */ + interface FindBySearchWithSort extends FindBySearchWithSkip { + /** + * Specify the sort order for results. + * + * @param sort the sort specifications. + */ + FindBySearchWithSkip withSort(SearchSort... sort); + +

FindBySearchWithSkip withSort(TypedPropertyPath property, + TypedPropertyPath... additionalProperties); + } + + /** + * Fluent method to specify highlighting. + */ + interface FindBySearchWithHighlight extends FindBySearchWithSort { + /** + * Enable highlighting on results with the given style and optional field restrictions. + * + * @param style the highlight style. + * @param fields optional fields to highlight. If empty, all matched fields are highlighted. + */ + FindBySearchWithSort withHighlight(HighlightStyle style, String... fields); + + /** + * Enable highlighting with the server's default style. + * + * @param fields optional fields to highlight. + */ + default FindBySearchWithSort withHighlight(String... fields) { + return withHighlight(HighlightStyle.SERVER_DEFAULT, fields); + } + +

FindBySearchWithSort withHighlight(HighlightStyle style, TypedPropertyPath field, + TypedPropertyPath... additionalFields); + + default

FindBySearchWithSort withHighlight(TypedPropertyPath field, + TypedPropertyPath... additionalFields) { + return withHighlight(HighlightStyle.SERVER_DEFAULT, field, additionalFields); + } + } + + /** + * Fluent method to specify facets. + */ + interface FindBySearchWithFacets extends FindBySearchWithHighlight { + /** + * Specify facets to include in the search results. + * + * @param facets a map of facet name to facet definition. + */ + FindBySearchWithHighlight withFacets(Map facets); + } + + /** + * Fluent method to specify which stored fields to return. + */ + interface FindBySearchWithFields extends FindBySearchWithFacets { + /** + * Specify which stored fields to include in the search results. + * + * @param fields the field names. + */ + FindBySearchWithFacets withFields(String... fields); + +

FindBySearchWithFacets withFields(TypedPropertyPath field, + TypedPropertyPath... additionalFields); + } + + /** + * Result type override (Optional). + */ + interface FindBySearchWithProjection extends FindBySearchWithFields { + + /** + * Define the target type fields should be mapped to. + * + * @param returnType must not be {@literal null}. + * @return new instance of {@link FindBySearchWithFields}. + * @throws IllegalArgumentException if returnType is {@literal null}. + */ + FindBySearchWithFields as(Class returnType); + } + + /** + * Fluent method to specify the FTS index name. + */ + interface FindBySearchWithIndex extends FindBySearchWithProjection { + /** + * Specify the FTS index name to query. + * + * @param indexName the name of the FTS index. + * @return new instance for further fluent configuration. + */ + FindBySearchWithProjection withIndex(String indexName); + } + + interface ReactiveFindBySearch extends FindBySearchWithIndex {} +} diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindBySearchOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindBySearchOperationSupport.java new file mode 100644 index 000000000..672111bd6 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindBySearchOperationSupport.java @@ -0,0 +1,436 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.core; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Arrays; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.core.TypedPropertyPath; +import org.springframework.data.couchbase.core.query.OptionsBuilder; +import org.springframework.util.Assert; + +import com.couchbase.client.java.search.HighlightStyle; +import com.couchbase.client.java.search.SearchMetaData; +import com.couchbase.client.java.search.SearchOptions; +import com.couchbase.client.java.search.SearchRequest; +import com.couchbase.client.java.search.SearchScanConsistency; +import com.couchbase.client.java.search.facet.SearchFacet; +import com.couchbase.client.java.search.result.ReactiveSearchResult; +import com.couchbase.client.java.search.result.SearchFacetResult; +import com.couchbase.client.java.search.result.SearchRow; +import com.couchbase.client.java.search.sort.SearchSort; + +/** + * {@link ReactiveFindBySearchOperation} implementations for Couchbase. + * + * @author Emilien Bevierre + * @since 6.2 + */ +public class ReactiveFindBySearchOperationSupport implements ReactiveFindBySearchOperation { + + private final ReactiveCouchbaseTemplate template; + private static final Logger LOG = LoggerFactory.getLogger(ReactiveFindBySearchOperationSupport.class); + + /** + * Limit applied when neither {@code withLimit(...)} nor {@code withOptions(...)} is used. Without an explicit size + * the FTS service returns only its default of 10 hits, which would silently truncate {@code all()} results. + */ + static final int DEFAULT_LIMIT = 10_000; + + public ReactiveFindBySearchOperationSupport(final ReactiveCouchbaseTemplate template) { + this.template = template; + } + + @Override + public ReactiveFindBySearch findBySearch(final Class domainType) { + return new ReactiveFindBySearchSupport<>(template, domainType, domainType, null, null, null, + OptionsBuilder.getScopeFrom(domainType), OptionsBuilder.getCollectionFrom(domainType), null, + null, null, null, null, null, null, template.support()); + } + + static class ReactiveFindBySearchSupport implements ReactiveFindBySearch { + + private final ReactiveCouchbaseTemplate template; + private final Class domainType; + private final Class returnType; + private final String indexName; + private final SearchRequest searchRequest; + private final SearchScanConsistency scanConsistency; + private final String scope; + private final String collection; + private final SearchOptions options; + private final SearchSort[] sort; + private final HighlightStyle highlightStyle; + private final String[] highlightFields; + private final Map facets; + private final String[] fields; + private final Integer[] limitSkip; // [0]=limit, [1]=skip; null means unset + private final ReactiveTemplateSupport support; + + ReactiveFindBySearchSupport(final ReactiveCouchbaseTemplate template, final Class domainType, + final Class returnType, final String indexName, final SearchRequest searchRequest, + final SearchScanConsistency scanConsistency, final String scope, final String collection, + final SearchOptions options, final SearchSort[] sort, final HighlightStyle highlightStyle, + final String[] highlightFields, final Map facets, final String[] fields, + final Integer[] limitSkip, final ReactiveTemplateSupport support) { + this.template = template; + this.domainType = domainType; + this.returnType = returnType; + this.indexName = indexName; + this.searchRequest = searchRequest; + this.scanConsistency = scanConsistency; + this.scope = scope; + this.collection = collection; + this.options = options; + this.sort = sort; + this.highlightStyle = highlightStyle; + this.highlightFields = highlightFields; + this.facets = facets; + this.fields = fields; + this.limitSkip = limitSkip; + this.support = support; + } + + @Override + public TerminatingFindBySearch matching(SearchRequest searchRequest) { + Assert.notNull(searchRequest, "SearchRequest must not be null"); + return new ReactiveFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, fields, + limitSkip, support); + } + + @Override + public FindBySearchWithProjection withIndex(String indexName) { + Assert.notNull(indexName, "Index name must not be null!"); + return new ReactiveFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, fields, + limitSkip, support); + } + + @Override + public FindBySearchWithFields as(final Class returnType) { + Assert.notNull(returnType, "returnType must not be null"); + return new ReactiveFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, + fields, limitSkip, support); + } + + @Override + public FindBySearchInScope withConsistency(SearchScanConsistency scanConsistency) { + return new ReactiveFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, fields, + limitSkip, support); + } + + @Override + public FindBySearchInCollection inScope(final String scope) { + return new ReactiveFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope != null ? scope : this.scope, collection, options, sort, highlightStyle, + highlightFields, facets, fields, limitSkip, support); + } + + @Override + public FindBySearchWithOptions inCollection(final String collection) { + return new ReactiveFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection != null ? collection : this.collection, options, sort, + highlightStyle, highlightFields, facets, fields, limitSkip, support); + } + + @Override + public FindBySearchWithQuery withOptions(final SearchOptions options) { + return new ReactiveFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options != null ? options : this.options, sort, highlightStyle, + highlightFields, facets, fields, limitSkip, support); + } + + @Override + public FindBySearchWithConsistency withLimit(int limit) { + Integer[] ls = limitSkip != null ? Arrays.copyOf(limitSkip, 2) : new Integer[2]; + ls[0] = limit; + return new ReactiveFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, + fields, ls, support); + } + + @Override + public FindBySearchWithLimit withSkip(int skip) { + Integer[] ls = limitSkip != null ? Arrays.copyOf(limitSkip, 2) : new Integer[2]; + ls[1] = skip; + return new ReactiveFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, + fields, ls, support); + } + + @Override + public FindBySearchWithSkip withSort(SearchSort... sort) { + return new ReactiveFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, + fields, limitSkip, support); + } + + @Override + public

FindBySearchWithSkip withSort(TypedPropertyPath property, + TypedPropertyPath... additionalProperties) { + return withSort(PropertyPathSupport.toSearchSorts(template.getConverter(), property, additionalProperties)); + } + + @Override + public FindBySearchWithSort withHighlight(HighlightStyle style, String... fields) { + return new ReactiveFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, style, fields, facets, this.fields, limitSkip, + support); + } + + @Override + public

FindBySearchWithSort withHighlight(HighlightStyle style, TypedPropertyPath field, + TypedPropertyPath... additionalFields) { + return withHighlight(style, + PropertyPathSupport.getMappedFieldPaths(template.getConverter(), field, additionalFields)); + } + + @Override + public FindBySearchWithHighlight withFacets(Map facets) { + return new ReactiveFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, + fields, limitSkip, support); + } + + @Override + public FindBySearchWithFacets withFields(String... fields) { + return new ReactiveFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest, + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, + fields, limitSkip, support); + } + + @Override + public

FindBySearchWithFacets withFields(TypedPropertyPath field, + TypedPropertyPath... additionalFields) { + return withFields(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), field, additionalFields)); + } + + @Override + public Mono one() { + return all().singleOrEmpty(); + } + + @Override + public Mono first() { + return all().next(); + } + + @Override + public Flux all() { + return Flux.defer(() -> { + Assert.notNull(indexName, "Index name must be specified via withIndex()"); + Assert.notNull(searchRequest, "SearchRequest must be specified via matching()"); + + if (LOG.isDebugEnabled()) { + LOG.debug("findBySearch index: {}", indexName); + } + + return TransactionalSupport.verifyNotInTransaction("findBySearch") + .thenMany(executeSearch() + .flatMapMany(ReactiveSearchResult::rows)) + .flatMapSequential(this::hydrateRow) + .onErrorMap(throwable -> { + if (throwable instanceof RuntimeException) { + return template.potentiallyConvertRuntimeException((RuntimeException) throwable); + } else { + return throwable; + } + }); + }); + } + + @Override + public Mono count() { + return Mono.defer(() -> { + Assert.notNull(indexName, "Index name must be specified via withIndex()"); + Assert.notNull(searchRequest, "SearchRequest must be specified via matching()"); + + // rows must be drained before the SDK emits the metadata trailer, even with limit 0 + return TransactionalSupport.verifyNotInTransaction("findBySearch") + .then(executeSearch(true)) + .flatMap(result -> result.rows().then(result.metaData())) + .map(metaData -> metaData.metrics().totalRows()) + .onErrorMap(throwable -> { + if (throwable instanceof RuntimeException) { + return template.potentiallyConvertRuntimeException((RuntimeException) throwable); + } else { + return throwable; + } + }); + }); + } + + @Override + public Mono exists() { + return count().map(count -> count > 0); + } + + @Override + public Flux rows() { + return Flux.defer(() -> { + Assert.notNull(indexName, "Index name must be specified via withIndex()"); + Assert.notNull(searchRequest, "SearchRequest must be specified via matching()"); + + return TransactionalSupport.verifyNotInTransaction("findBySearch") + .thenMany(executeSearch() + .flatMapMany(ReactiveSearchResult::rows)) + .onErrorMap(throwable -> { + if (throwable instanceof RuntimeException) { + return template.potentiallyConvertRuntimeException((RuntimeException) throwable); + } else { + return throwable; + } + }); + }); + } + + @Override + public Mono> result() { + return Mono.defer(() -> { + Assert.notNull(indexName, "Index name must be specified via withIndex()"); + Assert.notNull(searchRequest, "SearchRequest must be specified via matching()"); + + return TransactionalSupport.verifyNotInTransaction("findBySearch") + .then(executeSearch()) + .flatMap(this::collectFullResult) + .onErrorMap(throwable -> { + if (throwable instanceof RuntimeException) { + return template.potentiallyConvertRuntimeException((RuntimeException) throwable); + } else { + return throwable; + } + }); + }); + } + + private Mono> collectFullResult(ReactiveSearchResult reactiveResult) { + Mono> rowsMono = reactiveResult.rows().collectList(); + Mono metaMono = reactiveResult.metaData(); + Mono> facetsMono = reactiveResult.facets(); + + return Mono.zip(rowsMono, metaMono, facetsMono) + .flatMap(tuple -> { + java.util.List searchRows = tuple.getT1(); + SearchMetaData metaData = tuple.getT2(); + java.util.Map facetResults = tuple.getT3(); + + return Flux.fromIterable(searchRows) + .flatMapSequential(this::hydrateRow) + .collectList() + .map(entities -> new SearchResult<>(entities, searchRows, metaData, facetResults)); + }); + } + + /** + * Hydrates a SearchRow into an entity via KV GET, skipping documents that have been + * deleted between the FTS index update and the KV fetch (stale index entries). + *

+ * Misses are logged at WARN so operators can detect index staleness; persistent or high-volume + * misses typically indicate an out-of-sync FTS index. + */ + private Mono hydrateRow(SearchRow row) { + // findById already maps DocumentNotFoundException to an empty Mono, so misses surface as emptiness + return template.findById(returnType) + .inScope(scope) + .inCollection(collection) + .one(row.id()) + .switchIfEmpty(Mono.defer(() -> { + LOG.warn("Skipping stale FTS result for document id '{}': document not found in KV " + + "(index '{}' may be out of sync)", row.id(), indexName); + return Mono.empty(); + })); + } + private Mono executeSearch() { + return executeSearch(false); + } + + private Mono executeSearch(boolean metadataOnly) { + SearchOptions opts = buildSearchOptions(metadataOnly); + if (scope != null) { + return template.getCouchbaseClientFactory() + .withScope(scope).getScope().reactive() + .search(indexName, searchRequest, opts); + } else { + return template.getCouchbaseClientFactory() + .getCluster().reactive() + .search(indexName, searchRequest, opts); + } + } + + private SearchOptions buildSearchOptions(boolean metadataOnly) { + if (options != null) { + if (hasFluentOptions()) { + throw new IllegalArgumentException("withOptions() cannot be combined with withConsistency(), withSort(), " + + "withHighlight(), withFacets(), withFields(), withLimit() or withSkip(); " + + "set those directly on the SearchOptions instead"); + } + if (collection != null) { + options.collections(collection); + } + return options; + } + SearchOptions opts = SearchOptions.searchOptions(); + if (scanConsistency != null) { + opts.scanConsistency(scanConsistency); + } + if (collection != null) { + opts.collections(collection); + } + if (sort != null && sort.length > 0) { + opts.sort((Object[]) sort); + } + if (highlightStyle != null) { + if (highlightFields != null && highlightFields.length > 0) { + opts.highlight(highlightStyle, highlightFields); + } else { + opts.highlight(highlightStyle); + } + } + if (facets != null && !facets.isEmpty()) { + opts.facets(facets); + } + if (fields != null && fields.length > 0) { + opts.fields(fields); + } + if (metadataOnly) { + opts.limit(0); + } else { + if (limitSkip != null && limitSkip[0] != null) { + opts.limit(limitSkip[0]); + } else { + opts.limit(DEFAULT_LIMIT); + } + if (limitSkip != null && limitSkip[1] != null) { + opts.skip(limitSkip[1]); + } + } + return opts; + } + + private boolean hasFluentOptions() { + return scanConsistency != null || (sort != null && sort.length > 0) || highlightStyle != null + || (facets != null && !facets.isEmpty()) || (fields != null && fields.length > 0) || limitSkip != null; + } + } +} diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveFluentCouchbaseOperations.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveFluentCouchbaseOperations.java index 78fa9ef95..a4f5a1c3e 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveFluentCouchbaseOperations.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveFluentCouchbaseOperations.java @@ -21,6 +21,6 @@ */ public interface ReactiveFluentCouchbaseOperations extends ReactiveUpsertByIdOperation, ReactiveInsertByIdOperation, ReactiveReplaceByIdOperation, ReactiveFindByIdOperation, ReactiveExistsByIdOperation, - ReactiveFindByAnalyticsOperation, ReactiveFindFromReplicasByIdOperation, ReactiveFindByQueryOperation, - ReactiveRemoveByIdOperation, ReactiveRemoveByQueryOperation, ReactiveMutateInByIdOperation, - ReactiveRangeScanOperation {} + ReactiveFindByAnalyticsOperation, ReactiveFindBySearchOperation, ReactiveFindFromReplicasByIdOperation, + ReactiveFindByQueryOperation, ReactiveRemoveByIdOperation, ReactiveRemoveByQueryOperation, + ReactiveMutateInByIdOperation, ReactiveRangeScanOperation {} diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveMutateInByIdOperation.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveMutateInByIdOperation.java index 4069c6202..04ebd2165 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveMutateInByIdOperation.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveMutateInByIdOperation.java @@ -19,17 +19,20 @@ import com.couchbase.client.java.kv.MutateInOptions; import com.couchbase.client.java.kv.PersistTo; import com.couchbase.client.java.kv.ReplicateTo; +import org.springframework.data.core.TypedPropertyPath; import org.springframework.data.couchbase.core.support.*; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.time.Duration; +import java.util.Arrays; import java.util.Collection; /** * Mutate In Operations * * @author Tigran Babloyan + * @author Emilien Bevierre * @since 5.1 */ public interface ReactiveMutateInByIdOperation { @@ -98,6 +101,42 @@ interface MutateInByIdWithPaths extends TerminatingMutateInById, WithMutat * By default the CAS value is not provided. */ MutateInByIdWithPaths withCasProvided(); + + /** + * Type-safe variant of {@link #withRemovePaths(String...)} using property paths. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default MutateInByIdWithPaths withRemovePaths(TypedPropertyPath... removePaths) { + return withRemovePaths(Arrays.stream(removePaths).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } + + /** + * Type-safe variant of {@link #withInsertPaths(String...)} using property paths. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default MutateInByIdWithPaths withInsertPaths(TypedPropertyPath... insertPaths) { + return withInsertPaths(Arrays.stream(insertPaths).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } + + /** + * Type-safe variant of {@link #withUpsertPaths(String...)} using property paths. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default MutateInByIdWithPaths withUpsertPaths(TypedPropertyPath... upsertPaths) { + return withUpsertPaths(Arrays.stream(upsertPaths).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } + + /** + * Type-safe variant of {@link #withReplacePaths(String...)} using property paths. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default MutateInByIdWithPaths withReplacePaths(TypedPropertyPath... replacePaths) { + return withReplacePaths(Arrays.stream(replacePaths).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } } /** diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveMutateInByIdOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveMutateInByIdOperationSupport.java index de19112a1..83f32d93f 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveMutateInByIdOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveMutateInByIdOperationSupport.java @@ -15,6 +15,7 @@ */ package org.springframework.data.couchbase.core; +import org.springframework.data.core.TypedPropertyPath; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -206,6 +207,35 @@ public MutateInByIdWithDurability withReplacePaths(final String... replacePat durabilityLevel, expiry, support, removePaths, upsertPaths, insertPaths, Arrays.asList(replacePaths), provideCas); } + + @Override + @SafeVarargs + // maps property references to stored field names (honoring @Field aliases) via the converter + public final MutateInByIdWithPaths withRemovePaths(TypedPropertyPath... removePaths) { + return withRemovePaths(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), removePaths)); + } + + @Override + @SafeVarargs + // maps property references to stored field names (honoring @Field aliases) via the converter + public final MutateInByIdWithPaths withUpsertPaths(TypedPropertyPath... upsertPaths) { + return withUpsertPaths(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), upsertPaths)); + } + + @Override + @SafeVarargs + // maps property references to stored field names (honoring @Field aliases) via the converter + public final MutateInByIdWithPaths withInsertPaths(TypedPropertyPath... insertPaths) { + return withInsertPaths(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), insertPaths)); + } + + @Override + @SafeVarargs + // maps property references to stored field names (honoring @Field aliases) via the converter + public final MutateInByIdWithPaths withReplacePaths(TypedPropertyPath... replacePaths) { + return withReplacePaths(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), replacePaths)); + } + @Override public MutateInByIdWithPaths withCasProvided() { return new ReactiveMutateInByIdSupport<>(template, domainType, scope, collection, options, persistTo, replicateTo, diff --git a/src/main/java/org/springframework/data/couchbase/core/SearchResult.java b/src/main/java/org/springframework/data/couchbase/core/SearchResult.java new file mode 100644 index 000000000..c103914c1 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/SearchResult.java @@ -0,0 +1,91 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.core; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import com.couchbase.client.java.search.SearchMetaData; +import com.couchbase.client.java.search.result.SearchFacetResult; +import com.couchbase.client.java.search.result.SearchRow; + +/** + * Wrapper for FTS search results that provides access to hydrated entities along with FTS metadata, + * facet results, and raw search rows. + * + * @param the entity type + * @author Emilien Bevierre + * @since 6.2 + */ +public class SearchResult { + + private final List entities; + private final List rows; + private final SearchMetaData metaData; + private final Map facets; + + public SearchResult(List entities, List rows, SearchMetaData metaData, + Map facets) { + this.entities = entities != null ? entities : Collections.emptyList(); + this.rows = rows != null ? rows : Collections.emptyList(); + this.metaData = metaData; + this.facets = facets != null ? facets : Collections.emptyMap(); + } + + /** + * The hydrated entities, resolved from search row document IDs via KV GET. + */ + public List entities() { + return entities; + } + + /** + * The raw FTS search rows (with scores, fragments, locations, etc.). + */ + public List rows() { + return rows; + } + + /** + * The FTS search metadata including metrics (total rows, max score, execution time). + */ + public SearchMetaData metaData() { + return metaData; + } + + /** + * Facet results keyed by facet name, if facets were requested via SearchOptions. + */ + public Map facets() { + return facets; + } + + /** + * Convenience: the total number of matches as reported by the FTS service. + * Note this may differ from {@code entities().size()} when limit/skip are used. + */ + public long totalRows() { + return metaData != null ? metaData.metrics().totalRows() : entities.size(); + } + + /** + * Convenience: the maximum score across all results. + */ + public double maxScore() { + return metaData != null ? metaData.metrics().maxScore() : 0.0; + } +} diff --git a/src/main/java/org/springframework/data/couchbase/core/query/Query.java b/src/main/java/org/springframework/data/couchbase/core/query/Query.java index 2829ab05c..bc10c11c2 100644 --- a/src/main/java/org/springframework/data/couchbase/core/query/Query.java +++ b/src/main/java/org/springframework/data/couchbase/core/query/Query.java @@ -15,6 +15,8 @@ */ package org.springframework.data.couchbase.core.query; +import org.springframework.data.mapping.PersistentPropertyPath; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; import static org.springframework.util.Assert.*; import java.util.ArrayList; @@ -26,6 +28,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.data.core.TypedPropertyPath; import org.springframework.data.core.TypeInformation; import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; import org.springframework.data.couchbase.core.convert.CouchbaseConverter; @@ -47,6 +50,7 @@ /** * @author Michael Nitschinger * @author Michael Reiche + * @author Emilien Bevierre */ public class Query { @@ -56,6 +60,11 @@ public class Query { private int limit; private boolean distinct; private String[] distinctFields; + /** + * When distinct fields were given as {@link TypedPropertyPath}s, the paths are kept so they can be resolved to the + * mapped field names (honoring {@code @Field} aliases) once a converter is available. + */ + private TypedPropertyPath[] typedDistinctFields; protected Sort sort = Sort.unsorted(); private QueryScanConsistency queryScanConsistency; private Meta meta; @@ -77,6 +86,7 @@ public Query(Query that) { this.limit = that.limit; this.distinct = that.distinct; this.distinctFields = that.distinctFields; + this.typedDistinctFields = that.typedDistinctFields; this.sort = that.sort; this.queryScanConsistency = that.queryScanConsistency; this.meta = that.meta; @@ -173,6 +183,20 @@ public boolean isDistinct() { */ public Query distinct(String[] distinctFields) { this.distinctFields = distinctFields; + this.typedDistinctFields = null; + return this; + } + + /** + * Type-safe variant of {@link #distinct(String[])} using property references. + * + * @param distinctFields the property references to use as distinct fields. + * @since 6.1 + */ + @SafeVarargs + public final Query distinct(TypedPropertyPath... distinctFields) { + this.distinctFields = Arrays.stream(distinctFields).map(TypedPropertyPath::toDotPath).toArray(String[]::new); + this.typedDistinctFields = distinctFields; return this; } @@ -185,6 +209,20 @@ public String[] getDistinctFields() { return distinctFields; } + /** + * Resolves distinct fields given as {@link TypedPropertyPath}s to the stored field names (honoring {@code @Field} + * aliases) using the given converter. + */ + private String[] mappedDistinctFields(CouchbaseConverter converter) { + String[] mapped = new String[typedDistinctFields.length]; + for (int i = 0; i < typedDistinctFields.length; i++) { + PersistentPropertyPath path = converter.getMappingContext() + .getPersistentPropertyPath(typedDistinctFields[i]); + mapped[i] = path.toDotPath(CouchbasePersistentProperty::getFieldName); + } + return mapped; + } + /** * Sets the given pagination information on the {@link Query} instance. Will transparently set {@code skip} and * {@code limit} as well as applying the {@link Sort} instance defined with the {@link Pageable}. @@ -350,6 +388,10 @@ public String toN1qlSelectString(ReactiveCouchbaseTemplate template, Class domai public String toN1qlSelectString(CouchbaseConverter converter, String bucketName, String scopeName, String collectionName, Class domainClass, Class returnClass, boolean isCount, String[] distinctFields, String[] fields) { + if (typedDistinctFields != null && Arrays.equals(distinctFields, this.distinctFields)) { + // distinct fields were given as property references; resolve them to the mapped field names + distinctFields = mappedDistinctFields(converter); + } StringBasedN1qlQueryParser.N1qlSpelValues n1ql = getN1qlSpelValues(converter, bucketName, scopeName, collectionName, domainClass, returnClass, isCount, distinctFields, fields); final StringBuilder statement = new StringBuilder(); diff --git a/src/main/java/org/springframework/data/couchbase/core/query/QueryCriteria.java b/src/main/java/org/springframework/data/couchbase/core/query/QueryCriteria.java index ae0c5f3cd..1db492ea0 100644 --- a/src/main/java/org/springframework/data/couchbase/core/query/QueryCriteria.java +++ b/src/main/java/org/springframework/data/couchbase/core/query/QueryCriteria.java @@ -24,7 +24,10 @@ import java.util.List; import java.util.Locale; +import org.springframework.data.core.TypedPropertyPath; import org.springframework.data.couchbase.core.convert.CouchbaseConverter; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; +import org.springframework.data.mapping.PersistentPropertyPath; import org.jspecify.annotations.Nullable; import org.springframework.util.CollectionUtils; @@ -39,10 +42,16 @@ * @author Michael Reiche * @author Mauro Monti * @author Shubham Mishra + * @author Emilien Bevierre */ public class QueryCriteria implements QueryCriteriaDefinition { private N1QLExpression key; + /** + * When the criteria was created from a {@link TypedPropertyPath}, the path is kept so the key can be resolved to the + * mapped field name (honoring {@code @Field} aliases) once a converter is available at export time. + */ + private @Nullable TypedPropertyPath propertyPath; /** * Holds the chain itself, the current operator being always the last one. */ @@ -90,15 +99,31 @@ public static QueryCriteria where(N1QLExpression key) { return new QueryCriteria(null, key, null, null); } + /** + * Static factory method to create a Criteria using a type-safe property path. Accepts method references + * (e.g. {@code Person::getFirstname}) as well as composed paths + * (e.g. {@code PropertyPath.of(Person::getAddress).then(Address::getCity)}). + * + * @param propertyPath the type-safe property path. + * @since 6.1 + */ + public static QueryCriteria where(TypedPropertyPath propertyPath) { + QueryCriteria criteria = where(propertyPath.toDotPath()); + criteria.propertyPath = propertyPath; + return criteria; + } + // wrap criteria (including the criteriaChain) in a new QueryCriteria and set the queryChain of the original criteria // to just itself. The new query will be the value[0] of the original query. private void replaceThisAsWrapperOf(QueryCriteria criteria) { QueryCriteria qc = new QueryCriteria(criteria.criteriaChain, criteria.key, criteria.value, criteria.chainOperator, criteria.operator, criteria.format); + qc.propertyPath = criteria.propertyPath; criteriaChain.remove(criteria); // we replaced it with the clone criteriaChain = new LinkedList<>(); criteriaChain.add(this); key = N1QLExpression.WRAPPER(); + propertyPath = null; operator = ""; value = new Object[] { qc }; chainOperator = null; @@ -148,6 +173,19 @@ public QueryCriteria and(N1QLExpression key) { return new QueryCriteria(this.criteriaChain, key, null, ChainOperator.AND); } + /** + * Chain a Criteria using AND with a type-safe property path. Accepts method references + * (e.g. {@code Person::getFirstname}) as well as composed paths. + * + * @param propertyPath the type-safe property path. + * @since 6.1 + */ + public QueryCriteria and(TypedPropertyPath propertyPath) { + QueryCriteria criteria = and(propertyPath.toDotPath()); + criteria.propertyPath = propertyPath; + return criteria; + } + public QueryCriteria and(QueryCriteria criteria) { checkAndAddToCriteriaChain(); QueryCriteria newThis = wrap(this); @@ -183,6 +221,19 @@ public QueryCriteria or(N1QLExpression key) { return new QueryCriteria(this.criteriaChain, key, null, ChainOperator.OR); } + /** + * Chain a Criteria using OR with a type-safe property path. Accepts method references + * (e.g. {@code Person::getFirstname}) as well as composed paths. + * + * @param propertyPath the type-safe property path. + * @since 6.1 + */ + public QueryCriteria or(TypedPropertyPath propertyPath) { + QueryCriteria criteria = or(propertyPath.toDotPath()); + criteria.propertyPath = propertyPath; + return criteria; + } + public QueryCriteria or(QueryCriteria criteria) { checkAndAddToCriteriaChain(); QueryCriteria newThis = wrap(this); @@ -553,7 +604,7 @@ public String export() { // used only by tests */ private StringBuilder exportSingle(StringBuilder sb, int[] paramIndexPtr, JsonValue parameters, CouchbaseConverter converter) { - String fieldName = key == null ? null : key.toString(); // maybeBackTic(key); + String fieldName = key == null ? null : mappedFieldName(converter); // maybeBackTic(key); int valueLen = value == null ? 0 : value.length; Object[] v = new Object[valueLen + 2]; v[0] = fieldName; @@ -577,6 +628,20 @@ private StringBuilder exportSingle(StringBuilder sb, int[] paramIndexPtr, JsonVa return sb; } + /** + * Resolves the key to the stored field name. Criteria created from a {@link TypedPropertyPath} carry Java property + * names, which are translated segment-by-segment to the mapped field names (honoring {@code @Field} aliases) when a + * converter is available. String-based criteria are emitted verbatim. + */ + private String mappedFieldName(CouchbaseConverter converter) { + if (propertyPath == null || converter == null) { + return key.toString(); + } + PersistentPropertyPath path = converter.getMappingContext() + .getPersistentPropertyPath(propertyPath); + return path.toDotPath(CouchbasePersistentProperty::getFieldName); + } + /** * Possibly convert an operand to a positional or named parameter * diff --git a/src/main/java/org/springframework/data/couchbase/core/support/WithDistinct.java b/src/main/java/org/springframework/data/couchbase/core/support/WithDistinct.java index 92591c9ac..a7c5efc54 100644 --- a/src/main/java/org/springframework/data/couchbase/core/support/WithDistinct.java +++ b/src/main/java/org/springframework/data/couchbase/core/support/WithDistinct.java @@ -15,10 +15,15 @@ */ package org.springframework.data.couchbase.core.support; +import java.util.Arrays; + +import org.springframework.data.core.TypedPropertyPath; + /** * Interface for operations that take distinct fields * * @author Michael Reiche + * @author Emilien Bevierre * @param - the entity class */ public interface WithDistinct { @@ -28,4 +33,15 @@ public interface WithDistinct { * @param distinctFields - distinct fields */ Object distinct(String[] distinctFields); + + /** + * Type-safe variant of {@link #distinct(String[])} using property paths. + * + * @param distinctFields the property paths to use as distinct fields. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default Object distinct(TypedPropertyPath... distinctFields) { + return distinct(Arrays.stream(distinctFields).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } } diff --git a/src/main/java/org/springframework/data/couchbase/core/support/WithMutateInPaths.java b/src/main/java/org/springframework/data/couchbase/core/support/WithMutateInPaths.java index 1f9cc3019..4aeee4688 100644 --- a/src/main/java/org/springframework/data/couchbase/core/support/WithMutateInPaths.java +++ b/src/main/java/org/springframework/data/couchbase/core/support/WithMutateInPaths.java @@ -15,10 +15,15 @@ */ package org.springframework.data.couchbase.core.support; +import java.util.Arrays; + +import org.springframework.data.core.TypedPropertyPath; + /** * A common interface for all of Insert, Replace, Upsert and Remove mutations that take options. * * @author Tigran Babloyan + * @author Emilien Bevierre * @param - the entity class */ public interface WithMutateInPaths { @@ -29,4 +34,44 @@ public interface WithMutateInPaths { Object withReplacePaths(final String... replacePaths); Object withUpsertPaths(final String... upsertPaths); + + /** + * Type-safe variant of {@link #withRemovePaths(String...)} using property paths. + * + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default Object withRemovePaths(TypedPropertyPath... removePaths) { + return withRemovePaths(Arrays.stream(removePaths).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } + + /** + * Type-safe variant of {@link #withInsertPaths(String...)} using property paths. + * + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default Object withInsertPaths(TypedPropertyPath... insertPaths) { + return withInsertPaths(Arrays.stream(insertPaths).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } + + /** + * Type-safe variant of {@link #withReplacePaths(String...)} using property paths. + * + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default Object withReplacePaths(TypedPropertyPath... replacePaths) { + return withReplacePaths(Arrays.stream(replacePaths).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } + + /** + * Type-safe variant of {@link #withUpsertPaths(String...)} using property paths. + * + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default Object withUpsertPaths(TypedPropertyPath... upsertPaths) { + return withUpsertPaths(Arrays.stream(upsertPaths).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } } diff --git a/src/main/java/org/springframework/data/couchbase/core/support/WithProjecting.java b/src/main/java/org/springframework/data/couchbase/core/support/WithProjecting.java index 9e9c9894f..7eb40cf0c 100644 --- a/src/main/java/org/springframework/data/couchbase/core/support/WithProjecting.java +++ b/src/main/java/org/springframework/data/couchbase/core/support/WithProjecting.java @@ -15,13 +15,28 @@ */ package org.springframework.data.couchbase.core.support; +import java.util.Arrays; + +import org.springframework.data.core.TypedPropertyPath; + /** * A common interface for all of Insert, Replace, Upsert that take Projection * * @author Michael Reiche + * @author Emilien Bevierre * @param - the entity class */ public interface WithProjecting { Object project(String[] fields); + /** + * Type-safe variant of {@link #project(String[])} using property paths. + * + * @param fields the property paths to project. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default Object project(TypedPropertyPath... fields) { + return project(Arrays.stream(fields).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } } diff --git a/src/main/java/org/springframework/data/couchbase/core/support/WithProjectionId.java b/src/main/java/org/springframework/data/couchbase/core/support/WithProjectionId.java index 16c1704e1..ac9179f04 100644 --- a/src/main/java/org/springframework/data/couchbase/core/support/WithProjectionId.java +++ b/src/main/java/org/springframework/data/couchbase/core/support/WithProjectionId.java @@ -15,13 +15,28 @@ */ package org.springframework.data.couchbase.core.support; +import java.util.Arrays; + +import org.springframework.data.core.TypedPropertyPath; + /** * A common interface for those that support project() * * @author Michael Reiche + * @author Emilien Bevierre * @param - the entity class */ public interface WithProjectionId { Object project(String[] fields); + /** + * Type-safe variant of {@link #project(String[])} using property paths. + * + * @param fields the property paths to project. + * @since 6.1 + */ + @SuppressWarnings("unchecked") + default Object project(TypedPropertyPath... fields) { + return project(Arrays.stream(fields).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } } diff --git a/src/main/java/org/springframework/data/couchbase/core/support/WithSearchConsistency.java b/src/main/java/org/springframework/data/couchbase/core/support/WithSearchConsistency.java new file mode 100644 index 000000000..befcbc59b --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/support/WithSearchConsistency.java @@ -0,0 +1,28 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.core.support; + +import com.couchbase.client.java.search.SearchScanConsistency; + +/** + * Interface for operations that support search scan consistency + * + * @author Emilien Bevierre + * @param the entity class + */ +public interface WithSearchConsistency { + Object withConsistency(SearchScanConsistency scanConsistency); +} diff --git a/src/main/java/org/springframework/data/couchbase/core/support/WithSearchOptions.java b/src/main/java/org/springframework/data/couchbase/core/support/WithSearchOptions.java new file mode 100644 index 000000000..48cb1ff0a --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/support/WithSearchOptions.java @@ -0,0 +1,28 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.core.support; + +import com.couchbase.client.java.search.SearchOptions; + +/** + * Interface for operations that take SearchOptions + * + * @author Emilien Bevierre + * @param the entity class + */ +public interface WithSearchOptions { + Object withOptions(SearchOptions options); +} diff --git a/src/main/java/org/springframework/data/couchbase/core/support/WithSearchQuery.java b/src/main/java/org/springframework/data/couchbase/core/support/WithSearchQuery.java new file mode 100644 index 000000000..dd4027708 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/support/WithSearchQuery.java @@ -0,0 +1,28 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.core.support; + +import com.couchbase.client.java.search.SearchRequest; + +/** + * Interface for operations that take a SearchRequest + * + * @author Emilien Bevierre + * @param the entity class + */ +public interface WithSearchQuery { + Object matching(SearchRequest searchRequest); +} diff --git a/src/main/java/org/springframework/data/couchbase/repository/ScanConsistency.java b/src/main/java/org/springframework/data/couchbase/repository/ScanConsistency.java index 1250581f0..9de58d0ad 100644 --- a/src/main/java/org/springframework/data/couchbase/repository/ScanConsistency.java +++ b/src/main/java/org/springframework/data/couchbase/repository/ScanConsistency.java @@ -15,19 +15,21 @@ */ package org.springframework.data.couchbase.repository; +import com.couchbase.client.java.analytics.AnalyticsScanConsistency; +import com.couchbase.client.java.query.QueryScanConsistency; +import com.couchbase.client.java.search.SearchScanConsistency; + import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import com.couchbase.client.java.analytics.AnalyticsScanConsistency; -import com.couchbase.client.java.query.QueryScanConsistency; - /** * Scan Consistency Annotation * * @author Michael Reiche + * @author Emilien Bevierre */ @Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE, ElementType.TYPE }) @@ -48,4 +50,11 @@ */ AnalyticsScanConsistency analytics() default AnalyticsScanConsistency.NOT_BOUNDED; + /** + * Specifies a custom scan consistency for FTS (Full-Text Search) queries. + * + * @return the scan consistency configured, defaults to not bounded. + */ + SearchScanConsistency search() default SearchScanConsistency.NOT_BOUNDED; + } diff --git a/src/main/java/org/springframework/data/couchbase/repository/Search.java b/src/main/java/org/springframework/data/couchbase/repository/Search.java new file mode 100644 index 000000000..fc9bb8e13 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/repository/Search.java @@ -0,0 +1,74 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.repository; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.data.annotation.QueryAnnotation; + +/** + * Annotation to define a Full-Text Search (FTS) query on a repository method. + *

+ * The value is an FTS query string (as used by {@link com.couchbase.client.java.search.queries.QueryStringQuery}). + * The FTS index name must be specified via {@link SearchIndex} on the method or entity class. + *

+ * Supports positional parameter placeholders ({@code ?0}, {@code ?1}, etc.) which are replaced with method argument + * values at execution time. + *

+ * Parameter binding semantics (injection considerations): String and {@link Enum} values are always + * emitted as quoted FTS phrases ({@code "..."}), with embedded {@code "} and {@code \} escaped. This prevents a + * parameter value from breaking out of its phrase context and injecting FTS operators ({@code AND}, {@code OR}, + * {@code :}, {@code *}, field selectors, etc.). {@link Number} and {@link Boolean} values are emitted verbatim so they + * remain usable with range operators (e.g. {@code rating:>=?0}); their types are validated, so no operator injection + * is possible. Because string parameters are always phrase-quoted, there is no way to pass a raw FTS term or + * operator through a placeholder. Construct a {@link com.couchbase.client.java.search.SearchRequest} and use the + * template API ({@code CouchbaseOperations.findBySearch(...)}) if you need that. + *

+ * Example: + *

+ * @Search("description:pool AND city:\"San Francisco\"")
+ * @SearchIndex("hotel-search-index")
+ * List<Hotel> findHotelsWithPool();
+ *
+ * @Search("?0")
+ * @SearchIndex("hotel-search-index")
+ * List<Hotel> searchHotels(String query);
+ *
+ * @Search("city:?0 AND rating:>=?1")
+ * @SearchIndex("hotel-search-index")
+ * List<Hotel> findByCityAndMinRating(String city, int minRating);
+ * 
+ * + * @author Emilien Bevierre + * @since 6.2 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Documented +@QueryAnnotation +public @interface Search { + + /** + * An FTS query string. Supports positional parameter placeholders ({@code ?0}, {@code ?1}, etc.) + * which will be replaced with the corresponding method argument values. + * This will be used to create a {@link com.couchbase.client.java.search.queries.QueryStringQuery}. + */ + String value(); +} diff --git a/src/main/java/org/springframework/data/couchbase/repository/SearchIndex.java b/src/main/java/org/springframework/data/couchbase/repository/SearchIndex.java new file mode 100644 index 000000000..1383c497c --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/repository/SearchIndex.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.repository; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to specify the Full-Text Search (FTS) index name for a repository method or entity class. + *

+ * When placed on a repository method, it indicates that the method should use the specified FTS index. + * When placed on an entity class, it provides the default FTS index name for all search operations on that entity. + * + * @author Emilien Bevierre + * @since 6.2 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.TYPE, ElementType.ANNOTATION_TYPE }) +@Documented +public @interface SearchIndex { + + /** + * The name of the FTS index to use. + */ + String value(); +} diff --git a/src/main/java/org/springframework/data/couchbase/repository/query/CouchbaseQueryMethod.java b/src/main/java/org/springframework/data/couchbase/repository/query/CouchbaseQueryMethod.java index ba5cdf7f5..4fc795880 100644 --- a/src/main/java/org/springframework/data/couchbase/repository/query/CouchbaseQueryMethod.java +++ b/src/main/java/org/springframework/data/couchbase/repository/query/CouchbaseQueryMethod.java @@ -33,6 +33,7 @@ import org.springframework.data.couchbase.repository.Query; import org.springframework.data.couchbase.repository.ScanConsistency; import org.springframework.data.couchbase.repository.Scope; +import org.springframework.data.couchbase.repository.Search; import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.repository.core.RepositoryMetadata; @@ -42,6 +43,7 @@ import org.springframework.util.StringUtils; import com.couchbase.client.core.io.CollectionIdentifier; +import com.couchbase.client.java.search.SearchScanConsistency; /** * Represents a query method with couchbase extensions, allowing to discover if View-based query or N1QL-based query @@ -51,6 +53,7 @@ * @author Simon Baslé * @author Oliver Gierke * @author Michael Reiche + * @author Emilien Bevierre */ public class CouchbaseQueryMethod extends QueryMethod { @@ -73,6 +76,15 @@ public boolean hasN1qlAnnotation() { return getN1qlAnnotation() != null; } + /** + * If the method has a @Search annotation. + * + * @return true if it has the annotation, false otherwise. + */ + public boolean hasSearchAnnotation() { + return method.getAnnotation(Search.class) != null; + } + /** * Returns the @Query annotation if set, null otherwise. * @@ -196,4 +208,17 @@ public String getScope() { return OptionsBuilder.annotationString(Scope.class, CollectionIdentifier.DEFAULT_SCOPE, annotated); } + /** + * Returns the FTS scan consistency from the {@link ScanConsistency} annotation if present, null otherwise. + * + * @return the FTS scan consistency, or null if not annotated. + */ + public SearchScanConsistency getSearchScanConsistency() { + ScanConsistency annotation = getScanConsistencyAnnotation(); + if (annotation != null) { + return annotation.search(); + } + return null; + } + } diff --git a/src/main/java/org/springframework/data/couchbase/repository/query/ReactiveSearchBasedCouchbaseQuery.java b/src/main/java/org/springframework/data/couchbase/repository/query/ReactiveSearchBasedCouchbaseQuery.java new file mode 100644 index 000000000..1ccdd4f54 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/repository/query/ReactiveSearchBasedCouchbaseQuery.java @@ -0,0 +1,162 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.repository.query; + +import org.reactivestreams.Publisher; +import org.springframework.data.couchbase.core.ReactiveCouchbaseOperations; +import org.springframework.data.couchbase.core.ReactiveFindBySearchOperation; +import org.springframework.data.domain.Pageable; +import org.springframework.data.repository.query.ParametersParameterAccessor; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.ResultProcessor; +import org.springframework.util.Assert; + +import org.springframework.data.couchbase.repository.Search; +import org.springframework.data.couchbase.repository.SearchIndex; + +import com.couchbase.client.java.search.SearchQuery; +import com.couchbase.client.java.search.SearchRequest; +import com.couchbase.client.java.search.SearchScanConsistency; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Reactive {@link RepositoryQuery} implementation for FTS {@link Search}-annotated methods. + *

+ * Supports positional parameter substitution ({@code ?0}, {@code ?1}, etc.) in the query string. + * Supports {@link Pageable} parameters for limit/skip pagination. + * Supports {@link org.springframework.data.couchbase.repository.ScanConsistency} for FTS scan consistency. + *

+ * {@link org.springframework.data.domain.Page} and {@link org.springframework.data.domain.Slice} return types are + * not supported on reactive search methods. Use the imperative repository or the reactive template API + * ({@link org.springframework.data.couchbase.core.ReactiveFindBySearchOperation.TerminatingFindBySearch#result()}) + * if you need total row counts alongside entities. + * + * @author Emilien Bevierre + * @since 6.2 + */ +public class ReactiveSearchBasedCouchbaseQuery implements RepositoryQuery { + + private final ReactiveCouchbaseQueryMethod method; + private final ReactiveCouchbaseOperations operations; + private final String searchQueryTemplate; + private final String indexName; + + public ReactiveSearchBasedCouchbaseQuery(ReactiveCouchbaseQueryMethod method, + ReactiveCouchbaseOperations operations) { + Assert.notNull(method, "ReactiveCouchbaseQueryMethod must not be null!"); + Assert.notNull(operations, "ReactiveCouchbaseOperations must not be null!"); + + this.method = method; + this.operations = operations; + + Search searchAnnotation = method.getAnnotation(Search.class); + Assert.notNull(searchAnnotation, "Method must be annotated with @Search!"); + this.searchQueryTemplate = searchAnnotation.value(); + + this.indexName = resolveIndexName(method); + Assert.notNull(indexName, "FTS index name must be specified via @SearchIndex on the method or entity class!"); + } + + @Override + public Object execute(Object[] parameters) { + ReactiveCouchbaseParameterAccessor accessor = new ReactiveCouchbaseParameterAccessor(method, parameters); + + if (method.isCollectionQuery()) { + return accessor.resolveParameters().flatMapMany(resolvedAccessor -> Flux.from(doExecute(resolvedAccessor))); + } + return accessor.resolveParameters().flatMap(resolvedAccessor -> Mono.from(doExecute(resolvedAccessor))); + } + + private Publisher doExecute(ParametersParameterAccessor resolvedAccessor) { + ResultProcessor processor = method.getResultProcessor().withDynamicProjection(resolvedAccessor); + SearchRepositoryQuerySupport.validateSort(resolvedAccessor); + + String resolvedQuery = SearchBasedCouchbaseQuery.resolveParameters(searchQueryTemplate, resolvedAccessor); + SearchRequest request = SearchRequest.create(SearchQuery.queryString(resolvedQuery)); + Object result = executeDependingOnType(resolvedAccessor, request); + + return (Publisher) processor.processResult(result); + } + + @Override + public ReactiveCouchbaseQueryMethod getQueryMethod() { + return method; + } + + private Object executeDependingOnType(ParametersParameterAccessor accessor, SearchRequest request) { + if (method.isPageQuery() || method.isSliceQuery()) { + throw new UnsupportedOperationException( + "Page and Slice return types are not supported for reactive @Search repository methods. " + + "Use the imperative repository for pagination, or the reactive template API " + + "(ReactiveCouchbaseOperations.findBySearch(...).result()) to access total rows."); + } + + ReactiveFindBySearchOperation.FindBySearchWithQuery queryOp = createQueryOperation(accessor, true); + + if (method.isCountQuery()) { + return createQueryOperation(accessor, false).matching(request).count(); + } else if (method.isExistsQuery()) { + return createQueryOperation(accessor, false).matching(request).exists(); + } else if (method.isCollectionQuery()) { + return queryOp.matching(request).all(); + } else { + return queryOp.matching(request).one(); + } + } + + private ReactiveFindBySearchOperation.FindBySearchWithQuery createQueryOperation( + ParametersParameterAccessor accessor, boolean applyPagination) { + Class domainType = method.getEntityInformation().getJavaType(); + ReactiveFindBySearchOperation.FindBySearchWithProjection withIndex = operations.findBySearch(domainType) + .withIndex(indexName); + + ReactiveFindBySearchOperation.FindBySearchWithConsistency withPagination = applyPagination + ? applyPagination(withIndex, accessor) + : withIndex; + + SearchScanConsistency searchConsistency = method.getSearchScanConsistency(); + ReactiveFindBySearchOperation.FindBySearchInScope withConsistency = searchConsistency != null + ? withPagination.withConsistency(searchConsistency) + : withPagination; + + return withConsistency.inScope(method.getScope()).inCollection(method.getCollection()); + } + + private ReactiveFindBySearchOperation.FindBySearchWithConsistency applyPagination( + ReactiveFindBySearchOperation.FindBySearchWithProjection searchOp, + ParametersParameterAccessor accessor) { + Pageable pageable = accessor.getPageable(); + if (pageable.isPaged()) { + return searchOp + .withSkip((int) pageable.getOffset()) + .withLimit((int) pageable.getPageSize()); + } + return searchOp; + } + private static String resolveIndexName(ReactiveCouchbaseQueryMethod method) { + SearchIndex methodAnnotation = method.getAnnotation(SearchIndex.class); + if (methodAnnotation != null) { + return methodAnnotation.value(); + } + SearchIndex entityAnnotation = method.getEntityInformation().getJavaType().getAnnotation(SearchIndex.class); + if (entityAnnotation != null) { + return entityAnnotation.value(); + } + return null; + } +} diff --git a/src/main/java/org/springframework/data/couchbase/repository/query/SearchBasedCouchbaseQuery.java b/src/main/java/org/springframework/data/couchbase/repository/query/SearchBasedCouchbaseQuery.java new file mode 100644 index 000000000..66c4187bb --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/repository/query/SearchBasedCouchbaseQuery.java @@ -0,0 +1,184 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.repository.query; + +import java.util.List; + +import org.springframework.data.couchbase.core.CouchbaseOperations; +import org.springframework.data.couchbase.core.ExecutableFindBySearchOperation; +import org.springframework.data.couchbase.core.SearchResult; +import org.springframework.data.couchbase.repository.Search; +import org.springframework.data.couchbase.repository.SearchIndex; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.repository.query.ParametersParameterAccessor; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.ResultProcessor; +import org.springframework.util.Assert; + +import com.couchbase.client.java.search.SearchQuery; +import com.couchbase.client.java.search.SearchRequest; +import com.couchbase.client.java.search.SearchScanConsistency; + +/** + * {@link RepositoryQuery} implementation for FTS {@link Search}-annotated methods. + *

+ * Supports positional parameter substitution ({@code ?0}, {@code ?1}, etc.) in the query string. + * Supports {@link Pageable} parameters for limit/skip pagination. + * Supports {@link org.springframework.data.couchbase.repository.ScanConsistency} for FTS scan consistency. + * + * @author Emilien Bevierre + * @since 6.2 + */ +public class SearchBasedCouchbaseQuery implements RepositoryQuery { + + private final CouchbaseQueryMethod method; + private final CouchbaseOperations operations; + private final String searchQueryTemplate; + private final String indexName; + + public SearchBasedCouchbaseQuery(CouchbaseQueryMethod method, CouchbaseOperations operations) { + Assert.notNull(method, "CouchbaseQueryMethod must not be null!"); + Assert.notNull(operations, "CouchbaseOperations must not be null!"); + + this.method = method; + this.operations = operations; + + Search searchAnnotation = method.getAnnotation(Search.class); + Assert.notNull(searchAnnotation, "Method must be annotated with @Search!"); + this.searchQueryTemplate = searchAnnotation.value(); + + this.indexName = resolveIndexName(method); + Assert.notNull(indexName, "FTS index name must be specified via @SearchIndex on the method or entity class!"); + } + + @Override + public Object execute(Object[] parameters) { + ParametersParameterAccessor accessor = new ParametersParameterAccessor(method.getParameters(), parameters); + ResultProcessor processor = method.getResultProcessor().withDynamicProjection(accessor); + SearchRepositoryQuerySupport.validateSort(accessor); + + String resolvedQuery = resolveParameters(searchQueryTemplate, accessor); + SearchRequest request = SearchRequest.create(SearchQuery.queryString(resolvedQuery)); + Object result = executeDependingOnType(accessor, request); + + return processor.processResult(result); + } + + @Override + public CouchbaseQueryMethod getQueryMethod() { + return method; + } + + static String resolveParameters(String template, ParametersParameterAccessor accessor) { + return SearchRepositoryQuerySupport.bindQueryString(template, accessor); + } + + private Object executeDependingOnType(ParametersParameterAccessor accessor, SearchRequest request) { + ExecutableFindBySearchOperation.FindBySearchWithQuery queryOp = createQueryOperation(accessor, true); + + if (method.isCountQuery()) { + return createQueryOperation(accessor, false).matching(request).count(); + } else if (method.isExistsQuery()) { + return createQueryOperation(accessor, false).matching(request).exists(); + } else if (method.isPageQuery()) { + return executePage(accessor, request); + } else if (method.isSliceQuery()) { + return executeSlice(accessor, request); + } else if (method.isCollectionQuery()) { + return queryOp.matching(request).all(); + } else if (method.isStreamQuery()) { + return queryOp.matching(request).stream(); + } else { + return queryOp.matching(request).oneValue(); + } + } + + private PageImpl executePage(ParametersParameterAccessor accessor, SearchRequest request) { + Pageable pageable = accessor.getPageable(); + SearchResult searchResult = createQueryOperation(accessor, true).matching(request).result(); + return new PageImpl<>(searchResult.entities(), pageable, searchResult.totalRows()); + } + + private SliceImpl executeSlice(ParametersParameterAccessor accessor, SearchRequest request) { + Pageable pageable = accessor.getPageable(); + if (!pageable.isPaged()) { + return new SliceImpl<>(createQueryOperation(accessor, false).matching(request).all(), pageable, false); + } + + int pageSize = pageable.getPageSize(); + List content = createQueryOperation(accessor, false, pageSize + 1, (int) pageable.getOffset()).matching(request) + .all(); + boolean hasNext = pageable.isPaged() && content.size() > pageSize; + if (hasNext) { + content = content.subList(0, pageSize); + } + return new SliceImpl<>(content, pageable, hasNext); + } + + private ExecutableFindBySearchOperation.FindBySearchWithQuery createQueryOperation( + ParametersParameterAccessor accessor, boolean applyPagination) { + return createQueryOperation(accessor, applyPagination, null, null); + } + + private ExecutableFindBySearchOperation.FindBySearchWithQuery createQueryOperation( + ParametersParameterAccessor accessor, boolean applyPagination, Integer limitOverride, Integer skipOverride) { + Class domainType = method.getEntityInformation().getJavaType(); + ExecutableFindBySearchOperation.FindBySearchWithProjection withIndex = operations.findBySearch(domainType) + .withIndex(indexName); + + ExecutableFindBySearchOperation.FindBySearchWithConsistency withPagination = applyPagination + ? applyPagination(withIndex, accessor) + : applyLimitAndSkip(withIndex, limitOverride, skipOverride); + + SearchScanConsistency searchConsistency = method.getSearchScanConsistency(); + ExecutableFindBySearchOperation.FindBySearchInScope withConsistency = searchConsistency != null + ? withPagination.withConsistency(searchConsistency) + : withPagination; + + return withConsistency.inScope(method.getScope()).inCollection(method.getCollection()); + } + + private ExecutableFindBySearchOperation.FindBySearchWithConsistency applyPagination( + ExecutableFindBySearchOperation.FindBySearchWithProjection searchOp, + ParametersParameterAccessor accessor) { + Pageable pageable = accessor.getPageable(); + if (pageable.isPaged()) { + return applyLimitAndSkip(searchOp, (int) pageable.getPageSize(), (int) pageable.getOffset()); + } + return applyLimitAndSkip(searchOp, null, null); + } + + private ExecutableFindBySearchOperation.FindBySearchWithConsistency applyLimitAndSkip( + ExecutableFindBySearchOperation.FindBySearchWithProjection searchOp, Integer limit, Integer skip) { + ExecutableFindBySearchOperation.FindBySearchWithLimit withSkipApplied = skip != null + ? searchOp.withSkip(skip) + : searchOp; + return limit != null ? withSkipApplied.withLimit(limit) : withSkipApplied; + } + private static String resolveIndexName(CouchbaseQueryMethod method) { + SearchIndex methodAnnotation = method.getAnnotation(SearchIndex.class); + if (methodAnnotation != null) { + return methodAnnotation.value(); + } + SearchIndex entityAnnotation = method.getEntityInformation().getJavaType().getAnnotation(SearchIndex.class); + if (entityAnnotation != null) { + return entityAnnotation.value(); + } + return null; + } +} diff --git a/src/main/java/org/springframework/data/couchbase/repository/query/SearchRepositoryQuerySupport.java b/src/main/java/org/springframework/data/couchbase/repository/query/SearchRepositoryQuerySupport.java new file mode 100644 index 000000000..acd4664c9 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/repository/query/SearchRepositoryQuerySupport.java @@ -0,0 +1,99 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.repository.query; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.query.ParametersParameterAccessor; + +/** + * Shared support for repository-backed FTS query string binding. + * + * @author Emilien Bevierre + * @since 6.2 + */ +final class SearchRepositoryQuerySupport { + + private static final Pattern POSITIONAL_PARAM_PATTERN = Pattern.compile("\\?(\\d+)"); + + private SearchRepositoryQuerySupport() { + } + + static String bindQueryString(String template, ParametersParameterAccessor accessor) { + Matcher matcher = POSITIONAL_PARAM_PATTERN.matcher(template); + if (!matcher.find()) { + return template; + } + + StringBuilder sb = new StringBuilder(); + matcher.reset(); + + while (matcher.find()) { + int index = Integer.parseInt(matcher.group(1)); + Object value = accessor.getBindableValue(index); + matcher.appendReplacement(sb, Matcher.quoteReplacement(renderBindableValue(index, value))); + } + + matcher.appendTail(sb); + return sb.toString(); + } + + static void validateSort(ParametersParameterAccessor accessor) { + Sort sort = accessor.getSort(); + if (sort.isSorted()) { + throw new InvalidDataAccessApiUsageException( + "Spring Sort/Pageable sorting is not supported for @Search repository methods. " + + "FTS SearchSort semantics are richer than Spring Sort; use the template API with explicit SearchSorts."); + } + } + + private static String renderBindableValue(int index, Object value) { + if (value == null) { + throw new InvalidDataAccessApiUsageException( + "@Search parameter ?" + index + " resolved to null. Null values are not supported in FTS query strings."); + } + + if (value instanceof Iterable || value instanceof Map || value.getClass().isArray()) { + throw new InvalidDataAccessApiUsageException( + "@Search parameter ?" + index + " must be a scalar value. Collections, arrays, and maps are not supported; " + + "build a SearchRequest explicitly for complex FTS queries."); + } + + if (value instanceof Number || value instanceof Boolean) { + return value.toString(); + } + + return quoteString(value instanceof Enum ? ((Enum) value).name() : value.toString()); + } + + private static String quoteString(String value) { + StringBuilder escaped = new StringBuilder(value.length() + 2); + escaped.append('"'); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + if (ch == '\\' || ch == '"') { + escaped.append('\\'); + } + escaped.append(ch); + } + escaped.append('"'); + return escaped.toString(); + } +} diff --git a/src/main/java/org/springframework/data/couchbase/repository/support/CouchbaseRepositoryFactory.java b/src/main/java/org/springframework/data/couchbase/repository/support/CouchbaseRepositoryFactory.java index 44f98494f..3709ee003 100644 --- a/src/main/java/org/springframework/data/couchbase/repository/support/CouchbaseRepositoryFactory.java +++ b/src/main/java/org/springframework/data/couchbase/repository/support/CouchbaseRepositoryFactory.java @@ -30,6 +30,7 @@ import org.springframework.data.couchbase.repository.query.CouchbaseEntityInformation; import org.springframework.data.couchbase.repository.query.CouchbaseQueryMethod; import org.springframework.data.couchbase.repository.query.PartTreeCouchbaseQuery; +import org.springframework.data.couchbase.repository.query.SearchBasedCouchbaseQuery; import org.springframework.data.couchbase.repository.query.StringBasedCouchbaseQuery; import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.projection.ProjectionFactory; @@ -166,7 +167,13 @@ public RepositoryQuery resolveQuery(final Method method, final RepositoryMetadat CouchbaseQueryMethod queryMethod = new CouchbaseQueryMethod(method, metadata, factory, mappingContext); - if (queryMethod.hasN1qlAnnotation()) { + if (queryMethod.hasSearchAnnotation()) { + if (queryMethod.hasN1qlAnnotation()) { + throw new IllegalArgumentException( + "Method " + method + " must not be annotated with both @Search and @Query"); + } + return new SearchBasedCouchbaseQuery(queryMethod, couchbaseOperations); + } else if (queryMethod.hasN1qlAnnotation()) { return new StringBasedCouchbaseQuery(queryMethod, couchbaseOperations, valueExpressionDelegate, namedQueries); } else { diff --git a/src/main/java/org/springframework/data/couchbase/repository/support/ReactiveCouchbaseRepositoryFactory.java b/src/main/java/org/springframework/data/couchbase/repository/support/ReactiveCouchbaseRepositoryFactory.java index db5e5639b..09a6eac1d 100644 --- a/src/main/java/org/springframework/data/couchbase/repository/support/ReactiveCouchbaseRepositoryFactory.java +++ b/src/main/java/org/springframework/data/couchbase/repository/support/ReactiveCouchbaseRepositoryFactory.java @@ -26,6 +26,7 @@ import org.springframework.data.couchbase.repository.query.CouchbaseEntityInformation; import org.springframework.data.couchbase.repository.query.ReactiveCouchbaseQueryMethod; import org.springframework.data.couchbase.repository.query.ReactivePartTreeCouchbaseQuery; +import org.springframework.data.couchbase.repository.query.ReactiveSearchBasedCouchbaseQuery; import org.springframework.data.couchbase.repository.query.ReactiveStringBasedCouchbaseQuery; import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.projection.ProjectionFactory; @@ -155,7 +156,13 @@ public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ReactiveCouchbaseQueryMethod queryMethod = new ReactiveCouchbaseQueryMethod(method, metadata, factory, mappingContext); - if (queryMethod.hasN1qlAnnotation()) { + if (queryMethod.hasSearchAnnotation()) { + if (queryMethod.hasN1qlAnnotation()) { + throw new IllegalArgumentException( + "Method " + method + " must not be annotated with both @Search and @Query"); + } + return new ReactiveSearchBasedCouchbaseQuery(queryMethod, couchbaseOperations); + } else if (queryMethod.hasN1qlAnnotation()) { return new ReactiveStringBasedCouchbaseQuery(queryMethod, couchbaseOperations, valueExpressionDelegate, namedQueries); } else { diff --git a/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateFtsIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateFtsIntegrationTests.java new file mode 100644 index 000000000..e82a16b63 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateFtsIntegrationTests.java @@ -0,0 +1,312 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.core; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.data.annotation.Id; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.SimpleCouchbaseClientFactory; +import org.springframework.data.couchbase.core.convert.MappingCouchbaseConverter; +import org.springframework.data.couchbase.core.convert.translation.JacksonTranslationService; +import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext; +import org.springframework.data.couchbase.repository.Collection; +import org.springframework.data.couchbase.repository.Scope; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; + +import com.couchbase.client.core.deps.com.fasterxml.jackson.databind.DeserializationFeature; +import com.couchbase.client.java.Cluster; +import com.couchbase.client.java.json.JacksonTransformers; +import com.couchbase.client.java.manager.search.SearchIndex; +import com.couchbase.client.java.search.SearchQuery; +import com.couchbase.client.java.search.SearchRequest; +import com.couchbase.client.java.search.facet.SearchFacet; +import com.couchbase.client.java.search.result.SearchRow; +import com.couchbase.client.java.search.sort.SearchSort; + +/** + * FTS integration tests against travel-sample.inventory.airport. + *

+ * Requires a Couchbase cluster with the travel-sample bucket loaded and FTS enabled. + *

+ * A scope-level FTS index is created automatically on first run and reused on subsequent runs. + * Since the scope-level FTS index covers all collections in the inventory scope, queries use a + * conjunction with {@code type:airport} to restrict results to airport documents only. + * + * @author Emilien Bevierre + * @since 6.2 + */ +@IgnoreWhen(clusterTypes = ClusterType.MOCKED, missesCapabilities = Capabilities.SEARCH) +class CouchbaseTemplateFtsIntegrationTests extends JavaIntegrationTests { + + private static final String INDEX_NAME = "sd-fts-airport-idx"; + private static final String TS_BUCKET = "travel-sample"; + + private static CouchbaseClientFactory travelSampleClientFactory; + private static CouchbaseTemplate template; + private static ReactiveCouchbaseTemplate reactiveTemplate; + + private static final SearchQuery AIRPORT_TYPE_FILTER = SearchQuery.term("airport").field("type"); + + @BeforeAll + static void setupFtsTest() { + callSuperBeforeAll(new Object() {}); + + JacksonTransformers.MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + travelSampleClientFactory = new SimpleCouchbaseClientFactory(connectionString(), authenticator(), TS_BUCKET); + Cluster cluster = travelSampleClientFactory.getCluster(); + cluster.bucket(TS_BUCKET).waitUntilReady(Duration.ofSeconds(30)); + + CouchbaseMappingContext mappingContext = new CouchbaseMappingContext(); + mappingContext.setAutoIndexCreation(false); + mappingContext.afterPropertiesSet(); + MappingCouchbaseConverter converter = new MappingCouchbaseConverter(mappingContext, "t"); + JacksonTranslationService translationService = new JacksonTranslationService(); + translationService.afterPropertiesSet(); + + template = new CouchbaseTemplate(travelSampleClientFactory, converter, translationService); + reactiveTemplate = new ReactiveCouchbaseTemplate(travelSampleClientFactory, converter, translationService); + + ensureFtsIndex(cluster); + } + + @AfterAll + public static void tearDownFtsTest() { + // Index is intentionally NOT dropped: re-indexing 30k+ docs on each run is too slow. + if (travelSampleClientFactory != null) { + try { + travelSampleClientFactory.close(); + } catch (Exception e) { + LOGGER.warn("Failed to close travel-sample client factory: {}", e.getMessage()); + } + } + callSuperAfterAll(new Object() {}); + } + + private static SearchRequest airportSearch(SearchQuery query) { + return SearchRequest.create(SearchQuery.conjuncts(query, AIRPORT_TYPE_FILTER)); + } + + @Test + void searchByMatchQueryReturnsHydratedAirports() { + List results = template.findBySearch(TsAirport.class) + .withIndex(INDEX_NAME) + .matching(airportSearch(SearchQuery.match("France").field("country"))) + .all(); + + assertFalse(results.isEmpty(), "Expected airports in France"); + for (TsAirport airport : results) { + assertEquals("France", airport.country); + } + } + + @Test + void searchByQueryStringReturnsMatchingAirports() { + List results = template.findBySearch(TsAirport.class) + .withIndex(INDEX_NAME) + .matching(airportSearch(SearchQuery.queryString("+country:\"United States\" +faa:SFO"))) + .all(); + + assertFalse(results.isEmpty(), "Expected SFO airport"); + assertTrue(results.stream().anyMatch(a -> "SFO".equals(a.faa))); + } + + @Test + void searchCountForKnownCountry() { + long count = template.findBySearch(TsAirport.class) + .withIndex(INDEX_NAME) + .matching(airportSearch(SearchQuery.match("United States").field("country"))) + .count(); + + assertTrue(count > 100, "Expected many US airports in travel-sample"); + } + + @Test + void searchExistsForKnownAirport() { + boolean exists = template.findBySearch(TsAirport.class) + .withIndex(INDEX_NAME) + .matching(airportSearch(SearchQuery.match("SFO").field("faa"))) + .exists(); + + assertTrue(exists, "SFO should exist in travel-sample"); + } + + @Test + void searchFirstReturnsSingleEntity() { + TsAirport airport = template.findBySearch(TsAirport.class) + .withIndex(INDEX_NAME) + .matching(airportSearch(SearchQuery.match("United Kingdom").field("country"))) + .firstValue(); + + assertNotNull(airport, "Expected at least one UK airport"); + assertEquals("United Kingdom", airport.country); + } + + @Test + void searchWithLimitAndSkipForPagination() { + SearchRequest request = airportSearch(SearchQuery.match("France").field("country")); + + List page1 = template.findBySearch(TsAirport.class) + .withIndex(INDEX_NAME) + .withSkip(0) + .withLimit(5) + .matching(request) + .all(); + + List page2 = template.findBySearch(TsAirport.class) + .withIndex(INDEX_NAME) + .withSkip(5) + .withLimit(5) + .matching(request) + .all(); + + assertEquals(5, page1.size(), "First page should have 5 results"); + assertFalse(page2.isEmpty(), "Second page should have results"); + page2.forEach(a2 -> assertFalse(page1.stream().anyMatch(a1 -> a1.key.equals(a2.key)), + "Pages should not overlap")); + } + + @Test + void searchRawRowsProvideScoresAndIds() { + List rows = template.findBySearch(TsAirport.class) + .withIndex(INDEX_NAME) + .matching(airportSearch(SearchQuery.match("international").field("airportname"))) + .rows(); + + assertFalse(rows.isEmpty(), "Expected raw rows for 'international' airports"); + for (SearchRow row : rows) { + assertNotNull(row.id(), "Row should have a document ID"); + assertTrue(row.score() > 0, "Row should have a positive score"); + } + } + + @Test + void searchResultCombinesEntitiesAndMetadata() { + Map facets = Map.of("countries", SearchFacet.term("country", 5)); + SearchResult result = template.findBySearch(TsAirport.class) + .withIndex(INDEX_NAME) + .withFacets(facets) + .withLimit(3) + .matching(airportSearch(SearchQuery.matchAll())) + .result(); + + assertNotNull(result); + assertEquals(3, result.entities().size(), "Should have 3 hydrated entities"); + assertTrue(result.totalRows() > 3, "Total rows should exceed the limit"); + assertNotNull(result.metaData(), "Metadata should be present"); + assertFalse(result.facets().isEmpty(), "Facet results should be present"); + assertTrue(result.facets().containsKey("countries")); + } + + @Test + void searchWithSortByScoreDescending() { + List rows = template.findBySearch(TsAirport.class) + .withIndex(INDEX_NAME) + .withSort(SearchSort.byScore().desc(true)) + .withLimit(10) + .matching(airportSearch(SearchQuery.match("international").field("airportname"))) + .rows(); + + assertFalse(rows.isEmpty()); + for (int i = 1; i < rows.size(); i++) { + assertTrue(rows.get(i - 1).score() >= rows.get(i).score(), + "Results should be sorted by score descending"); + } + } + + @Test + void reactiveSearchReturnsHydratedEntities() { + List results = reactiveTemplate.findBySearch(TsAirport.class) + .withIndex(INDEX_NAME) + .withLimit(5) + .matching(airportSearch(SearchQuery.match("United Kingdom").field("country"))) + .all() + .collectList() + .block(); + + assertNotNull(results); + assertFalse(results.isEmpty(), "Expected UK airports"); + for (TsAirport airport : results) { + assertEquals("United Kingdom", airport.country); + } + } + + // --- Domain entity for travel-sample airports --- + + @Scope("inventory") + @Collection("airport") + static class TsAirport { + @Id String key; + String airportname; + String city; + String country; + String faa; + String icao; + String tz; + } + + // --- Helpers --- + + private static void ensureFtsIndex(Cluster cluster) { + com.couchbase.client.java.Scope scope = cluster.bucket(TS_BUCKET).scope("inventory"); + try { + scope.searchIndexes().getIndex(INDEX_NAME); + LOGGER.info("FTS index '{}' already exists, reusing.", INDEX_NAME); + } catch (com.couchbase.client.core.error.IndexNotFoundException ex) { + SearchIndex index = new SearchIndex(INDEX_NAME, TS_BUCKET); + scope.searchIndexes().upsertIndex(index); + LOGGER.info("Created FTS index '{}'", INDEX_NAME); + } + waitForFtsIndex(scope); + } + + private static void waitForFtsIndex(com.couchbase.client.java.Scope scope) { + // Phase 1: wait for the index to be queryable + for (int i = 0; i < 30; i++) { + try { + scope.search(INDEX_NAME, SearchRequest.create(SearchQuery.matchAll())); + break; + } catch (Exception ex) { + if (i == 29) + throw new RuntimeException("FTS index did not become queryable in time", ex); + sleepMs(2000); + } + } + // Phase 2: wait for documents to be indexed + for (int i = 0; i < 60; i++) { + try { + long docCount = scope.searchIndexes().getIndexedDocumentsCount(INDEX_NAME); + if (docCount > 0) { + LOGGER.info("FTS index '{}' ready with {} indexed documents", INDEX_NAME, docCount); + return; + } + } catch (Exception ignored) {} + sleepMs(2000); + } + LOGGER.warn("FTS index '{}' may not have finished indexing", INDEX_NAME); + } +} diff --git a/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateSearchIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateSearchIntegrationTests.java new file mode 100644 index 000000000..0a92f4519 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateSearchIntegrationTests.java @@ -0,0 +1,357 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.core; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.domain.Config; +import org.springframework.data.couchbase.domain.User; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import com.couchbase.client.java.Cluster; +import com.couchbase.client.java.Collection; +import com.couchbase.client.java.json.JsonObject; +import com.couchbase.client.java.kv.MutationResult; +import com.couchbase.client.java.kv.MutationState; +import com.couchbase.client.java.manager.search.SearchIndex; +import com.couchbase.client.java.search.SearchOptions; +import com.couchbase.client.java.search.SearchQuery; +import com.couchbase.client.java.search.SearchRequest; +import com.couchbase.client.java.search.result.SearchRow; + +/** + * Integration tests for FTS via CouchbaseTemplate. + * + * @author Emilien Bevierre + * + * @since 6.2 + */ +@IgnoreWhen(clusterTypes = ClusterType.MOCKED, missesCapabilities = Capabilities.SEARCH) +@SpringJUnitConfig(Config.class) +@DirtiesContext +class CouchbaseTemplateSearchIntegrationTests extends JavaIntegrationTests { + + private static final String INDEX_NAME = "sd-fts-test-index"; + + @Autowired CouchbaseTemplate couchbaseTemplate; + @Autowired ReactiveCouchbaseTemplate reactiveCouchbaseTemplate; + + @BeforeAll + static void setupFtsIndex() { + callSuperBeforeAll(new Object() {}); + Cluster cluster = Cluster.connect(connectionString(), username(), password()); + try { + cluster.bucket(bucketName()).waitUntilReady(Duration.ofSeconds(30)); + SearchIndex searchIndex = new SearchIndex(INDEX_NAME, bucketName()); + cluster.searchIndexes().upsertIndex(searchIndex); + waitForFtsIndex(cluster, INDEX_NAME); + } finally { + logCluster(cluster, "setupFtsIndex"); + } + } + + @AfterAll + static void tearDownFtsIndex() { + Cluster cluster = Cluster.connect(connectionString(), username(), password()); + try { + cluster.searchIndexes().dropIndex(INDEX_NAME); + } catch (Exception e) { + LOGGER.warn("Failed to drop FTS index: {}", e.getMessage()); + } finally { + logCluster(cluster, "tearDownFtsIndex"); + } + callSuperAfterAll(new Object() {}); + cluster.disconnect(); + } + + /** + * Inserts a document via the raw SDK to get the MutationResult (with mutation tokens), + * then builds SearchOptions with consistentWith so FTS waits for index catch-up. + */ + private InsertedDoc insertRawDoc(String id, String firstname, String lastname) { + Collection col = couchbaseTemplate.getCouchbaseClientFactory().getDefaultCollection(); + JsonObject content = JsonObject.create() + .put("id", id) + .put("firstname", firstname) + .put("lastname", lastname) + .put("t", "abstractuser"); // typeKey used by Config + MutationResult result = col.insert(id, content); + MutationState ms = MutationState.from(result.mutationToken().get()); + return new InsertedDoc(id, ms); + } + + private void removeRawDoc(String id) { + try { + couchbaseTemplate.getCouchbaseClientFactory().getDefaultCollection().remove(id); + } catch (Exception e) { + LOGGER.warn("Cleanup failed for {}: {}", id, e.getMessage()); + } + } + + private SearchOptions consistentOptions(MutationState ms) { + return SearchOptions.searchOptions().consistentWith(ms); + } + + @Test + void searchAllReturnsHydratedEntities() { + InsertedDoc doc = insertRawDoc("fts-all-" + UUID.randomUUID(), "allsearchfn", "allsearchln"); + try { + List results = couchbaseTemplate.findBySearch(User.class) + .withIndex(INDEX_NAME) + .withOptions(consistentOptions(doc.ms)) + .matching(SearchRequest.create(SearchQuery.queryString("allsearchfn"))) + .all(); + + assertFalse(results.isEmpty(), "Expected at least one result"); + User found = results.stream().filter(u -> doc.id.equals(u.getId())).findFirst().orElse(null); + assertNotNull(found, "Expected to find inserted user by id"); + assertEquals("allsearchfn", found.getFirstname()); + assertEquals("allsearchln", found.getLastname()); + } finally { + removeRawDoc(doc.id); + } + } + + @Test + void searchFirstReturnsOneEntity() { + InsertedDoc doc = insertRawDoc("fts-first-" + UUID.randomUUID(), "firstsearchfn", "firstsearchln"); + try { + User result = couchbaseTemplate.findBySearch(User.class) + .withIndex(INDEX_NAME) + .withOptions(consistentOptions(doc.ms)) + .matching(SearchRequest.create(SearchQuery.queryString("firstsearchfn"))) + .firstValue(); + + assertNotNull(result, "Expected first() to return a result"); + } finally { + removeRawDoc(doc.id); + } + } + + // Uses ConsistentWith to avoid flakiness from eventual consistency. + @Test + void searchOneReturnsExactlyOne() { + String unique = "uniquefts" + UUID.randomUUID().toString().replace("-", ""); + InsertedDoc doc = insertRawDoc("fts-one-" + UUID.randomUUID(), unique, "onesearchln"); + try { + User result = couchbaseTemplate.findBySearch(User.class) + .withIndex(INDEX_NAME) + .withOptions(consistentOptions(doc.ms)) + .matching(SearchRequest.create(SearchQuery.queryString(unique))) + .oneValue(); + + assertNotNull(result, "Expected exactly one result"); + assertEquals(doc.id, result.getId()); + } finally { + removeRawDoc(doc.id); + } + } + + @Test + void searchCountReturnsPositive() { + InsertedDoc doc = insertRawDoc("fts-count-" + UUID.randomUUID(), "countsearchfn", "countsearchln"); + try { + long count = couchbaseTemplate.findBySearch(User.class) + .withIndex(INDEX_NAME) + .withOptions(consistentOptions(doc.ms)) + .matching(SearchRequest.create(SearchQuery.queryString("countsearchfn"))) + .count(); + + assertTrue(count > 0, "Expected count > 0"); + } finally { + removeRawDoc(doc.id); + } + } + + @Test + void searchExistsReturnsTrue() { + InsertedDoc doc = insertRawDoc("fts-exists-" + UUID.randomUUID(), "existssearchfn", "existssearchln"); + try { + boolean exists = couchbaseTemplate.findBySearch(User.class) + .withIndex(INDEX_NAME) + .withOptions(consistentOptions(doc.ms)) + .matching(SearchRequest.create(SearchQuery.queryString("existssearchfn"))) + .exists(); + + assertTrue(exists, "Expected exists to be true"); + } finally { + removeRawDoc(doc.id); + } + } + + @Test + void searchRawRowsReturnsScoresAndIds() { + InsertedDoc doc = insertRawDoc("fts-rows-" + UUID.randomUUID(), "rawrowsfn", "rawrowsln"); + try { + List rows = couchbaseTemplate.findBySearch(User.class) + .withIndex(INDEX_NAME) + .withOptions(consistentOptions(doc.ms)) + .matching(SearchRequest.create(SearchQuery.queryString("rawrowsfn"))) + .rows(); + + assertFalse(rows.isEmpty(), "Expected at least one SearchRow"); + SearchRow row = rows.stream().filter(r -> doc.id.equals(r.id())).findFirst().orElse(null); + assertNotNull(row, "Expected to find our document in raw rows"); + assertTrue(row.score() > 0, "Expected a positive score"); + } finally { + removeRawDoc(doc.id); + } + } + + @Test + void reactiveSearchAllReturnsHydratedEntities() { + InsertedDoc doc = insertRawDoc("fts-rx-all-" + UUID.randomUUID(), "rxallsearchfn", "rxallsearchln"); + try { + List results = reactiveCouchbaseTemplate.findBySearch(User.class) + .withIndex(INDEX_NAME) + .withOptions(consistentOptions(doc.ms)) + .matching(SearchRequest.create(SearchQuery.queryString("rxallsearchfn"))) + .all() + .collectList() + .block(); + + assertNotNull(results); + assertFalse(results.isEmpty(), "Expected at least one reactive result"); + assertTrue(results.stream().anyMatch(u -> doc.id.equals(u.getId())), + "Expected to find the inserted user"); + } finally { + removeRawDoc(doc.id); + } + } + + @Test + void reactiveSearchCountReturnsPositive() { + InsertedDoc doc = insertRawDoc("fts-rx-count-" + UUID.randomUUID(), "rxcountsearchfn", "rxcountsearchln"); + try { + Long count = reactiveCouchbaseTemplate.findBySearch(User.class) + .withIndex(INDEX_NAME) + .withOptions(consistentOptions(doc.ms)) + .matching(SearchRequest.create(SearchQuery.queryString("rxcountsearchfn"))) + .count() + .block(); + + assertNotNull(count); + assertTrue(count > 0, "Expected reactive count > 0"); + } finally { + removeRawDoc(doc.id); + } + } + + @Test + void reactiveSearchRowsReturnsResults() { + InsertedDoc doc = insertRawDoc("fts-rx-rows-" + UUID.randomUUID(), "rxrowssearchfn", "rxrowssearchln"); + try { + List rows = reactiveCouchbaseTemplate.findBySearch(User.class) + .withIndex(INDEX_NAME) + .withOptions(consistentOptions(doc.ms)) + .matching(SearchRequest.create(SearchQuery.queryString("rxrowssearchfn"))) + .rows() + .collectList() + .block(); + + assertNotNull(rows); + assertFalse(rows.isEmpty(), "Expected at least one reactive SearchRow"); + assertTrue(rows.stream().anyMatch(r -> doc.id.equals(r.id())), + "Expected to find our document in reactive raw rows"); + } finally { + removeRawDoc(doc.id); + } + } + + @Test + void searchWithMatchQuery() { + InsertedDoc doc = insertRawDoc("fts-match-" + UUID.randomUUID(), "matchqueryfn", "matchqueryln"); + try { + List results = couchbaseTemplate.findBySearch(User.class) + .withIndex(INDEX_NAME) + .withOptions(consistentOptions(doc.ms)) + .matching(SearchRequest.create(SearchQuery.match("matchqueryfn"))) + .all(); + + assertFalse(results.isEmpty(), "MatchQuery should find the document"); + assertTrue(results.stream().anyMatch(u -> doc.id.equals(u.getId()))); + } finally { + removeRawDoc(doc.id); + } + } + + @Test + void searchNoResults() { + // Search for a term that definitely doesn't exist + String nonsense = "zzzznonexistent" + UUID.randomUUID().toString().replace("-", ""); + List results = couchbaseTemplate.findBySearch(User.class) + .withIndex(INDEX_NAME) + .matching(SearchRequest.create(SearchQuery.queryString(nonsense))) + .all(); + + assertTrue(results.isEmpty(), "Expected no results for nonsense query"); + } + + @Test + void searchNoResultsCountIsZero() { + String nonsense = "zzzznonexistent" + UUID.randomUUID().toString().replace("-", ""); + long count = couchbaseTemplate.findBySearch(User.class) + .withIndex(INDEX_NAME) + .matching(SearchRequest.create(SearchQuery.queryString(nonsense))) + .count(); + + assertEquals(0, count, "Expected count == 0 for nonsense query"); + } + + @Test + void searchNoResultsExistsIsFalse() { + String nonsense = "zzzznonexistent" + UUID.randomUUID().toString().replace("-", ""); + boolean exists = couchbaseTemplate.findBySearch(User.class) + .withIndex(INDEX_NAME) + .matching(SearchRequest.create(SearchQuery.queryString(nonsense))) + .exists(); + + assertFalse(exists, "Expected exists == false for nonsense query"); + } + + private static void waitForFtsIndex(Cluster cluster, String indexName) { + int maxRetries = 30; + for (int i = 0; i < maxRetries; i++) { + try { + cluster.searchQuery(indexName, SearchQuery.queryString("*")); + return; + } catch (Exception ex) { + String msg = ex.getMessage() != null ? ex.getMessage() : ""; + boolean retryable = msg.contains("no planPIndexes") || msg.contains("pindex_consistency") + || msg.contains("pindex not available") || msg.contains("index not found"); + if (!retryable || i >= maxRetries - 1) { + throw new RuntimeException("FTS index " + indexName + " did not become ready in time", ex); + } + sleepMs(2000); + } + } + } + + private record InsertedDoc(String id, MutationState ms) {} +} diff --git a/src/test/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperationTests.java b/src/test/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperationTests.java new file mode 100644 index 000000000..55147966c --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperationTests.java @@ -0,0 +1,49 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.core; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.data.core.TypedPropertyPath; + +import com.couchbase.client.java.search.HighlightStyle; + +/** + * Unit tests verifying the type-safe {@link TypedPropertyPath} overloads are declared on the + * {@link ExecutableFindBySearchOperation} and {@link ReactiveFindBySearchOperation} fluent interfaces. + * + * @author Emilien Bevierre + * @since 6.2 + */ +class ExecutableFindBySearchOperationTests { + + @Test + void typedPropertyReferenceOverloadsAreDeclaredOnInterfaces() throws Exception { + assertNotNull(ExecutableFindBySearchOperation.FindBySearchWithSort.class + .getMethod("withSort", TypedPropertyPath.class, TypedPropertyPath[].class)); + assertNotNull(ExecutableFindBySearchOperation.FindBySearchWithHighlight.class + .getMethod("withHighlight", HighlightStyle.class, TypedPropertyPath.class, TypedPropertyPath[].class)); + assertNotNull(ExecutableFindBySearchOperation.FindBySearchWithFields.class + .getMethod("withFields", TypedPropertyPath.class, TypedPropertyPath[].class)); + assertNotNull(ReactiveFindBySearchOperation.FindBySearchWithSort.class + .getMethod("withSort", TypedPropertyPath.class, TypedPropertyPath[].class)); + assertNotNull(ReactiveFindBySearchOperation.FindBySearchWithHighlight.class + .getMethod("withHighlight", HighlightStyle.class, TypedPropertyPath.class, TypedPropertyPath[].class)); + assertNotNull(ReactiveFindBySearchOperation.FindBySearchWithFields.class + .getMethod("withFields", TypedPropertyPath.class, TypedPropertyPath[].class)); + } +} diff --git a/src/test/java/org/springframework/data/couchbase/core/PropertyPathSupportTests.java b/src/test/java/org/springframework/data/couchbase/core/PropertyPathSupportTests.java new file mode 100644 index 000000000..756264f4b --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/core/PropertyPathSupportTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2026-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.core; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.springframework.data.core.PropertyPath; +import org.springframework.data.annotation.Id; +import org.springframework.data.couchbase.core.convert.MappingCouchbaseConverter; +import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext; +import org.springframework.data.couchbase.core.mapping.Document; +import org.springframework.data.couchbase.core.mapping.Field; + +class PropertyPathSupportTests { + + private final MappingCouchbaseConverter converter; + + PropertyPathSupportTests() { + CouchbaseMappingContext mappingContext = new CouchbaseMappingContext(); + mappingContext.setInitialEntitySet(Set.of(SearchDocument.class, SearchAddress.class)); + mappingContext.afterPropertiesSet(); + this.converter = new MappingCouchbaseConverter(mappingContext); + } + + @Test + void resolvesAlternativeFieldNames() { + assertEquals("nickname", + PropertyPathSupport.getMappedFieldPath(converter, PropertyPath.of(SearchDocument::getMiddlename))); + } + + @Test + void resolvesNestedPropertyPaths() { + assertEquals("address.city", PropertyPathSupport.getMappedFieldPath(converter, + PropertyPath.of(SearchDocument::getAddress).then(SearchAddress::getCity))); + } + + @Test + void resolvesMultipleMappedFieldPaths() { + assertArrayEquals(new String[] { "nickname", "address.city" }, PropertyPathSupport + .getMappedFieldPaths(converter, PropertyPath.of(SearchDocument::getMiddlename), + PropertyPath.of(SearchDocument::getAddress).then(SearchAddress::getCity))); + } + + @Document + static class SearchDocument { + + @Id String id; + + @Field("nickname") String middlename; + + SearchAddress address; + + String getMiddlename() { + return middlename; + } + + SearchAddress getAddress() { + return address; + } + } + + @Document + static class SearchAddress { + + String city; + + String getCity() { + return city; + } + } +} diff --git a/src/test/java/org/springframework/data/couchbase/core/SearchResultTests.java b/src/test/java/org/springframework/data/couchbase/core/SearchResultTests.java new file mode 100644 index 000000000..ee9a5fa04 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/core/SearchResultTests.java @@ -0,0 +1,74 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.core; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link SearchResult}. + * + * @author Emilien Bevierre + * @since 6.2 + */ +class SearchResultTests { + + @Test + void constructorWithNullsDefaultsToEmptyCollections() { + SearchResult result = new SearchResult<>(null, null, null, null); + assertNotNull(result.entities()); + assertTrue(result.entities().isEmpty()); + assertNotNull(result.rows()); + assertTrue(result.rows().isEmpty()); + assertNull(result.metaData()); + assertNotNull(result.facets()); + assertTrue(result.facets().isEmpty()); + } + + @Test + void entitiesReturnedCorrectly() { + List entities = Arrays.asList("a", "b", "c"); + SearchResult result = new SearchResult<>(entities, null, null, null); + assertEquals(3, result.entities().size()); + assertEquals("a", result.entities().get(0)); + } + + @Test + void totalRowsWithNullMetaDataFallsBackToEntitySize() { + SearchResult result = new SearchResult<>(Arrays.asList("a", "b"), null, null, null); + assertEquals(2, result.totalRows()); + } + + @Test + void maxScoreWithNullMetaDataReturnsZero() { + SearchResult result = new SearchResult<>(Collections.emptyList(), null, null, null); + assertEquals(0.0, result.maxScore()); + } + + @Test + void facetsReturnedCorrectly() { + SearchResult result = new SearchResult<>(null, null, null, Collections.emptyMap()); + assertTrue(result.facets().isEmpty()); + } +} diff --git a/src/test/java/org/springframework/data/couchbase/core/query/TypeSafePropertyReferenceTests.java b/src/test/java/org/springframework/data/couchbase/core/query/TypeSafePropertyReferenceTests.java new file mode 100644 index 000000000..cde75b2fa --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/core/query/TypeSafePropertyReferenceTests.java @@ -0,0 +1,225 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.core.query; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.data.couchbase.core.query.QueryCriteria.where; + +import org.junit.jupiter.api.Test; + +import org.springframework.data.core.PropertyPath; +import org.springframework.data.core.TypedPropertyPath; +import org.springframework.data.couchbase.domain.Address; +import org.springframework.data.couchbase.domain.Airport; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.domain.User; +import org.springframework.data.couchbase.core.convert.MappingCouchbaseConverter; +import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext; +import org.springframework.data.domain.Sort; + +/** + * Unit tests for type-safe property references in QueryCriteria, Query, and Sort. + * + * @author Emilien Bevierre + */ +class TypeSafePropertyReferenceTests { + + private final MappingCouchbaseConverter converter; + + TypeSafePropertyReferenceTests() { + CouchbaseMappingContext mappingContext = new CouchbaseMappingContext(); + mappingContext.afterPropertiesSet(); + this.converter = new MappingCouchbaseConverter(mappingContext); + } + + // --- @Field alias resolution (Person.middlename is stored as "nickname") --- + + @Test + void whereWithFieldAliasResolvesMappedName() { + QueryCriteria c = where(Person::getMiddlename).is("Ollie"); + assertEquals("nickname = \"Ollie\"", c.export(null, null, converter)); + } + + @Test + void andOrWithFieldAliasResolvesMappedName() { + QueryCriteria c = where(Person::getFirstname).is("Oliver") + .and(Person::getMiddlename).is("Ollie") + .or(Person::getMiddlename).is("O"); + assertEquals("firstname = \"Oliver\" and nickname = \"Ollie\" or nickname = \"O\"", + c.export(null, null, converter)); + } + + @Test + void whereWithoutConverterFallsBackToPropertyName() { + QueryCriteria c = where(Person::getMiddlename).is("Ollie"); + assertEquals("middlename = \"Ollie\"", c.export()); + } + + @Test + void distinctWithFieldAliasResolvesMappedName() { + Query query = new Query().distinct(Person::getMiddlename); + String statement = query.toN1qlSelectString(converter, "b", null, null, Person.class, null, false, + query.getDistinctFields(), null); + assertTrue(statement.contains("nickname"), "expected mapped field name in: " + statement); + assertFalse(statement.contains("middlename"), "raw property name must not leak into: " + statement); + } + + // --- QueryCriteria.where() --- + + @Test + void whereWithMethodReference() { + QueryCriteria c = where(User::getFirstname).is("Cynthia"); + assertEquals("firstname = \"Cynthia\"", c.export()); + } + + @Test + void whereWithNestedPropertyPath() { + TypedPropertyPath cityPath = PropertyPath.of(Person::getAddress).then(Address::getCity); + QueryCriteria c = where(cityPath).is("Paris"); + assertEquals("address.city = \"Paris\"", c.export()); + } + + // --- QueryCriteria.and() --- + + @Test + void andWithMethodReference() { + QueryCriteria c = where(User::getFirstname).is("Charles") + .and(User::getLastname).is("Darwin"); + assertEquals("firstname = \"Charles\" and lastname = \"Darwin\"", c.export()); + } + + @Test + void andWithNestedPropertyPath() { + TypedPropertyPath streetPath = PropertyPath.of(Person::getAddress).then(Address::getStreet); + QueryCriteria c = where(Person::getFirstname).is("Oliver") + .and(streetPath).is("Main St"); + assertEquals("firstname = \"Oliver\" and address.street = \"Main St\"", c.export()); + } + + // --- QueryCriteria.or() --- + + @Test + void orWithMethodReference() { + QueryCriteria c = where(User::getFirstname).is("Oliver") + .or(User::getLastname).is("Gierke"); + assertEquals("firstname = \"Oliver\" or lastname = \"Gierke\"", c.export()); + } + + @Test + void orWithNestedPropertyPath() { + TypedPropertyPath cityPath = PropertyPath.of(Person::getAddress).then(Address::getCity); + QueryCriteria c = where(Person::getFirstname).is("Oliver") + .or(cityPath).is("Berlin"); + assertEquals("firstname = \"Oliver\" or address.city = \"Berlin\"", c.export()); + } + + // --- Query.distinct() with TypedPropertyPath --- + + @Test + void queryDistinctWithTypedPropertyPath() { + Query q = new Query(); + q.distinct(PropertyPath.of(Airport::getIata)); + assertEquals(1, q.getDistinctFields().length); + assertEquals("iata", q.getDistinctFields()[0]); + } + + @Test + void queryDistinctWithNestedTypedPropertyPath() { + Query q = new Query(); + q.distinct(PropertyPath.of(Person::getAddress).then(Address::getCity)); + assertEquals(1, q.getDistinctFields().length); + assertEquals("address.city", q.getDistinctFields()[0]); + } + + @Test + void queryDistinctWithMultipleTypedPropertyPaths() { + Query q = new Query(); + q.distinct(PropertyPath.of(Airport::getIata), PropertyPath.of(Airport::getIcao)); + assertEquals(2, q.getDistinctFields().length); + assertEquals("iata", q.getDistinctFields()[0]); + assertEquals("icao", q.getDistinctFields()[1]); + } + + // --- Sort.by() with TypedPropertyPath (Spring Data Commons API) --- + + @Test + void sortByTypedPropertyPath() { + Sort sort = Sort.by(PropertyPath.of(User::getFirstname)); + assertEquals("firstname: ASC", sort.iterator().next().toString()); + } + + @Test + void sortByMultipleTypedPropertyPaths() { + Sort sort = Sort.by(PropertyPath.of(User::getFirstname), PropertyPath.of(User::getLastname)); + var orders = sort.toList(); + assertEquals(2, orders.size()); + assertEquals("firstname: ASC", orders.get(0).toString()); + assertEquals("lastname: ASC", orders.get(1).toString()); + } + + @Test + void sortByNestedTypedPropertyPath() { + Sort sort = Sort.by(PropertyPath.of(Person::getAddress).then(Address::getCity)); + assertEquals("address.city: ASC", sort.iterator().next().toString()); + } + + @Test + void sortOrderWithTypedPropertyPath() { + Sort.Order order = Sort.Order.desc(PropertyPath.of(User::getFirstname)); + assertEquals("firstname: DESC", order.toString()); + } + + // --- Sort with Query --- + + @Test + void querySortWithTypedPropertyPath() { + Query query = new Query(); + query.with(Sort.by(PropertyPath.of(Airport::getIata))); + StringBuilder sb = new StringBuilder(); + query.appendSort(sb); + assertEquals(" ORDER BY iata ASC", sb.toString()); + } + + @Test + void querySortDescWithTypedPropertyPath() { + Query query = new Query(); + query.with(Sort.by(Sort.Direction.DESC, PropertyPath.of(Airport::getIata))); + StringBuilder sb = new StringBuilder(); + query.appendSort(sb); + assertEquals(" ORDER BY iata DESC", sb.toString()); + } + + @Test + void backwardsCompatibilityStringBased() { + QueryCriteria c = where("firstname").is("Oliver"); + assertEquals("firstname = \"Oliver\"", c.export()); + + Query q = new Query(); + q.distinct(new String[] { "iata", "icao" }); + assertEquals(2, q.getDistinctFields().length); + } + + @Test + void complexChainWithMixedApproach() { + TypedPropertyPath cityPath = PropertyPath.of(Person::getAddress).then(Address::getCity); + QueryCriteria c = where(Person::getFirstname).is("Oliver") + .and("lastname").is("Gierke") + .or(cityPath).is("Berlin"); + assertEquals("firstname = \"Oliver\" and lastname = \"Gierke\" or address.city = \"Berlin\"", c.export()); + } +} diff --git a/src/test/java/org/springframework/data/couchbase/repository/ReactiveSearchRepositoryFtsIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/repository/ReactiveSearchRepositoryFtsIntegrationTests.java new file mode 100644 index 000000000..d12f09914 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/repository/ReactiveSearchRepositoryFtsIntegrationTests.java @@ -0,0 +1,234 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.repository; + +import java.time.Duration; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.data.annotation.Id; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.SimpleCouchbaseClientFactory; +import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; +import org.springframework.data.couchbase.core.convert.MappingCouchbaseConverter; +import org.springframework.data.couchbase.core.convert.translation.JacksonTranslationService; +import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext; +import org.springframework.data.couchbase.repository.config.ReactiveRepositoryOperationsMapping; +import org.springframework.data.couchbase.repository.support.ReactiveCouchbaseRepositoryFactory; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.data.repository.Repository; + +import com.couchbase.client.core.deps.com.fasterxml.jackson.databind.DeserializationFeature; +import com.couchbase.client.java.Cluster; +import com.couchbase.client.java.json.JacksonTransformers; +import com.couchbase.client.java.search.SearchQuery; +import com.couchbase.client.java.search.SearchRequest; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Reactive {@link Search} repository integration tests against {@code travel-sample.inventory.airport}. + *

+ * Coverage for the wrapper-type adaptation in {@link + * org.springframework.data.couchbase.repository.query.ReactiveSearchBasedCouchbaseQuery}: methods declared + * to return {@link Mono} (count / exists / one) must surface as a {@code Mono} through the repository proxy, + * not be coerced into a {@code Flux}. Methods declared to return {@link Flux} keep their multi-value semantics. + * + * @author Emilien Bevierre + * @since 6.2 + */ +@IgnoreWhen(clusterTypes = ClusterType.MOCKED, missesCapabilities = Capabilities.SEARCH) +class ReactiveSearchRepositoryFtsIntegrationTests extends JavaIntegrationTests { + + private static final String INDEX_NAME = "sd-fts-airport-idx"; + private static final String TS_BUCKET = "travel-sample"; + + private static CouchbaseClientFactory travelSampleClientFactory; + private static AirportSearchRepository repository; + + @BeforeAll + static void setupRepository() { + callSuperBeforeAll(new Object() {}); + + JacksonTransformers.MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + travelSampleClientFactory = new SimpleCouchbaseClientFactory(connectionString(), authenticator(), TS_BUCKET); + Cluster cluster = travelSampleClientFactory.getCluster(); + cluster.bucket(TS_BUCKET).waitUntilReady(Duration.ofSeconds(30)); + + CouchbaseMappingContext mappingContext = new CouchbaseMappingContext(); + mappingContext.setAutoIndexCreation(false); + mappingContext.afterPropertiesSet(); + MappingCouchbaseConverter converter = new MappingCouchbaseConverter(mappingContext, "t"); + JacksonTranslationService translationService = new JacksonTranslationService(); + translationService.afterPropertiesSet(); + + ReactiveCouchbaseTemplate reactiveTemplate = new ReactiveCouchbaseTemplate(travelSampleClientFactory, converter, + translationService); + + ensureFtsIndex(cluster); + + ReactiveCouchbaseRepositoryFactory factory = new ReactiveCouchbaseRepositoryFactory( + new ReactiveRepositoryOperationsMapping(reactiveTemplate)); + repository = factory.getRepository(AirportSearchRepository.class); + } + + @AfterAll + static void tearDownRepository() { + if (travelSampleClientFactory != null) { + try { + travelSampleClientFactory.close(); + } catch (Exception e) { + LOGGER.warn("Failed to close travel-sample client factory: {}", e.getMessage()); + } + } + callSuperAfterAll(new Object() {}); + } + + @Test + void monoCountReturnsSingleLong() { + Mono result = repository.countByCountry("United States"); + + StepVerifier.create(result) + .assertNext(count -> { + if (count == null || count <= 0) { + throw new AssertionError("Expected non-zero count, got " + count); + } + }) + .verifyComplete(); + } + + @Test + void monoExistsReturnsSingleBoolean() { + Mono hit = repository.existsByFaa("SFO"); + Mono miss = repository.existsByFaa("ZZZZZZZ"); + + StepVerifier.create(hit).expectNext(Boolean.TRUE).verifyComplete(); + StepVerifier.create(miss).expectNext(Boolean.FALSE).verifyComplete(); + } + + @Test + void monoOneReturnsSingleEntity() { + Mono result = repository.findOneByFaa("SFO"); + + StepVerifier.create(result) + .assertNext(airport -> { + if (!"SFO".equals(airport.faa)) { + throw new AssertionError("Expected SFO, got " + airport.faa); + } + }) + .verifyComplete(); + } + + @Test + void fluxAllReturnsMultipleEntities() { + Flux result = repository.findAllByCountry("France"); + + StepVerifier.create(result.collectList()) + .assertNext(list -> { + if (list.isEmpty()) { + throw new AssertionError("Expected airports in France"); + } + for (TsAirport airport : list) { + if (!"France".equals(airport.country)) { + throw new AssertionError("Expected France, got " + airport.country); + } + } + }) + .verifyComplete(); + } + + // --- Repository under test --- + // Each ?N is substituted as a quoted FTS term (see SearchRepositoryQuerySupport#renderBindableValue), + // so the field name must live in the template, not in the bound parameter. + + interface AirportSearchRepository extends Repository { + + @Search("country:?0") + Flux findAllByCountry(String country); + + @Search("faa:?0") + Mono findOneByFaa(String faa); + + @Search("country:?0") + Mono countByCountry(String country); + + @Search("faa:?0") + Mono existsByFaa(String faa); + } + + // --- Domain entity for travel-sample airports --- + // @Scope/@Collection are required so ReactiveFindBySearchOperationSupport.findBySearch(domainType) + // auto-resolves them via OptionsBuilder.getScopeFrom/getCollectionFrom, which routes the + // search through the inventory scope where the FTS index actually lives. Without them the SDK + // hits cluster-level by short name and fails with IndexNotFoundException. + + @Scope("inventory") + @Collection("airport") + @SearchIndex(INDEX_NAME) + static class TsAirport { + @Id String key; + String airportname; + String country; + String faa; + } + + // --- Helpers --- + + private static void ensureFtsIndex(Cluster cluster) { + com.couchbase.client.java.Scope scope = cluster.bucket(TS_BUCKET).scope("inventory"); + try { + scope.searchIndexes().getIndex(INDEX_NAME); + LOGGER.info("FTS index '{}' already exists, reusing.", INDEX_NAME); + } catch (com.couchbase.client.core.error.IndexNotFoundException ex) { + com.couchbase.client.java.manager.search.SearchIndex index = new com.couchbase.client.java.manager.search.SearchIndex( + INDEX_NAME, TS_BUCKET); + scope.searchIndexes().upsertIndex(index); + LOGGER.info("Created FTS index '{}'", INDEX_NAME); + } + waitForFtsIndex(scope); + } + + private static void waitForFtsIndex(com.couchbase.client.java.Scope scope) { + for (int i = 0; i < 30; i++) { + try { + scope.search(INDEX_NAME, SearchRequest.create(SearchQuery.matchAll())); + break; + } catch (Exception ex) { + if (i == 29) + throw new RuntimeException("FTS index did not become queryable in time", ex); + sleepMs(2000); + } + } + for (int i = 0; i < 60; i++) { + try { + long docCount = scope.searchIndexes().getIndexedDocumentsCount(INDEX_NAME); + if (docCount > 0) { + LOGGER.info("FTS index '{}' ready with {} indexed documents", INDEX_NAME, docCount); + return; + } + } catch (Exception ignored) {} + sleepMs(2000); + } + LOGGER.warn("FTS index '{}' may not have finished indexing", INDEX_NAME); + } +} diff --git a/src/test/java/org/springframework/data/couchbase/repository/SearchAnnotationTests.java b/src/test/java/org/springframework/data/couchbase/repository/SearchAnnotationTests.java new file mode 100644 index 000000000..230c9c33d --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/repository/SearchAnnotationTests.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.repository; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link Search} and {@link SearchIndex} annotations. + * + * @author Emilien Bevierre + * @since 6.2 + */ +class SearchAnnotationTests { + + @Test + void searchAnnotationsAreRuntimeRetained() { + assertTrue(Search.class.isAnnotation()); + assertTrue(SearchIndex.class.isAnnotation()); + } + + @Test + void searchIndexAnnotationOnClass() { + SearchIndex annotation = AnnotatedEntity.class.getAnnotation(SearchIndex.class); + assertNotNull(annotation); + assertEquals("my-search-index", annotation.value()); + } + + @Test + void searchIndexAnnotationOnMethod() throws Exception { + java.lang.reflect.Method method = AnnotatedMethod.class.getMethod("search"); + SearchIndex annotation = method.getAnnotation(SearchIndex.class); + assertNotNull(annotation); + assertEquals("method-level-index", annotation.value()); + } + + @Test + void searchAnnotationOnMethod() throws Exception { + java.lang.reflect.Method method = AnnotatedMethod.class.getMethod("search"); + Search annotation = method.getAnnotation(Search.class); + assertNotNull(annotation); + assertEquals("description:?0", annotation.value()); + } + + @SearchIndex("my-search-index") + static class AnnotatedEntity { + } + + interface AnnotatedMethod { + @Search("description:?0") + @SearchIndex("method-level-index") + void search(); + } +} diff --git a/src/test/java/org/springframework/data/couchbase/repository/query/ReactiveSearchBasedCouchbaseQueryTests.java b/src/test/java/org/springframework/data/couchbase/repository/query/ReactiveSearchBasedCouchbaseQueryTests.java new file mode 100644 index 000000000..8717996c5 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/repository/query/ReactiveSearchBasedCouchbaseQueryTests.java @@ -0,0 +1,343 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.repository.query; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.couchbase.core.ReactiveCouchbaseOperations; +import org.springframework.data.couchbase.core.ReactiveFindBySearchOperation; +import org.springframework.data.couchbase.core.SearchResult; +import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentEntity; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; +import org.springframework.data.core.TypedPropertyPath; +import org.springframework.data.couchbase.domain.User; +import org.springframework.data.couchbase.repository.Search; +import org.springframework.data.couchbase.repository.SearchIndex; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Unit tests for {@link ReactiveSearchBasedCouchbaseQuery}. + * + * @author Emilien Bevierre + * @since 6.2 + */ +class ReactiveSearchBasedCouchbaseQueryTests { + + private final MappingContext, CouchbasePersistentProperty> mappingContext; + + ReactiveSearchBasedCouchbaseQueryTests() { + CouchbaseMappingContext ctx = new CouchbaseMappingContext(); + ctx.setInitialEntitySet(java.util.Collections.singleton(User.class)); + ctx.afterPropertiesSet(); + this.mappingContext = ctx; + } + + @Test + void executeProjectsReactiveResults() { + AtomicReference capturedQuery = new AtomicReference<>(); + ReactiveSearchBasedCouchbaseQuery query = new ReactiveSearchBasedCouchbaseQuery( + createQueryMethod("searchProjected", "match"), + createOperations(List.of(user("1", "alpha")), 1, capturedQuery)); + + Publisher result = (Publisher) query.execute(new Object[] { "match" }); + + StepVerifier.create(result) + .assertNext(value -> assertEquals("alpha", ((NameOnly) value).getFirstname())) + .verifyComplete(); + assertEquals("\"match\"", capturedQuery.get()); + } + + @Test + void executeResolvesReactiveWrapperParameters() { + AtomicReference capturedQuery = new AtomicReference<>(); + ReactiveSearchBasedCouchbaseQuery query = new ReactiveSearchBasedCouchbaseQuery( + createQueryMethod("searchReactiveParam", Mono.just("match")), + createOperations(List.of(user("1", "alpha")), 1, capturedQuery)); + + Publisher result = (Publisher) query.execute(new Object[] { Mono.just("match") }); + + StepVerifier.create(result) + .assertNext(value -> assertEquals("alpha", ((NameOnly) value).getFirstname())) + .verifyComplete(); + assertEquals("\"match\"", capturedQuery.get()); + } + + @Test + void rejectsPageReturnTypeAtConstruction() { + Throwable thrown = org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, + () -> createQueryMethod("searchPaged", "match", PageRequest.of(0, 10))); + org.junit.jupiter.api.Assertions.assertInstanceOf(InvalidDataAccessApiUsageException.class, thrown.getCause()); + } + + @Test + void rejectsSliceReturnTypeAtConstruction() { + Throwable thrown = org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, + () -> createQueryMethod("searchSliced", "match", PageRequest.of(0, 10))); + org.junit.jupiter.api.Assertions.assertInstanceOf(InvalidDataAccessApiUsageException.class, thrown.getCause()); + } + + @Test + void executeRejectsSpringSortParameters() { + ReactiveSearchBasedCouchbaseQuery query = new ReactiveSearchBasedCouchbaseQuery( + createQueryMethod("searchSorted", "match", Sort.by("firstname")), + createOperations(List.of(user("1", "alpha")), 1, new AtomicReference<>())); + + StepVerifier.create((Publisher) query.execute(new Object[] { "match", Sort.by("firstname") })) + .expectError(InvalidDataAccessApiUsageException.class) + .verify(); + } + + interface TestReactiveSearchRepository extends ReactiveCrudRepository { + + @Search("?0") + @SearchIndex("test-index") + Flux searchProjected(String query); + + @Search("?0") + @SearchIndex("test-index") + Flux searchReactiveParam(Mono query); + + @Search("?0") + @SearchIndex("test-index") + Flux searchSorted(String query, Sort sort); + + @Search("?0") + @SearchIndex("test-index") + Mono> searchPaged(String query, Pageable pageable); + + @Search("?0") + @SearchIndex("test-index") + Mono> searchSliced(String query, Pageable pageable); + } + + interface NameOnly { + String getFirstname(); + } + + private ReactiveCouchbaseQueryMethod createQueryMethod(String methodName, Object... args) { + try { + Method method = findMethod(methodName, args); + DefaultRepositoryMetadata metadata = new DefaultRepositoryMetadata(TestReactiveSearchRepository.class); + return new ReactiveCouchbaseQueryMethod(method, metadata, new SpelAwareProxyProjectionFactory(), + mappingContext); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private Method findMethod(String name, Object... args) { + for (Method m : TestReactiveSearchRepository.class.getMethods()) { + if (m.getName().equals(name) && m.getParameterCount() == args.length) { + return m; + } + } + throw new IllegalArgumentException("Method " + name + " with " + args.length + " params not found"); + } + + private ReactiveCouchbaseOperations createOperations(List results, long totalCount, + AtomicReference capturedQuery) { + return (ReactiveCouchbaseOperations) Proxy.newProxyInstance(ReactiveCouchbaseOperations.class.getClassLoader(), + new Class[] { ReactiveCouchbaseOperations.class }, (proxy, method, args) -> { + if ("findBySearch".equals(method.getName())) { + return new StubReactiveFindBySearch<>(results, totalCount, capturedQuery, null, null); + } + throw new UnsupportedOperationException( + "Unexpected ReactiveCouchbaseOperations method: " + method.getName()); + }); + } + + private User user(String id, String firstname) { + return new User(id, firstname, "lastname"); + } + + private static final class StubReactiveFindBySearch + implements ReactiveFindBySearchOperation.ReactiveFindBySearch { + + private final List results; + private final long totalCount; + private final AtomicReference capturedQuery; + private final Integer limit; + private final Integer skip; + + private StubReactiveFindBySearch(List results, long totalCount, AtomicReference capturedQuery, + Integer limit, Integer skip) { + this.results = results; + this.totalCount = totalCount; + this.capturedQuery = capturedQuery; + this.limit = limit; + this.skip = skip; + } + + @Override + public Mono one() { + return Flux.fromIterable(values()).next(); + } + + @Override + public Mono first() { + return one(); + } + + @Override + public Flux all() { + return Flux.fromIterable(values()); + } + + @Override + public Mono count() { + return Mono.just(totalCount); + } + + @Override + public Mono exists() { + return Mono.just(totalCount > 0); + } + + @Override + public Flux rows() { + return Flux.empty(); + } + + @Override + public Mono> result() { + return Mono.just(new SearchResult<>(values(), Collections.emptyList(), null, Collections.emptyMap())); + } + + @Override + public ReactiveFindBySearchOperation.TerminatingFindBySearch matching( + com.couchbase.client.java.search.SearchRequest searchRequest) { + capturedQuery.set(searchRequest.toCore().toJson().get("query").get("query").asText()); + return this; + } + + @Override + public ReactiveFindBySearchOperation.FindBySearchWithProjection withIndex(String indexName) { + return this; + } + + @Override + public ReactiveFindBySearchOperation.FindBySearchWithFields as(Class returnType) { + return new StubReactiveFindBySearch<>(results, totalCount, capturedQuery, limit, skip); + } + + @Override + public ReactiveFindBySearchOperation.FindBySearchInScope withConsistency( + com.couchbase.client.java.search.SearchScanConsistency scanConsistency) { + return this; + } + + @Override + public ReactiveFindBySearchOperation.FindBySearchInCollection inScope(String scope) { + return this; + } + + @Override + public ReactiveFindBySearchOperation.FindBySearchWithOptions inCollection(String collection) { + return this; + } + + @Override + public ReactiveFindBySearchOperation.FindBySearchWithQuery withOptions( + com.couchbase.client.java.search.SearchOptions options) { + return this; + } + + @Override + public ReactiveFindBySearchOperation.FindBySearchWithConsistency withLimit(int limit) { + return new StubReactiveFindBySearch<>(results, totalCount, capturedQuery, limit, skip); + } + + @Override + public ReactiveFindBySearchOperation.FindBySearchWithLimit withSkip(int skip) { + return new StubReactiveFindBySearch<>(results, totalCount, capturedQuery, limit, skip); + } + + @Override + public ReactiveFindBySearchOperation.FindBySearchWithSkip withSort( + com.couchbase.client.java.search.sort.SearchSort... sort) { + return this; + } + + @Override + public

ReactiveFindBySearchOperation.FindBySearchWithSkip withSort(TypedPropertyPath property, + TypedPropertyPath... additionalProperties) { + return this; + } + + @Override + public ReactiveFindBySearchOperation.FindBySearchWithSort withHighlight( + com.couchbase.client.java.search.HighlightStyle style, String... fields) { + return this; + } + + @Override + public

ReactiveFindBySearchOperation.FindBySearchWithSort withHighlight( + com.couchbase.client.java.search.HighlightStyle style, TypedPropertyPath field, + TypedPropertyPath... additionalFields) { + return this; + } + + @Override + public ReactiveFindBySearchOperation.FindBySearchWithHighlight withFacets( + Map facets) { + return this; + } + + @Override + public ReactiveFindBySearchOperation.FindBySearchWithFacets withFields(String... fields) { + return this; + } + + @Override + public

ReactiveFindBySearchOperation.FindBySearchWithFacets withFields( + TypedPropertyPath field, TypedPropertyPath... additionalFields) { + return this; + } + + @SuppressWarnings("unchecked") + private List values() { + int fromIndex = Math.min(skip != null ? skip : 0, results.size()); + int toIndex = results.size(); + if (limit != null) { + toIndex = Math.min(fromIndex + limit, results.size()); + } + return (List) results.subList(fromIndex, toIndex); + } + } +} diff --git a/src/test/java/org/springframework/data/couchbase/repository/query/SearchBasedCouchbaseQueryTests.java b/src/test/java/org/springframework/data/couchbase/repository/query/SearchBasedCouchbaseQueryTests.java new file mode 100644 index 000000000..02299a399 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/repository/query/SearchBasedCouchbaseQueryTests.java @@ -0,0 +1,440 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.repository.query; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.Proxy; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.couchbase.core.CouchbaseOperations; +import org.springframework.data.couchbase.core.ExecutableFindBySearchOperation; +import org.springframework.data.couchbase.core.SearchResult; +import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentEntity; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; +import org.springframework.data.core.TypedPropertyPath; +import org.springframework.data.couchbase.domain.User; +import org.springframework.data.couchbase.repository.Search; +import org.springframework.data.couchbase.repository.SearchIndex; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; +import org.springframework.data.repository.query.DefaultParameters; +import org.springframework.data.repository.query.ParametersParameterAccessor; +import org.springframework.data.repository.query.ParametersSource; + +/** + * Unit tests for {@link SearchBasedCouchbaseQuery} parameter resolution logic. + * + * @author Emilien Bevierre + * @since 6.2 + */ +class SearchBasedCouchbaseQueryTests { + + private final MappingContext, CouchbasePersistentProperty> mappingContext; + + SearchBasedCouchbaseQueryTests() { + CouchbaseMappingContext ctx = new CouchbaseMappingContext(); + ctx.setInitialEntitySet(java.util.Collections.singleton(User.class)); + ctx.afterPropertiesSet(); + this.mappingContext = ctx; + } + + @Test + void resolveParametersWithNoPlaceholders() { + ParametersParameterAccessor accessor = createAccessor("findByStaticQuery"); + String resolved = SearchBasedCouchbaseQuery.resolveParameters("description:pool", accessor); + assertEquals("description:pool", resolved); + } + + @Test + void resolveParametersSinglePositional() { + ParametersParameterAccessor accessor = createAccessor("searchByQuery", "hello world"); + String resolved = SearchBasedCouchbaseQuery.resolveParameters("?0", accessor); + assertEquals("\"hello world\"", resolved); + } + + @Test + void resolveParametersMultiplePositional() { + ParametersParameterAccessor accessor = createAccessor("searchByCityAndRating", "NYC", 5); + String resolved = SearchBasedCouchbaseQuery.resolveParameters("city:?0 AND rating:>=?1", accessor); + assertEquals("city:\"NYC\" AND rating:>=5", resolved); + } + + @Test + void resolveParametersWithSpecialCharacters() { + ParametersParameterAccessor accessor = createAccessor("searchByQuery", "hello \"world\""); + String resolved = SearchBasedCouchbaseQuery.resolveParameters("?0", accessor); + assertEquals("\"hello \\\"world\\\"\"", resolved); + } + + @Test + void resolveParametersDoesNotModifyStringWithoutPlaceholders() { + ParametersParameterAccessor accessor = createAccessor("findByStaticQuery"); + String template = "description:\"San Francisco\" AND rating:>3"; + String resolved = SearchBasedCouchbaseQuery.resolveParameters(template, accessor); + assertEquals(template, resolved); + } + + @Test + void hasSearchAnnotationReturnsTrueForAnnotatedMethod() throws Exception { + CouchbaseQueryMethod method = createQueryMethod("findByStaticQuery"); + assertTrue(method.hasSearchAnnotation()); + } + + @Test + void hasSearchAnnotationReturnsFalseForUnannotatedMethod() throws Exception { + CouchbaseQueryMethod method = createQueryMethod("findAll"); + assertFalse(method.hasSearchAnnotation()); + } + + @Test + void resolveParametersRejectsNullValues() { + ParametersParameterAccessor accessor = createAccessor("searchNullable", new Object[] { null }); + assertThrows(InvalidDataAccessApiUsageException.class, + () -> SearchBasedCouchbaseQuery.resolveParameters("?0", accessor)); + } + + @Test + void resolveParametersRejectsCollectionValues() { + ParametersParameterAccessor accessor = createAccessor("searchCollection", List.of("a", "b")); + assertThrows(InvalidDataAccessApiUsageException.class, + () -> SearchBasedCouchbaseQuery.resolveParameters("?0", accessor)); + } + + @Test + void executeProjectsPagedResults() { + SearchBasedCouchbaseQuery query = new SearchBasedCouchbaseQuery( + createQueryMethod("searchPage", "match", PageRequest.of(1, 1)), + createOperations(List.of(user("1", "alpha"), user("2", "bravo"), user("3", "charlie")), 3)); + + Object result = query.execute(new Object[] { "match", PageRequest.of(1, 1) }); + + assertInstanceOf(Page.class, result); + Page page = (Page) result; + assertEquals(3, page.getTotalElements()); + assertEquals(1, page.getContent().size()); + assertInstanceOf(NameOnly.class, page.getContent().get(0)); + assertEquals("bravo", ((NameOnly) page.getContent().get(0)).getFirstname()); + } + + @Test + void executeProjectsSlicedResults() { + SearchBasedCouchbaseQuery query = new SearchBasedCouchbaseQuery( + createQueryMethod("searchSlice", "match", PageRequest.of(0, 2)), + createOperations(List.of(user("1", "alpha"), user("2", "bravo"), user("3", "charlie")), 3)); + + Object result = query.execute(new Object[] { "match", PageRequest.of(0, 2) }); + + assertInstanceOf(Slice.class, result); + Slice slice = (Slice) result; + assertTrue(slice.hasNext()); + assertEquals(2, slice.getContent().size()); + assertInstanceOf(NameOnly.class, slice.getContent().get(0)); + } + + @Test + void executeSupportsDynamicProjection() { + SearchBasedCouchbaseQuery query = new SearchBasedCouchbaseQuery( + createQueryMethod("searchDynamicProjection", "match", NameOnly.class), + createOperations(List.of(user("1", "alpha")), 1)); + + Object result = query.execute(new Object[] { "match", NameOnly.class }); + + assertInstanceOf(List.class, result); + assertEquals("alpha", ((NameOnly) ((List) result).get(0)).getFirstname()); + } + + @Test + void executeRejectsSpringSortParameters() { + SearchBasedCouchbaseQuery query = new SearchBasedCouchbaseQuery( + createQueryMethod("searchSorted", "match", Sort.by("firstname")), + createOperations(List.of(user("1", "alpha")), 1)); + + assertThrows(InvalidDataAccessApiUsageException.class, + () -> query.execute(new Object[] { "match", Sort.by("firstname") })); + } + + // --- Test repository interface for reflection --- + + interface TestSearchRepository extends CrudRepository { + + @Search("description:pool") + @SearchIndex("test-index") + List findByStaticQuery(); + + @Search("?0") + @SearchIndex("test-index") + List searchByQuery(String query); + + @Search("city:?0 AND rating:>=?1") + @SearchIndex("test-index") + List searchByCityAndRating(String city, int rating); + + @Search("?0") + @SearchIndex("test-index") + List searchNullable(String query); + + @Search("?0") + @SearchIndex("test-index") + List searchCollection(List query); + + @Search("?0") + @SearchIndex("test-index") + Page searchPage(String query, Pageable pageable); + + @Search("?0") + @SearchIndex("test-index") + Slice searchSlice(String query, Pageable pageable); + + @Search("?0") + @SearchIndex("test-index") + List searchDynamicProjection(String query, Class type); + + @Search("?0") + @SearchIndex("test-index") + List searchSorted(String query, Sort sort); + + List findAll(); + } + + interface NameOnly { + String getFirstname(); + } + + // --- Helpers --- + + private ParametersParameterAccessor createAccessor(String methodName, Object... args) { + try { + Method method = findMethod(methodName, args); + DefaultParameters params = new DefaultParameters(ParametersSource.of(method)); + return new ParametersParameterAccessor(params, args); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private CouchbaseQueryMethod createQueryMethod(String methodName, Object... args) { + try { + Method method = findMethod(methodName, args); + DefaultRepositoryMetadata metadata = new DefaultRepositoryMetadata(TestSearchRepository.class); + return new CouchbaseQueryMethod(method, metadata, new SpelAwareProxyProjectionFactory(), mappingContext); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private Method findMethod(String name, Object... args) { + for (Method m : TestSearchRepository.class.getMethods()) { + if (m.getName().equals(name) && m.getParameterCount() == args.length) { + return m; + } + } + throw new IllegalArgumentException("Method " + name + " with " + args.length + " params not found"); + } + + private CouchbaseOperations createOperations(List results, long totalCount) { + return (CouchbaseOperations) Proxy.newProxyInstance(CouchbaseOperations.class.getClassLoader(), + new Class[] { CouchbaseOperations.class }, (proxy, method, args) -> { + if ("findBySearch".equals(method.getName())) { + return new StubExecutableFindBySearch<>(results, totalCount, null, null); + } + throw new UnsupportedOperationException("Unexpected CouchbaseOperations method: " + method.getName()); + }); + } + + private User user(String id, String firstname) { + return new User(id, firstname, "lastname"); + } + + private static final class StubExecutableFindBySearch + implements ExecutableFindBySearchOperation.ExecutableFindBySearch { + + private final List results; + private final long totalCount; + private final Integer limit; + private final Integer skip; + + private StubExecutableFindBySearch(List results, long totalCount, Integer limit, Integer skip) { + this.results = results; + this.totalCount = totalCount; + this.limit = limit; + this.skip = skip; + } + + @Override + public T oneValue() { + List values = values(); + return values.isEmpty() ? null : values.get(0); + } + + @Override + public T firstValue() { + return oneValue(); + } + + @Override + public List all() { + return values(); + } + + @Override + public Stream stream() { + return values().stream(); + } + + @Override + public long count() { + return totalCount; + } + + @Override + public boolean exists() { + return totalCount > 0; + } + + @Override + public List rows() { + return Collections.emptyList(); + } + + @Override + public SearchResult result() { + com.couchbase.client.core.api.search.result.CoreSearchMetrics coreMetrics = + new com.couchbase.client.core.api.search.result.CoreSearchMetrics( + java.time.Duration.ZERO, totalCount, 0.0, 1, 0); + com.couchbase.client.core.api.search.CoreSearchMetaData coreMetaData = + new com.couchbase.client.core.api.search.CoreSearchMetaData( + Collections.emptyMap(), coreMetrics); + com.couchbase.client.java.search.SearchMetaData metaData = + new com.couchbase.client.java.search.SearchMetaData(coreMetaData); + return new SearchResult<>(values(), Collections.emptyList(), metaData, Collections.emptyMap()); + } + + @Override + public ExecutableFindBySearchOperation.TerminatingFindBySearch matching( + com.couchbase.client.java.search.SearchRequest searchRequest) { + return this; + } + + @Override + public ExecutableFindBySearchOperation.FindBySearchWithProjection withIndex(String indexName) { + return this; + } + + @Override + public ExecutableFindBySearchOperation.FindBySearchWithFields as(Class returnType) { + return new StubExecutableFindBySearch<>(results, totalCount, limit, skip); + } + + @Override + public ExecutableFindBySearchOperation.FindBySearchInScope withConsistency( + com.couchbase.client.java.search.SearchScanConsistency scanConsistency) { + return this; + } + + @Override + public ExecutableFindBySearchOperation.FindBySearchInCollection inScope(String scope) { + return this; + } + + @Override + public ExecutableFindBySearchOperation.FindBySearchWithOptions inCollection(String collection) { + return this; + } + + @Override + public ExecutableFindBySearchOperation.FindBySearchWithQuery withOptions( + com.couchbase.client.java.search.SearchOptions options) { + return this; + } + + @Override + public ExecutableFindBySearchOperation.FindBySearchWithConsistency withLimit(int limit) { + return new StubExecutableFindBySearch<>(results, totalCount, limit, skip); + } + + @Override + public ExecutableFindBySearchOperation.FindBySearchWithLimit withSkip(int skip) { + return new StubExecutableFindBySearch<>(results, totalCount, limit, skip); + } + + @Override + public ExecutableFindBySearchOperation.FindBySearchWithSkip withSort( + com.couchbase.client.java.search.sort.SearchSort... sort) { + return this; + } + + @Override + public

ExecutableFindBySearchOperation.FindBySearchWithSkip withSort(TypedPropertyPath property, + TypedPropertyPath... additionalProperties) { + return this; + } + + @Override + public ExecutableFindBySearchOperation.FindBySearchWithSort withHighlight( + com.couchbase.client.java.search.HighlightStyle style, String... fields) { + return this; + } + + @Override + public

ExecutableFindBySearchOperation.FindBySearchWithSort withHighlight( + com.couchbase.client.java.search.HighlightStyle style, TypedPropertyPath field, + TypedPropertyPath... additionalFields) { + return this; + } + + @Override + public ExecutableFindBySearchOperation.FindBySearchWithHighlight withFacets( + Map facets) { + return this; + } + + @Override + public ExecutableFindBySearchOperation.FindBySearchWithFacets withFields(String... fields) { + return this; + } + + @Override + public

ExecutableFindBySearchOperation.FindBySearchWithFacets withFields( + TypedPropertyPath field, TypedPropertyPath... additionalFields) { + return this; + } + + @SuppressWarnings("unchecked") + private List values() { + int fromIndex = Math.min(skip != null ? skip : 0, results.size()); + int toIndex = results.size(); + if (limit != null) { + toIndex = Math.min(fromIndex + limit, results.size()); + } + return (List) results.subList(fromIndex, toIndex); + } + } +} diff --git a/src/test/java/org/springframework/data/couchbase/repository/query/SearchScanConsistencyTests.java b/src/test/java/org/springframework/data/couchbase/repository/query/SearchScanConsistencyTests.java new file mode 100644 index 000000000..fe24c0e62 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/repository/query/SearchScanConsistencyTests.java @@ -0,0 +1,115 @@ +/* + * Copyright 2025-present the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.repository.query; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.Method; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentEntity; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; +import org.springframework.data.couchbase.domain.User; +import org.springframework.data.couchbase.repository.ScanConsistency; +import org.springframework.data.couchbase.repository.Search; +import org.springframework.data.couchbase.repository.SearchIndex; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; + +import com.couchbase.client.java.search.SearchScanConsistency; + +/** + * Unit tests for the {@link ScanConsistency#search()} attribute and its resolution + * through {@link CouchbaseQueryMethod#getSearchScanConsistency()}. + * + * @author Emilien Bevierre + * @since 6.2 + */ +class SearchScanConsistencyTests { + + private final MappingContext, CouchbasePersistentProperty> mappingContext; + + SearchScanConsistencyTests() { + CouchbaseMappingContext ctx = new CouchbaseMappingContext(); + ctx.setInitialEntitySet(java.util.Collections.singleton(User.class)); + ctx.afterPropertiesSet(); + this.mappingContext = ctx; + } + + @Test + void searchScanConsistencyIsResolvedFromMethodAnnotation() { + CouchbaseQueryMethod method = createQueryMethod(TestScanConsistencyRepository.class, "searchWithConsistency"); + SearchScanConsistency consistency = method.getSearchScanConsistency(); + assertNotNull(consistency); + assertEquals(SearchScanConsistency.NOT_BOUNDED, consistency); + } + + @Test + void searchScanConsistencyDefaultsToNotBoundedWhenAnnotatedWithoutSearchAttribute() { + CouchbaseQueryMethod method = createQueryMethod(TestScanConsistencyRepository.class, + "searchWithoutExplicitSearchConsistency"); + SearchScanConsistency consistency = method.getSearchScanConsistency(); + // @ScanConsistency is present (on the query attribute), so getSearchScanConsistency() + // returns the default for the search attribute which is NOT_BOUNDED + assertNotNull(consistency); + assertEquals(SearchScanConsistency.NOT_BOUNDED, consistency); + } + + @Test + void searchScanConsistencyAnnotationValueAttribute() throws NoSuchMethodException { + // Verify the search attribute exists on the ScanConsistency annotation + ScanConsistency annotation = TestScanConsistencyRepository.class + .getMethod("searchWithConsistency") + .getAnnotation(ScanConsistency.class); + assertNotNull(annotation); + // Just verify the annotation type has the search() attribute + assertDoesNotThrow(() -> ScanConsistency.class.getMethod("search")); + } + + interface TestScanConsistencyRepository extends CrudRepository { + + @Search("test query") + @SearchIndex("test-index") + @ScanConsistency(search = SearchScanConsistency.NOT_BOUNDED) + List searchWithConsistency(); + + @Search("test query") + @SearchIndex("test-index") + @ScanConsistency(query = com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS) + List searchWithoutExplicitSearchConsistency(); + } + + private CouchbaseQueryMethod createQueryMethod(Class repoClass, String methodName) { + try { + Method method = null; + for (Method m : repoClass.getMethods()) { + if (m.getName().equals(methodName)) { + method = m; + break; + } + } + assertNotNull(method, "Method " + methodName + " not found"); + DefaultRepositoryMetadata metadata = new DefaultRepositoryMetadata(repoClass); + return new CouchbaseQueryMethod(method, metadata, new SpelAwareProxyProjectionFactory(), mappingContext); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +}