Skip to content

Commit e375fd0

Browse files
emilienbevclaude
andcommitted
Merge branch 'AddFTS' into AddTypeSafetyPropertyReferences
Resolves the modify/delete conflict on ExecutableFindBySearchOperationTests: AddFTS removed the fluent-chain hierarchy checks in favor of real integration tests; the file is kept trimmed to only the typed-overload declarations added by this branch. Signed-off-by: Emilien Bevierre <emilien.bevierre@couchbase.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 parents 6b98cb8 + 4265e6a commit e375fd0

10 files changed

Lines changed: 326 additions & 103 deletions

src/main/java/org/springframework/data/couchbase/core/ReactiveFindBySearchOperationSupport.java

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ static class ReactiveFindBySearchSupport<T> implements ReactiveFindBySearch<T> {
105105

106106
@Override
107107
public TerminatingFindBySearch<T> matching(SearchRequest searchRequest) {
108-
Assert.notNull(searchRequest, "SearchRequest must not be null!");
108+
Assert.notNull(searchRequest, "SearchRequest must not be null");
109109
return new ReactiveFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest,
110110
scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, fields,
111111
limitSkip, support);
@@ -121,7 +121,7 @@ public FindBySearchWithProjection<T> withIndex(String indexName) {
121121

122122
@Override
123123
public <R> FindBySearchWithFields<R> as(final Class<R> returnType) {
124-
Assert.notNull(returnType, "returnType must not be null!");
124+
Assert.notNull(returnType, "returnType must not be null");
125125
return new ReactiveFindBySearchSupport<>(template, domainType, returnType, indexName, searchRequest,
126126
scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets,
127127
fields, limitSkip, support);
@@ -336,18 +336,20 @@ private Mono<SearchResult<T>> collectFullResult(ReactiveSearchResult reactiveRes
336336
}
337337

338338
/**
339-
* Hydrates a SearchRow into an entity via KV GET, silently skipping documents that have been
339+
* Hydrates a SearchRow into an entity via KV GET, skipping documents that have been
340340
* deleted between the FTS index update and the KV fetch (stale index entries).
341+
* <p>
342+
* Misses are logged at WARN so operators can detect index staleness; persistent or high-volume
343+
* misses typically indicate an out-of-sync FTS index.
341344
*/
342345
private Mono<T> hydrateRow(SearchRow row) {
343346
return template.findById(returnType)
344347
.inScope(scope)
345348
.inCollection(collection)
346349
.one(row.id())
347350
.onErrorResume(com.couchbase.client.core.error.DocumentNotFoundException.class, ex -> {
348-
if (LOG.isDebugEnabled()) {
349-
LOG.debug("Skipping stale FTS result for document id '{}': document not found in KV", row.id());
350-
}
351+
LOG.warn("Skipping stale FTS result for document id '{}': document not found in KV "
352+
+ "(index '{}' may be out of sync)", row.id(), indexName);
351353
return Mono.empty();
352354
});
353355
}

src/main/java/org/springframework/data/couchbase/repository/ScanConsistency.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,16 @@
1515
*/
1616
package org.springframework.data.couchbase.repository;
1717

18+
import com.couchbase.client.java.analytics.AnalyticsScanConsistency;
19+
import com.couchbase.client.java.query.QueryScanConsistency;
20+
import com.couchbase.client.java.search.SearchScanConsistency;
21+
1822
import java.lang.annotation.Documented;
1923
import java.lang.annotation.ElementType;
2024
import java.lang.annotation.Retention;
2125
import java.lang.annotation.RetentionPolicy;
2226
import java.lang.annotation.Target;
2327

24-
import com.couchbase.client.java.analytics.AnalyticsScanConsistency;
25-
import com.couchbase.client.java.query.QueryScanConsistency;
26-
import com.couchbase.client.java.search.SearchScanConsistency;
27-
2828
/**
2929
* Scan Consistency Annotation
3030
*

src/main/java/org/springframework/data/couchbase/repository/Search.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,18 @@
3030
* The FTS index name must be specified via {@link SearchIndex} on the method or entity class.
3131
* <p>
3232
* Supports positional parameter placeholders ({@code ?0}, {@code ?1}, etc.) which are replaced with method argument
33-
* values at execution time. Example:
33+
* values at execution time.
34+
* <p>
35+
* <strong>Parameter binding semantics (injection considerations):</strong> String and {@link Enum} values are always
36+
* emitted as quoted FTS phrases ({@code "..."}), with embedded {@code "} and {@code \} escaped. This prevents a
37+
* parameter value from breaking out of its phrase context and injecting FTS operators ({@code AND}, {@code OR},
38+
* {@code :}, {@code *}, field selectors, etc.). {@link Number} and {@link Boolean} values are emitted verbatim so they
39+
* remain usable with range operators (e.g. {@code rating:>=?0}); their types are validated, so no operator injection
40+
* is possible. Because string parameters are always phrase-quoted, there is no way to pass a <em>raw</em> FTS term or
41+
* operator through a placeholder. Construct a {@link com.couchbase.client.java.search.SearchRequest} and use the
42+
* template API ({@code CouchbaseOperations.findBySearch(...)}) if you need that.
43+
* <p>
44+
* Example:
3445
* <pre>
3546
* &#64;Search("description:pool AND city:\"San Francisco\"")
3647
* &#64;SearchIndex("hotel-search-index")

src/main/java/org/springframework/data/couchbase/repository/query/ReactiveSearchBasedCouchbaseQuery.java

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@
1515
*/
1616
package org.springframework.data.couchbase.repository.query;
1717

18+
import org.reactivestreams.Publisher;
1819
import org.springframework.data.couchbase.core.ReactiveCouchbaseOperations;
1920
import org.springframework.data.couchbase.core.ReactiveFindBySearchOperation;
2021
import org.springframework.data.domain.Pageable;
2122
import org.springframework.data.repository.query.ParametersParameterAccessor;
2223
import org.springframework.data.repository.query.RepositoryQuery;
2324
import org.springframework.data.repository.query.ResultProcessor;
24-
import org.springframework.data.repository.util.ReactiveWrapperConverters;
2525
import org.springframework.util.Assert;
2626

2727
import org.springframework.data.couchbase.repository.Search;
@@ -31,12 +31,20 @@
3131
import com.couchbase.client.java.search.SearchRequest;
3232
import com.couchbase.client.java.search.SearchScanConsistency;
3333

34+
import reactor.core.publisher.Flux;
35+
import reactor.core.publisher.Mono;
36+
3437
/**
3538
* Reactive {@link RepositoryQuery} implementation for FTS {@link Search}-annotated methods.
3639
* <p>
3740
* Supports positional parameter substitution ({@code ?0}, {@code ?1}, etc.) in the query string.
3841
* Supports {@link Pageable} parameters for limit/skip pagination.
3942
* Supports {@link org.springframework.data.couchbase.repository.ScanConsistency} for FTS scan consistency.
43+
* <p>
44+
* {@link org.springframework.data.domain.Page} and {@link org.springframework.data.domain.Slice} return types are
45+
* <em>not</em> supported on reactive search methods. Use the imperative repository or the reactive template API
46+
* ({@link org.springframework.data.couchbase.core.ReactiveFindBySearchOperation.TerminatingFindBySearch#result()})
47+
* if you need total row counts alongside entities.
4048
*
4149
* @author Emilien Bevierre
4250
* @since 6.2
@@ -68,16 +76,21 @@ public ReactiveSearchBasedCouchbaseQuery(ReactiveCouchbaseQueryMethod method,
6876
public Object execute(Object[] parameters) {
6977
ReactiveCouchbaseParameterAccessor accessor = new ReactiveCouchbaseParameterAccessor(method, parameters);
7078

71-
return accessor.resolveParameters().flatMapMany(resolvedAccessor -> {
72-
ResultProcessor processor = method.getResultProcessor().withDynamicProjection(resolvedAccessor);
73-
SearchRepositoryQuerySupport.validateSort(resolvedAccessor);
79+
if (method.isCollectionQuery()) {
80+
return accessor.resolveParameters().flatMapMany(resolvedAccessor -> Flux.from(doExecute(resolvedAccessor)));
81+
}
82+
return accessor.resolveParameters().flatMap(resolvedAccessor -> Mono.from(doExecute(resolvedAccessor)));
83+
}
7484

75-
String resolvedQuery = SearchBasedCouchbaseQuery.resolveParameters(searchQueryTemplate, resolvedAccessor);
76-
SearchRequest request = SearchRequest.create(SearchQuery.queryString(resolvedQuery));
77-
Object result = executeDependingOnType(resolvedAccessor, request);
85+
private Publisher<?> doExecute(ParametersParameterAccessor resolvedAccessor) {
86+
ResultProcessor processor = method.getResultProcessor().withDynamicProjection(resolvedAccessor);
87+
SearchRepositoryQuerySupport.validateSort(resolvedAccessor);
7888

79-
return ReactiveWrapperConverters.toWrapper(processor.processResult(result), reactor.core.publisher.Flux.class);
80-
});
89+
String resolvedQuery = SearchBasedCouchbaseQuery.resolveParameters(searchQueryTemplate, resolvedAccessor);
90+
SearchRequest request = SearchRequest.create(SearchQuery.queryString(resolvedQuery));
91+
Object result = executeDependingOnType(resolvedAccessor, request);
92+
93+
return (Publisher<?>) processor.processResult(result);
8194
}
8295

8396
@Override
@@ -86,6 +99,13 @@ public ReactiveCouchbaseQueryMethod getQueryMethod() {
8699
}
87100

88101
private Object executeDependingOnType(ParametersParameterAccessor accessor, SearchRequest request) {
102+
if (method.isPageQuery() || method.isSliceQuery()) {
103+
throw new UnsupportedOperationException(
104+
"Page and Slice return types are not supported for reactive @Search repository methods. "
105+
+ "Use the imperative repository for pagination, or the reactive template API "
106+
+ "(ReactiveCouchbaseOperations.findBySearch(...).result()) to access total rows.");
107+
}
108+
89109
ReactiveFindBySearchOperation.FindBySearchWithQuery<?> queryOp = createQueryOperation(accessor, true);
90110

91111
if (method.isCountQuery()) {

src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateSearchIntegrationTests.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ static void tearDownFtsIndex() {
8686
logCluster(cluster, "tearDownFtsIndex");
8787
}
8888
callSuperAfterAll(new Object() {});
89+
cluster.disconnect();
8990
}
9091

9192
/**
@@ -152,7 +153,7 @@ void searchFirstReturnsOneEntity() {
152153
}
153154
}
154155

155-
/// Uses ConsistentWith to avoid flakiness from eventual consistency.
156+
// Uses ConsistentWith to avoid flakiness from eventual consistency.
156157
@Test
157158
void searchOneReturnsExactlyOne() {
158159
String unique = "uniquefts" + UUID.randomUUID().toString().replace("-", "");

src/test/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperationTests.java

Lines changed: 2 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -17,95 +17,20 @@
1717

1818
import static org.junit.jupiter.api.Assertions.*;
1919

20-
import java.util.Collections;
21-
import java.util.HashMap;
22-
import java.util.Map;
23-
2420
import org.junit.jupiter.api.Test;
2521
import org.springframework.data.core.TypedPropertyPath;
2622

2723
import com.couchbase.client.java.search.HighlightStyle;
28-
import com.couchbase.client.java.search.SearchOptions;
29-
import com.couchbase.client.java.search.SearchRequest;
30-
import com.couchbase.client.java.search.SearchQuery;
31-
import com.couchbase.client.java.search.SearchScanConsistency;
32-
import com.couchbase.client.java.search.facet.SearchFacet;
33-
import com.couchbase.client.java.search.sort.SearchSort;
3424

3525
/**
36-
* Unit tests verifying the fluent builder API compiles and chains correctly for
37-
* {@link ExecutableFindBySearchOperation} and {@link ReactiveFindBySearchOperation}.
38-
* <p>
39-
* These tests verify the type-safe fluent chain, not actual query execution (which requires a cluster).
26+
* Unit tests verifying the type-safe {@link TypedPropertyPath} overloads are declared on the
27+
* {@link ExecutableFindBySearchOperation} and {@link ReactiveFindBySearchOperation} fluent interfaces.
4028
*
4129
* @author Emilien Bevierre
4230
* @since 6.2
4331
*/
4432
class ExecutableFindBySearchOperationTests {
4533

46-
@Test
47-
void fluentChainInterfaceHierarchyCompilesCorrectly() {
48-
// This test verifies the interface hierarchy is correct at compile time.
49-
// We verify that all the intermediate types in the chain can be assigned correctly.
50-
51-
// The full chain from top to bottom:
52-
// ExecutableFindBySearch -> FindBySearchWithIndex -> FindBySearchWithProjection
53-
// -> FindBySearchWithFields -> FindBySearchWithFacets -> FindBySearchWithHighlight
54-
// -> FindBySearchWithSort -> FindBySearchWithSkip -> FindBySearchWithLimit
55-
// -> FindBySearchWithConsistency -> FindBySearchInScope -> FindBySearchInCollection
56-
// -> FindBySearchWithOptions -> FindBySearchWithQuery -> TerminatingFindBySearch
57-
58-
// If this test compiles, the chain is valid.
59-
assertNotNull(ExecutableFindBySearchOperation.ExecutableFindBySearch.class);
60-
assertNotNull(ExecutableFindBySearchOperation.FindBySearchWithIndex.class);
61-
assertNotNull(ExecutableFindBySearchOperation.FindBySearchWithProjection.class);
62-
assertNotNull(ExecutableFindBySearchOperation.FindBySearchWithFields.class);
63-
assertNotNull(ExecutableFindBySearchOperation.FindBySearchWithFacets.class);
64-
assertNotNull(ExecutableFindBySearchOperation.FindBySearchWithHighlight.class);
65-
assertNotNull(ExecutableFindBySearchOperation.FindBySearchWithSort.class);
66-
assertNotNull(ExecutableFindBySearchOperation.FindBySearchWithSkip.class);
67-
assertNotNull(ExecutableFindBySearchOperation.FindBySearchWithLimit.class);
68-
assertNotNull(ExecutableFindBySearchOperation.FindBySearchWithConsistency.class);
69-
assertNotNull(ExecutableFindBySearchOperation.FindBySearchInScope.class);
70-
assertNotNull(ExecutableFindBySearchOperation.FindBySearchInCollection.class);
71-
assertNotNull(ExecutableFindBySearchOperation.FindBySearchWithOptions.class);
72-
assertNotNull(ExecutableFindBySearchOperation.FindBySearchWithQuery.class);
73-
assertNotNull(ExecutableFindBySearchOperation.TerminatingFindBySearch.class);
74-
}
75-
76-
@Test
77-
void reactiveFluentChainInterfaceHierarchyCompilesCorrectly() {
78-
assertNotNull(ReactiveFindBySearchOperation.ReactiveFindBySearch.class);
79-
assertNotNull(ReactiveFindBySearchOperation.FindBySearchWithIndex.class);
80-
assertNotNull(ReactiveFindBySearchOperation.FindBySearchWithProjection.class);
81-
assertNotNull(ReactiveFindBySearchOperation.FindBySearchWithFields.class);
82-
assertNotNull(ReactiveFindBySearchOperation.FindBySearchWithFacets.class);
83-
assertNotNull(ReactiveFindBySearchOperation.FindBySearchWithHighlight.class);
84-
assertNotNull(ReactiveFindBySearchOperation.FindBySearchWithSort.class);
85-
assertNotNull(ReactiveFindBySearchOperation.FindBySearchWithSkip.class);
86-
assertNotNull(ReactiveFindBySearchOperation.FindBySearchWithLimit.class);
87-
assertNotNull(ReactiveFindBySearchOperation.FindBySearchWithConsistency.class);
88-
assertNotNull(ReactiveFindBySearchOperation.FindBySearchInScope.class);
89-
assertNotNull(ReactiveFindBySearchOperation.FindBySearchInCollection.class);
90-
assertNotNull(ReactiveFindBySearchOperation.FindBySearchWithOptions.class);
91-
assertNotNull(ReactiveFindBySearchOperation.FindBySearchWithQuery.class);
92-
assertNotNull(ReactiveFindBySearchOperation.TerminatingFindBySearch.class);
93-
}
94-
95-
@Test
96-
void searchResultTerminalOperationIsDeclaredOnInterface() throws Exception {
97-
// Verify result() is declared on TerminatingFindBySearch
98-
assertNotNull(ExecutableFindBySearchOperation.TerminatingFindBySearch.class.getMethod("result"));
99-
assertNotNull(ReactiveFindBySearchOperation.TerminatingFindBySearch.class.getMethod("result"));
100-
}
101-
102-
@Test
103-
void highlightDefaultMethodUsesServerDefault() {
104-
// Verify the default method signature exists and provides the right HighlightStyle
105-
// This is a compile-time check -- the default highlight method delegates to withHighlight(SERVER_DEFAULT, fields)
106-
assertNotNull(HighlightStyle.SERVER_DEFAULT);
107-
}
108-
10934
@Test
11035
void typedPropertyReferenceOverloadsAreDeclaredOnInterfaces() throws Exception {
11136
assertNotNull(ExecutableFindBySearchOperation.FindBySearchWithSort.class

src/test/java/org/springframework/data/couchbase/core/SearchResultTests.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,16 @@
1515
*/
1616
package org.springframework.data.couchbase.core;
1717

18-
import static org.junit.jupiter.api.Assertions.*;
18+
import org.junit.jupiter.api.Test;
1919

2020
import java.util.Arrays;
2121
import java.util.Collections;
2222
import java.util.List;
23-
import java.util.Map;
2423

25-
import org.junit.jupiter.api.Test;
24+
import static org.junit.jupiter.api.Assertions.assertEquals;
25+
import static org.junit.jupiter.api.Assertions.assertNotNull;
26+
import static org.junit.jupiter.api.Assertions.assertNull;
27+
import static org.junit.jupiter.api.Assertions.assertTrue;
2628

2729
/**
2830
* Unit tests for {@link SearchResult}.

0 commit comments

Comments
 (0)