From e763f6b29a6b51b807299e6468bd8f4b30eb3608 Mon Sep 17 00:00:00 2001 From: Emilien Bevierre Date: Fri, 3 Jul 2026 17:36:59 +0100 Subject: [PATCH 1/2] Add type-safe property paths overloads (#2126) Add TypedPropertyPath overloads for query criteria, distinct, projections, mutate-in and FTS sort/highlight/fields. Fixes #2126 Signed-off-by: Emilien Bevierre --- .agents/skills/code-review/SKILL.md | 118 ++++++++++++ .claude/skills/code-review | 1 + .factory/settings.json | 5 + AGENTS.md | 85 ++++++++ CLAUDE.md | 87 +++++++++ skills-lock.json | 10 + .../core/ExecutableFindByIdOperation.java | 14 ++ .../core/ExecutableFindByQueryOperation.java | 26 +++ .../core/ExecutableFindBySearchOperation.java | 15 ++ ...xecutableFindBySearchOperationSupport.java | 27 ++- .../core/ExecutableMutateInByIdOperation.java | 39 ++++ .../core/ReactiveFindByIdOperation.java | 14 ++ .../core/ReactiveFindByQueryOperation.java | 26 +++ .../core/ReactiveFindBySearchOperation.java | 15 ++ .../ReactiveFindBySearchOperationSupport.java | 41 ++-- .../core/ReactiveMutateInByIdOperation.java | 35 ++++ .../core/SearchPropertyPathSupport.java | 66 +++++++ .../data/couchbase/core/query/Query.java | 14 ++ .../couchbase/core/query/QueryCriteria.java | 36 ++++ .../couchbase/core/support/WithDistinct.java | 16 ++ .../core/support/WithMutateInPaths.java | 45 +++++ .../core/support/WithProjecting.java | 15 ++ .../core/support/WithProjectionId.java | 15 ++ .../ReactiveSearchBasedCouchbaseQuery.java | 1 - .../query/SearchBasedCouchbaseQuery.java | 1 - .../ExecutableFindBySearchOperationTests.java | 49 +++++ .../core/SearchPropertyPathSupportTests.java | 88 +++++++++ .../query/TypeSafePropertyReferenceTests.java | 181 ++++++++++++++++++ ...eactiveSearchBasedCouchbaseQueryTests.java | 20 ++ .../query/SearchBasedCouchbaseQueryTests.java | 20 ++ 30 files changed, 1108 insertions(+), 17 deletions(-) create mode 100644 .agents/skills/code-review/SKILL.md create mode 120000 .claude/skills/code-review create mode 100644 .factory/settings.json create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 skills-lock.json create mode 100644 src/main/java/org/springframework/data/couchbase/core/SearchPropertyPathSupport.java create mode 100644 src/test/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/core/SearchPropertyPathSupportTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/core/query/TypeSafePropertyReferenceTests.java diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md new file mode 100644 index 000000000..8b98fe4c0 --- /dev/null +++ b/.agents/skills/code-review/SKILL.md @@ -0,0 +1,118 @@ +--- +name: code-review +description: Review code changes for security, performance, and correctness. Trigger with a PR URL or diff, "review this before I merge", "is this code safe?", or when checking a change for N+1 queries, injection risks, missing edge cases, or error handling gaps. +argument-hint: "" +--- + +# /code-review + +> If you see unfamiliar placeholders or need to check which tools are connected, see [CONNECTORS.md](../../CONNECTORS.md). + +Review code changes with a structured lens on security, performance, correctness, and maintainability. + +## Usage + +``` +/code-review +``` + +Review the provided code changes: @$1 + +If no specific file or URL is provided, ask what to review. + +## How It Works + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CODE REVIEW │ +├─────────────────────────────────────────────────────────────────┤ +│ STANDALONE (always works) │ +│ ✓ Paste a diff, PR URL, or point to files │ +│ ✓ Security audit (OWASP top 10, injection, auth) │ +│ ✓ Performance review (N+1, memory leaks, complexity) │ +│ ✓ Correctness (edge cases, error handling, race conditions) │ +│ ✓ Style (naming, structure, readability) │ +│ ✓ Actionable suggestions with code examples │ +├─────────────────────────────────────────────────────────────────┤ +│ SUPERCHARGED (when you connect your tools) │ +│ + Source control: Pull PR diff automatically │ +│ + Project tracker: Link findings to tickets │ +│ + Knowledge base: Check against team coding standards │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Review Dimensions + +### Security +- SQL injection, XSS, CSRF +- Authentication and authorization flaws +- Secrets or credentials in code +- Insecure deserialization +- Path traversal +- SSRF + +### Performance +- N+1 queries +- Unnecessary memory allocations +- Algorithmic complexity (O(n²) in hot paths) +- Missing database indexes +- Unbounded queries or loops +- Resource leaks + +### Correctness +- Edge cases (empty input, null, overflow) +- Race conditions and concurrency issues +- Error handling and propagation +- Off-by-one errors +- Type safety + +### Maintainability +- Naming clarity +- Single responsibility +- Duplication +- Test coverage +- Documentation for non-obvious logic + +## Output + +```markdown +## Code Review: [PR title or file] + +### Summary +[1-2 sentence overview of the changes and overall quality] + +### Critical Issues +| # | File | Line | Issue | Severity | +|---|------|------|-------|----------| +| 1 | [file] | [line] | [description] | 🔴 Critical | + +### Suggestions +| # | File | Line | Suggestion | Category | +|---|------|------|------------|----------| +| 1 | [file] | [line] | [description] | Performance | + +### What Looks Good +- [Positive observations] + +### Verdict +[Approve / Request Changes / Needs Discussion] +``` + +## If Connectors Available + +If **~~source control** is connected: +- Pull the PR diff automatically from the URL +- Check CI status and test results + +If **~~project tracker** is connected: +- Link findings to related tickets +- Verify the PR addresses the stated requirements + +If **~~knowledge base** is connected: +- Check changes against team coding standards and style guides + +## Tips + +1. **Provide context** — "This is a hot path" or "This handles PII" helps me focus. +2. **Specify concerns** — "Focus on security" narrows the review. +3. **Include tests** — I'll check test coverage and quality too. diff --git a/.claude/skills/code-review b/.claude/skills/code-review new file mode 120000 index 000000000..2d88efdf9 --- /dev/null +++ b/.claude/skills/code-review @@ -0,0 +1 @@ +../../.agents/skills/code-review \ No newline at end of file diff --git a/.factory/settings.json b/.factory/settings.json new file mode 100644 index 000000000..565f14af7 --- /dev/null +++ b/.factory/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "core@factory-plugins": true + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..862558384 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,85 @@ +# Spring Data Couchbase — Agent Guide + +## Build & Common Commands + +```bash +# Build (skip tests) +./mvnw clean package -DskipTests + +# Run unit tests only (files matching *Test.java / *Tests.java, excludes *IntegrationTests.java) +./mvnw test + +# Run integration tests (requires a Couchbase cluster — see below) +./mvnw verify + +# Run a single unit test class +./mvnw test -Dtest=QueryCriteriaTests + +# Run a single integration test class +./mvnw verify -Dit.test=CouchbaseTemplateKeyValueIntegrationTests + +# Compile only +./mvnw compile +``` + +## Test Infrastructure + +Tests split into two categories controlled by Maven plugins: +- **Unit tests** (`maven-surefire-plugin`): `**/*Test.java`, `**/*Tests.java` +- **Integration tests** (`maven-failsafe-plugin`): `**/*IntegrationTests.java` + +Integration tests require a Couchbase cluster. The cluster type is configured in `src/test/resources/integration.properties`: + +| `cluster.type` | Description | +|---|---| +| `mocked` (default) | Uses `CouchbaseMock` in-process — no real Couchbase needed | +| `unmanaged` | Connects to a running Couchbase server | + +For local development against a real cluster, create `src/test/resources/integration.local.properties` (git-ignored) overriding `cluster.type=unmanaged` and `cluster.unmanaged.seed=:`. + +The `ClusterInvocationProvider` JUnit 5 extension bootstraps the cluster before integration tests run. All integration test classes extend `ClusterAwareIntegrationTests` (or `CollectionAwareIntegrationTests` for collection-scoped tests). Use `@IgnoreWhen` to conditionally skip tests based on cluster capabilities/type. + +## Architecture Overview + +### Package Structure (`org.springframework.data.couchbase`) + +| Package | Purpose | +|---|---| +| `config` | `AbstractCouchbaseConfiguration` — the main Spring `@Configuration` base class users extend to configure everything (connection, converter, template beans) | +| `core` | `CouchbaseTemplate` / `ReactiveCouchbaseTemplate` — central operation classes; fluent operation API | +| `core.convert` | `MappingCouchbaseConverter`, custom converters, `JacksonTranslationService` (JSON serialization) | +| `core.mapping` | Persistent entity/property model, annotations (`@Document`, `@Field`, `@Expiry`, `@Durability`), event callbacks | +| `core.mapping.id` | ID generation strategies (`@GeneratedValue`, `@IdPrefix`, `@IdSuffix`) | +| `core.query` | `Query`, `QueryCriteria`, N1QL expression building | +| `core.index` | Index annotations (`@QueryIndexed`, `@CompositeQueryIndex`) and auto-index creator | +| `repository` | `CouchbaseRepository` / `ReactiveCouchbaseRepository` interfaces and annotations (`@Query`, `@ScanConsistency`, `@Scope`, `@Collection`) | +| `repository.support` | Factory classes, `SimpleCouchbaseRepository`, `SimpleReactiveCouchbaseRepository` | +| `repository.query` | Query derivation from method names (`PartTree*`) and `@Query` string parsing (`StringBased*`) — both imperative and reactive | +| `transaction` | `CouchbaseCallbackTransactionManager`, `CouchbaseTransactionalOperator`, interceptors | +| `cache` | `CouchbaseCacheManager` / `CouchbaseCache` — Spring Cache abstraction implementation | + +### Dual Imperative/Reactive Model + +Every major operation has both a blocking and a reactive variant: +- `CouchbaseTemplate` wraps `ReactiveCouchbaseTemplate` — the reactive template is the canonical implementation. +- Operation interfaces follow the pattern `ExecutableXxxByIdOperation` (blocking) / `ReactiveXxxByIdOperation` (reactive), with `*Support` classes as implementations (e.g., `ExecutableInsertByIdOperationSupport`). +- The `core/support/` package contains fine-grained `With*` builder interfaces (e.g., `WithExpiry`, `WithDurability`, `InCollection`) that compose into the fluent API. + +### Repository Query Execution Flow + +1. `CouchbaseRepositoryFactory` / `ReactiveCouchbaseRepositoryFactory` creates repository proxies. +2. For derived queries: `CouchbaseQueryMethod` → `N1qlQueryCreator` builds a `Query` from the `PartTree`. +3. For `@Query` string queries: `StringBasedCouchbaseQuery` / `StringBasedN1qlQueryParser` handles N1QL template substitution. +4. Execution delegates to `CouchbaseTemplate.findByQuery()` which runs N1QL (SQL++) via the SDK. +5. Analytics queries follow the same path using `ExecutableFindByAnalyticsOperation`. + +### Entity Mapping + +- `MappingCouchbaseConverter` converts between Java objects and `CouchbaseDocument` (a `Map`-backed structure). +- Type information is stored in a configurable key (default `_class`, overridable via `AbstractCouchbaseConfiguration.typeKey()`). +- Custom type mapping: extend `DefaultCouchbaseTypeMapper`, override `typeKey()`, and register a custom `MappingCouchbaseConverter` bean. +- Field encryption uses `CryptoConverter` + `couchbase-encryption` SDK dependency. + +### Test Domain Model + +`src/test/java/.../domain/` contains shared test entities (`User`, `Airport`, `Airline`, `Person`, etc.) and their repositories. `Config` extends `AbstractCouchbaseConfiguration` and is the shared Spring context configuration for most integration tests. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..74293e581 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,87 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Common Commands + +```bash +# Build (skip tests) +./mvnw clean package -DskipTests + +# Run unit tests only (files matching *Test.java / *Tests.java, excludes *IntegrationTests.java) +./mvnw test + +# Run integration tests (requires a Couchbase cluster — see below) +./mvnw verify + +# Run a single unit test class +./mvnw test -Dtest=QueryCriteriaTests + +# Run a single integration test class +./mvnw verify -Dit.test=CouchbaseTemplateKeyValueIntegrationTests + +# Compile only +./mvnw compile +``` + +## Test Infrastructure + +Tests split into two categories controlled by Maven plugins: +- **Unit tests** (`maven-surefire-plugin`): `**/*Test.java`, `**/*Tests.java` +- **Integration tests** (`maven-failsafe-plugin`): `**/*IntegrationTests.java` + +Integration tests require a Couchbase cluster. The cluster type is configured in `src/test/resources/integration.properties`: + +| `cluster.type` | Description | +|---|---| +| `mocked` (default) | Uses `CouchbaseMock` in-process — no real Couchbase needed | +| `unmanaged` | Connects to a running Couchbase server | + +For local development against a real cluster, create `src/test/resources/integration.local.properties` (git-ignored) overriding `cluster.type=unmanaged` and `cluster.unmanaged.seed=:`. + +The `ClusterInvocationProvider` JUnit 5 extension bootstraps the cluster before integration tests run. All integration test classes extend `ClusterAwareIntegrationTests` (or `CollectionAwareIntegrationTests` for collection-scoped tests). Use `@IgnoreWhen` to conditionally skip tests based on cluster capabilities/type. + +## Architecture Overview + +### Package Structure (`org.springframework.data.couchbase`) + +| Package | Purpose | +|---|---| +| `config` | `AbstractCouchbaseConfiguration` — the main Spring `@Configuration` base class users extend to configure everything (connection, converter, template beans) | +| `core` | `CouchbaseTemplate` / `ReactiveCouchbaseTemplate` — central operation classes; fluent operation API | +| `core.convert` | `MappingCouchbaseConverter`, custom converters, `JacksonTranslationService` (JSON serialization) | +| `core.mapping` | Persistent entity/property model, annotations (`@Document`, `@Field`, `@Expiry`, `@Durability`), event callbacks | +| `core.mapping.id` | ID generation strategies (`@GeneratedValue`, `@IdPrefix`, `@IdSuffix`) | +| `core.query` | `Query`, `QueryCriteria`, N1QL expression building | +| `core.index` | Index annotations (`@QueryIndexed`, `@CompositeQueryIndex`) and auto-index creator | +| `repository` | `CouchbaseRepository` / `ReactiveCouchbaseRepository` interfaces and annotations (`@Query`, `@ScanConsistency`, `@Scope`, `@Collection`) | +| `repository.support` | Factory classes, `SimpleCouchbaseRepository`, `SimpleReactiveCouchbaseRepository` | +| `repository.query` | Query derivation from method names (`PartTree*`) and `@Query` string parsing (`StringBased*`) — both imperative and reactive | +| `transaction` | `CouchbaseCallbackTransactionManager`, `CouchbaseTransactionalOperator`, interceptors | +| `cache` | `CouchbaseCacheManager` / `CouchbaseCache` — Spring Cache abstraction implementation | + +### Dual Imperative/Reactive Model + +Every major operation has both a blocking and a reactive variant: +- `CouchbaseTemplate` wraps `ReactiveCouchbaseTemplate` — the reactive template is the canonical implementation. +- Operation interfaces follow the pattern `ExecutableXxxByIdOperation` (blocking) / `ReactiveXxxByIdOperation` (reactive), with `*Support` classes as implementations (e.g., `ExecutableInsertByIdOperationSupport`). +- The `core/support/` package contains fine-grained `With*` builder interfaces (e.g., `WithExpiry`, `WithDurability`, `InCollection`) that compose into the fluent API. + +### Repository Query Execution Flow + +1. `CouchbaseRepositoryFactory` / `ReactiveCouchbaseRepositoryFactory` creates repository proxies. +2. For derived queries: `CouchbaseQueryMethod` → `N1qlQueryCreator` builds a `Query` from the `PartTree`. +3. For `@Query` string queries: `StringBasedCouchbaseQuery` / `StringBasedN1qlQueryParser` handles N1QL template substitution. +4. Execution delegates to `CouchbaseTemplate.findByQuery()` which runs N1QL (SQL++) via the SDK. +5. Analytics queries follow the same path using `ExecutableFindByAnalyticsOperation`. + +### Entity Mapping + +- `MappingCouchbaseConverter` converts between Java objects and `CouchbaseDocument` (a `Map`-backed structure). +- Type information is stored in a configurable key (default `_class`, overridable via `AbstractCouchbaseConfiguration.typeKey()`). +- Custom type mapping: extend `DefaultCouchbaseTypeMapper`, override `typeKey()`, and register a custom `MappingCouchbaseConverter` bean. +- Field encryption uses `CryptoConverter` + `couchbase-encryption` SDK dependency. + +### Test Domain Model + +`src/test/java/.../domain/` contains shared test entities (`User`, `Airport`, `Airline`, `Person`, etc.) and their repositories. `Config` extends `AbstractCouchbaseConfiguration` and is the shared Spring context configuration for most integration tests. \ No newline at end of file diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 000000000..7886a0154 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "code-review": { + "source": "anthropics/knowledge-work-plugins", + "sourceType": "github", + "computedHash": "919719020590499db120437b53defa7dcb05e138aa5ce9e55034b88dbad7fff8" + } + } +} 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/ExecutableFindByQueryOperation.java b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByQueryOperation.java index 3fc32d06b..fdb7c2297 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByQueryOperation.java +++ b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindByQueryOperation.java @@ -16,10 +16,13 @@ package org.springframework.data.couchbase.core; import java.util.List; +import java.util.Arrays; 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/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..faa4ff05e 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(SearchPropertyPathSupport.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, + SearchPropertyPathSupport.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(SearchPropertyPathSupport.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/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/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/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..d1fb5e5de 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(SearchPropertyPathSupport.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, + SearchPropertyPathSupport.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(SearchPropertyPathSupport.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..e69bbe3bc 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,38 @@ 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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/SearchPropertyPathSupport.java b/src/main/java/org/springframework/data/couchbase/core/SearchPropertyPathSupport.java new file mode 100644 index 000000000..2e161a16c --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/SearchPropertyPathSupport.java @@ -0,0 +1,66 @@ +/* + * 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; + +final class SearchPropertyPathSupport { + + private SearchPropertyPathSupport() { + } + + 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/query/Query.java b/src/main/java/org/springframework/data/couchbase/core/query/Query.java index 2829ab05c..8d0f73dcb 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 @@ -26,6 +26,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 +48,7 @@ /** * @author Michael Nitschinger * @author Michael Reiche + * @author Emilien Bevierre */ public class Query { @@ -176,6 +178,18 @@ public Query distinct(String[] distinctFields) { 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); + return this; + } + /** * distinctFields for query (non-null but empty means all fields) ? {@code distinctFields}. * 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..859d669b7 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,6 +24,7 @@ import java.util.List; import java.util.Locale; +import org.springframework.data.core.TypedPropertyPath; import org.springframework.data.couchbase.core.convert.CouchbaseConverter; import org.jspecify.annotations.Nullable; import org.springframework.util.CollectionUtils; @@ -39,6 +40,7 @@ * @author Michael Reiche * @author Mauro Monti * @author Shubham Mishra + * @author Emilien Bevierre */ public class QueryCriteria implements QueryCriteriaDefinition { @@ -90,6 +92,18 @@ 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) { + return where(propertyPath.toDotPath()); + } + // 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) { @@ -148,6 +162,17 @@ 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) { + return and(propertyPath.toDotPath()); + } + public QueryCriteria and(QueryCriteria criteria) { checkAndAddToCriteriaChain(); QueryCriteria newThis = wrap(this); @@ -183,6 +208,17 @@ 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) { + return or(propertyPath.toDotPath()); + } + public QueryCriteria or(QueryCriteria criteria) { checkAndAddToCriteriaChain(); QueryCriteria newThis = wrap(this); 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..fb05d8431 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/SearchPropertyPathSupportTests.java b/src/test/java/org/springframework/data/couchbase/core/SearchPropertyPathSupportTests.java new file mode 100644 index 000000000..2ad305d0a --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/core/SearchPropertyPathSupportTests.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 SearchPropertyPathSupportTests { + + private final MappingCouchbaseConverter converter; + + SearchPropertyPathSupportTests() { + CouchbaseMappingContext mappingContext = new CouchbaseMappingContext(); + mappingContext.setInitialEntitySet(Set.of(SearchDocument.class, SearchAddress.class)); + mappingContext.afterPropertiesSet(); + this.converter = new MappingCouchbaseConverter(mappingContext); + } + + @Test + void resolvesAlternativeFieldNames() { + assertEquals("nickname", + SearchPropertyPathSupport.getMappedFieldPath(converter, PropertyPath.of(SearchDocument::getMiddlename))); + } + + @Test + void resolvesNestedPropertyPaths() { + assertEquals("address.city", SearchPropertyPathSupport.getMappedFieldPath(converter, + PropertyPath.of(SearchDocument::getAddress).then(SearchAddress::getCity))); + } + + @Test + void resolvesMultipleMappedFieldPaths() { + assertArrayEquals(new String[] { "nickname", "address.city" }, SearchPropertyPathSupport + .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..1456489ff --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/core/query/TypeSafePropertyReferenceTests.java @@ -0,0 +1,181 @@ +/* + * 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.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.domain.Sort; + +/** + * Unit tests for type-safe property references in QueryCriteria, Query, and Sort. + * + * @author Emilien Bevierre + */ +class TypeSafePropertyReferenceTests { + + // --- 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()); From 786e4fc0d194c5ec59c83bdf7d6356608265b577 Mon Sep 17 00:00:00 2001 From: Emilien Bevierre Date: Fri, 3 Jul 2026 17:37:05 +0100 Subject: [PATCH 2/2] Resolve typed property paths to mapped field names outside FTS Typed overloads baked raw property names into N1QL, KV projections and mutate-in paths, ignoring @Field aliases. Resolve them through the converter, mirroring the FTS overloads. Signed-off-by: Emilien Bevierre --- .agents/skills/code-review/SKILL.md | 118 ------------------ .claude/skills/code-review | 1 - .factory/settings.json | 5 - AGENTS.md | 85 ------------- CLAUDE.md | 87 ------------- skills-lock.json | 10 -- .../ExecutableFindByIdOperationSupport.java | 8 ++ .../core/ExecutableFindByQueryOperation.java | 2 +- ...ExecutableFindByQueryOperationSupport.java | 15 +++ ...xecutableFindBySearchOperationSupport.java | 6 +- ...xecutableMutateInByIdOperationSupport.java | 30 +++++ ...hSupport.java => PropertyPathSupport.java} | 20 ++- .../ReactiveFindByIdOperationSupport.java | 8 ++ .../ReactiveFindByQueryOperationSupport.java | 15 +++ .../ReactiveFindBySearchOperationSupport.java | 6 +- .../core/ReactiveMutateInByIdOperation.java | 4 + .../ReactiveMutateInByIdOperationSupport.java | 30 +++++ .../data/couchbase/core/query/Query.java | 28 +++++ .../couchbase/core/query/QueryCriteria.java | 37 +++++- .../couchbase/core/support/WithDistinct.java | 2 +- ...sts.java => PropertyPathSupportTests.java} | 10 +- .../query/TypeSafePropertyReferenceTests.java | 44 +++++++ 22 files changed, 246 insertions(+), 325 deletions(-) delete mode 100644 .agents/skills/code-review/SKILL.md delete mode 120000 .claude/skills/code-review delete mode 100644 .factory/settings.json delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md delete mode 100644 skills-lock.json rename src/main/java/org/springframework/data/couchbase/core/{SearchPropertyPathSupport.java => PropertyPathSupport.java} (80%) rename src/test/java/org/springframework/data/couchbase/core/{SearchPropertyPathSupportTests.java => PropertyPathSupportTests.java} (88%) diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md deleted file mode 100644 index 8b98fe4c0..000000000 --- a/.agents/skills/code-review/SKILL.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -name: code-review -description: Review code changes for security, performance, and correctness. Trigger with a PR URL or diff, "review this before I merge", "is this code safe?", or when checking a change for N+1 queries, injection risks, missing edge cases, or error handling gaps. -argument-hint: "" ---- - -# /code-review - -> If you see unfamiliar placeholders or need to check which tools are connected, see [CONNECTORS.md](../../CONNECTORS.md). - -Review code changes with a structured lens on security, performance, correctness, and maintainability. - -## Usage - -``` -/code-review -``` - -Review the provided code changes: @$1 - -If no specific file or URL is provided, ask what to review. - -## How It Works - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ CODE REVIEW │ -├─────────────────────────────────────────────────────────────────┤ -│ STANDALONE (always works) │ -│ ✓ Paste a diff, PR URL, or point to files │ -│ ✓ Security audit (OWASP top 10, injection, auth) │ -│ ✓ Performance review (N+1, memory leaks, complexity) │ -│ ✓ Correctness (edge cases, error handling, race conditions) │ -│ ✓ Style (naming, structure, readability) │ -│ ✓ Actionable suggestions with code examples │ -├─────────────────────────────────────────────────────────────────┤ -│ SUPERCHARGED (when you connect your tools) │ -│ + Source control: Pull PR diff automatically │ -│ + Project tracker: Link findings to tickets │ -│ + Knowledge base: Check against team coding standards │ -└─────────────────────────────────────────────────────────────────┘ -``` - -## Review Dimensions - -### Security -- SQL injection, XSS, CSRF -- Authentication and authorization flaws -- Secrets or credentials in code -- Insecure deserialization -- Path traversal -- SSRF - -### Performance -- N+1 queries -- Unnecessary memory allocations -- Algorithmic complexity (O(n²) in hot paths) -- Missing database indexes -- Unbounded queries or loops -- Resource leaks - -### Correctness -- Edge cases (empty input, null, overflow) -- Race conditions and concurrency issues -- Error handling and propagation -- Off-by-one errors -- Type safety - -### Maintainability -- Naming clarity -- Single responsibility -- Duplication -- Test coverage -- Documentation for non-obvious logic - -## Output - -```markdown -## Code Review: [PR title or file] - -### Summary -[1-2 sentence overview of the changes and overall quality] - -### Critical Issues -| # | File | Line | Issue | Severity | -|---|------|------|-------|----------| -| 1 | [file] | [line] | [description] | 🔴 Critical | - -### Suggestions -| # | File | Line | Suggestion | Category | -|---|------|------|------------|----------| -| 1 | [file] | [line] | [description] | Performance | - -### What Looks Good -- [Positive observations] - -### Verdict -[Approve / Request Changes / Needs Discussion] -``` - -## If Connectors Available - -If **~~source control** is connected: -- Pull the PR diff automatically from the URL -- Check CI status and test results - -If **~~project tracker** is connected: -- Link findings to related tickets -- Verify the PR addresses the stated requirements - -If **~~knowledge base** is connected: -- Check changes against team coding standards and style guides - -## Tips - -1. **Provide context** — "This is a hot path" or "This handles PII" helps me focus. -2. **Specify concerns** — "Focus on security" narrows the review. -3. **Include tests** — I'll check test coverage and quality too. diff --git a/.claude/skills/code-review b/.claude/skills/code-review deleted file mode 120000 index 2d88efdf9..000000000 --- a/.claude/skills/code-review +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/code-review \ No newline at end of file diff --git a/.factory/settings.json b/.factory/settings.json deleted file mode 100644 index 565f14af7..000000000 --- a/.factory/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "enabledPlugins": { - "core@factory-plugins": true - } -} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 862558384..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,85 +0,0 @@ -# Spring Data Couchbase — Agent Guide - -## Build & Common Commands - -```bash -# Build (skip tests) -./mvnw clean package -DskipTests - -# Run unit tests only (files matching *Test.java / *Tests.java, excludes *IntegrationTests.java) -./mvnw test - -# Run integration tests (requires a Couchbase cluster — see below) -./mvnw verify - -# Run a single unit test class -./mvnw test -Dtest=QueryCriteriaTests - -# Run a single integration test class -./mvnw verify -Dit.test=CouchbaseTemplateKeyValueIntegrationTests - -# Compile only -./mvnw compile -``` - -## Test Infrastructure - -Tests split into two categories controlled by Maven plugins: -- **Unit tests** (`maven-surefire-plugin`): `**/*Test.java`, `**/*Tests.java` -- **Integration tests** (`maven-failsafe-plugin`): `**/*IntegrationTests.java` - -Integration tests require a Couchbase cluster. The cluster type is configured in `src/test/resources/integration.properties`: - -| `cluster.type` | Description | -|---|---| -| `mocked` (default) | Uses `CouchbaseMock` in-process — no real Couchbase needed | -| `unmanaged` | Connects to a running Couchbase server | - -For local development against a real cluster, create `src/test/resources/integration.local.properties` (git-ignored) overriding `cluster.type=unmanaged` and `cluster.unmanaged.seed=:`. - -The `ClusterInvocationProvider` JUnit 5 extension bootstraps the cluster before integration tests run. All integration test classes extend `ClusterAwareIntegrationTests` (or `CollectionAwareIntegrationTests` for collection-scoped tests). Use `@IgnoreWhen` to conditionally skip tests based on cluster capabilities/type. - -## Architecture Overview - -### Package Structure (`org.springframework.data.couchbase`) - -| Package | Purpose | -|---|---| -| `config` | `AbstractCouchbaseConfiguration` — the main Spring `@Configuration` base class users extend to configure everything (connection, converter, template beans) | -| `core` | `CouchbaseTemplate` / `ReactiveCouchbaseTemplate` — central operation classes; fluent operation API | -| `core.convert` | `MappingCouchbaseConverter`, custom converters, `JacksonTranslationService` (JSON serialization) | -| `core.mapping` | Persistent entity/property model, annotations (`@Document`, `@Field`, `@Expiry`, `@Durability`), event callbacks | -| `core.mapping.id` | ID generation strategies (`@GeneratedValue`, `@IdPrefix`, `@IdSuffix`) | -| `core.query` | `Query`, `QueryCriteria`, N1QL expression building | -| `core.index` | Index annotations (`@QueryIndexed`, `@CompositeQueryIndex`) and auto-index creator | -| `repository` | `CouchbaseRepository` / `ReactiveCouchbaseRepository` interfaces and annotations (`@Query`, `@ScanConsistency`, `@Scope`, `@Collection`) | -| `repository.support` | Factory classes, `SimpleCouchbaseRepository`, `SimpleReactiveCouchbaseRepository` | -| `repository.query` | Query derivation from method names (`PartTree*`) and `@Query` string parsing (`StringBased*`) — both imperative and reactive | -| `transaction` | `CouchbaseCallbackTransactionManager`, `CouchbaseTransactionalOperator`, interceptors | -| `cache` | `CouchbaseCacheManager` / `CouchbaseCache` — Spring Cache abstraction implementation | - -### Dual Imperative/Reactive Model - -Every major operation has both a blocking and a reactive variant: -- `CouchbaseTemplate` wraps `ReactiveCouchbaseTemplate` — the reactive template is the canonical implementation. -- Operation interfaces follow the pattern `ExecutableXxxByIdOperation` (blocking) / `ReactiveXxxByIdOperation` (reactive), with `*Support` classes as implementations (e.g., `ExecutableInsertByIdOperationSupport`). -- The `core/support/` package contains fine-grained `With*` builder interfaces (e.g., `WithExpiry`, `WithDurability`, `InCollection`) that compose into the fluent API. - -### Repository Query Execution Flow - -1. `CouchbaseRepositoryFactory` / `ReactiveCouchbaseRepositoryFactory` creates repository proxies. -2. For derived queries: `CouchbaseQueryMethod` → `N1qlQueryCreator` builds a `Query` from the `PartTree`. -3. For `@Query` string queries: `StringBasedCouchbaseQuery` / `StringBasedN1qlQueryParser` handles N1QL template substitution. -4. Execution delegates to `CouchbaseTemplate.findByQuery()` which runs N1QL (SQL++) via the SDK. -5. Analytics queries follow the same path using `ExecutableFindByAnalyticsOperation`. - -### Entity Mapping - -- `MappingCouchbaseConverter` converts between Java objects and `CouchbaseDocument` (a `Map`-backed structure). -- Type information is stored in a configurable key (default `_class`, overridable via `AbstractCouchbaseConfiguration.typeKey()`). -- Custom type mapping: extend `DefaultCouchbaseTypeMapper`, override `typeKey()`, and register a custom `MappingCouchbaseConverter` bean. -- Field encryption uses `CryptoConverter` + `couchbase-encryption` SDK dependency. - -### Test Domain Model - -`src/test/java/.../domain/` contains shared test entities (`User`, `Airport`, `Airline`, `Person`, etc.) and their repositories. `Config` extends `AbstractCouchbaseConfiguration` and is the shared Spring context configuration for most integration tests. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 74293e581..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,87 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Build & Common Commands - -```bash -# Build (skip tests) -./mvnw clean package -DskipTests - -# Run unit tests only (files matching *Test.java / *Tests.java, excludes *IntegrationTests.java) -./mvnw test - -# Run integration tests (requires a Couchbase cluster — see below) -./mvnw verify - -# Run a single unit test class -./mvnw test -Dtest=QueryCriteriaTests - -# Run a single integration test class -./mvnw verify -Dit.test=CouchbaseTemplateKeyValueIntegrationTests - -# Compile only -./mvnw compile -``` - -## Test Infrastructure - -Tests split into two categories controlled by Maven plugins: -- **Unit tests** (`maven-surefire-plugin`): `**/*Test.java`, `**/*Tests.java` -- **Integration tests** (`maven-failsafe-plugin`): `**/*IntegrationTests.java` - -Integration tests require a Couchbase cluster. The cluster type is configured in `src/test/resources/integration.properties`: - -| `cluster.type` | Description | -|---|---| -| `mocked` (default) | Uses `CouchbaseMock` in-process — no real Couchbase needed | -| `unmanaged` | Connects to a running Couchbase server | - -For local development against a real cluster, create `src/test/resources/integration.local.properties` (git-ignored) overriding `cluster.type=unmanaged` and `cluster.unmanaged.seed=:`. - -The `ClusterInvocationProvider` JUnit 5 extension bootstraps the cluster before integration tests run. All integration test classes extend `ClusterAwareIntegrationTests` (or `CollectionAwareIntegrationTests` for collection-scoped tests). Use `@IgnoreWhen` to conditionally skip tests based on cluster capabilities/type. - -## Architecture Overview - -### Package Structure (`org.springframework.data.couchbase`) - -| Package | Purpose | -|---|---| -| `config` | `AbstractCouchbaseConfiguration` — the main Spring `@Configuration` base class users extend to configure everything (connection, converter, template beans) | -| `core` | `CouchbaseTemplate` / `ReactiveCouchbaseTemplate` — central operation classes; fluent operation API | -| `core.convert` | `MappingCouchbaseConverter`, custom converters, `JacksonTranslationService` (JSON serialization) | -| `core.mapping` | Persistent entity/property model, annotations (`@Document`, `@Field`, `@Expiry`, `@Durability`), event callbacks | -| `core.mapping.id` | ID generation strategies (`@GeneratedValue`, `@IdPrefix`, `@IdSuffix`) | -| `core.query` | `Query`, `QueryCriteria`, N1QL expression building | -| `core.index` | Index annotations (`@QueryIndexed`, `@CompositeQueryIndex`) and auto-index creator | -| `repository` | `CouchbaseRepository` / `ReactiveCouchbaseRepository` interfaces and annotations (`@Query`, `@ScanConsistency`, `@Scope`, `@Collection`) | -| `repository.support` | Factory classes, `SimpleCouchbaseRepository`, `SimpleReactiveCouchbaseRepository` | -| `repository.query` | Query derivation from method names (`PartTree*`) and `@Query` string parsing (`StringBased*`) — both imperative and reactive | -| `transaction` | `CouchbaseCallbackTransactionManager`, `CouchbaseTransactionalOperator`, interceptors | -| `cache` | `CouchbaseCacheManager` / `CouchbaseCache` — Spring Cache abstraction implementation | - -### Dual Imperative/Reactive Model - -Every major operation has both a blocking and a reactive variant: -- `CouchbaseTemplate` wraps `ReactiveCouchbaseTemplate` — the reactive template is the canonical implementation. -- Operation interfaces follow the pattern `ExecutableXxxByIdOperation` (blocking) / `ReactiveXxxByIdOperation` (reactive), with `*Support` classes as implementations (e.g., `ExecutableInsertByIdOperationSupport`). -- The `core/support/` package contains fine-grained `With*` builder interfaces (e.g., `WithExpiry`, `WithDurability`, `InCollection`) that compose into the fluent API. - -### Repository Query Execution Flow - -1. `CouchbaseRepositoryFactory` / `ReactiveCouchbaseRepositoryFactory` creates repository proxies. -2. For derived queries: `CouchbaseQueryMethod` → `N1qlQueryCreator` builds a `Query` from the `PartTree`. -3. For `@Query` string queries: `StringBasedCouchbaseQuery` / `StringBasedN1qlQueryParser` handles N1QL template substitution. -4. Execution delegates to `CouchbaseTemplate.findByQuery()` which runs N1QL (SQL++) via the SDK. -5. Analytics queries follow the same path using `ExecutableFindByAnalyticsOperation`. - -### Entity Mapping - -- `MappingCouchbaseConverter` converts between Java objects and `CouchbaseDocument` (a `Map`-backed structure). -- Type information is stored in a configurable key (default `_class`, overridable via `AbstractCouchbaseConfiguration.typeKey()`). -- Custom type mapping: extend `DefaultCouchbaseTypeMapper`, override `typeKey()`, and register a custom `MappingCouchbaseConverter` bean. -- Field encryption uses `CryptoConverter` + `couchbase-encryption` SDK dependency. - -### Test Domain Model - -`src/test/java/.../domain/` contains shared test entities (`User`, `Airport`, `Airline`, `Person`, etc.) and their repositories. `Config` extends `AbstractCouchbaseConfiguration` and is the shared Spring context configuration for most integration tests. \ No newline at end of file diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index 7886a0154..000000000 --- a/skills-lock.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "version": 1, - "skills": { - "code-review": { - "source": "anthropics/knowledge-work-plugins", - "sourceType": "github", - "computedHash": "919719020590499db120437b53defa7dcb05e138aa5ce9e55034b88dbad7fff8" - } - } -} 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 fdb7c2297..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,8 +15,8 @@ */ package org.springframework.data.couchbase.core; -import java.util.List; import java.util.Arrays; +import java.util.List; import java.util.Optional; import java.util.stream.Stream; 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/ExecutableFindBySearchOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperationSupport.java index faa4ff05e..88552a1a3 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ExecutableFindBySearchOperationSupport.java @@ -223,7 +223,7 @@ public FindBySearchWithSkip withSort(SearchSort... sort) { @Override public

FindBySearchWithSkip withSort(TypedPropertyPath property, TypedPropertyPath... additionalProperties) { - return withSort(SearchPropertyPathSupport.toSearchSorts(template.getConverter(), property, additionalProperties)); + return withSort(PropertyPathSupport.toSearchSorts(template.getConverter(), property, additionalProperties)); } @Override @@ -236,7 +236,7 @@ public FindBySearchWithSort withHighlight(HighlightStyle style, String... fie public

FindBySearchWithSort withHighlight(HighlightStyle style, TypedPropertyPath field, TypedPropertyPath... additionalFields) { return withHighlight(style, - SearchPropertyPathSupport.getMappedFieldPaths(template.getConverter(), field, additionalFields)); + PropertyPathSupport.getMappedFieldPaths(template.getConverter(), field, additionalFields)); } @Override @@ -256,7 +256,7 @@ public FindBySearchWithFacets withFields(String... fields) { @Override public

FindBySearchWithFacets withFields(TypedPropertyPath field, TypedPropertyPath... additionalFields) { - return withFields(SearchPropertyPathSupport.getMappedFieldPaths(template.getConverter(), field, additionalFields)); + return withFields(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), field, additionalFields)); } } } 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/SearchPropertyPathSupport.java b/src/main/java/org/springframework/data/couchbase/core/PropertyPathSupport.java similarity index 80% rename from src/main/java/org/springframework/data/couchbase/core/SearchPropertyPathSupport.java rename to src/main/java/org/springframework/data/couchbase/core/PropertyPathSupport.java index 2e161a16c..1090962c6 100644 --- a/src/main/java/org/springframework/data/couchbase/core/SearchPropertyPathSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/PropertyPathSupport.java @@ -25,9 +25,25 @@ import com.couchbase.client.java.search.sort.SearchSort; -final class SearchPropertyPathSupport { +/** + * 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) { - private SearchPropertyPathSupport() { + 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) { 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/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/ReactiveFindBySearchOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindBySearchOperationSupport.java index d1fb5e5de..672111bd6 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindBySearchOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindBySearchOperationSupport.java @@ -189,7 +189,7 @@ public FindBySearchWithSkip withSort(SearchSort... sort) { @Override public

FindBySearchWithSkip withSort(TypedPropertyPath property, TypedPropertyPath... additionalProperties) { - return withSort(SearchPropertyPathSupport.toSearchSorts(template.getConverter(), property, additionalProperties)); + return withSort(PropertyPathSupport.toSearchSorts(template.getConverter(), property, additionalProperties)); } @Override @@ -203,7 +203,7 @@ public FindBySearchWithSort withHighlight(HighlightStyle style, String... fie public

FindBySearchWithSort withHighlight(HighlightStyle style, TypedPropertyPath field, TypedPropertyPath... additionalFields) { return withHighlight(style, - SearchPropertyPathSupport.getMappedFieldPaths(template.getConverter(), field, additionalFields)); + PropertyPathSupport.getMappedFieldPaths(template.getConverter(), field, additionalFields)); } @Override @@ -223,7 +223,7 @@ public FindBySearchWithFacets withFields(String... fields) { @Override public

FindBySearchWithFacets withFields(TypedPropertyPath field, TypedPropertyPath... additionalFields) { - return withFields(SearchPropertyPathSupport.getMappedFieldPaths(template.getConverter(), field, additionalFields)); + return withFields(PropertyPathSupport.getMappedFieldPaths(template.getConverter(), field, additionalFields)); } @Override 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 e69bbe3bc..04ebd2165 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveMutateInByIdOperation.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveMutateInByIdOperation.java @@ -106,6 +106,7 @@ interface MutateInByIdWithPaths extends TerminatingMutateInById, WithMutat * 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)); } @@ -114,6 +115,7 @@ default MutateInByIdWithPaths withRemovePaths(TypedPropertyPath... remo * 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)); } @@ -122,6 +124,7 @@ default MutateInByIdWithPaths withInsertPaths(TypedPropertyPath... inse * 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)); } @@ -130,6 +133,7 @@ default MutateInByIdWithPaths withUpsertPaths(TypedPropertyPath... upse * 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 8d0f73dcb..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; @@ -58,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; @@ -79,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; @@ -175,6 +183,7 @@ public boolean isDistinct() { */ public Query distinct(String[] distinctFields) { this.distinctFields = distinctFields; + this.typedDistinctFields = null; return this; } @@ -187,6 +196,7 @@ public Query distinct(String[] distinctFields) { @SafeVarargs public final Query distinct(TypedPropertyPath... distinctFields) { this.distinctFields = Arrays.stream(distinctFields).map(TypedPropertyPath::toDotPath).toArray(String[]::new); + this.typedDistinctFields = distinctFields; return this; } @@ -199,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}. @@ -364,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 859d669b7..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 @@ -26,6 +26,8 @@ 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; @@ -45,6 +47,11 @@ 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. */ @@ -101,7 +108,9 @@ public static QueryCriteria where(N1QLExpression key) { * @since 6.1 */ public static QueryCriteria where(TypedPropertyPath propertyPath) { - return where(propertyPath.toDotPath()); + 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 @@ -109,10 +118,12 @@ public static QueryCriteria where(TypedPropertyPath propertyPath) { 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; @@ -170,7 +181,9 @@ public QueryCriteria and(N1QLExpression key) { * @since 6.1 */ public QueryCriteria and(TypedPropertyPath propertyPath) { - return and(propertyPath.toDotPath()); + QueryCriteria criteria = and(propertyPath.toDotPath()); + criteria.propertyPath = propertyPath; + return criteria; } public QueryCriteria and(QueryCriteria criteria) { @@ -216,7 +229,9 @@ public QueryCriteria or(N1QLExpression key) { * @since 6.1 */ public QueryCriteria or(TypedPropertyPath propertyPath) { - return or(propertyPath.toDotPath()); + QueryCriteria criteria = or(propertyPath.toDotPath()); + criteria.propertyPath = propertyPath; + return criteria; } public QueryCriteria or(QueryCriteria criteria) { @@ -589,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; @@ -613,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 fb05d8431..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 @@ -40,7 +40,7 @@ public interface WithDistinct { * @param distinctFields the property paths to use as distinct fields. * @since 6.1 */ - @SuppressWarnings("unchecked") + @SuppressWarnings("unchecked") default Object distinct(TypedPropertyPath... distinctFields) { return distinct(Arrays.stream(distinctFields).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); } diff --git a/src/test/java/org/springframework/data/couchbase/core/SearchPropertyPathSupportTests.java b/src/test/java/org/springframework/data/couchbase/core/PropertyPathSupportTests.java similarity index 88% rename from src/test/java/org/springframework/data/couchbase/core/SearchPropertyPathSupportTests.java rename to src/test/java/org/springframework/data/couchbase/core/PropertyPathSupportTests.java index 2ad305d0a..756264f4b 100644 --- a/src/test/java/org/springframework/data/couchbase/core/SearchPropertyPathSupportTests.java +++ b/src/test/java/org/springframework/data/couchbase/core/PropertyPathSupportTests.java @@ -28,11 +28,11 @@ import org.springframework.data.couchbase.core.mapping.Document; import org.springframework.data.couchbase.core.mapping.Field; -class SearchPropertyPathSupportTests { +class PropertyPathSupportTests { private final MappingCouchbaseConverter converter; - SearchPropertyPathSupportTests() { + PropertyPathSupportTests() { CouchbaseMappingContext mappingContext = new CouchbaseMappingContext(); mappingContext.setInitialEntitySet(Set.of(SearchDocument.class, SearchAddress.class)); mappingContext.afterPropertiesSet(); @@ -42,18 +42,18 @@ class SearchPropertyPathSupportTests { @Test void resolvesAlternativeFieldNames() { assertEquals("nickname", - SearchPropertyPathSupport.getMappedFieldPath(converter, PropertyPath.of(SearchDocument::getMiddlename))); + PropertyPathSupport.getMappedFieldPath(converter, PropertyPath.of(SearchDocument::getMiddlename))); } @Test void resolvesNestedPropertyPaths() { - assertEquals("address.city", SearchPropertyPathSupport.getMappedFieldPath(converter, + assertEquals("address.city", PropertyPathSupport.getMappedFieldPath(converter, PropertyPath.of(SearchDocument::getAddress).then(SearchAddress::getCity))); } @Test void resolvesMultipleMappedFieldPaths() { - assertArrayEquals(new String[] { "nickname", "address.city" }, SearchPropertyPathSupport + assertArrayEquals(new String[] { "nickname", "address.city" }, PropertyPathSupport .getMappedFieldPaths(converter, PropertyPath.of(SearchDocument::getMiddlename), PropertyPath.of(SearchDocument::getAddress).then(SearchAddress::getCity))); } 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 index 1456489ff..cde75b2fa 100644 --- a/src/test/java/org/springframework/data/couchbase/core/query/TypeSafePropertyReferenceTests.java +++ b/src/test/java/org/springframework/data/couchbase/core/query/TypeSafePropertyReferenceTests.java @@ -16,6 +16,8 @@ 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; @@ -26,6 +28,8 @@ 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; /** @@ -35,6 +39,46 @@ */ 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