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 index 4aaef6f54..0342ab34c 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperation.java +++ b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperation.java @@ -21,6 +21,7 @@ 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; @@ -170,6 +171,9 @@ interface FindBySearchWithSkip extends FindBySearchWithLimit { interface FindBySearchWithSort extends FindBySearchWithSkip { FindBySearchWithSkip withSort(SearchSort... sort); + +

FindBySearchWithSkip withSort(TypedPropertyPath property, + TypedPropertyPath... additionalProperties); } interface FindBySearchWithHighlight extends FindBySearchWithSort { @@ -178,6 +182,14 @@ interface FindBySearchWithHighlight extends FindBySearchWithSort { 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 { @@ -186,6 +198,9 @@ interface FindBySearchWithFacets extends FindBySearchWithHighlight { interface FindBySearchWithFields extends FindBySearchWithFacets { FindBySearchWithFacets withFields(String... fields); + +

FindBySearchWithFacets withFields(TypedPropertyPath field, + TypedPropertyPath... additionalFields); } interface FindBySearchWithProjection extends FindBySearchWithFields { diff --git a/src/main/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperationSupport.java index 2b67067ba..88552a1a3 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperationSupport.java @@ -19,6 +19,7 @@ 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; @@ -190,8 +191,8 @@ public FindBySearchWithOptions inCollection(final String collection) { @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); + scanConsistency, scope, collection, options != null ? options : this.options, sort, highlightStyle, + highlightFields, facets, fields, limitSkip); } @Override @@ -219,11 +220,23 @@ public FindBySearchWithSkip withSort(SearchSort... sort) { 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); + 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 @@ -239,5 +252,11 @@ public FindBySearchWithFacets withFields(String... fields) { 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/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/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 index 9cf63e741..4df68cd27 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindBySearchOperation.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindBySearchOperation.java @@ -21,6 +21,7 @@ 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; @@ -198,6 +199,9 @@ interface FindBySearchWithSort extends FindBySearchWithSkip { * @param sort the sort specifications. */ FindBySearchWithSkip withSort(SearchSort... sort); + +

FindBySearchWithSkip withSort(TypedPropertyPath property, + TypedPropertyPath... additionalProperties); } /** @@ -220,6 +224,14 @@ interface FindBySearchWithHighlight extends FindBySearchWithSort { 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); + } } /** @@ -244,6 +256,9 @@ interface FindBySearchWithFields extends FindBySearchWithFacets { * @param fields the field names. */ FindBySearchWithFacets withFields(String... fields); + +

FindBySearchWithFacets withFields(TypedPropertyPath field, + TypedPropertyPath... additionalFields); } /** diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindBySearchOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindBySearchOperationSupport.java index cacf4bb92..672111bd6 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindBySearchOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindBySearchOperationSupport.java @@ -23,6 +23,7 @@ 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; @@ -112,16 +113,16 @@ static class ReactiveFindBySearchSupport implements ReactiveFindBySearch { 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); + 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); + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, fields, + limitSkip, support); } @Override @@ -135,8 +136,8 @@ public FindBySearchWithFields as(final Class returnType) { @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); + scanConsistency, scope, collection, options, sort, highlightStyle, highlightFields, facets, fields, + limitSkip, support); } @Override @@ -156,8 +157,8 @@ public FindBySearchWithOptions inCollection(final String collection) { @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); + scanConsistency, scope, collection, options != null ? options : this.options, sort, highlightStyle, + highlightFields, facets, fields, limitSkip, support); } @Override @@ -185,11 +186,24 @@ public FindBySearchWithSkip withSort(SearchSort... sort) { 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); + 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 @@ -206,6 +220,12 @@ public FindBySearchWithFacets withFields(String... fields) { 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(); @@ -341,7 +361,6 @@ private Mono hydrateRow(SearchRow row) { return Mono.empty(); })); } - private Mono executeSearch() { return executeSearch(false); } 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/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/repository/query/ReactiveSearchBasedCouchbaseQuery.java b/src/main/java/org/springframework/data/couchbase/repository/query/ReactiveSearchBasedCouchbaseQuery.java index 7e4b00955..1ccdd4f54 100644 --- a/src/main/java/org/springframework/data/couchbase/repository/query/ReactiveSearchBasedCouchbaseQuery.java +++ b/src/main/java/org/springframework/data/couchbase/repository/query/ReactiveSearchBasedCouchbaseQuery.java @@ -148,7 +148,6 @@ private ReactiveFindBySearchOperation.FindBySearchWithConsistency applyPagina } return searchOp; } - private static String resolveIndexName(ReactiveCouchbaseQueryMethod method) { SearchIndex methodAnnotation = method.getAnnotation(SearchIndex.class); if (methodAnnotation != 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 index e97ccfc68..66c4187bb 100644 --- a/src/main/java/org/springframework/data/couchbase/repository/query/SearchBasedCouchbaseQuery.java +++ b/src/main/java/org/springframework/data/couchbase/repository/query/SearchBasedCouchbaseQuery.java @@ -170,7 +170,6 @@ private ExecutableFindBySearchOperation.FindBySearchWithConsistency applyLimi : searchOp; return limit != null ? withSkipApplied.withLimit(limit) : withSkipApplied; } - private static String resolveIndexName(CouchbaseQueryMethod method) { SearchIndex methodAnnotation = method.getAnnotation(SearchIndex.class); if (methodAnnotation != null) { 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/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/query/ReactiveSearchBasedCouchbaseQueryTests.java b/src/test/java/org/springframework/data/couchbase/repository/query/ReactiveSearchBasedCouchbaseQueryTests.java index 456fee36b..8717996c5 100644 --- a/src/test/java/org/springframework/data/couchbase/repository/query/ReactiveSearchBasedCouchbaseQueryTests.java +++ b/src/test/java/org/springframework/data/couchbase/repository/query/ReactiveSearchBasedCouchbaseQueryTests.java @@ -33,6 +33,7 @@ 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; @@ -293,12 +294,25 @@ public ReactiveFindBySearchOperation.FindBySearchWithSkip withSort( 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) { @@ -310,6 +324,12 @@ public ReactiveFindBySearchOperation.FindBySearchWithFacets withFields(String 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()); 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 index 2fd786c60..02299a399 100644 --- a/src/test/java/org/springframework/data/couchbase/repository/query/SearchBasedCouchbaseQueryTests.java +++ b/src/test/java/org/springframework/data/couchbase/repository/query/SearchBasedCouchbaseQueryTests.java @@ -32,6 +32,7 @@ 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; @@ -390,12 +391,25 @@ public ExecutableFindBySearchOperation.FindBySearchWithSkip withSort( 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) { @@ -407,6 +421,12 @@ public ExecutableFindBySearchOperation.FindBySearchWithFacets withFields(Stri 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());