From 4bcc7615049742ec95be2ad5057bb6ad26f9d997 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Thu, 14 May 2026 16:54:23 -0300 Subject: [PATCH 1/6] feat: spec for new BaseFilter --- specs/base-filter.md | 282 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 specs/base-filter.md diff --git a/specs/base-filter.md b/specs/base-filter.md new file mode 100644 index 0000000..954ce8c --- /dev/null +++ b/specs/base-filter.md @@ -0,0 +1,282 @@ +# BaseFilter — Annotation-driven JPA Criteria filtering + +Status: Draft +Branch: `base-filter` +Author: Leo +Module: `backend-core-model` (abstraction + annotations), `backend-core-data-impl` (JPA processor + DAO hooks) + +## 1. Motivation + +The current filtering abstraction (`QuerySpec` + `Constraint` hierarchy + `ConstraintTransformer`) is imperative and verbose: callers must construct `Constraint` objects, register them on a `QuerySpec`, and let `ConstraintTransformerJpaImpl` turn them into JPA `Predicate`s. It works, but every new query surface in a downstream project ends up reimplementing the same boilerplate. + +`BaseFilter` replaces that with a *declarative* model: application developers define a POJO whose fields describe the filterable criteria, annotate the fields with lightweight metadata, and pass the populated instance to the DAO. The DAO base implementation reflects on the filter, builds a JPA `CriteriaQuery`, and runs it. The existing `QuerySpec`-based DAO methods — and the supporting `QuerySpec` / `Constraint` / `ConstraintTransformer` types — are deprecated as part of this change but **not** marked for removal; downstream consumers can migrate at their own pace. + +This spec covers the abstract class, the initial annotation set, the criteria-building pipeline in the DAO base, and the extensibility surface. + +## 2. Goals and non-goals + +### Goals + +- A single abstract `BaseFilter` class that downstream filters extend. +- Filter fields describe *what entity attribute they map to* and *what semantic role* the field plays (single value, lower bound, upper bound). The operator is inferred from the metadata, not declared per field. +- Built-in support for sort orders, first result, and max result, mirroring `QuerySpec`'s pagination semantics. +- Two equally supported construction styles on every filter: a **POJO style** (no-arg constructor + chainable setters) and a **Builder style** (typed, inheritance-aware builder, populated by `Filter.builder()...build()`). +- A JPA `CriteriaQuery` builder living in the DAO base, with well-defined hook methods so subclasses can add predicates/joins/projections that don't fit the declarative model. +- Coexist with `QuerySpec`-based DAO methods during a deprecation window. + +### Non-goals (v1) + +- Operator-specific annotations (`@Like`, `@ILike`, `@GreaterThan`, `@In`, `@IsNull`, etc.). The first cut ships only *metadata* annotations that describe the role of the field; operator-specific annotations will be revisited once the abstraction proves out. +- Boolean composition annotations (`@Or`, `@Not`). +- Pluggable annotation processors (registering third-party annotations). Extensibility is via DAO hook methods only. +- Reusing or wrapping `QuerySpec`, `Constraint`, or `ConstraintTransformer`. The new pipeline is independent. +- Replacing `findById` / `findAll` / `save` / `update` / `delete`. Only the filter-shaped methods are touched. + +## 3. Public API surface + +### 3.1 `BaseFilter` (in `backend-core-model`) + +```java +package com.flowingcode.backendcore.model.filter; + +@Getter +@Setter +@Accessors(chain = true) +@SuperBuilder(toBuilder = true) +@NoArgsConstructor +public abstract class BaseFilter { + + public enum Order { ASC, DESC } + + @Singular("order") + private Map orders; // initialized to LinkedHashMap; never null + + private Integer firstResult; // null or >= 0 + private Integer maxResult; // null or >= 0 + + // Convenience mutators kept on the POJO surface + public BaseFilter addOrder(String attribute); // defaults to ASC + public BaseFilter addOrder(String attribute, Order direction); +} +``` + +Two construction styles are supported, and both populate the same underlying state: + +```java +// POJO style — no-arg ctor + chainable setters +PersonFilter f = new PersonFilter() + .setName("Ada") + .setBirthDateFrom(LocalDate.of(1990, 1, 1)); +f.addOrder("name"); +f.setMaxResult(50); + +// Builder style — Lombok @SuperBuilder +PersonFilter f = PersonFilter.builder() + .name("Ada") + .birthDateFrom(LocalDate.of(1990, 1, 1)) + .order("name", BaseFilter.Order.ASC) // @Singular generates per-entry adder + .maxResult(50) + .build(); +``` + +Notes on the dual-style design: + +- Lombok's `@SuperBuilder` is the simplest way to produce a typed builder that correctly composes with inheritance — subclasses opt in by adding `@SuperBuilder` and their builder inherits the base fields automatically. The existing codebase already relies on Lombok (`@Getter`/`@Setter`/`@Accessors(chain=true)` on `QuerySpec`), so this matches established conventions. +- The chainable POJO setters (via `@Accessors(chain = true)`) are kept so callers who already work with mutable filters — including code wired through frameworks that prefer no-arg construction + property binding (e.g. JSON deserialization, query-param binding) — don't pay any extra ceremony. +- `@Singular("order")` exposes a per-entry `.order(key, value)` adder on the builder while still letting `.orders(map)` set the whole map. The internal store is a `LinkedHashMap` so insertion order is preserved across both styles. +- `toBuilder = true` lets callers rebuild a tweaked copy of an existing filter (`f.toBuilder().maxResult(10).build()`), useful for paging. +- The sort-order attribute string follows the same dotted-path convention as `@Attribute` (see §3.2) so callers can sort across joins. +- Pagination validators throw `IllegalArgumentException` on negative values, matching `QuerySpec`. Validation logic is centralized in a private helper (or in the setters themselves) and the `@SuperBuilder`-generated `build()` is wired through an explicit constructor that delegates to the setters, so both styles enforce the same invariants. If keeping the builder honest via a custom constructor proves clumsy in practice, the implementer can fall back to writing the builder explicitly — the public surface (`PersonFilter.builder()...build()`) is what the spec commits to, not the Lombok mechanics. + +### 3.2 Annotations (in `backend-core-model`, package `com.flowingcode.backendcore.model.filter`) + +All annotations are field-level (`@Target(ElementType.FIELD)`), retained at runtime, and purely *metadata* — they describe how a filter field relates to the entity, not the operator to apply. + +#### `@Attribute` + +```java +@Retention(RUNTIME) @Target(FIELD) +public @interface Attribute { + /** Dotted attribute path on the target entity, e.g. "city.state.name". */ + String value(); +} +``` + +- Maps a filter field to one or more entity attributes via a dotted path. Path traversal follows the same `split("\\.")` + auto-join convention used today in `ConstraintTransformerJpaImpl`, including the inner-join-by-default behavior. +- A filter field with `@Attribute` and no role annotation defaults to **equality** (`cb.equal(...)`). + +#### `@From` and `@To` + +```java +@Retention(RUNTIME) @Target(FIELD) +public @interface From { + /** When true (default), the lower bound is inclusive (>=); when false, strict (>). */ + boolean inclusive() default true; +} + +@Retention(RUNTIME) @Target(FIELD) +public @interface To { + /** When true (default), the upper bound is inclusive (<=); when false, strict (<). */ + boolean inclusive() default true; +} +``` + +- `@From` marks the field as the **lower bound** of a range comparison on its `@Attribute`; `inclusive` controls whether the comparison is `>=` (default) or `>`. +- `@To` marks the field as the **upper bound**; `inclusive` controls `<=` (default) or `<`. +- Single-sided behavior: + - `@From` alone → `>=` or `>` depending on `inclusive`. + - `@To` alone → `<=` or `<` depending on `inclusive`. +- Paired behavior — when the same `@Attribute("x")` value appears on two fields, one with `@From` and the other with `@To`: + - **Both bounds inclusive and both values non-null** → emit a single `cb.between(...)` predicate (BETWEEN is inclusive on both sides in JPA/SQL). + - **Any bound exclusive, or mixed inclusivity** → fall back to two predicates ANDed together (`> lower AND < upper`, `>= lower AND < upper`, etc.). The BETWEEN optimization is dropped because JPA `between` cannot express exclusivity. + - **Only one side non-null** → emit the single available comparison using that side's `inclusive` setting; the other side contributes nothing. +- `@From` / `@To` without `@Attribute` is a configuration error and must fail fast at startup or on first use, with a clear message. + +#### `@WhenNull` + +```java +@Retention(RUNTIME) @Target(FIELD) +public @interface WhenNull { + Policy value(); + + enum Policy { SKIP, IS_NULL } +} +``` + +- Per-field override for null handling. Default is `SKIP` (no predicate emitted), so most filter fields don't need this annotation. +- `IS_NULL` makes a null field emit `cb.isNull(...)` against the resolved attribute path. +- Only meaningful on fields that also carry `@Attribute`. On `@From` / `@To`, `SKIP` is the only sensible policy and the processor must reject `IS_NULL` with a clear error. + +### 3.3 DAO surface changes (in `backend-core-data` and `backend-core-data-impl`) + +Add overloads to `QueryDao` and `ConversionJpaDaoSupport` that take a `BaseFilter`: + +```java +// backend-core-data, com.flowingcode.backendcore.dao.QueryDao +List filter(BaseFilter filter); +Optional filterWithSingleResult(BaseFilter filter); +long count(BaseFilter filter); +``` + +The existing `QuerySpec` overloads stay but are annotated `@Deprecated(forRemoval = false)` with javadoc pointing at `BaseFilter`. Removal is deferred to a future major version. + +`ConversionJpaDaoSupport` provides default implementations that delegate to a `BaseFilterJpaProcessor` (see §4) and convert results via the existing `convertFrom`. `JpaDaoSupport` inherits transparently. + +## 4. Criteria-building pipeline + +A new package-private (or `protected`-accessible) class lives alongside the existing `FilterProcesor`: + +```java +// backend-core-data-impl +class BaseFilterJpaProcessor, K extends Serializable> { ... } +``` + +For each call, the processor: + +1. **Reflects** the `BaseFilter` subclass to discover annotated fields. Reflection results (field handles, attribute paths, role classification, null policy) are **cached per filter class** in a static map to avoid re-walking the class on every query. +2. **Validates** the annotations at first encounter (range-pair consistency, `@From`/`@To` requires `@Attribute`, `@WhenNull(IS_NULL)` requires `@Attribute` and forbids `@From`/`@To`, no duplicate single-value `@Attribute` on the same path, range pairs share the exact same attribute path). +3. **Builds predicates** by walking the cached field metadata: + - Single-attribute fields → equality. + - `@From`-only / `@To`-only → comparison predicate honoring the field's `inclusive` flag (`>` / `>=` / `<` / `<=`). + - `@From` + `@To` on the same `@Attribute` path → `cb.between(...)` only when both values are non-null **and** both bounds are inclusive; otherwise two ANDed comparison predicates with each side's `inclusive` setting honored independently. When only one side is non-null, emit just that side's comparison. + - Null values are skipped unless `@WhenNull(IS_NULL)` is present. +4. **Resolves attribute paths** via the same auto-joining strategy used today in `ConstraintTransformerJpaImpl` (split on `.`, reuse existing joins when the join type matches). The path/join helper should be **extracted into a shared utility** so both the legacy transformer and the new processor consume it, but the new processor does not depend on `Constraint` / `ConstraintTransformer`. +5. **Applies sort orders** from `BaseFilter.getOrders()` in insertion order. +6. **Calls the DAO hook methods** (see §5) so subclasses can mutate the in-progress `CriteriaQuery`. +7. **Applies pagination** via `firstResult` / `maxResult` on the `TypedQuery`. + +The pipeline is implemented twice in shape (once for the `T` result query, once for the `Long` count query), but the predicate assembly is shared. `filterWithSingleResult` runs the standard list query and enforces single-result semantics, matching the existing `FilterProcesor` behavior. + +## 5. Extensibility — DAO hook methods + +Customization happens on the DAO base, not on the filter. `ConversionJpaDaoSupport` (and by inheritance `JpaDaoSupport`) exposes hook methods that default to no-ops: + +```java +/** Add predicates that don't fit the declarative model. Return null or empty to add nothing. */ +default Collection customizePredicates( + BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, Root root) { + return Collections.emptyList(); +} + +/** Last-chance hook to mutate the CriteriaQuery (joins, projections, group by, distinct, etc.) before execution. */ +default void customizeCriteria( + BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, Root root) { + // no-op +} +``` + +Both hooks are called once per query (filter, count, single-result). They receive the same `CriteriaQuery` instance that the processor is building, so subclasses can call `cq.distinct(true)`, add `LEFT JOIN FETCH`-style joins (within Criteria limits), or attach predicates that the annotation model can't express (e.g. correlated subqueries, function calls). + +Why hooks and not annotation processors: it keeps the abstraction surface small and predictable, and it routes customization through the DAO — the layer that already owns the entity-shaped logic — instead of fanning custom behavior across filter classes. + +## 6. Usage example + +```java +@Getter +@Setter +@Accessors(chain = true) +@SuperBuilder(toBuilder = true) +@NoArgsConstructor +public class PersonFilter extends BaseFilter { + + @Attribute("name") + private String name; // null → skipped; non-null → name = :v + + @Attribute("birthDate") @From + private LocalDate birthDateFrom; // null → skipped; non-null → birthDate >= :v + + @Attribute("birthDate") @To(inclusive = false) + private LocalDate birthDateToExclusive; // pairs with birthDateFrom; strict upper bound + + @Attribute("address.city.name") + private String cityName; // auto-joins address → city, equality on name + + @Attribute("deletedAt") @WhenNull(WhenNull.Policy.IS_NULL) + private Instant deletedAt; // null → deletedAt IS NULL; non-null → equality +} + +// POJO style +PersonFilter f1 = new PersonFilter() + .setBirthDateFrom(LocalDate.of(1990, 1, 1)); +f1.addOrder("name"); +f1.setMaxResult(50); + +// Builder style +PersonFilter f2 = PersonFilter.builder() + .birthDateFrom(LocalDate.of(1990, 1, 1)) + .order("name", BaseFilter.Order.ASC) + .maxResult(50) + .build(); + +List people = personDao.filter(f2); +``` + +With both bounds set on `birthDate`, the processor emits `birthDate >= :from AND birthDate < :to` rather than a `BETWEEN`, because the upper bound is exclusive. + +## 7. Deprecation plan for `QuerySpec` + +- All `QuerySpec`-typed DAO method overloads on `QueryDao` and `ConversionJpaDaoSupport` are marked `@Deprecated(forRemoval = false)` with javadoc pointing at the `BaseFilter` overloads. +- `QuerySpec`, the `Constraint` hierarchy (`Constraint`, `ConstraintBuilder`, every concrete `Attribute*Constraint` and `DisjunctionConstraint` / `NegatedConstraint` / `RelationalConstraint`), and the transformer types (`ConstraintTransformer`, `ConstraintTransformerJpaImpl`, `ConstraintTransformerException`) are also marked `@Deprecated(forRemoval = false)` in this change. Nothing is scheduled for removal yet — downstream code can keep compiling and running, just with deprecation warnings. +- Internally, the path/join helper inside `ConstraintTransformerJpaImpl` is **extracted to a non-deprecated utility** so the new processor can consume it without depending on the deprecated transformer. The deprecated transformer keeps working by delegating to that utility. +- A follow-up issue tracks: (a) eventually flipping the deprecations to `forRemoval = true` once downstream usage is gone, (b) removing the deprecated DAO method overloads and the constraint types in a future major version. + +## 8. Module placement + +- `backend-core-model` — `BaseFilter`, `@Attribute`, `@From`, `@To`, `@WhenNull`. Package: `com.flowingcode.backendcore.model.filter`. +- `backend-core-data` — new method signatures on `QueryDao`. +- `backend-core-data-impl` — `BaseFilterJpaProcessor`, default-method implementations on `ConversionJpaDaoSupport`, shared path/join utility extracted from `ConstraintTransformerJpaImpl`. + +## 9. Open questions + +1. **Sort order via annotation.** Should a filter class be able to declare a default sort via annotation (e.g. `@DefaultSort("createdAt DESC")`)? Out of scope for v1; callers use `addOrder`. +2. **Validation timing.** Should the per-class reflection/validation run eagerly at startup (e.g. via a CDI extension or a Spring `BeanPostProcessor`) or lazily on first use? Spec assumes lazy with caching. Eager validation can be added later without API changes. +3. **Field discovery.** Inherited fields from a deeper hierarchy (filter extending filter) should be supported. Confirm whether non-public fields require `setAccessible(true)` allowances in target deployments. +4. **Builder validation hookup.** The spec commits to validation firing from both POJO and builder paths but leaves the precise Lombok wiring (custom constructor accepting the builder vs. hand-written builder) to the implementer. Worth a quick prototype before locking in. + +## 10. Out of scope / follow-ups + +- Operator-specific annotations (`@Like`, `@ILike`, `@In`, `@IsNull` as a standalone, `@Gt`/`@Lt`, etc.). Revisit once usage patterns emerge. +- Boolean composition (`@Or` groups, `@Not`). +- Pluggable / user-registered annotation processors. +- Removal of `QuerySpec` and friends. +- Projection / returned-attributes support equivalent to `QuerySpec.returnedAttributes`. From 7aa87b80f2ce432db251d341c6df0ea415e6a4d3 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Tue, 23 Jun 2026 16:00:14 -0300 Subject: [PATCH 2/6] feat: new BaseFIlter to use as a base for building queries Basic default implementation for the BaseFilters, extendable to enable creation of custom constraints. Deprecating QuerySpec and all related methods --- .../dao/jpa/AttributePathResolver.java | 116 +++++ .../dao/jpa/BaseFilterJpaProcessor.java | 484 ++++++++++++++++++ .../dao/jpa/ConstraintTransformerJpaImpl.java | 5 + .../dao/jpa/ConversionJpaDaoSupport.java | 98 +++- .../dao/jpa/BaseFilterDaoHookTest.java | 246 +++++++++ .../dao/jpa/JpaDaoSupportTest.java | 1 + .../flowingcode/backendcore/dao/QueryDao.java | 32 ++ .../backendcore/model/Constraint.java | 5 + .../backendcore/model/ConstraintBuilder.java | 5 + .../model/ConstraintTransformer.java | 5 +- .../model/ConstraintTransformerException.java | 5 +- .../backendcore/model/QuerySpec.java | 8 + .../AttributeBetweenConstraint.java | 1 + .../constraints/AttributeConstraint.java | 1 + .../constraints/AttributeILikeConstraint.java | 1 + .../constraints/AttributeInConstraint.java | 1 + .../constraints/AttributeLikeConstraint.java | 1 + .../constraints/AttributeNullConstraint.java | 1 + .../AttributeRelationalConstraint.java | 1 + .../constraints/DisjunctionConstraint.java | 1 + .../model/constraints/NegatedConstraint.java | 1 + .../constraints/RelationalConstraint.java | 1 + .../backendcore/model/filter/Attribute.java | 71 +++ .../backendcore/model/filter/BaseFilter.java | 174 +++++++ .../backendcore/model/filter/From.java | 56 ++ .../backendcore/model/filter/To.java | 56 ++ .../backendcore/model/filter/WhenNull.java | 56 ++ .../model/filter/BaseFilterTest.java | 148 ++++++ specs/base-filter.md | 41 +- 29 files changed, 1614 insertions(+), 8 deletions(-) create mode 100644 backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java create mode 100644 backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/BaseFilterJpaProcessor.java create mode 100644 backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java create mode 100644 backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Attribute.java create mode 100644 backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java create mode 100644 backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/From.java create mode 100644 backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/To.java create mode 100644 backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/WhenNull.java create mode 100644 backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java new file mode 100644 index 0000000..c519cd6 --- /dev/null +++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java @@ -0,0 +1,116 @@ +/*- + * #%L + * Commons Backend - Data Access Layer Implementations + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * 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 + * + * http://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. + * #L% + */ +package com.flowingcode.backendcore.dao.jpa; + +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; + +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.From; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.JoinType; + +/** + * Resolves a dotted attribute path on a JPA {@code From} root into a leaf + * {@code Expression}, auto-joining associations along the way and reusing + * existing joins when one is already present on the same attribute and join + * type. + * + *

Instances are not thread-safe: a new resolver should be created per + * {@code CriteriaQuery}. + */ +public class AttributePathResolver { + + private final From root; + + private JoinType currentJoinType = JoinType.INNER; + + public AttributePathResolver(From root) { + this.root = Objects.requireNonNull(root, "root"); + } + + /** Returns the join type currently used when creating new joins. */ + public JoinType getCurrentJoinType() { + return currentJoinType; + } + + /** Sets the join type used for newly created joins by subsequent resolutions. */ + public void setCurrentJoinType(JoinType joinType) { + this.currentJoinType = Objects.requireNonNull(joinType, "joinType"); + } + + /** + * Resolves {@code attributePath} into an {@code Expression} of the leaf + * attribute on the root, auto-joining as needed. + */ + public Expression resolve(String attributePath) { + return resolve(attributePath, Object.class); + } + + /** + * Resolves {@code attributePath} and verifies the leaf attribute's Java type + * is assignable to {@code expectedType}. + * + * @throws ClassCastException if the leaf attribute type isn't compatible + */ + @SuppressWarnings("unchecked") + public Expression resolve(String attributePath, Class expectedType) { + Objects.requireNonNull(attributePath, "attributePath"); + String[] path = attributePath.split("\\."); + String attributeName = path[path.length - 1]; + String[] joinPath = Arrays.copyOf(path, path.length - 1); + Expression expression = traverse(root, joinPath).get(attributeName); + boxed(expression.getJavaType()).asSubclass(expectedType); + return (Expression) expression; + } + + private From traverse(From source, String[] path) { + From from = source; + for (String name : path) { + from = join(from, name); + } + return from; + } + + @SuppressWarnings("rawtypes") + private From join(From source, String attributeName) { + Optional existing = source.getJoins().stream() + .map(j -> (Join) j) + .filter(j -> j.getAttribute().getName().equals(attributeName)) + .filter(j -> j.getJoinType() == currentJoinType) + .findFirst(); + return existing.orElseGet(() -> source.join(attributeName, currentJoinType)); + } + + private static Class boxed(Class type) { + if (type.isPrimitive()) { + if (type == boolean.class) return Boolean.class; + if (type == int.class) return Integer.class; + if (type == long.class) return Long.class; + if (type == byte.class) return Byte.class; + if (type == short.class) return Short.class; + if (type == char.class) return Character.class; + if (type == float.class) return Float.class; + if (type == double.class) return Double.class; + } + return type; + } +} diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/BaseFilterJpaProcessor.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/BaseFilterJpaProcessor.java new file mode 100644 index 0000000..8b11b66 --- /dev/null +++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/BaseFilterJpaProcessor.java @@ -0,0 +1,484 @@ +/*- + * #%L + * Commons Backend - Data Access Layer Implementations + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * 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 + * + * http://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. + * #L% + */ +package com.flowingcode.backendcore.dao.jpa; + +import java.io.Serializable; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import com.flowingcode.backendcore.model.Identifiable; +import com.flowingcode.backendcore.model.filter.Attribute; +import com.flowingcode.backendcore.model.filter.BaseFilter; +import com.flowingcode.backendcore.model.filter.From; +import com.flowingcode.backendcore.model.filter.To; +import com.flowingcode.backendcore.model.filter.WhenNull; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +/** + * Builds and executes JPA {@code CriteriaQuery} from a {@link BaseFilter}. + * + *

The processor reflects on the filter class once (per JVM), caches the + * resulting metadata, and produces predicates honoring {@link Attribute}, + * {@link com.flowingcode.backendcore.model.filter.From} / + * {@link com.flowingcode.backendcore.model.filter.To} ranges and + * {@link WhenNull} null-handling policies. DAO subclasses contribute + * non-declarative predicates and other {@code CriteriaQuery} mutations through + * the supplied {@link Hooks}. + * + *

Instances are not thread-safe. Create a new instance per query + * invocation. + */ +class BaseFilterJpaProcessor, K extends Serializable> { + + /** DAO-supplied callbacks that augment the declaratively built criteria. */ + interface Hooks { + Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb, + CriteriaQuery cq, Root root); + + void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root); + } + + private static final ConcurrentMap, FilterMetadata> METADATA_CACHE = + new ConcurrentHashMap<>(); + + private final EntityManager em; + private final Class persistentClass; + private final Hooks hooks; + + static , K extends Serializable> BaseFilterJpaProcessor of( + EntityManager em, Class persistentClass, Hooks hooks) { + return new BaseFilterJpaProcessor<>(em, persistentClass, hooks); + } + + private BaseFilterJpaProcessor(EntityManager em, Class persistentClass, Hooks hooks) { + this.em = em; + this.persistentClass = persistentClass; + this.hooks = hooks; + } + + List filter(BaseFilter filter) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(persistentClass); + Root root = cq.from(persistentClass); + cq.select(root); + applyPredicates(filter, cb, cq, root); + applyOrders(filter, cb, cq, root); + hooks.customizeCriteria(filter, cb, cq, root); + TypedQuery query = em.createQuery(cq); + applyPaging(query, filter); + return query.getResultList(); + } + + Optional filterWithSingleResult(BaseFilter filter) { + List result = filter(filter); + if (result.size() > 1) { + throw new IllegalStateException("Current filter returned more than one result"); + } + return result.stream().findFirst(); + } + + long count(BaseFilter filter) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Long.class); + Root root = cq.from(persistentClass); + cq.select(cb.count(root)); + applyPredicates(filter, cb, cq, root); + hooks.customizeCriteria(filter, cb, cq, root); + return em.createQuery(cq).getSingleResult(); + } + + // ----- query assembly ----- + + private void applyPredicates(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + FilterMetadata metadata = metadataFor(filter.getClass()); + AttributePathResolver resolver = new AttributePathResolver(root); + + List predicates = new ArrayList<>(); + for (FieldHandler handler : metadata.handlers) { + Predicate predicate; + try { + predicate = handler.toPredicate(filter, cb, resolver); + } catch (IllegalAccessException e) { + throw new IllegalStateException( + "Cannot read field " + handler.describe() + " on " + filter.getClass(), e); + } + if (predicate != null) { + predicates.add(predicate); + } + } + + Collection extra = hooks.customizePredicates(filter, cb, cq, root); + if (extra != null && !extra.isEmpty()) { + predicates.addAll(extra); + } + + if (!predicates.isEmpty()) { + cq.where(predicates.toArray(new Predicate[0])); + } + } + + private void applyOrders(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + Map orders = filter.getOrders(); + if (orders == null || orders.isEmpty()) { + return; + } + AttributePathResolver resolver = new AttributePathResolver(root); + List jpaOrders = new ArrayList<>(orders.size()); + for (Entry e : orders.entrySet()) { + Expression expr = resolver.resolve(e.getKey()); + jpaOrders.add(e.getValue() == BaseFilter.Order.ASC ? cb.asc(expr) : cb.desc(expr)); + } + cq.orderBy(jpaOrders); + } + + private void applyPaging(TypedQuery query, BaseFilter filter) { + if (filter.getFirstResult() != null) { + query.setFirstResult(filter.getFirstResult()); + } + if (filter.getMaxResult() != null) { + query.setMaxResults(filter.getMaxResult()); + } + } + + // ----- metadata reflection + caching ----- + + private static FilterMetadata metadataFor(Class filterClass) { + return METADATA_CACHE.computeIfAbsent(filterClass, BaseFilterJpaProcessor::buildMetadata); + } + + private static FilterMetadata buildMetadata(Class filterClass) { + // Index every declared field in the hierarchy by name so the value-accessor + // helper can read any of them, including unannotated ones. Closer-to-leaf + // declarations win on name shadowing. + Map fieldsByName = new LinkedHashMap<>(); + List annotated = new ArrayList<>(); + Class c = filterClass; + while (c != null && c != Object.class) { + for (Field f : c.getDeclaredFields()) { + if (!fieldsByName.containsKey(f.getName())) { + f.setAccessible(true); + fieldsByName.put(f.getName(), f); + } + if (c != BaseFilter.class && (f.isAnnotationPresent(Attribute.class) + || f.isAnnotationPresent(From.class) || f.isAnnotationPresent(To.class) + || f.isAnnotationPresent(WhenNull.class))) { + annotated.add(f); + } + } + c = c.getSuperclass(); + } + + // Validate per-field constraints and group declarative fields by attribute path. + Map> byPath = new HashMap<>(); + for (Field f : annotated) { + Attribute attr = f.getAnnotation(Attribute.class); + From from = f.getAnnotation(From.class); + To to = f.getAnnotation(To.class); + WhenNull whenNull = f.getAnnotation(WhenNull.class); + + if ((from != null || to != null || whenNull != null) && attr == null) { + throw new IllegalStateException("Field " + describe(f) + + " has @From/@To/@WhenNull but no @Attribute"); + } + if (attr != null && attr.manual() + && (from != null || to != null || whenNull != null)) { + throw new IllegalStateException("Field " + describe(f) + + " is @Attribute(manual=true); it cannot combine with @From, @To or @WhenNull"); + } + if (from != null && to != null) { + throw new IllegalStateException("Field " + describe(f) + + " cannot be both @From and @To"); + } + if (whenNull != null && (from != null || to != null)) { + throw new IllegalStateException("Field " + describe(f) + + " cannot combine @WhenNull with @From or @To"); + } + // Manual fields are tracked in fieldsByName already; skip declarative grouping. + if (attr.manual()) { + continue; + } + byPath.computeIfAbsent(attr.value(), k -> new ArrayList<>()).add(f); + } + + List handlers = new ArrayList<>(); + for (Entry> entry : byPath.entrySet()) { + String path = entry.getKey(); + List group = entry.getValue(); + handlers.add(buildHandler(path, group)); + } + return new FilterMetadata(handlers, Collections.unmodifiableMap(fieldsByName)); + } + + private static FieldHandler buildHandler(String path, List group) { + if (group.size() == 1) { + Field f = group.get(0); + From from = f.getAnnotation(From.class); + To to = f.getAnnotation(To.class); + if (from != null) { + return new LowerBoundHandler(path, f, from.inclusive()); + } + if (to != null) { + return new UpperBoundHandler(path, f, to.inclusive()); + } + WhenNull whenNull = f.getAnnotation(WhenNull.class); + WhenNull.Policy policy = whenNull != null ? whenNull.value() : WhenNull.Policy.SKIP; + return new EqualityHandler(path, f, policy); + } + if (group.size() == 2) { + Field a = group.get(0); + Field b = group.get(1); + From fromA = a.getAnnotation(From.class); + To toA = a.getAnnotation(To.class); + From fromB = b.getAnnotation(From.class); + To toB = b.getAnnotation(To.class); + Field lower = null; + Field upper = null; + boolean lowerInclusive = true; + boolean upperInclusive = true; + if (fromA != null && toB != null) { + lower = a; + upper = b; + lowerInclusive = fromA.inclusive(); + upperInclusive = toB.inclusive(); + } else if (fromB != null && toA != null) { + lower = b; + upper = a; + lowerInclusive = fromB.inclusive(); + upperInclusive = toA.inclusive(); + } + if (lower != null) { + return new RangePairHandler(path, lower, upper, lowerInclusive, upperInclusive); + } + } + throw new IllegalStateException("Attribute path \"" + path + + "\" is mapped by multiple filter fields that do not form a @From/@To pair: " + + describe(group)); + } + + private static String describe(Field f) { + return f.getDeclaringClass().getSimpleName() + "." + f.getName(); + } + + private static String describe(List fields) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < fields.size(); i++) { + if (i > 0) sb.append(", "); + sb.append(describe(fields.get(i))); + } + return sb.toString(); + } + + // ----- handler types ----- + + /** + * Reads {@code fieldName} on the given {@code filter} using the cached + * reflection metadata. Intended to be called from DAO hooks that need to + * consume the value of a field marked {@code @Attribute(manual=true)} (or any + * other field on the filter) without re-doing reflection. + * + * @throws IllegalArgumentException if the filter class has no field with that + * name + */ + static Object readField(BaseFilter filter, String fieldName) { + FilterMetadata metadata = metadataFor(filter.getClass()); + Field f = metadata.fieldsByName.get(fieldName); + if (f == null) { + throw new IllegalArgumentException("Filter " + filter.getClass().getSimpleName() + + " has no field named: " + fieldName); + } + try { + return f.get(filter); + } catch (IllegalAccessException e) { + throw new IllegalStateException( + "Cannot read field " + describe(f) + " on " + filter.getClass(), e); + } + } + + private static final class FilterMetadata { + final List handlers; + final Map fieldsByName; + + FilterMetadata(List handlers, Map fieldsByName) { + this.handlers = List.copyOf(handlers); + this.fieldsByName = fieldsByName; + } + } + + private abstract static class FieldHandler { + final String attributePath; + + FieldHandler(String attributePath) { + this.attributePath = attributePath; + } + + abstract Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, + AttributePathResolver resolver) throws IllegalAccessException; + + abstract String describe(); + } + + private static final class EqualityHandler extends FieldHandler { + private final Field field; + private final WhenNull.Policy nullPolicy; + + EqualityHandler(String path, Field field, WhenNull.Policy nullPolicy) { + super(path); + this.field = field; + this.nullPolicy = nullPolicy; + } + + @Override + Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolver resolver) + throws IllegalAccessException { + Object value = field.get(filter); + if (value == null) { + return nullPolicy == WhenNull.Policy.IS_NULL ? cb.isNull(resolver.resolve(attributePath)) + : null; + } + return cb.equal(resolver.resolve(attributePath), value); + } + + @Override + String describe() { + return BaseFilterJpaProcessor.describe(field); + } + } + + private static final class LowerBoundHandler extends FieldHandler { + private final Field field; + private final boolean inclusive; + + LowerBoundHandler(String path, Field field, boolean inclusive) { + super(path); + this.field = field; + this.inclusive = inclusive; + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolver resolver) + throws IllegalAccessException { + Object value = field.get(filter); + if (value == null) return null; + Expression expr = resolver.resolve(attributePath, Comparable.class); + return inclusive ? cb.greaterThanOrEqualTo(expr, (Comparable) value) + : cb.greaterThan(expr, (Comparable) value); + } + + @Override + String describe() { + return BaseFilterJpaProcessor.describe(field); + } + } + + private static final class UpperBoundHandler extends FieldHandler { + private final Field field; + private final boolean inclusive; + + UpperBoundHandler(String path, Field field, boolean inclusive) { + super(path); + this.field = field; + this.inclusive = inclusive; + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolver resolver) + throws IllegalAccessException { + Object value = field.get(filter); + if (value == null) return null; + Expression expr = resolver.resolve(attributePath, Comparable.class); + return inclusive ? cb.lessThanOrEqualTo(expr, (Comparable) value) + : cb.lessThan(expr, (Comparable) value); + } + + @Override + String describe() { + return BaseFilterJpaProcessor.describe(field); + } + } + + private static final class RangePairHandler extends FieldHandler { + private final Field lower; + private final Field upper; + private final boolean lowerInclusive; + private final boolean upperInclusive; + + RangePairHandler(String path, Field lower, Field upper, boolean lowerInclusive, + boolean upperInclusive) { + super(path); + this.lower = lower; + this.upper = upper; + this.lowerInclusive = lowerInclusive; + this.upperInclusive = upperInclusive; + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolver resolver) + throws IllegalAccessException { + Object lo = lower.get(filter); + Object hi = upper.get(filter); + if (lo == null && hi == null) return null; + Expression expr = resolver.resolve(attributePath, Comparable.class); + + if (lo != null && hi != null && lowerInclusive && upperInclusive) { + return cb.between(expr, (Comparable) lo, (Comparable) hi); + } + + List parts = new ArrayList<>(2); + if (lo != null) { + parts.add(lowerInclusive ? cb.greaterThanOrEqualTo(expr, (Comparable) lo) + : cb.greaterThan(expr, (Comparable) lo)); + } + if (hi != null) { + parts.add(upperInclusive ? cb.lessThanOrEqualTo(expr, (Comparable) hi) + : cb.lessThan(expr, (Comparable) hi)); + } + return parts.size() == 1 ? parts.get(0) : cb.and(parts.toArray(new Predicate[0])); + } + + @Override + String describe() { + return BaseFilterJpaProcessor.describe(lower) + " + " + + BaseFilterJpaProcessor.describe(upper); + } + } +} diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java index 593dda9..1e22039 100644 --- a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java +++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java @@ -49,7 +49,12 @@ * JPA/Criteria implementation of {@link ConstraintTransformer}. * *

Instances are not thread-safe. A new instance must be created for each query. + * + * @deprecated Part of the legacy {@code QuerySpec}-based filter API. New code + * should use {@link com.flowingcode.backendcore.model.filter.BaseFilter} + * and the DAO {@code filter(BaseFilter)} overloads. */ +@Deprecated(since = "1.2.0", forRemoval = false) @RequiredArgsConstructor public class ConstraintTransformerJpaImpl extends ConstraintTransformer { diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConversionJpaDaoSupport.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConversionJpaDaoSupport.java index a2ab5e4..b7ffd2a 100644 --- a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConversionJpaDaoSupport.java +++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConversionJpaDaoSupport.java @@ -22,6 +22,8 @@ import java.io.Serializable; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Map.Entry; import java.util.Objects; @@ -38,6 +40,7 @@ import com.flowingcode.backendcore.dao.CrudDao; import com.flowingcode.backendcore.model.Identifiable; import com.flowingcode.backendcore.model.QuerySpec; +import com.flowingcode.backendcore.model.filter.BaseFilter; public interface ConversionJpaDaoSupport, K extends Serializable> extends CrudDao { @@ -101,17 +104,23 @@ default List findAll() { } @Override + @Deprecated(since = "1.2.0", forRemoval = false) + @SuppressWarnings("deprecation") default long count(QuerySpec filter) { return FilterProcesor.of(getEntityManager(), getPersistentClass()).count(filter); } @Override + @Deprecated(since = "1.2.0", forRemoval = false) + @SuppressWarnings("deprecation") default List filter(QuerySpec filter) { return FilterProcesor.of(getEntityManager(), getPersistentClass()).filter(filter).stream() .map(this::convertFrom).collect(Collectors.toList()); } - + @Override + @Deprecated(since = "1.2.0", forRemoval = false) + @SuppressWarnings("deprecation") default Optional filterWithSingleResult(QuerySpec filter) { List filtered = FilterProcesor.of(getEntityManager(), getPersistentClass()).filter(filter); if (filtered.size()>1) { @@ -121,6 +130,93 @@ default Optional filterWithSingleResult(QuerySpec filter) { .map(this::convertFrom).findAny(); } + @Override + default List filter(BaseFilter filter) { + return baseFilterProcessor().filter(filter).stream() + .map(this::convertFrom).collect(Collectors.toList()); + } + + @Override + default Optional filterWithSingleResult(BaseFilter filter) { + return baseFilterProcessor().filterWithSingleResult(filter).map(this::convertFrom); + } + + @Override + default long count(BaseFilter filter) { + return baseFilterProcessor().count(filter); + } + + /** + * Hook for adding non-declarative predicates to a {@link BaseFilter}-driven + * query. Predicates returned here are ANDed with those derived from the + * filter's annotations. + * + *

Defaults to no extra predicates. + */ + default Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb, + CriteriaQuery cq, Root root) { + return Collections.emptyList(); + } + + /** + * Last-chance hook to mutate the in-progress {@code CriteriaQuery} (e.g. + * {@code distinct}, projections, group-by). Called once per filter, count, + * and single-result query after predicates have been applied. + * + *

Defaults to a no-op. + */ + default void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + // no-op + } + + /** + * Reads the value of the given field on {@code filter} without forcing the + * hook to do its own reflection. Backed by the same cached metadata used to + * build the declarative predicates. + * + *

Useful from {@link #customizePredicates} when consuming a field + * declared as {@code @Attribute(manual = true)}, but works for any field + * declared on the filter (annotated or not). + * + * @throws IllegalArgumentException if the filter class has no field with the + * given name + */ + default Object getFilterFieldValue(BaseFilter filter, String fieldName) { + return BaseFilterJpaProcessor.readField(filter, fieldName); + } + + /** + * Typed convenience overload of {@link #getFilterFieldValue(BaseFilter, String)}; + * casts the value through {@code type} so the caller doesn't have to. + * + * @throws IllegalArgumentException if the filter class has no such field + * @throws ClassCastException if the stored value is not assignable to + * {@code type} + */ + default V getFilterFieldValue(BaseFilter filter, String fieldName, Class type) { + Object value = getFilterFieldValue(filter, fieldName); + return value == null ? null : type.cast(value); + } + + private BaseFilterJpaProcessor baseFilterProcessor() { + final ConversionJpaDaoSupport self = this; + return BaseFilterJpaProcessor.of(getEntityManager(), getPersistentClass(), + new BaseFilterJpaProcessor.Hooks() { + @Override + public Collection customizePredicates(BaseFilter filter, + CriteriaBuilder cb, CriteriaQuery cq, Root root) { + return self.customizePredicates(filter, cb, cq, root); + } + + @Override + public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, + CriteriaQuery cq, Root root) { + self.customizeCriteria(filter, cb, cq, root); + } + }); + } + static class FilterProcesor, K extends Serializable> { private Class persistentClass; diff --git a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java new file mode 100644 index 0000000..5ee2f5e --- /dev/null +++ b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java @@ -0,0 +1,246 @@ +/*- + * #%L + * Commons Backend - Data Access Layer Implementations + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * 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 + * + * http://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. + * #L% + */ +package com.flowingcode.backendcore.dao.jpa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.Persistence; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.flowingcode.backendcore.model.filter.Attribute; +import com.flowingcode.backendcore.model.filter.BaseFilter; +import com.flowingcode.backendcore.model.filter.From; +import com.flowingcode.backendcore.model.impl.Person; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.SuperBuilder; + +class BaseFilterDaoHookTest { + + @Getter + @Setter + @Accessors(chain = true) + @NoArgsConstructor + @SuperBuilder + static class PersonFilter extends BaseFilter { + // Empty: this suite exercises the DAO hooks, not the annotation pipeline. + } + + static class HookDao implements JpaDaoSupport { + + final EntityManagerFactory emf; + final AtomicInteger predicatesCalls = new AtomicInteger(); + final AtomicInteger criteriaCalls = new AtomicInteger(); + final AtomicBoolean restrictByName = new AtomicBoolean(); + + HookDao(EntityManagerFactory emf) { + this.emf = emf; + } + + @Override + public EntityManager getEntityManager() { + return emf.createEntityManager(); + } + + @Override + public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb, + CriteriaQuery cq, Root root) { + predicatesCalls.incrementAndGet(); + if (!restrictByName.get()) { + return Collections.emptyList(); + } + List ps = new ArrayList<>(); + ps.add(cb.equal(root.get("name"), "John")); + return ps; + } + + @Override + public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + criteriaCalls.incrementAndGet(); + } + } + + private HookDao dao; + + @BeforeEach + void setUp() { + EntityManagerFactory emf = Persistence.createEntityManagerFactory("person"); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + for (String name : new String[] {"John", "Jane", "Alice", "John"}) { + Person p = new Person(); + p.setName(name); + p.setLastName("Doe"); + em.persist(p); + } + em.getTransaction().commit(); + em.close(); + dao = new HookDao(emf); + } + + @Test + void hooksFireOnFilterAndCount() { + PersonFilter f = PersonFilter.builder().build(); + dao.predicatesCalls.set(0); + dao.criteriaCalls.set(0); + + dao.filter(f); + dao.count(f); + + assertEquals(2, dao.predicatesCalls.get(), + "customizePredicates should fire on filter() and count()"); + assertEquals(2, dao.criteriaCalls.get(), + "customizeCriteria should fire on filter() and count()"); + } + + @Test + void hookFiresOnFilterWithSingleResult() { + // Restrict to a name with exactly one match so the call succeeds. + dao.restrictByName.set(true); + // The data has two "John"s, so override the restriction inline. + HookDao d = new HookDao(dao.emf) { + @Override + public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb, + CriteriaQuery cq, Root root) { + predicatesCalls.incrementAndGet(); + return List.of(cb.equal(root.get("name"), "Jane")); + } + }; + d.filterWithSingleResult(PersonFilter.builder().build()); + assertEquals(1, d.predicatesCalls.get()); + assertEquals(1, d.criteriaCalls.get()); + } + + @Test + void predicateHookRestrictsResults() { + PersonFilter f = PersonFilter.builder().build(); + + long unrestricted = dao.count(f); + assertEquals(4, unrestricted); + + dao.restrictByName.set(true); + long restricted = dao.count(f); + assertEquals(2, restricted, "predicate hook should restrict to two Johns"); + + List people = dao.filter(f); + assertEquals(2, people.size()); + assertTrue(people.stream().allMatch(p -> "John".equals(p.getName()))); + } + + @Getter + @Setter + @Accessors(chain = true) + @NoArgsConstructor + @SuperBuilder + static class ManualPersonFilter extends BaseFilter { + + // Manual field: processor records it but never produces a predicate. The + // hook consumes the value via getFilterFieldValue and builds the LIKE. + @Attribute(value = "name", manual = true) + private String nameLike; + } + + @Test + void manualField_isSkipped_andHookUsesHelperToBuildPredicate() { + HookDao d = new HookDao(dao.emf) { + @Override + public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb, + CriteriaQuery cq, Root root) { + predicatesCalls.incrementAndGet(); + String pattern = getFilterFieldValue(filter, "nameLike", String.class); + return pattern == null ? Collections.emptyList() + : List.of(cb.like(root.get("name"), pattern)); + } + }; + + // No pattern set → no extra predicate → all four people returned. + assertEquals(4, d.count(ManualPersonFilter.builder().build())); + + // Pattern set → hook builds the LIKE → matches the two "John"s. + ManualPersonFilter f = ManualPersonFilter.builder().nameLike("Jo%").build(); + assertEquals(2, d.count(f)); + List results = d.filter(f); + assertEquals(2, results.size()); + assertTrue(results.stream().allMatch(p -> p.getName().startsWith("Jo"))); + } + + @Getter + @Setter + @Accessors(chain = true) + @NoArgsConstructor + @SuperBuilder + static class BadManualFilter extends BaseFilter { + // Invalid: @Attribute(manual=true) cannot combine with @From / @To / @WhenNull. + @Attribute(value = "birthDay", manual = true) @From + private java.util.Date birthDayFrom; + } + + @Test + void manualField_rejectsRangeAnnotations() { + BadManualFilter bad = BadManualFilter.builder().build(); + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> dao.count(bad)); + assertTrue(ex.getMessage().contains("manual=true"), + "error message should mention manual=true; got: " + ex.getMessage()); + } + + @Test + void getFilterFieldValue_rejectsUnknownField() { + assertThrows(IllegalArgumentException.class, + () -> dao.getFilterFieldValue(PersonFilter.builder().build(), "noSuchField")); + } + + @Test + void criteriaHookCanMutateQuery() { + // Validate the hook can run cq.* operations against the live query. Applying + // distinct on a SELECT-entity query with unique IDs is a no-op result-wise + // but a valid Criteria mutation that exercises the hook end-to-end. + HookDao d = new HookDao(dao.emf) { + @Override + public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + cq.distinct(true); + } + }; + List people = d.filter(PersonFilter.builder().build()); + assertEquals(4, people.size()); + } +} diff --git a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java index d624c5e..abc2e5c 100644 --- a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java +++ b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java @@ -45,6 +45,7 @@ import com.flowingcode.backendcore.model.impl.State; import com.github.javafaker.Faker; +@SuppressWarnings("deprecation") class JpaDaoSupportTest { private PersonCrudDaoImpl dao; diff --git a/backend-core-data/src/main/java/com/flowingcode/backendcore/dao/QueryDao.java b/backend-core-data/src/main/java/com/flowingcode/backendcore/dao/QueryDao.java index 5801439..b98deee 100644 --- a/backend-core-data/src/main/java/com/flowingcode/backendcore/dao/QueryDao.java +++ b/backend-core-data/src/main/java/com/flowingcode/backendcore/dao/QueryDao.java @@ -23,6 +23,7 @@ import java.util.Optional; import com.flowingcode.backendcore.model.QuerySpec; +import com.flowingcode.backendcore.model.filter.BaseFilter; public interface QueryDao { @@ -30,10 +31,41 @@ public interface QueryDao { List findAll(); + /** + * @deprecated Use {@link #filter(BaseFilter)} with a {@link BaseFilter} + * subclass. + */ + @Deprecated(since = "1.2.0", forRemoval = false) List filter(QuerySpec filter); + /** + * @deprecated Use {@link #filterWithSingleResult(BaseFilter)} with a + * {@link BaseFilter} subclass. + */ + @Deprecated(since = "1.2.0", forRemoval = false) Optional filterWithSingleResult(QuerySpec filter); + /** + * @deprecated Use {@link #count(BaseFilter)} with a {@link BaseFilter} + * subclass. + */ + @Deprecated(since = "1.2.0", forRemoval = false) long count(QuerySpec filter); + /** + * Returns the entities that match the given {@code filter}. Annotations on + * the filter fields drive the generated JPA Criteria. + */ + List filter(BaseFilter filter); + + /** + * Returns the single entity matching the given {@code filter}, if any. + * + * @throws IllegalStateException if more than one entity matches + */ + Optional filterWithSingleResult(BaseFilter filter); + + /** Returns the number of entities matching the given {@code filter}. */ + long count(BaseFilter filter); + } diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java index 5ae1d30..5b88fc2 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java @@ -22,6 +22,11 @@ import com.flowingcode.backendcore.model.constraints.DisjunctionConstraint; import com.flowingcode.backendcore.model.constraints.NegatedConstraint; +/** + * @deprecated Part of the legacy {@link QuerySpec}-based filter API. New code + * should use {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ +@Deprecated(since = "1.2.0", forRemoval = false) public interface Constraint { default Constraint not() { diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintBuilder.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintBuilder.java index a61780e..d39357d 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintBuilder.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintBuilder.java @@ -36,6 +36,11 @@ import lombok.NonNull; import lombok.RequiredArgsConstructor; +/** + * @deprecated Part of the legacy {@link QuerySpec}-based filter API. New code + * should use {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ +@Deprecated(since = "1.2.0", forRemoval = false) @RequiredArgsConstructor(access = AccessLevel.PROTECTED) public class ConstraintBuilder { diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java index 8c2213f..aa1456f 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java @@ -37,8 +37,11 @@ * and one or more of the {@code transform*Constraint} methods for providing the actual representations for the underlying database technology. * * @param The type of the implementation-specific representation of the constraint. - * @author Javier Godoy / Flowing Code + * @author Javier Godoy / Flowing Code + * @deprecated Part of the legacy {@link QuerySpec}-based filter API. New code + * should use {@link com.flowingcode.backendcore.model.filter.BaseFilter}. */ +@Deprecated(since = "1.2.0", forRemoval = false) public abstract class ConstraintTransformer implements Function { /**Return an implementation-specific representation of the constraint. diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformerException.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformerException.java index 3d80407..bcb4ce3 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformerException.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformerException.java @@ -21,9 +21,12 @@ /** * Thrown by {@link ConstraintTransformer} when the {@link QuerySpec} contains an unsupported {@link Constraint}. - * + * * @author Javier Godoy / Flowing Code + * @deprecated Part of the legacy {@link QuerySpec}-based filter API. New code + * should use {@link com.flowingcode.backendcore.model.filter.BaseFilter}. */ +@Deprecated(since = "1.2.0", forRemoval = false) public class ConstraintTransformerException extends RuntimeException { private static final long serialVersionUID = 1L; diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/QuerySpec.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/QuerySpec.java index dc63cfe..329fdd9 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/QuerySpec.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/QuerySpec.java @@ -30,6 +30,14 @@ import lombok.Setter; import lombok.experimental.Accessors; +/** + * @deprecated Use {@link com.flowingcode.backendcore.model.filter.BaseFilter} + * and the annotation-driven filter API instead. This type, the + * {@link Constraint} hierarchy and the {@link ConstraintTransformer} + * support are retained for backwards compatibility but slated for + * removal in a future major version. + */ +@Deprecated(since = "1.2.0", forRemoval = false) @Accessors(chain=true) public class QuerySpec { diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeBetweenConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeBetweenConstraint.java index 5096e51..92106da 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeBetweenConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeBetweenConstraint.java @@ -26,6 +26,7 @@ import lombok.NonNull; import lombok.experimental.FieldDefaults; +@Deprecated(since = "1.2.0", forRemoval = false) @Getter @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class AttributeBetweenConstraint implements AttributeConstraint { diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeConstraint.java index a47378b..8897fde 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeConstraint.java @@ -21,6 +21,7 @@ import com.flowingcode.backendcore.model.Constraint; +@Deprecated(since = "1.2.0", forRemoval = false) public interface AttributeConstraint extends Constraint { String getAttribute(); diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeILikeConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeILikeConstraint.java index ec2a1cd..62fead0 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeILikeConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeILikeConstraint.java @@ -25,6 +25,7 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +@Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeInConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeInConstraint.java index aed5f21..7729e16 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeInConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeInConstraint.java @@ -27,6 +27,7 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +@Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeLikeConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeLikeConstraint.java index a343c19..3946cf7 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeLikeConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeLikeConstraint.java @@ -25,6 +25,7 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +@Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeNullConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeNullConstraint.java index 99b355d..cf0812e 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeNullConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeNullConstraint.java @@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +@Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeRelationalConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeRelationalConstraint.java index da3292a..ea2c416 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeRelationalConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeRelationalConstraint.java @@ -25,6 +25,7 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +@Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java index 7cc1859..725a182 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java @@ -29,6 +29,7 @@ import lombok.experimental.FieldDefaults; /** A constraint that is satisfied when any of its member constraints is satisfied (logical OR). */ +@Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor(access = AccessLevel.PRIVATE) @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/NegatedConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/NegatedConstraint.java index 3630eda..af75332 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/NegatedConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/NegatedConstraint.java @@ -27,6 +27,7 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +@Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/RelationalConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/RelationalConstraint.java index 6663723..30f38e6 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/RelationalConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/RelationalConstraint.java @@ -21,6 +21,7 @@ import com.flowingcode.backendcore.model.Constraint; +@Deprecated(since = "1.2.0", forRemoval = false) public interface RelationalConstraint extends Constraint { String EQ = "="; diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Attribute.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Attribute.java new file mode 100644 index 0000000..a6f60d7 --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Attribute.java @@ -0,0 +1,71 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * 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 + * + * http://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. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Maps a filter field to an entity attribute. + * + *

The value is a dotted attribute path on the target entity (e.g. + * {@code "city.state.name"}). Path traversal auto-joins associations using + * inner joins, reusing existing joins when one is already present on the same + * attribute and join type. + * + *

A filter field carrying only {@code @Attribute} is interpreted as an + * equality predicate against the resolved attribute. Pair it with {@link From} + * or {@link To} to express range comparisons, or with {@link WhenNull} to + * control how a null field value is handled. + * + *

Set {@link #manual()} to {@code true} when the predicate for the field is + * built by hand in a DAO hook (e.g. + * {@code ConversionJpaDaoSupport#customizePredicates}). The processor will skip + * declarative predicate building for the field but still track it so the + * field's value can be retrieved via + * {@code ConversionJpaDaoSupport#getFilterFieldValue}. Manual fields cannot + * combine with {@link From}, {@link To} or {@link WhenNull}, since none of + * those have meaning when the predicate is hand-built. + * + * @see From + * @see To + * @see WhenNull + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Attribute { + + /** + * Dotted attribute path on the target entity (e.g. {@code "city.state.name"}). + * On a {@link #manual() manual} field the value is informational — the + * processor never resolves it — and is still useful as documentation of + * which entity attribute the hook is expected to target. + */ + String value(); + + /** + * When {@code true}, the processor records the field for value lookup but + * does not generate a declarative predicate. The caller is responsible for + * producing the predicate in a DAO hook. + */ + boolean manual() default false; +} diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java new file mode 100644 index 0000000..7961d7a --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java @@ -0,0 +1,174 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * 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 + * + * http://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. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.SuperBuilder; + +/** + * Base class for annotation-driven filters consumed by the DAO layer. + * + *

Filter subclasses declare typed fields annotated with {@link Attribute} + * and (optionally) {@link From} / {@link To} / {@link WhenNull}. The DAO base + * reflects on the populated instance to build a JPA {@code CriteriaQuery}. + * + *

Two construction styles are supported and produce equivalent state: + * + *

{@code
+ * // POJO style
+ * PersonFilter f = new PersonFilter()
+ *         .setName("Ada")
+ *         .setBirthDateFrom(LocalDate.of(1990, 1, 1));
+ * f.addOrder("name");
+ * f.setMaxResult(50);
+ *
+ * // Builder style (Lombok @SuperBuilder)
+ * PersonFilter f = PersonFilter.builder()
+ *         .name("Ada")
+ *         .birthDateFrom(LocalDate.of(1990, 1, 1))
+ *         .addOrder("name", BaseFilter.Order.ASC)
+ *         .maxResult(50)
+ *         .build();
+ * }
+ * + *

Pagination validators ({@link #setFirstResult(Integer)} / + * {@link #setMaxResult(Integer)}) reject negative values from the POJO path, + * and the builder's {@code firstResult(...)} / {@code maxResult(...)} methods + * apply the same checks before {@code build()} returns, so both styles enforce + * the same invariants. + */ +@Getter +@Setter +@Accessors(chain = true) +@NoArgsConstructor +@SuperBuilder(toBuilder = true) +public abstract class BaseFilter { + + /** Sort direction for an ordering entry. */ + public enum Order { + ASC, DESC + } + + private Map orders; + + private Integer firstResult; + + private Integer maxResult; + + /** Adds an ascending order on {@code attribute}. */ + public BaseFilter addOrder(String attribute) { + return addOrder(attribute, Order.ASC); + } + + /** Adds an order on {@code attribute} with the given {@code direction}. */ + public BaseFilter addOrder(String attribute, Order direction) { + if (this.orders == null) { + this.orders = new LinkedHashMap<>(); + } + this.orders.put(attribute, direction); + return this; + } + + /** + * Returns the configured sort orders, preserving insertion order. Never + * {@code null}; an empty map indicates no ordering. + */ + public Map getOrders() { + return orders == null ? Collections.emptyMap() : orders; + } + + /** + * Sets the position of the first result to retrieve (numbered from 0). + * + * @param firstResult the position, or {@code null} to clear + * @throws IllegalArgumentException if the argument is negative + */ + public BaseFilter setFirstResult(Integer firstResult) { + validateNonNegative(firstResult, "firstResult"); + this.firstResult = firstResult; + return this; + } + + /** + * Sets the maximum number of results to retrieve. + * + * @param maxResult the cap, or {@code null} for no cap + * @throws IllegalArgumentException if the argument is negative + */ + public BaseFilter setMaxResult(Integer maxResult) { + validateNonNegative(maxResult, "maxResult"); + this.maxResult = maxResult; + return this; + } + + private static void validateNonNegative(Integer value, String name) { + if (value != null && value < 0) { + throw new IllegalArgumentException(name + " must be >= 0"); + } + } + + /** + * Inner builder declared explicitly so that paging validation and the + * per-entry order adder are available on the builder API. Lombok's + * {@code @SuperBuilder} fills in the rest (field accumulation, {@code self()}, + * {@code build()}, the subclass plumbing). + */ + public abstract static class BaseFilterBuilder> { + + /** Adds an ascending order on {@code attribute}. */ + public B addOrder(String attribute) { + return addOrder(attribute, Order.ASC); + } + + /** Adds an order on {@code attribute} with the given {@code direction}. */ + public B addOrder(String attribute, Order direction) { + if (this.orders == null) { + this.orders = new LinkedHashMap<>(); + } + this.orders.put(attribute, direction); + return self(); + } + + /** + * @throws IllegalArgumentException if {@code firstResult} is negative + */ + public B firstResult(Integer firstResult) { + validateNonNegative(firstResult, "firstResult"); + this.firstResult = firstResult; + return self(); + } + + /** + * @throws IllegalArgumentException if {@code maxResult} is negative + */ + public B maxResult(Integer maxResult) { + validateNonNegative(maxResult, "maxResult"); + this.maxResult = maxResult; + return self(); + } + } +} diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/From.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/From.java new file mode 100644 index 0000000..9848b1b --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/From.java @@ -0,0 +1,56 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * 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 + * + * http://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. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a filter field as the lower bound of a range comparison on its + * {@link Attribute}. + * + *

When {@link #inclusive()} is {@code true} (default) the predicate is + * {@code attribute >= value}; when {@code false}, it becomes + * {@code attribute > value}. + * + *

If another field on the same filter declares the same {@code @Attribute} + * value and carries a {@link To} annotation, the two fields form a range. With + * both bounds non-null and both inclusive, the processor emits a single + * {@code BETWEEN} predicate; otherwise two ANDed comparison predicates are + * emitted honoring each side's {@code inclusive} setting. + * + *

Must be paired with {@link Attribute}; otherwise the processor reports a + * configuration error. + * + * @see Attribute + * @see To + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface From { + + /** + * When {@code true} (default), the lower bound is inclusive ({@code >=}). + * When {@code false}, the bound is strict ({@code >}). + */ + boolean inclusive() default true; +} diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/To.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/To.java new file mode 100644 index 0000000..f3d25d2 --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/To.java @@ -0,0 +1,56 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * 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 + * + * http://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. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a filter field as the upper bound of a range comparison on its + * {@link Attribute}. + * + *

When {@link #inclusive()} is {@code true} (default) the predicate is + * {@code attribute <= value}; when {@code false}, it becomes + * {@code attribute < value}. + * + *

If another field on the same filter declares the same {@code @Attribute} + * value and carries a {@link From} annotation, the two fields form a range. + * With both bounds non-null and both inclusive, the processor emits a single + * {@code BETWEEN} predicate; otherwise two ANDed comparison predicates are + * emitted honoring each side's {@code inclusive} setting. + * + *

Must be paired with {@link Attribute}; otherwise the processor reports a + * configuration error. + * + * @see Attribute + * @see From + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface To { + + /** + * When {@code true} (default), the upper bound is inclusive ({@code <=}). + * When {@code false}, the bound is strict ({@code <}). + */ + boolean inclusive() default true; +} diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/WhenNull.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/WhenNull.java new file mode 100644 index 0000000..acdb75a --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/WhenNull.java @@ -0,0 +1,56 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * 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 + * + * http://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. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Overrides how a {@code null} value on the annotated filter field is handled. + * + *

The default policy for any field carrying {@link Attribute} is + * {@link Policy#SKIP}: a null field contributes no predicate. Use this + * annotation with {@link Policy#IS_NULL} to instead emit a + * {@code attribute IS NULL} predicate. + * + *

{@code @WhenNull} requires {@link Attribute} on the same field and is not + * allowed alongside {@link From} or {@link To}, since range bounds have no + * sensible {@code IS_NULL} semantics. + * + * @see Attribute + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface WhenNull { + + Policy value(); + + /** Policy applied to a null filter field. */ + enum Policy { + + /** Emit no predicate for this field when its value is {@code null}. */ + SKIP, + + /** Emit an {@code IS NULL} predicate when the field value is {@code null}. */ + IS_NULL + } +} diff --git a/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java new file mode 100644 index 0000000..1b3bd41 --- /dev/null +++ b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java @@ -0,0 +1,148 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * 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 + * + * http://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. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.LocalDate; +import java.util.Arrays; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.SuperBuilder; + +import org.junit.jupiter.api.Test; + +class BaseFilterTest { + + @Getter + @Setter + @Accessors(chain = true) + @NoArgsConstructor + @SuperBuilder(toBuilder = true) + static class SampleFilter extends BaseFilter { + + @Attribute("name") + private String name; + + @Attribute("birthDate") @From + private LocalDate birthDateFrom; + + @Attribute("birthDate") @To(inclusive = false) + private LocalDate birthDateTo; + } + + @Test + void pojo_chainableSetters_carryAllState() { + SampleFilter f = new SampleFilter() + .setName("Ada") + .setBirthDateFrom(LocalDate.of(1990, 1, 1)) + .setBirthDateTo(LocalDate.of(2000, 1, 1)); + f.addOrder("name").setMaxResult(50).setFirstResult(10); + + assertEquals("Ada", f.getName()); + assertEquals(LocalDate.of(1990, 1, 1), f.getBirthDateFrom()); + assertEquals(LocalDate.of(2000, 1, 1), f.getBirthDateTo()); + assertEquals(50, f.getMaxResult()); + assertEquals(10, f.getFirstResult()); + assertIterableEquals(Arrays.asList("name"), f.getOrders().keySet()); + assertEquals(BaseFilter.Order.ASC, f.getOrders().get("name")); + } + + @Test + void builder_setsState() { + SampleFilter f = SampleFilter.builder() + .name("Ada") + .birthDateFrom(LocalDate.of(1990, 1, 1)) + .addOrder("name", BaseFilter.Order.DESC) + .addOrder("birthDate", BaseFilter.Order.ASC) + .firstResult(0) + .maxResult(25) + .build(); + + assertEquals("Ada", f.getName()); + assertEquals(LocalDate.of(1990, 1, 1), f.getBirthDateFrom()); + assertNull(f.getBirthDateTo()); + assertEquals(0, f.getFirstResult()); + assertEquals(25, f.getMaxResult()); + assertIterableEquals(Arrays.asList("name", "birthDate"), f.getOrders().keySet()); + assertEquals(BaseFilter.Order.DESC, f.getOrders().get("name")); + assertEquals(BaseFilter.Order.ASC, f.getOrders().get("birthDate")); + } + + @Test + void orders_preserveInsertionOrder() { + SampleFilter f = new SampleFilter(); + f.addOrder("c").addOrder("a").addOrder("b", BaseFilter.Order.DESC); + + assertIterableEquals(Arrays.asList("c", "a", "b"), f.getOrders().keySet()); + assertEquals(BaseFilter.Order.ASC, f.getOrders().get("c")); + assertEquals(BaseFilter.Order.DESC, f.getOrders().get("b")); + } + + @Test + void emptyFilter_hasEmptyOrders_andNullPaging() { + SampleFilter f = new SampleFilter(); + assertEquals(0, f.getOrders().size()); + assertNull(f.getFirstResult()); + assertNull(f.getMaxResult()); + } + + @Test + void firstResult_rejectsNegativeFromPojo() { + SampleFilter f = new SampleFilter(); + assertThrows(IllegalArgumentException.class, () -> f.setFirstResult(-1)); + } + + @Test + void maxResult_rejectsNegativeFromPojo() { + SampleFilter f = new SampleFilter(); + assertThrows(IllegalArgumentException.class, () -> f.setMaxResult(-1)); + } + + @Test + void firstResult_rejectsNegativeFromBuilder() { + assertThrows(IllegalArgumentException.class, + () -> SampleFilter.builder().firstResult(-1).build()); + } + + @Test + void maxResult_rejectsNegativeFromBuilder() { + assertThrows(IllegalArgumentException.class, + () -> SampleFilter.builder().maxResult(-1).build()); + } + + @Test + void toBuilder_producesIndependentCopy() { + SampleFilter original = SampleFilter.builder().name("Ada").maxResult(50).build(); + SampleFilter tweaked = original.toBuilder().maxResult(10).build(); + + assertEquals(50, original.getMaxResult()); + assertEquals(10, tweaked.getMaxResult()); + assertEquals("Ada", tweaked.getName()); + assertNotSame(original, tweaked); + } +} diff --git a/specs/base-filter.md b/specs/base-filter.md index 954ce8c..2ad9600 100644 --- a/specs/base-filter.md +++ b/specs/base-filter.md @@ -74,7 +74,7 @@ f.setMaxResult(50); PersonFilter f = PersonFilter.builder() .name("Ada") .birthDateFrom(LocalDate.of(1990, 1, 1)) - .order("name", BaseFilter.Order.ASC) // @Singular generates per-entry adder + .addOrder("name", BaseFilter.Order.ASC) .maxResult(50) .build(); ``` @@ -83,10 +83,10 @@ Notes on the dual-style design: - Lombok's `@SuperBuilder` is the simplest way to produce a typed builder that correctly composes with inheritance — subclasses opt in by adding `@SuperBuilder` and their builder inherits the base fields automatically. The existing codebase already relies on Lombok (`@Getter`/`@Setter`/`@Accessors(chain=true)` on `QuerySpec`), so this matches established conventions. - The chainable POJO setters (via `@Accessors(chain = true)`) are kept so callers who already work with mutable filters — including code wired through frameworks that prefer no-arg construction + property binding (e.g. JSON deserialization, query-param binding) — don't pay any extra ceremony. -- `@Singular("order")` exposes a per-entry `.order(key, value)` adder on the builder while still letting `.orders(map)` set the whole map. The internal store is a `LinkedHashMap` so insertion order is preserved across both styles. +- The inner `BaseFilterBuilder` is declared explicitly so that paging validation and a per-entry `.addOrder(...)` adder are part of the builder API; Lombok fills in the rest (fields, `self()`, `build()`, the subclass plumbing). The built `orders` map is a `LinkedHashMap` (mutable, insertion-ordered) so subsequent POJO-style `addOrder(...)` calls still work after `build()`. - `toBuilder = true` lets callers rebuild a tweaked copy of an existing filter (`f.toBuilder().maxResult(10).build()`), useful for paging. - The sort-order attribute string follows the same dotted-path convention as `@Attribute` (see §3.2) so callers can sort across joins. -- Pagination validators throw `IllegalArgumentException` on negative values, matching `QuerySpec`. Validation logic is centralized in a private helper (or in the setters themselves) and the `@SuperBuilder`-generated `build()` is wired through an explicit constructor that delegates to the setters, so both styles enforce the same invariants. If keeping the builder honest via a custom constructor proves clumsy in practice, the implementer can fall back to writing the builder explicitly — the public surface (`PersonFilter.builder()...build()`) is what the spec commits to, not the Lombok mechanics. +- Pagination validators throw `IllegalArgumentException` on negative values, matching `QuerySpec`. Validation lives in both the POJO setters and the corresponding builder methods (`firstResult(...)` / `maxResult(...)` on `BaseFilterBuilder`), so both construction styles enforce the same invariants. ### 3.2 Annotations (in `backend-core-model`, package `com.flowingcode.backendcore.model.filter`) @@ -99,11 +99,15 @@ All annotations are field-level (`@Target(ElementType.FIELD)`), retained at runt public @interface Attribute { /** Dotted attribute path on the target entity, e.g. "city.state.name". */ String value(); + + /** When true, predicate building is the hook's responsibility. */ + boolean manual() default false; } ``` - Maps a filter field to one or more entity attributes via a dotted path. Path traversal follows the same `split("\\.")` + auto-join convention used today in `ConstraintTransformerJpaImpl`, including the inner-join-by-default behavior. - A filter field with `@Attribute` and no role annotation defaults to **equality** (`cb.equal(...)`). +- `manual = true` is the escape hatch for predicates that don't fit the declarative model. The processor records the field (so the value-accessor helper can read it) but emits no predicate; the DAO's `customizePredicates` hook is responsible for the constraint. On a manual field the `value()` is informational — the processor never resolves it — but stays useful as documentation of which entity attribute the hook is expected to target. `manual = true` cannot combine with `@From`, `@To`, or `@WhenNull`, since none of those have meaning when the predicate is hand-built. #### `@From` and `@To` @@ -209,6 +213,17 @@ Both hooks are called once per query (filter, count, single-result). They receiv Why hooks and not annotation processors: it keeps the abstraction surface small and predictable, and it routes customization through the DAO — the layer that already owns the entity-shaped logic — instead of fanning custom behavior across filter classes. +### 5.1 Value-accessor helper + +Hooks frequently need to read filter field values to build predicates by hand. To avoid forcing every implementer to maintain its own reflection logic, the DAO base exposes: + +```java +default Object getFilterFieldValue(BaseFilter filter, String fieldName); +default V getFilterFieldValue(BaseFilter filter, String fieldName, Class type); +``` + +Both overloads are backed by the same cached reflection used to build the declarative predicates, so the lookup is essentially a map read. The helper works on any field declared on the filter (annotated or not); it is especially useful for fields marked `@Attribute(manual = true)`, where the declarative path has been intentionally skipped. + ## 6. Usage example ```java @@ -233,6 +248,22 @@ public class PersonFilter extends BaseFilter { @Attribute("deletedAt") @WhenNull(WhenNull.Policy.IS_NULL) private Instant deletedAt; // null → deletedAt IS NULL; non-null → equality + + @Attribute(value = "nickname", manual = true) + private String nicknameLike; // processor skips this; hook builds the LIKE +} + +// DAO with a manual predicate +class PersonDao implements JpaDaoSupport { + // ... getEntityManager() ... + + @Override + public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb, + CriteriaQuery cq, Root root) { + String pattern = getFilterFieldValue(filter, "nicknameLike", String.class); + return pattern == null ? List.of() + : List.of(cb.like(root.get("nickname"), "%" + pattern + "%")); + } } // POJO style @@ -244,7 +275,7 @@ f1.setMaxResult(50); // Builder style PersonFilter f2 = PersonFilter.builder() .birthDateFrom(LocalDate.of(1990, 1, 1)) - .order("name", BaseFilter.Order.ASC) + .addOrder("name", BaseFilter.Order.ASC) .maxResult(50) .build(); @@ -271,7 +302,7 @@ With both bounds set on `birthDate`, the processor emits `birthDate >= :from AND 1. **Sort order via annotation.** Should a filter class be able to declare a default sort via annotation (e.g. `@DefaultSort("createdAt DESC")`)? Out of scope for v1; callers use `addOrder`. 2. **Validation timing.** Should the per-class reflection/validation run eagerly at startup (e.g. via a CDI extension or a Spring `BeanPostProcessor`) or lazily on first use? Spec assumes lazy with caching. Eager validation can be added later without API changes. 3. **Field discovery.** Inherited fields from a deeper hierarchy (filter extending filter) should be supported. Confirm whether non-public fields require `setAccessible(true)` allowances in target deployments. -4. **Builder validation hookup.** The spec commits to validation firing from both POJO and builder paths but leaves the precise Lombok wiring (custom constructor accepting the builder vs. hand-written builder) to the implementer. Worth a quick prototype before locking in. +4. **Builder validation hookup.** Resolved during implementation: the inner `BaseFilterBuilder` is declared explicitly with `@SuperBuilder` filling in the missing parts, and the `firstResult(...)` / `maxResult(...)` builder methods carry the same validation as the POJO setters. ## 10. Out of scope / follow-ups From 40f07ab0403d4e79e14a10a8b36177afd1f681db Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Mon, 6 Jul 2026 17:57:02 -0300 Subject: [PATCH 3/6] fix: address code review comments on BaseFilter - run filterWithSingleResult as a dedicated non-paged query using getSingleResult - copy orders map in builder addOrder to keep toBuilder copies independent - validate attribute paths eagerly in AttributePathResolver - close EntityManagerFactory after each BaseFilterDaoHookTest --- .../dao/jpa/AttributePathResolver.java | 6 ++++++ .../dao/jpa/BaseFilterJpaProcessor.java | 18 ++++++++++++++---- .../dao/jpa/BaseFilterDaoHookTest.java | 11 ++++++++++- .../backendcore/model/filter/BaseFilter.java | 7 ++++--- .../model/filter/BaseFilterTest.java | 13 +++++++++++++ 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java index c519cd6..13c6b45 100644 --- a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java +++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java @@ -69,11 +69,17 @@ public Expression resolve(String attributePath) { * Resolves {@code attributePath} and verifies the leaf attribute's Java type * is assignable to {@code expectedType}. * + * @throws IllegalArgumentException if {@code attributePath} is blank, has a + * leading or trailing dot, or contains empty segments * @throws ClassCastException if the leaf attribute type isn't compatible */ @SuppressWarnings("unchecked") public Expression resolve(String attributePath, Class expectedType) { Objects.requireNonNull(attributePath, "attributePath"); + if (attributePath.isBlank() || attributePath.startsWith(".") + || attributePath.endsWith(".") || attributePath.contains("..")) { + throw new IllegalArgumentException("Invalid attributePath: \"" + attributePath + "\""); + } String[] path = attributePath.split("\\."); String attributeName = path[path.length - 1]; String[] joinPath = Arrays.copyOf(path, path.length - 1); diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/BaseFilterJpaProcessor.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/BaseFilterJpaProcessor.java index 8b11b66..fd543eb 100644 --- a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/BaseFilterJpaProcessor.java +++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/BaseFilterJpaProcessor.java @@ -41,6 +41,8 @@ import com.flowingcode.backendcore.model.filter.WhenNull; import jakarta.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import jakarta.persistence.NonUniqueResultException; import jakarta.persistence.TypedQuery; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; @@ -105,11 +107,19 @@ List filter(BaseFilter filter) { } Optional filterWithSingleResult(BaseFilter filter) { - List result = filter(filter); - if (result.size() > 1) { - throw new IllegalStateException("Current filter returned more than one result"); + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(persistentClass); + Root root = cq.from(persistentClass); + cq.select(root); + applyPredicates(filter, cb, cq, root); + hooks.customizeCriteria(filter, cb, cq, root); + try { + return Optional.of(em.createQuery(cq).getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } catch (NonUniqueResultException e) { + throw new IllegalStateException("Current filter returned more than one result", e); } - return result.stream().findFirst(); } long count(BaseFilter filter) { diff --git a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java index 5ee2f5e..8ae614b 100644 --- a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java +++ b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java @@ -38,6 +38,7 @@ import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -99,10 +100,11 @@ public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQue } private HookDao dao; + private EntityManagerFactory emf; @BeforeEach void setUp() { - EntityManagerFactory emf = Persistence.createEntityManagerFactory("person"); + emf = Persistence.createEntityManagerFactory("person"); EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); for (String name : new String[] {"John", "Jane", "Alice", "John"}) { @@ -116,6 +118,13 @@ void setUp() { dao = new HookDao(emf); } + @AfterEach + void tearDown() { + if (emf != null && emf.isOpen()) { + emf.close(); + } + } + @Test void hooksFireOnFilterAndCount() { PersonFilter f = PersonFilter.builder().build(); diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java index 7961d7a..752a442 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java @@ -146,9 +146,10 @@ public B addOrder(String attribute) { /** Adds an order on {@code attribute} with the given {@code direction}. */ public B addOrder(String attribute, Order direction) { - if (this.orders == null) { - this.orders = new LinkedHashMap<>(); - } + // Always copy: a builder obtained via toBuilder() shares the map reference + // with the source filter, so mutating in place would leak into it. + this.orders = this.orders == null ? new LinkedHashMap<>() + : new LinkedHashMap<>(this.orders); this.orders.put(attribute, direction); return self(); } diff --git a/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java index 1b3bd41..bf75332 100644 --- a/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java +++ b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java @@ -145,4 +145,17 @@ void toBuilder_producesIndependentCopy() { assertEquals("Ada", tweaked.getName()); assertNotSame(original, tweaked); } + + @Test + void toBuilder_ordersAreIsolatedFromOriginal() { + SampleFilter original = SampleFilter.builder().addOrder("a").build(); + SampleFilter tweaked = original.toBuilder() + .addOrder("b", BaseFilter.Order.DESC) + .build(); + + assertIterableEquals(Arrays.asList("a"), original.getOrders().keySet(), + "adding an order on the clone builder must not mutate the original"); + assertIterableEquals(Arrays.asList("a", "b"), tweaked.getOrders().keySet()); + assertEquals(BaseFilter.Order.DESC, tweaked.getOrders().get("b")); + } } From 9babdd8619a1c63e1080a9bc22dd1ad35feb4a82 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Tue, 28 Jul 2026 11:41:51 -0300 Subject: [PATCH 4/6] fix: added default methods and updated javadocs and tests --- .../dao/jpa/ConversionJpaDaoSupport.java | 10 +++++ .../flowingcode/backendcore/dao/QueryDao.java | 45 +++++++++++++++++-- .../AttributeBetweenConstraint.java | 5 +++ .../constraints/AttributeConstraint.java | 5 +++ .../constraints/AttributeILikeConstraint.java | 5 +++ .../constraints/AttributeInConstraint.java | 5 +++ .../constraints/AttributeLikeConstraint.java | 5 +++ .../constraints/AttributeNullConstraint.java | 5 +++ .../AttributeRelationalConstraint.java | 5 +++ .../constraints/DisjunctionConstraint.java | 8 +++- .../model/constraints/NegatedConstraint.java | 5 +++ .../constraints/RelationalConstraint.java | 5 +++ .../model/filter/BaseFilterTest.java | 13 +++--- 13 files changed, 110 insertions(+), 11 deletions(-) diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConversionJpaDaoSupport.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConversionJpaDaoSupport.java index b7ffd2a..42ba92f 100644 --- a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConversionJpaDaoSupport.java +++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConversionJpaDaoSupport.java @@ -103,6 +103,9 @@ default List findAll() { .map(this::convertFrom).collect(Collectors.toList()); } + /** + * @deprecated Use {@link #count(BaseFilter)} with a {@link BaseFilter} subclass. + */ @Override @Deprecated(since = "1.2.0", forRemoval = false) @SuppressWarnings("deprecation") @@ -110,6 +113,9 @@ default long count(QuerySpec filter) { return FilterProcesor.of(getEntityManager(), getPersistentClass()).count(filter); } + /** + * @deprecated Use {@link #filter(BaseFilter)} with a {@link BaseFilter} subclass. + */ @Override @Deprecated(since = "1.2.0", forRemoval = false) @SuppressWarnings("deprecation") @@ -118,6 +124,10 @@ default List filter(QuerySpec filter) { .map(this::convertFrom).collect(Collectors.toList()); } + /** + * @deprecated Use {@link #filterWithSingleResult(BaseFilter)} with a + * {@link BaseFilter} subclass. + */ @Override @Deprecated(since = "1.2.0", forRemoval = false) @SuppressWarnings("deprecation") diff --git a/backend-core-data/src/main/java/com/flowingcode/backendcore/dao/QueryDao.java b/backend-core-data/src/main/java/com/flowingcode/backendcore/dao/QueryDao.java index b98deee..054fd10 100644 --- a/backend-core-data/src/main/java/com/flowingcode/backendcore/dao/QueryDao.java +++ b/backend-core-data/src/main/java/com/flowingcode/backendcore/dao/QueryDao.java @@ -55,17 +55,54 @@ public interface QueryDao { /** * Returns the entities that match the given {@code filter}. Annotations on * the filter fields drive the generated JPA Criteria. + * + *

The default implementation throws {@link UnsupportedOperationException} + * so that existing {@code QueryDao} implementors keep compiling when + * upgrading. DAOs built on {@code JpaDaoSupport} / + * {@code ConversionJpaDaoSupport} override it with the actual behavior; + * standalone implementors should override it too. + * + * @throws UnsupportedOperationException if the implementation does not + * support {@link BaseFilter}-based queries */ - List filter(BaseFilter filter); + default List filter(BaseFilter filter) { + throw new UnsupportedOperationException( + "filter(BaseFilter) is not implemented by this QueryDao"); + } /** * Returns the single entity matching the given {@code filter}, if any. * + *

The default implementation throws {@link UnsupportedOperationException} + * so that existing {@code QueryDao} implementors keep compiling when + * upgrading. DAOs built on {@code JpaDaoSupport} / + * {@code ConversionJpaDaoSupport} override it with the actual behavior; + * standalone implementors should override it too. + * * @throws IllegalStateException if more than one entity matches + * @throws UnsupportedOperationException if the implementation does not + * support {@link BaseFilter}-based queries */ - Optional filterWithSingleResult(BaseFilter filter); + default Optional filterWithSingleResult(BaseFilter filter) { + throw new UnsupportedOperationException( + "filterWithSingleResult(BaseFilter) is not implemented by this QueryDao"); + } - /** Returns the number of entities matching the given {@code filter}. */ - long count(BaseFilter filter); + /** + * Returns the number of entities matching the given {@code filter}. + * + *

The default implementation throws {@link UnsupportedOperationException} + * so that existing {@code QueryDao} implementors keep compiling when + * upgrading. DAOs built on {@code JpaDaoSupport} / + * {@code ConversionJpaDaoSupport} override it with the actual behavior; + * standalone implementors should override it too. + * + * @throws UnsupportedOperationException if the implementation does not + * support {@link BaseFilter}-based queries + */ + default long count(BaseFilter filter) { + throw new UnsupportedOperationException( + "count(BaseFilter) is not implemented by this QueryDao"); + } } diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeBetweenConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeBetweenConstraint.java index 92106da..d3512aa 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeBetweenConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeBetweenConstraint.java @@ -26,6 +26,11 @@ import lombok.NonNull; import lombok.experimental.FieldDefaults; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ @Deprecated(since = "1.2.0", forRemoval = false) @Getter @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeConstraint.java index 8897fde..49d6488 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeConstraint.java @@ -21,6 +21,11 @@ import com.flowingcode.backendcore.model.Constraint; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ @Deprecated(since = "1.2.0", forRemoval = false) public interface AttributeConstraint extends Constraint { diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeILikeConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeILikeConstraint.java index 62fead0..019e721 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeILikeConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeILikeConstraint.java @@ -25,6 +25,11 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ @Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeInConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeInConstraint.java index 7729e16..1d693bd 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeInConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeInConstraint.java @@ -27,6 +27,11 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ @Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeLikeConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeLikeConstraint.java index 3946cf7..fb0a168 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeLikeConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeLikeConstraint.java @@ -25,6 +25,11 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ @Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeNullConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeNullConstraint.java index cf0812e..c9127d2 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeNullConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeNullConstraint.java @@ -6,6 +6,11 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ @Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeRelationalConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeRelationalConstraint.java index ea2c416..74b17f9 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeRelationalConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeRelationalConstraint.java @@ -25,6 +25,11 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ @Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java index 725a182..825fc7d 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java @@ -28,7 +28,13 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; -/** A constraint that is satisfied when any of its member constraints is satisfied (logical OR). */ +/** + * A constraint that is satisfied when any of its member constraints is satisfied (logical OR). + * + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ @Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor(access = AccessLevel.PRIVATE) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/NegatedConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/NegatedConstraint.java index af75332..87f0cc0 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/NegatedConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/NegatedConstraint.java @@ -27,6 +27,11 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ @Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/RelationalConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/RelationalConstraint.java index 30f38e6..e9b46e1 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/RelationalConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/RelationalConstraint.java @@ -21,6 +21,11 @@ import com.flowingcode.backendcore.model.Constraint; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ @Deprecated(since = "1.2.0", forRemoval = false) public interface RelationalConstraint extends Constraint { diff --git a/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java index bf75332..bbb93c9 100644 --- a/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java +++ b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java @@ -26,6 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import java.time.LocalDate; +import java.time.Month; import java.util.Arrays; import lombok.Getter; @@ -59,13 +60,13 @@ static class SampleFilter extends BaseFilter { void pojo_chainableSetters_carryAllState() { SampleFilter f = new SampleFilter() .setName("Ada") - .setBirthDateFrom(LocalDate.of(1990, 1, 1)) - .setBirthDateTo(LocalDate.of(2000, 1, 1)); + .setBirthDateFrom(LocalDate.of(1990, Month.JANUARY, 1)) + .setBirthDateTo(LocalDate.of(2000, Month.JANUARY, 1)); f.addOrder("name").setMaxResult(50).setFirstResult(10); assertEquals("Ada", f.getName()); - assertEquals(LocalDate.of(1990, 1, 1), f.getBirthDateFrom()); - assertEquals(LocalDate.of(2000, 1, 1), f.getBirthDateTo()); + assertEquals(LocalDate.of(1990, Month.JANUARY, 1), f.getBirthDateFrom()); + assertEquals(LocalDate.of(2000, Month.JANUARY, 1), f.getBirthDateTo()); assertEquals(50, f.getMaxResult()); assertEquals(10, f.getFirstResult()); assertIterableEquals(Arrays.asList("name"), f.getOrders().keySet()); @@ -76,7 +77,7 @@ void pojo_chainableSetters_carryAllState() { void builder_setsState() { SampleFilter f = SampleFilter.builder() .name("Ada") - .birthDateFrom(LocalDate.of(1990, 1, 1)) + .birthDateFrom(LocalDate.of(1990, Month.JANUARY, 1)) .addOrder("name", BaseFilter.Order.DESC) .addOrder("birthDate", BaseFilter.Order.ASC) .firstResult(0) @@ -84,7 +85,7 @@ void builder_setsState() { .build(); assertEquals("Ada", f.getName()); - assertEquals(LocalDate.of(1990, 1, 1), f.getBirthDateFrom()); + assertEquals(LocalDate.of(1990, Month.JANUARY, 1), f.getBirthDateFrom()); assertNull(f.getBirthDateTo()); assertEquals(0, f.getFirstResult()); assertEquals(25, f.getMaxResult()); From 17288728e6b6353c19d1588f091d6c53ad708613 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Thu, 24 Sep 2026 17:27:11 -0300 Subject: [PATCH 5/6] fix: prevent BaseFilter orders from being shared between filters The orders map could still leak between instances after the previous fix: toBuilder() copied the map reference and the instance addOrder mutated it in place, the builder and POJO orders setters stored the caller's map, and getOrders() returned the live map. Both addOrder variants now copy before writing, both setters copy their argument, and getOrders() returns an unmodifiable view. --- .../backendcore/model/filter/BaseFilter.java | 45 +++++++++++++--- .../model/filter/BaseFilterTest.java | 52 +++++++++++++++++++ 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java index 752a442..e7a5f0e 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java @@ -60,6 +60,11 @@ * and the builder's {@code firstResult(...)} / {@code maxResult(...)} methods * apply the same checks before {@code build()} returns, so both styles enforce * the same invariants. + * + *

The sort orders are never shared: maps passed to {@link #setOrders(Map)} or + * to the builder are copied, {@link #addOrder(String, Order)} copies before + * writing, and {@link #getOrders()} returns an unmodifiable view. A filter + * obtained through {@code toBuilder()} is therefore independent of its source. */ @Getter @Setter @@ -86,9 +91,9 @@ public BaseFilter addOrder(String attribute) { /** Adds an order on {@code attribute} with the given {@code direction}. */ public BaseFilter addOrder(String attribute, Order direction) { - if (this.orders == null) { - this.orders = new LinkedHashMap<>(); - } + // Copy before writing: the map may be shared with the builder this filter + // was built from, or with another filter built from that same builder. + this.orders = copyOf(this.orders); this.orders.put(attribute, direction); return this; } @@ -96,9 +101,26 @@ public BaseFilter addOrder(String attribute, Order direction) { /** * Returns the configured sort orders, preserving insertion order. Never * {@code null}; an empty map indicates no ordering. + * + * @return an unmodifiable view of the sort orders */ public Map getOrders() { - return orders == null ? Collections.emptyMap() : orders; + return orders == null ? Collections.emptyMap() : Collections.unmodifiableMap(orders); + } + + /** + * Replaces the sort orders with a copy of {@code orders}, so later changes to + * the argument do not affect this filter. + * + * @param orders the sort orders in application order, or {@code null} to clear + */ + public BaseFilter setOrders(Map orders) { + this.orders = orders == null ? null : copyOf(orders); + return this; + } + + private static Map copyOf(Map orders) { + return orders == null ? new LinkedHashMap<>() : new LinkedHashMap<>(orders); } /** @@ -148,12 +170,23 @@ public B addOrder(String attribute) { public B addOrder(String attribute, Order direction) { // Always copy: a builder obtained via toBuilder() shares the map reference // with the source filter, so mutating in place would leak into it. - this.orders = this.orders == null ? new LinkedHashMap<>() - : new LinkedHashMap<>(this.orders); + this.orders = copyOf(this.orders); this.orders.put(attribute, direction); return self(); } + /** + * Replaces the sort orders with a copy of {@code orders}, so later changes + * to the argument do not affect the built filter. + * + * @param orders the sort orders in application order, or {@code null} to + * clear + */ + public B orders(Map orders) { + this.orders = orders == null ? null : copyOf(orders); + return self(); + } + /** * @throws IllegalArgumentException if {@code firstResult} is negative */ diff --git a/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java index bbb93c9..6aeafc7 100644 --- a/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java +++ b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java @@ -28,6 +28,8 @@ import java.time.LocalDate; import java.time.Month; import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; import lombok.Getter; import lombok.NoArgsConstructor; @@ -159,4 +161,54 @@ void toBuilder_ordersAreIsolatedFromOriginal() { assertIterableEquals(Arrays.asList("a", "b"), tweaked.getOrders().keySet()); assertEquals(BaseFilter.Order.DESC, tweaked.getOrders().get("b")); } + + @Test + void toBuilder_instanceAddOrderOnCopy_doesNotLeakIntoOriginal() { + SampleFilter original = SampleFilter.builder().name("Ada").addOrder("name").build(); + SampleFilter copy = original.toBuilder().build(); + copy.addOrder("birthDate", BaseFilter.Order.DESC); + + assertIterableEquals(Arrays.asList("name"), original.getOrders().keySet()); + assertIterableEquals(Arrays.asList("name", "birthDate"), copy.getOrders().keySet()); + } + + @Test + void builderOrdersSetter_copiesCallerMap() { + Map caller = new LinkedHashMap<>(Map.of("name", BaseFilter.Order.ASC)); + SampleFilter f = SampleFilter.builder().orders(caller).build(); + caller.put("sneaky", BaseFilter.Order.DESC); + + assertIterableEquals(Arrays.asList("name"), f.getOrders().keySet()); + } + + @Test + void pojoOrdersSetter_copiesCallerMap() { + Map caller = new LinkedHashMap<>(Map.of("name", BaseFilter.Order.ASC)); + SampleFilter f = new SampleFilter(); + f.setOrders(caller); + caller.put("sneaky", BaseFilter.Order.DESC); + + assertIterableEquals(Arrays.asList("name"), f.getOrders().keySet()); + } + + @Test + void getOrders_isUnmodifiable_whetherSetOrNot() { + SampleFilter unset = new SampleFilter(); + SampleFilter set = SampleFilter.builder().addOrder("name").build(); + + assertThrows(UnsupportedOperationException.class, + () -> unset.getOrders().put("injected", BaseFilter.Order.DESC)); + assertThrows(UnsupportedOperationException.class, + () -> set.getOrders().put("injected", BaseFilter.Order.DESC)); + } + + @Test + void filtersBuiltFromSameBuilder_doNotShareOrders() { + SampleFilter.SampleFilterBuilder builder = SampleFilter.builder().addOrder("name"); + SampleFilter first = builder.build(); + SampleFilter second = builder.build(); + first.addOrder("birthDate"); + + assertIterableEquals(Arrays.asList("name"), second.getOrders().keySet()); + } } From d4d7892e5794a9ca5dd9c25227bca90eec97da70 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Thu, 24 Sep 2026 17:27:11 -0300 Subject: [PATCH 6/6] feat: add @Like, @In and @Or annotations to BaseFilter @Like matches String fields with LIKE, escaping wildcards unless match = RAW, optionally ignoring case. @In matches Collection fields with IN; an empty collection is skipped unless whenEmpty = MATCH_NONE. @Or places a field's predicate in the filter's single disjunction. Associations are left-joined where an inner join would drop rows the query must return: IS_NULL predicates, @Or fields and sort orders. A join is reused whatever its type, so each association is joined once. AttributePathResolver becomes package-private. customizeCriteria may no longer replace the query selection or group the count query, and a distinct count query now counts distinct roots. getFilterFieldValue is removed: hooks read filter values through the filter's accessors, which filters must provide for the fields hooks consume. This also fixes annotated fields shadowed by a subclass field, which were never made accessible. Fix the hook tests, which failed to resolve the entity type of anonymous DAO subclasses and leaked an EntityManager per query, and add end-to-end coverage of the annotation pipeline. Update the spec. --- .../dao/jpa/AttributePathResolver.java | 53 ++- .../dao/jpa/BaseFilterJpaProcessor.java | 352 +++++++++++----- .../dao/jpa/ConversionJpaDaoSupport.java | 60 ++- .../dao/jpa/BaseFilterDaoHookTest.java | 106 ++++- .../dao/jpa/BaseFilterPipelineTest.java | 388 ++++++++++++++++++ .../backendcore/model/filter/Attribute.java | 37 +- .../backendcore/model/filter/In.java | 58 +++ .../backendcore/model/filter/Like.java | 72 ++++ .../backendcore/model/filter/Or.java | 59 +++ .../backendcore/model/filter/WhenNull.java | 8 +- specs/base-filter.md | 136 ++++-- 11 files changed, 1089 insertions(+), 240 deletions(-) create mode 100644 backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterPipelineTest.java create mode 100644 backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/In.java create mode 100644 backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Like.java create mode 100644 backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Or.java diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java index 13c6b45..edce1ca 100644 --- a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java +++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java @@ -30,52 +30,48 @@ /** * Resolves a dotted attribute path on a JPA {@code From} root into a leaf - * {@code Expression}, auto-joining associations along the way and reusing - * existing joins when one is already present on the same attribute and join - * type. + * {@code Expression}, auto-joining associations along the way. + * + *

The caller chooses the join type of each resolution. A join that already + * exists on the same attribute is reused whatever its type, so an association + * is joined at most once per query. Reuse is safe for the declarative filter + * predicates, which are always combined as a conjunction: an inner join is only + * requested for a conjunct that rejects {@code null}, so every result row has + * that association anyway, and a left join behaves as an inner join under such + * a conjunct. * *

Instances are not thread-safe: a new resolver should be created per * {@code CriteriaQuery}. */ -public class AttributePathResolver { +class AttributePathResolver { private final From root; - private JoinType currentJoinType = JoinType.INNER; - - public AttributePathResolver(From root) { + AttributePathResolver(From root) { this.root = Objects.requireNonNull(root, "root"); } - /** Returns the join type currently used when creating new joins. */ - public JoinType getCurrentJoinType() { - return currentJoinType; - } - - /** Sets the join type used for newly created joins by subsequent resolutions. */ - public void setCurrentJoinType(JoinType joinType) { - this.currentJoinType = Objects.requireNonNull(joinType, "joinType"); - } - /** * Resolves {@code attributePath} into an {@code Expression} of the leaf - * attribute on the root, auto-joining as needed. + * attribute on the root, creating missing joins with {@code joinType}. */ - public Expression resolve(String attributePath) { - return resolve(attributePath, Object.class); + Expression resolve(String attributePath, JoinType joinType) { + return resolve(attributePath, Object.class, joinType); } /** - * Resolves {@code attributePath} and verifies the leaf attribute's Java type - * is assignable to {@code expectedType}. + * Resolves {@code attributePath}, creating missing joins with + * {@code joinType}, and verifies the leaf attribute's Java type is assignable + * to {@code expectedType}. * * @throws IllegalArgumentException if {@code attributePath} is blank, has a * leading or trailing dot, or contains empty segments * @throws ClassCastException if the leaf attribute type isn't compatible */ @SuppressWarnings("unchecked") - public Expression resolve(String attributePath, Class expectedType) { + Expression resolve(String attributePath, Class expectedType, JoinType joinType) { Objects.requireNonNull(attributePath, "attributePath"); + Objects.requireNonNull(joinType, "joinType"); if (attributePath.isBlank() || attributePath.startsWith(".") || attributePath.endsWith(".") || attributePath.contains("..")) { throw new IllegalArgumentException("Invalid attributePath: \"" + attributePath + "\""); @@ -83,27 +79,26 @@ public Expression resolve(String attributePath, Class expectedType) { String[] path = attributePath.split("\\."); String attributeName = path[path.length - 1]; String[] joinPath = Arrays.copyOf(path, path.length - 1); - Expression expression = traverse(root, joinPath).get(attributeName); + Expression expression = traverse(root, joinPath, joinType).get(attributeName); boxed(expression.getJavaType()).asSubclass(expectedType); return (Expression) expression; } - private From traverse(From source, String[] path) { + private From traverse(From source, String[] path, JoinType joinType) { From from = source; for (String name : path) { - from = join(from, name); + from = join(from, name, joinType); } return from; } @SuppressWarnings("rawtypes") - private From join(From source, String attributeName) { + private From join(From source, String attributeName, JoinType joinType) { Optional existing = source.getJoins().stream() .map(j -> (Join) j) .filter(j -> j.getAttribute().getName().equals(attributeName)) - .filter(j -> j.getJoinType() == currentJoinType) .findFirst(); - return existing.orElseGet(() -> source.join(attributeName, currentJoinType)); + return existing.orElseGet(() -> source.join(attributeName, joinType)); } private static Class boxed(Class type) { diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/BaseFilterJpaProcessor.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/BaseFilterJpaProcessor.java index fd543eb..ea3dbdf 100644 --- a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/BaseFilterJpaProcessor.java +++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/BaseFilterJpaProcessor.java @@ -20,23 +20,27 @@ package com.flowingcode.backendcore.dao.jpa; import java.io.Serializable; +import java.lang.annotation.Annotation; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.stream.Collectors; import com.flowingcode.backendcore.model.Identifiable; import com.flowingcode.backendcore.model.filter.Attribute; import com.flowingcode.backendcore.model.filter.BaseFilter; import com.flowingcode.backendcore.model.filter.From; +import com.flowingcode.backendcore.model.filter.In; +import com.flowingcode.backendcore.model.filter.Like; +import com.flowingcode.backendcore.model.filter.Or; import com.flowingcode.backendcore.model.filter.To; import com.flowingcode.backendcore.model.filter.WhenNull; @@ -47,19 +51,20 @@ import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Selection; /** * Builds and executes JPA {@code CriteriaQuery} from a {@link BaseFilter}. * *

The processor reflects on the filter class once (per JVM), caches the * resulting metadata, and produces predicates honoring {@link Attribute}, - * {@link com.flowingcode.backendcore.model.filter.From} / - * {@link com.flowingcode.backendcore.model.filter.To} ranges and - * {@link WhenNull} null-handling policies. DAO subclasses contribute - * non-declarative predicates and other {@code CriteriaQuery} mutations through - * the supplied {@link Hooks}. + * {@link From} / {@link To} ranges, {@link Like} and {@link In} matching, + * {@link WhenNull} null-handling policies and the {@link Or} disjunction. DAO + * subclasses contribute non-declarative predicates and other + * {@code CriteriaQuery} mutations through the supplied {@link Hooks}. * *

Instances are not thread-safe. Create a new instance per query * invocation. @@ -75,7 +80,15 @@ void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery c Root root); } - private static final ConcurrentMap, FilterMetadata> METADATA_CACHE = + /** Annotations that choose the operator of a field's predicate; at most one per field. */ + private static final List> OPERATORS = + List.of(From.class, To.class, Like.class, In.class); + + /** Annotations that are only meaningful on a declarative {@code @Attribute} field. */ + private static final List> MODIFIERS = + List.of(From.class, To.class, Like.class, In.class, WhenNull.class, Or.class); + + private static final ConcurrentMap, List> HANDLER_CACHE = new ConcurrentHashMap<>(); private final EntityManager em; @@ -101,6 +114,7 @@ List filter(BaseFilter filter) { applyPredicates(filter, cb, cq, root); applyOrders(filter, cb, cq, root); hooks.customizeCriteria(filter, cb, cq, root); + requireSelection(cq, root); TypedQuery query = em.createQuery(cq); applyPaging(query, filter); return query.getResultList(); @@ -113,6 +127,7 @@ Optional filterWithSingleResult(BaseFilter filter) { cq.select(root); applyPredicates(filter, cb, cq, root); hooks.customizeCriteria(filter, cb, cq, root); + requireSelection(cq, root); try { return Optional.of(em.createQuery(cq).getSingleResult()); } catch (NoResultException e) { @@ -126,21 +141,39 @@ long count(BaseFilter filter) { CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery cq = cb.createQuery(Long.class); Root root = cq.from(persistentClass); - cq.select(cb.count(root)); + Expression count = cb.count(root); + cq.select(count); applyPredicates(filter, cb, cq, root); hooks.customizeCriteria(filter, cb, cq, root); + requireSelection(cq, count); + if (!cq.getGroupList().isEmpty()) { + throw new IllegalStateException( + "customizeCriteria must not add a GROUP BY to a count query, which would return" + + " one count per group; check CriteriaQuery.getResultType() to skip it"); + } + if (cq.isDistinct()) { + // SELECT DISTINCT COUNT(x) still counts duplicates; count distinct roots instead. + cq.distinct(false); + cq.select(cb.countDistinct(root)); + } return em.createQuery(cq).getSingleResult(); } + private static void requireSelection(CriteriaQuery cq, Selection expected) { + if (cq.getSelection() != expected) { + throw new IllegalStateException("customizeCriteria must not replace the query selection"); + } + } + // ----- query assembly ----- private void applyPredicates(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, Root root) { - FilterMetadata metadata = metadataFor(filter.getClass()); AttributePathResolver resolver = new AttributePathResolver(root); - List predicates = new ArrayList<>(); - for (FieldHandler handler : metadata.handlers) { + List conjunction = new ArrayList<>(); + List disjunction = new ArrayList<>(); + for (FieldHandler handler : handlersFor(filter.getClass())) { Predicate predicate; try { predicate = handler.toPredicate(filter, cb, resolver); @@ -149,30 +182,36 @@ private void applyPredicates(BaseFilter filter, CriteriaBuilder cb, CriteriaQuer "Cannot read field " + handler.describe() + " on " + filter.getClass(), e); } if (predicate != null) { - predicates.add(predicate); + (handler.or ? disjunction : conjunction).add(predicate); } } + // A disjunction without disjuncts is false, so it is only added when some + // @Or field contributed a predicate; otherwise it would match no rows. + if (!disjunction.isEmpty()) { + conjunction.add(cb.or(disjunction.toArray(new Predicate[0]))); + } Collection extra = hooks.customizePredicates(filter, cb, cq, root); if (extra != null && !extra.isEmpty()) { - predicates.addAll(extra); + conjunction.addAll(extra); } - if (!predicates.isEmpty()) { - cq.where(predicates.toArray(new Predicate[0])); + if (!conjunction.isEmpty()) { + cq.where(conjunction.toArray(new Predicate[0])); } } private void applyOrders(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, Root root) { Map orders = filter.getOrders(); - if (orders == null || orders.isEmpty()) { + if (orders.isEmpty()) { return; } AttributePathResolver resolver = new AttributePathResolver(root); List jpaOrders = new ArrayList<>(orders.size()); for (Entry e : orders.entrySet()) { - Expression expr = resolver.resolve(e.getKey()); + // Left join: sorting must not drop rows whose association is null. + Expression expr = resolver.resolve(e.getKey(), JoinType.LEFT); jpaOrders.add(e.getValue() == BaseFilter.Order.ASC ? cb.asc(expr) : cb.desc(expr)); } cq.orderBy(jpaOrders); @@ -189,61 +228,33 @@ private void applyPaging(TypedQuery query, BaseFilter filter) { // ----- metadata reflection + caching ----- - private static FilterMetadata metadataFor(Class filterClass) { - return METADATA_CACHE.computeIfAbsent(filterClass, BaseFilterJpaProcessor::buildMetadata); + private static List handlersFor(Class filterClass) { + return HANDLER_CACHE.computeIfAbsent(filterClass, BaseFilterJpaProcessor::buildHandlers); } - private static FilterMetadata buildMetadata(Class filterClass) { - // Index every declared field in the hierarchy by name so the value-accessor - // helper can read any of them, including unannotated ones. Closer-to-leaf - // declarations win on name shadowing. - Map fieldsByName = new LinkedHashMap<>(); + private static List buildHandlers(Class filterClass) { + // Collect the annotated fields of every class between the filter and + // BaseFilter. A field shadowed by a subclass field of the same name is still + // a filter field of its own. List annotated = new ArrayList<>(); - Class c = filterClass; - while (c != null && c != Object.class) { + for (Class c = filterClass; c != BaseFilter.class; c = c.getSuperclass()) { for (Field f : c.getDeclaredFields()) { - if (!fieldsByName.containsKey(f.getName())) { - f.setAccessible(true); - fieldsByName.put(f.getName(), f); - } - if (c != BaseFilter.class && (f.isAnnotationPresent(Attribute.class) - || f.isAnnotationPresent(From.class) || f.isAnnotationPresent(To.class) - || f.isAnnotationPresent(WhenNull.class))) { + if (f.isAnnotationPresent(Attribute.class) || !annotationsOn(f, MODIFIERS).isEmpty()) { annotated.add(f); } } - c = c.getSuperclass(); } // Validate per-field constraints and group declarative fields by attribute path. Map> byPath = new HashMap<>(); for (Field f : annotated) { + validate(f); Attribute attr = f.getAnnotation(Attribute.class); - From from = f.getAnnotation(From.class); - To to = f.getAnnotation(To.class); - WhenNull whenNull = f.getAnnotation(WhenNull.class); - - if ((from != null || to != null || whenNull != null) && attr == null) { - throw new IllegalStateException("Field " + describe(f) - + " has @From/@To/@WhenNull but no @Attribute"); - } - if (attr != null && attr.manual() - && (from != null || to != null || whenNull != null)) { - throw new IllegalStateException("Field " + describe(f) - + " is @Attribute(manual=true); it cannot combine with @From, @To or @WhenNull"); - } - if (from != null && to != null) { - throw new IllegalStateException("Field " + describe(f) - + " cannot be both @From and @To"); - } - if (whenNull != null && (from != null || to != null)) { - throw new IllegalStateException("Field " + describe(f) - + " cannot combine @WhenNull with @From or @To"); - } - // Manual fields are tracked in fieldsByName already; skip declarative grouping. + // Manual fields are read by the DAO hook through the filter's accessors. if (attr.manual()) { continue; } + f.setAccessible(true); byPath.computeIfAbsent(attr.value(), k -> new ArrayList<>()).add(f); } @@ -253,23 +264,71 @@ private static FilterMetadata buildMetadata(Class filterCl List group = entry.getValue(); handlers.add(buildHandler(path, group)); } - return new FilterMetadata(handlers, Collections.unmodifiableMap(fieldsByName)); + return List.copyOf(handlers); + } + + private static void validate(Field f) { + Attribute attr = f.getAnnotation(Attribute.class); + List modifiers = annotationsOn(f, MODIFIERS); + List operators = annotationsOn(f, OPERATORS); + + if (attr == null) { + throw new IllegalStateException("Field " + describe(f) + " has " + + String.join(", ", modifiers) + " but no @Attribute"); + } + if (attr.manual() && !modifiers.isEmpty()) { + throw new IllegalStateException("Field " + describe(f) + + " is @Attribute(manual=true); it cannot combine with " + + String.join(", ", modifiers)); + } + if (operators.size() > 1) { + throw new IllegalStateException("Field " + describe(f) + " cannot combine " + + String.join(" and ", operators) + "; use at most one of @From, @To, @Like or @In"); + } + if (f.isAnnotationPresent(WhenNull.class) && !operators.isEmpty()) { + throw new IllegalStateException("Field " + describe(f) + + " cannot combine @WhenNull with " + operators.get(0)); + } + if (f.isAnnotationPresent(Like.class) && f.getType() != String.class) { + throw new IllegalStateException("Field " + describe(f) + + " is @Like, so its type must be String; found " + f.getType().getName()); + } + if (f.isAnnotationPresent(In.class) && !Collection.class.isAssignableFrom(f.getType())) { + throw new IllegalStateException("Field " + describe(f) + + " is @In, so its type must be a Collection; found " + f.getType().getName()); + } + } + + private static List annotationsOn(Field f, List> types) { + return types.stream() + .filter(f::isAnnotationPresent) + .map(type -> "@" + type.getSimpleName()) + .collect(Collectors.toList()); } private static FieldHandler buildHandler(String path, List group) { if (group.size() == 1) { Field f = group.get(0); + boolean or = f.isAnnotationPresent(Or.class); From from = f.getAnnotation(From.class); - To to = f.getAnnotation(To.class); if (from != null) { - return new LowerBoundHandler(path, f, from.inclusive()); + return new LowerBoundHandler(path, or, f, from.inclusive()); } + To to = f.getAnnotation(To.class); if (to != null) { - return new UpperBoundHandler(path, f, to.inclusive()); + return new UpperBoundHandler(path, or, f, to.inclusive()); + } + Like like = f.getAnnotation(Like.class); + if (like != null) { + return new LikeHandler(path, or, f, like.ignoreCase(), like.match()); + } + In in = f.getAnnotation(In.class); + if (in != null) { + return new InHandler(path, or, f, in.whenEmpty()); } WhenNull whenNull = f.getAnnotation(WhenNull.class); WhenNull.Policy policy = whenNull != null ? whenNull.value() : WhenNull.Policy.SKIP; - return new EqualityHandler(path, f, policy); + return new EqualityHandler(path, or, f, policy); } if (group.size() == 2) { Field a = group.get(0); @@ -294,7 +353,12 @@ private static FieldHandler buildHandler(String path, List group) { upperInclusive = toA.inclusive(); } if (lower != null) { - return new RangePairHandler(path, lower, upper, lowerInclusive, upperInclusive); + boolean or = lower.isAnnotationPresent(Or.class); + if (or != upper.isAnnotationPresent(Or.class)) { + throw new IllegalStateException("Range fields " + describe(group) + + " on attribute path \"" + path + "\" must either both carry @Or or neither"); + } + return new RangePairHandler(path, or, lower, upper, lowerInclusive, upperInclusive); } } throw new IllegalStateException("Attribute path \"" + path @@ -317,45 +381,23 @@ private static String describe(List fields) { // ----- handler types ----- - /** - * Reads {@code fieldName} on the given {@code filter} using the cached - * reflection metadata. Intended to be called from DAO hooks that need to - * consume the value of a field marked {@code @Attribute(manual=true)} (or any - * other field on the filter) without re-doing reflection. - * - * @throws IllegalArgumentException if the filter class has no field with that - * name - */ - static Object readField(BaseFilter filter, String fieldName) { - FilterMetadata metadata = metadataFor(filter.getClass()); - Field f = metadata.fieldsByName.get(fieldName); - if (f == null) { - throw new IllegalArgumentException("Filter " + filter.getClass().getSimpleName() - + " has no field named: " + fieldName); - } - try { - return f.get(filter); - } catch (IllegalAccessException e) { - throw new IllegalStateException( - "Cannot read field " + describe(f) + " on " + filter.getClass(), e); - } - } - - private static final class FilterMetadata { - final List handlers; - final Map fieldsByName; - - FilterMetadata(List handlers, Map fieldsByName) { - this.handlers = List.copyOf(handlers); - this.fieldsByName = fieldsByName; - } - } - private abstract static class FieldHandler { final String attributePath; + final boolean or; - FieldHandler(String attributePath) { + FieldHandler(String attributePath, boolean or) { this.attributePath = attributePath; + this.or = or; + } + + /** + * Join type for the path of a predicate that rejects {@code null}: a left + * join inside the disjunction, so that a row with a {@code null} + * association can still match through another disjunct, and an inner join + * otherwise. + */ + JoinType joinType() { + return or ? JoinType.LEFT : JoinType.INNER; } abstract Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, @@ -368,8 +410,8 @@ private static final class EqualityHandler extends FieldHandler { private final Field field; private final WhenNull.Policy nullPolicy; - EqualityHandler(String path, Field field, WhenNull.Policy nullPolicy) { - super(path); + EqualityHandler(String path, boolean or, Field field, WhenNull.Policy nullPolicy) { + super(path, or); this.field = field; this.nullPolicy = nullPolicy; } @@ -379,10 +421,13 @@ Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolv throws IllegalAccessException { Object value = field.get(filter); if (value == null) { - return nullPolicy == WhenNull.Policy.IS_NULL ? cb.isNull(resolver.resolve(attributePath)) + // IS NULL must also match rows whose association is null, which an + // inner join would drop before the predicate is evaluated. + return nullPolicy == WhenNull.Policy.IS_NULL + ? cb.isNull(resolver.resolve(attributePath, JoinType.LEFT)) : null; } - return cb.equal(resolver.resolve(attributePath), value); + return cb.equal(resolver.resolve(attributePath, joinType()), value); } @Override @@ -395,8 +440,8 @@ private static final class LowerBoundHandler extends FieldHandler { private final Field field; private final boolean inclusive; - LowerBoundHandler(String path, Field field, boolean inclusive) { - super(path); + LowerBoundHandler(String path, boolean or, Field field, boolean inclusive) { + super(path, or); this.field = field; this.inclusive = inclusive; } @@ -407,7 +452,7 @@ Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolv throws IllegalAccessException { Object value = field.get(filter); if (value == null) return null; - Expression expr = resolver.resolve(attributePath, Comparable.class); + Expression expr = resolver.resolve(attributePath, Comparable.class, joinType()); return inclusive ? cb.greaterThanOrEqualTo(expr, (Comparable) value) : cb.greaterThan(expr, (Comparable) value); } @@ -422,8 +467,8 @@ private static final class UpperBoundHandler extends FieldHandler { private final Field field; private final boolean inclusive; - UpperBoundHandler(String path, Field field, boolean inclusive) { - super(path); + UpperBoundHandler(String path, boolean or, Field field, boolean inclusive) { + super(path, or); this.field = field; this.inclusive = inclusive; } @@ -434,7 +479,7 @@ Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolv throws IllegalAccessException { Object value = field.get(filter); if (value == null) return null; - Expression expr = resolver.resolve(attributePath, Comparable.class); + Expression expr = resolver.resolve(attributePath, Comparable.class, joinType()); return inclusive ? cb.lessThanOrEqualTo(expr, (Comparable) value) : cb.lessThan(expr, (Comparable) value); } @@ -451,9 +496,9 @@ private static final class RangePairHandler extends FieldHandler { private final boolean lowerInclusive; private final boolean upperInclusive; - RangePairHandler(String path, Field lower, Field upper, boolean lowerInclusive, + RangePairHandler(String path, boolean or, Field lower, Field upper, boolean lowerInclusive, boolean upperInclusive) { - super(path); + super(path, or); this.lower = lower; this.upper = upper; this.lowerInclusive = lowerInclusive; @@ -467,7 +512,7 @@ Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolv Object lo = lower.get(filter); Object hi = upper.get(filter); if (lo == null && hi == null) return null; - Expression expr = resolver.resolve(attributePath, Comparable.class); + Expression expr = resolver.resolve(attributePath, Comparable.class, joinType()); if (lo != null && hi != null && lowerInclusive && upperInclusive) { return cb.between(expr, (Comparable) lo, (Comparable) hi); @@ -491,4 +536,89 @@ String describe() { + BaseFilterJpaProcessor.describe(upper); } } + + private static final class LikeHandler extends FieldHandler { + private static final char ESCAPE = '\\'; + + private final Field field; + private final boolean ignoreCase; + private final Like.Match match; + + LikeHandler(String path, boolean or, Field field, boolean ignoreCase, Like.Match match) { + super(path, or); + this.field = field; + this.ignoreCase = ignoreCase; + this.match = match; + } + + @Override + Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolver resolver) + throws IllegalAccessException { + String value = (String) field.get(filter); + if (value == null) return null; + Expression expr = resolver.resolve(attributePath, String.class, joinType()); + if (ignoreCase) { + expr = cb.lower(expr); + value = value.toLowerCase(Locale.ROOT); + } + if (match == Like.Match.RAW) { + return cb.like(expr, value); + } + return cb.like(expr, pattern(value), ESCAPE); + } + + private String pattern(String value) { + String escaped = escape(value); + return switch (match) { + case STARTS_WITH -> escaped + "%"; + case ENDS_WITH -> "%" + escaped; + case CONTAINS, RAW -> "%" + escaped + "%"; + }; + } + + /** Escapes the wildcards so that {@code value} is matched literally. */ + private static String escape(String value) { + StringBuilder sb = new StringBuilder(value.length()); + for (char ch : value.toCharArray()) { + if (ch == ESCAPE || ch == '%' || ch == '_') { + sb.append(ESCAPE); + } + sb.append(ch); + } + return sb.toString(); + } + + @Override + String describe() { + return BaseFilterJpaProcessor.describe(field); + } + } + + private static final class InHandler extends FieldHandler { + private final Field field; + private final In.EmptyPolicy whenEmpty; + + InHandler(String path, boolean or, Field field, In.EmptyPolicy whenEmpty) { + super(path, or); + this.field = field; + this.whenEmpty = whenEmpty; + } + + @Override + Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolver resolver) + throws IllegalAccessException { + Collection values = (Collection) field.get(filter); + if (values == null) return null; + if (values.isEmpty()) { + // A disjunction without disjuncts is false: it matches no rows. + return whenEmpty == In.EmptyPolicy.MATCH_NONE ? cb.disjunction() : null; + } + return resolver.resolve(attributePath, joinType()).in(values); + } + + @Override + String describe() { + return BaseFilterJpaProcessor.describe(field); + } + } } diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConversionJpaDaoSupport.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConversionJpaDaoSupport.java index 42ba92f..34eeb1d 100644 --- a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConversionJpaDaoSupport.java +++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConversionJpaDaoSupport.java @@ -159,9 +159,25 @@ default long count(BaseFilter filter) { /** * Hook for adding non-declarative predicates to a {@link BaseFilter}-driven * query. Predicates returned here are ANDed with those derived from the - * filter's annotations. + * filter's annotations, including the disjunction of its + * {@link com.flowingcode.backendcore.model.filter.Or @Or} fields. To combine a + * hand-built predicate with {@code OR}, return it already combined, e.g. + * {@code cb.or(...)}. * - *

Defaults to no extra predicates. + *

The hook reads the values it needs through the accessors of the concrete + * filter class, typically the fields declared as + * {@link com.flowingcode.backendcore.model.filter.Attribute#manual() + * manual}: + * + *

{@code
+	 * if (filter instanceof PersonFilter f && f.getSearch() != null) {
+	 *     return List.of(cb.like(root.get("name"), "%" + f.getSearch() + "%"));
+	 * }
+	 * return List.of();
+	 * }
+ * + *

Called once per filter, count and single-result query, with whichever + * filter class the caller passed. Defaults to no extra predicates. */ default Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, Root root) { @@ -170,8 +186,15 @@ default Collection customizePredicates(BaseFilter filter, CriteriaBui /** * Last-chance hook to mutate the in-progress {@code CriteriaQuery} (e.g. - * {@code distinct}, projections, group-by). Called once per filter, count, - * and single-result query after predicates have been applied. + * {@code distinct}, extra roots or joins). Called once per filter, count and + * single-result query after predicates have been applied; the count query is + * the one whose {@link CriteriaQuery#getResultType()} is {@code Long}. + * + *

The hook must keep the selection the query was created with, which is + * the entity root, or the count of it: replacing it (for instance, with a + * projection) makes the query fail with an {@code IllegalStateException}. + * The hook must not add a {@code GROUP BY} to the count query either. A + * {@code distinct(true)} set on the count query counts distinct entities. * *

Defaults to a no-op. */ @@ -180,35 +203,6 @@ default void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQu // no-op } - /** - * Reads the value of the given field on {@code filter} without forcing the - * hook to do its own reflection. Backed by the same cached metadata used to - * build the declarative predicates. - * - *

Useful from {@link #customizePredicates} when consuming a field - * declared as {@code @Attribute(manual = true)}, but works for any field - * declared on the filter (annotated or not). - * - * @throws IllegalArgumentException if the filter class has no field with the - * given name - */ - default Object getFilterFieldValue(BaseFilter filter, String fieldName) { - return BaseFilterJpaProcessor.readField(filter, fieldName); - } - - /** - * Typed convenience overload of {@link #getFilterFieldValue(BaseFilter, String)}; - * casts the value through {@code type} so the caller doesn't have to. - * - * @throws IllegalArgumentException if the filter class has no such field - * @throws ClassCastException if the stored value is not assignable to - * {@code type} - */ - default V getFilterFieldValue(BaseFilter filter, String fieldName, Class type) { - Object value = getFilterFieldValue(filter, fieldName); - return value == null ? null : type.cast(value); - } - private BaseFilterJpaProcessor baseFilterProcessor() { final ConversionJpaDaoSupport self = this; return BaseFilterJpaProcessor.of(getEntityManager(), getPersistentClass(), diff --git a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java index 8ae614b..b5edcf8 100644 --- a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java +++ b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java @@ -45,6 +45,7 @@ import com.flowingcode.backendcore.model.filter.Attribute; import com.flowingcode.backendcore.model.filter.BaseFilter; import com.flowingcode.backendcore.model.filter.From; +import com.flowingcode.backendcore.model.impl.City; import com.flowingcode.backendcore.model.impl.Person; import lombok.Getter; @@ -66,18 +67,25 @@ static class PersonFilter extends BaseFilter { static class HookDao implements JpaDaoSupport { - final EntityManagerFactory emf; + final EntityManager em; final AtomicInteger predicatesCalls = new AtomicInteger(); final AtomicInteger criteriaCalls = new AtomicInteger(); final AtomicBoolean restrictByName = new AtomicBoolean(); - HookDao(EntityManagerFactory emf) { - this.emf = emf; + HookDao(EntityManager em) { + this.em = em; } @Override public EntityManager getEntityManager() { - return emf.createEntityManager(); + return em; + } + + // Tests subclass this DAO anonymously, and the default reflective lookup only + // inspects the interfaces implemented directly by the runtime class. + @Override + public Class getPersistentClass() { + return Person.class; } @Override @@ -101,6 +109,7 @@ public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQue private HookDao dao; private EntityManagerFactory emf; + private EntityManager em; @BeforeEach void setUp() { @@ -115,11 +124,16 @@ void setUp() { } em.getTransaction().commit(); em.close(); - dao = new HookDao(emf); + // One EntityManager for all the DAOs of a test, like one per transaction under Spring. + this.em = emf.createEntityManager(); + dao = new HookDao(this.em); } @AfterEach void tearDown() { + if (em != null && em.isOpen()) { + em.close(); + } if (emf != null && emf.isOpen()) { emf.close(); } @@ -145,7 +159,7 @@ void hookFiresOnFilterWithSingleResult() { // Restrict to a name with exactly one match so the call succeeds. dao.restrictByName.set(true); // The data has two "John"s, so override the restriction inline. - HookDao d = new HookDao(dao.emf) { + HookDao d = new HookDao(em) { @Override public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, Root root) { @@ -181,22 +195,23 @@ void predicateHookRestrictsResults() { @SuperBuilder static class ManualPersonFilter extends BaseFilter { - // Manual field: processor records it but never produces a predicate. The - // hook consumes the value via getFilterFieldValue and builds the LIKE. + // Manual field: the processor never produces a predicate for it. The hook + // reads the value through the getter and builds the predicate itself. @Attribute(value = "name", manual = true) private String nameLike; } @Test - void manualField_isSkipped_andHookUsesHelperToBuildPredicate() { - HookDao d = new HookDao(dao.emf) { + void manualField_isSkipped_andHookReadsItThroughTheGetter() { + HookDao d = new HookDao(em) { @Override public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, Root root) { predicatesCalls.incrementAndGet(); - String pattern = getFilterFieldValue(filter, "nameLike", String.class); - return pattern == null ? Collections.emptyList() - : List.of(cb.like(root.get("name"), pattern)); + if (filter instanceof ManualPersonFilter f && f.getNameLike() != null) { + return List.of(cb.like(root.get("name"), f.getNameLike())); + } + return Collections.emptyList(); } }; @@ -231,18 +246,12 @@ void manualField_rejectsRangeAnnotations() { "error message should mention manual=true; got: " + ex.getMessage()); } - @Test - void getFilterFieldValue_rejectsUnknownField() { - assertThrows(IllegalArgumentException.class, - () -> dao.getFilterFieldValue(PersonFilter.builder().build(), "noSuchField")); - } - @Test void criteriaHookCanMutateQuery() { // Validate the hook can run cq.* operations against the live query. Applying // distinct on a SELECT-entity query with unique IDs is a no-op result-wise // but a valid Criteria mutation that exercises the hook end-to-end. - HookDao d = new HookDao(dao.emf) { + HookDao d = new HookDao(em) { @Override public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, Root root) { @@ -252,4 +261,61 @@ public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQue List people = d.filter(PersonFilter.builder().build()); assertEquals(4, people.size()); } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + void criteriaHookCannotReplaceSelection() { + HookDao d = new HookDao(em) { + @Override + public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + ((CriteriaQuery) cq).select(root.get("name")); + } + }; + PersonFilter f = PersonFilter.builder().build(); + + assertThrows(IllegalStateException.class, () -> d.filter(f)); + assertThrows(IllegalStateException.class, () -> d.filterWithSingleResult(f)); + assertThrows(IllegalStateException.class, () -> d.count(f)); + } + + @Test + void criteriaHookCannotGroupCountQuery() { + HookDao d = new HookDao(em) { + @Override + public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + cq.groupBy(root.get("name")); + } + }; + assertThrows(IllegalStateException.class, () -> d.count(PersonFilter.builder().build())); + } + + @Test + void distinctFromCriteriaHook_countsDistinctEntities() { + // A second root yields one row per (person, city) pair; distinct collapses + // them back to the four people in both the list and the count query. + EntityManager tx = emf.createEntityManager(); + tx.getTransaction().begin(); + for (String name : new String[] {"Rome", "Paris"}) { + City city = new City(); + city.setName(name); + tx.persist(city); + } + tx.getTransaction().commit(); + tx.close(); + + HookDao d = new HookDao(em) { + @Override + public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + cq.from(City.class); + cq.distinct(true); + } + }; + PersonFilter f = PersonFilter.builder().build(); + + assertEquals(4, d.filter(f).size()); + assertEquals(4, d.count(f)); + } } diff --git a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterPipelineTest.java b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterPipelineTest.java new file mode 100644 index 0000000..9614dde --- /dev/null +++ b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterPipelineTest.java @@ -0,0 +1,388 @@ +/*- + * #%L + * Commons Backend - Data Access Layer Implementations + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * 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 + * + * http://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. + * #L% + */ +package com.flowingcode.backendcore.dao.jpa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.Persistence; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.flowingcode.backendcore.model.filter.Attribute; +import com.flowingcode.backendcore.model.filter.BaseFilter; +import com.flowingcode.backendcore.model.filter.From; +import com.flowingcode.backendcore.model.filter.In; +import com.flowingcode.backendcore.model.filter.Like; +import com.flowingcode.backendcore.model.filter.Or; +import com.flowingcode.backendcore.model.filter.To; +import com.flowingcode.backendcore.model.filter.WhenNull; +import com.flowingcode.backendcore.model.impl.City; +import com.flowingcode.backendcore.model.impl.Person; + +import lombok.Setter; +import lombok.experimental.Accessors; + +/** End-to-end coverage of the annotation pipeline against an in-memory database. */ +class BaseFilterPipelineTest { + + static class PersonDao implements JpaDaoSupport { + + final EntityManager em; + final AtomicInteger rootJoins = new AtomicInteger(-1); + + PersonDao(EntityManager em) { + this.em = em; + } + + @Override + public EntityManager getEntityManager() { + return em; + } + + @Override + public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + rootJoins.set(root.getJoins().size()); + } + } + + private EntityManagerFactory emf; + private EntityManager em; + private PersonDao dao; + + @BeforeEach + void setUp() { + emf = Persistence.createEntityManagerFactory("person"); + EntityManager tx = emf.createEntityManager(); + tx.getTransaction().begin(); + City laPlata = new City(); + laPlata.setName("La Plata"); + laPlata.setPopulation(700_000); + tx.persist(laPlata); + City unnamed = new City(); + tx.persist(unnamed); + persist(tx, "John", "Doe", laPlata); + persist(tx, "Jane", "Doe", unnamed); + persist(tx, "Alice", "Smith", null); + persist(tx, "johnny", "smith", null); + persist(tx, "Ann_B", "Roe", null); + persist(tx, "AnnXB", "Roe", null); + tx.getTransaction().commit(); + tx.close(); + + em = emf.createEntityManager(); + dao = new PersonDao(em); + } + + private static void persist(EntityManager tx, String name, String lastName, City city) { + Person p = new Person(); + p.setName(name); + p.setLastName(lastName); + p.setCity(city); + tx.persist(p); + } + + @AfterEach + void tearDown() { + if (em != null && em.isOpen()) { + em.close(); + } + if (emf != null && emf.isOpen()) { + emf.close(); + } + } + + /** Asserts the people matched by {@code filter}, and that {@code count()} agrees. */ + private void assertMatches(BaseFilter filter, String... expectedNames) { + List actual = dao.filter(filter).stream() + .map(Person::getName) + .sorted() + .collect(Collectors.toList()); + assertEquals(Stream.of(expectedNames).sorted().collect(Collectors.toList()), actual); + assertEquals(expectedNames.length, dao.count(filter), "count() must agree with filter()"); + } + + private static final String[] EVERYONE = {"John", "Jane", "Alice", "johnny", "Ann_B", "AnnXB"}; + + // ----- @Like ----- + + @Setter + @Accessors(chain = true) + static class LikeFilter extends BaseFilter { + @Attribute("name") @Like + private String nameContains; + + @Attribute("lastName") @Like(ignoreCase = true, match = Like.Match.STARTS_WITH) + private String lastNameStartsIgnoringCase; + + @Attribute("city.name") @Like(match = Like.Match.ENDS_WITH) + private String cityEnds; + } + + @Setter + @Accessors(chain = true) + static class RawLikeFilter extends BaseFilter { + @Attribute("name") @Like(match = Like.Match.RAW) + private String namePattern; + + @Attribute("lastName") @Like(match = Like.Match.STARTS_WITH) + private String lastNameStarts; + } + + @Test + void like_containsByDefault_andIsCaseSensitive() { + assertMatches(new LikeFilter().setNameContains("Joh"), "John"); + } + + @Test + void like_ignoreCase_lowerCasesBothSides() { + assertMatches(new LikeFilter().setLastNameStartsIgnoringCase("SMI"), "Alice", "johnny"); + assertMatches(new RawLikeFilter().setLastNameStarts("Smi"), "Alice"); + } + + @Test + void like_endsWith_onNestedPath() { + assertMatches(new LikeFilter().setCityEnds("Plata"), "John"); + } + + @Test + void like_escapesWildcardsInTheValue() { + // Unescaped, "_" would also match "AnnXB" and "%" would match everyone. + assertMatches(new LikeFilter().setNameContains("n_B"), "Ann_B"); + assertMatches(new LikeFilter().setNameContains("%")); + } + + @Test + void like_raw_keepsWildcards() { + assertMatches(new RawLikeFilter().setNamePattern("Ann_B"), "Ann_B", "AnnXB"); + } + + @Test + void like_nullValue_isSkipped() { + assertMatches(new LikeFilter(), EVERYONE); + } + + // ----- @In ----- + + @Setter + @Accessors(chain = true) + static class InFilter extends BaseFilter { + @Attribute("lastName") @In + private List lastNames; + + @Attribute("name") @In(whenEmpty = In.EmptyPolicy.MATCH_NONE) + private List names; + } + + @Test + void in_matchesAnyElement() { + assertMatches(new InFilter().setLastNames(List.of("Doe", "Roe")), "John", "Jane", "Ann_B", "AnnXB"); + } + + @Test + void in_emptyCollection_isSkippedByDefault() { + assertMatches(new InFilter().setLastNames(List.of()), EVERYONE); + } + + @Test + void in_emptyCollection_matchesNoneWhenConfigured() { + assertMatches(new InFilter().setNames(List.of())); + } + + @Test + void in_nullValue_isSkipped() { + assertMatches(new InFilter(), EVERYONE); + } + + // ----- @Or ----- + + @Setter + @Accessors(chain = true) + static class OrFilter extends BaseFilter { + @Attribute("city.name") @Or + private String cityName; + + @Attribute("name") @Or + private String name; + + @Attribute("lastName") + private String lastName; + } + + @Test + void or_matchesThroughAnyDisjunct_includingRowsWithANullAssociation() { + // Alice has no city: an inner join on city would drop her before the OR runs. + assertMatches(new OrFilter().setCityName("La Plata").setName("Alice"), "John", "Alice"); + } + + @Test + void or_disjunctionIsAndedWithTheOtherFields() { + assertMatches(new OrFilter().setCityName("La Plata").setName("Alice").setLastName("Smith"), + "Alice"); + } + + @Test + void or_withoutAnyValue_doesNotRestrict() { + // An empty disjunction is false; it must be omitted rather than match nothing. + assertMatches(new OrFilter(), EVERYONE); + } + + @Test + void or_singleValue_actsAsThatPredicate() { + assertMatches(new OrFilter().setName("Alice"), "Alice"); + } + + // ----- join types ----- + + @Setter + @Accessors(chain = true) + static class CityNameFilter extends BaseFilter { + @Attribute("city.name") @WhenNull(WhenNull.Policy.IS_NULL) + private String cityName; + } + + @Test + void isNull_onNestedPath_matchesNullAssociations() { + // Jane's city has no name; the other four have no city at all. + assertMatches(new CityNameFilter(), "Jane", "Alice", "johnny", "Ann_B", "AnnXB"); + } + + @Test + void isNullPolicy_withValue_isPlainEquality() { + assertMatches(new CityNameFilter().setCityName("La Plata"), "John"); + } + + @Test + void orderingByNestedPath_doesNotDropRows() { + OrFilter filter = new OrFilter(); + filter.addOrder("city.name"); + assertMatches(filter, EVERYONE); + } + + @Setter + @Accessors(chain = true) + static class SharedJoinFilter extends BaseFilter { + @Attribute("city.name") + private String cityName; + + @Attribute("city.population") @Or + private Integer population; + + @Attribute("name") @Or + private String name; + } + + @Test + void associationUsedByAndAndOrFields_isJoinedOnce() { + SharedJoinFilter filter = new SharedJoinFilter() + .setCityName("La Plata").setPopulation(700_000).setName("Nobody"); + filter.addOrder("city.name"); + assertMatches(filter, "John"); + assertEquals(1, dao.rootJoins.get(), "city must be joined once"); + } + + // ----- field discovery ----- + + static class NameFilter extends BaseFilter { + @Attribute("name") + private String name; + } + + static class ShadowingFilter extends NameFilter { + @Attribute("lastName") + private String name; + } + + @Test + void annotatedFieldShadowedBySubclassField_isStillReadable() { + assertMatches(new ShadowingFilter(), EVERYONE); + } + + // ----- configuration errors ----- + + static class LikeOnNonString extends BaseFilter { + @Attribute("city.population") @Like + private Integer population; + } + + static class InOnNonCollection extends BaseFilter { + @Attribute("name") @In + private String name; + } + + static class LikeWithFrom extends BaseFilter { + @Attribute("name") @Like @From + private String name; + } + + static class InWithWhenNull extends BaseFilter { + @Attribute("lastName") @In @WhenNull(WhenNull.Policy.IS_NULL) + private List lastNames; + } + + static class OrWithoutAttribute extends BaseFilter { + @Or + private String name; + } + + static class OrOnManualField extends BaseFilter { + @Attribute(value = "name", manual = true) @Or + private String name; + } + + static class OrOnOneSideOfRange extends BaseFilter { + @Attribute("city.population") @From @Or + private Integer populationFrom; + + @Attribute("city.population") @To + private Integer populationTo; + } + + private void assertRejected(BaseFilter filter, String expectedInMessage) { + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> dao.count(filter)); + assertTrue(ex.getMessage().contains(expectedInMessage), + "message should mention " + expectedInMessage + "; got: " + ex.getMessage()); + } + + @Test + void invalidConfigurations_areRejected() { + assertRejected(new LikeOnNonString(), "must be String"); + assertRejected(new InOnNonCollection(), "must be a Collection"); + assertRejected(new LikeWithFrom(), "cannot combine @From and @Like"); + assertRejected(new InWithWhenNull(), "cannot combine @WhenNull with @In"); + assertRejected(new OrWithoutAttribute(), "has @Or but no @Attribute"); + assertRejected(new OrOnManualField(), "manual=true"); + assertRejected(new OrOnOneSideOfRange(), "must either both carry @Or or neither"); + } +} diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Attribute.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Attribute.java index a6f60d7..2383889 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Attribute.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Attribute.java @@ -28,27 +28,36 @@ * Maps a filter field to an entity attribute. * *

The value is a dotted attribute path on the target entity (e.g. - * {@code "city.state.name"}). Path traversal auto-joins associations using - * inner joins, reusing existing joins when one is already present on the same - * attribute and join type. + * {@code "city.state.name"}). Path traversal auto-joins associations, reusing + * an existing join when one is already present on the same attribute. Joins are + * inner joins, except where an inner join would drop rows the predicate is meant + * to match: paths of {@link Or} fields, {@code IS NULL} predicates from + * {@link WhenNull}, and sort orders use left joins. Consequently, an equality or + * comparison on a nested path never matches rows whose association is + * {@code null}. * *

A filter field carrying only {@code @Attribute} is interpreted as an * equality predicate against the resolved attribute. Pair it with {@link From} - * or {@link To} to express range comparisons, or with {@link WhenNull} to - * control how a null field value is handled. + * or {@link To} to express range comparisons, with {@link Like} or {@link In} + * for pattern and membership matching, with {@link WhenNull} to control how a + * null field value is handled, or with {@link Or} to place the predicate in the + * filter's disjunction. * *

Set {@link #manual()} to {@code true} when the predicate for the field is * built by hand in a DAO hook (e.g. - * {@code ConversionJpaDaoSupport#customizePredicates}). The processor will skip - * declarative predicate building for the field but still track it so the - * field's value can be retrieved via - * {@code ConversionJpaDaoSupport#getFilterFieldValue}. Manual fields cannot - * combine with {@link From}, {@link To} or {@link WhenNull}, since none of - * those have meaning when the predicate is hand-built. + * {@code ConversionJpaDaoSupport#customizePredicates}). The processor skips + * the field, and the hook reads its value through the filter's accessor, so a + * manual field must have one. Manual fields cannot + * combine with {@link From}, {@link To}, {@link Like}, {@link In}, + * {@link WhenNull} or {@link Or}, since none of those have meaning when the + * predicate is hand-built. * * @see From * @see To + * @see Like + * @see In * @see WhenNull + * @see Or */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) @@ -63,9 +72,9 @@ String value(); /** - * When {@code true}, the processor records the field for value lookup but - * does not generate a declarative predicate. The caller is responsible for - * producing the predicate in a DAO hook. + * When {@code true}, the processor does not generate a declarative predicate + * for the field. The caller is responsible for producing the predicate in a + * DAO hook, reading the value through the filter's accessor. */ boolean manual() default false; } diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/In.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/In.java new file mode 100644 index 0000000..e19c6aa --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/In.java @@ -0,0 +1,58 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * 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 + * + * http://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. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Matches the {@link Attribute} of a {@code Collection} filter field with an + * {@code IN} predicate: the attribute must equal one of the collection's + * elements. + * + *

A {@code null} field value contributes no predicate. An empty collection is + * handled according to {@link #whenEmpty()}, which defaults to + * {@link EmptyPolicy#SKIP}: an empty selection is treated as "no criterion", the + * same as {@code null}, rather than as "match nothing". + * + *

Must be paired with {@link Attribute}, and cannot combine with + * {@link From}, {@link To}, {@link Like} or {@link WhenNull}. + * + * @see Attribute + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface In { + + /** How an empty collection is handled. Defaults to {@link EmptyPolicy#SKIP}. */ + EmptyPolicy whenEmpty() default EmptyPolicy.SKIP; + + /** Policy applied to an empty collection. */ + enum EmptyPolicy { + + /** Emit no predicate, as for a {@code null} field value. */ + SKIP, + + /** Emit a predicate that matches no rows. */ + MATCH_NONE + } +} diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Like.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Like.java new file mode 100644 index 0000000..3265212 --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Like.java @@ -0,0 +1,72 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * 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 + * + * http://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. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Matches the {@link Attribute} of a {@code String} filter field with a + * {@code LIKE} predicate instead of equality. + * + *

With the default {@link Match#CONTAINS}, the field value is searched + * anywhere in the attribute. The {@code CONTAINS}, {@code STARTS_WITH} and + * {@code ENDS_WITH} modes escape the {@code %} and {@code _} wildcards (and the + * escape character itself) in the field value, so the value is always matched + * literally. Use {@link Match#RAW} to pass the field value through as the + * pattern, wildcards included. + * + *

Set {@link #ignoreCase()} to compare case-insensitively; both sides are + * lower-cased before comparing. + * + *

A {@code null} field value contributes no predicate. Must be paired with + * {@link Attribute}, and cannot combine with {@link From}, {@link To}, + * {@link In} or {@link WhenNull}. + * + * @see Attribute + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Like { + + /** When {@code true}, the comparison ignores case. Defaults to {@code false}. */ + boolean ignoreCase() default false; + + /** Where the field value must occur in the attribute. Defaults to {@link Match#CONTAINS}. */ + Match match() default Match.CONTAINS; + + /** Position of the field value within the matched attribute. */ + enum Match { + + /** The field value is used as the pattern verbatim, wildcards included. */ + RAW, + + /** The attribute contains the field value. */ + CONTAINS, + + /** The attribute starts with the field value. */ + STARTS_WITH, + + /** The attribute ends with the field value. */ + ENDS_WITH + } +} diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Or.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Or.java new file mode 100644 index 0000000..b1a165a --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Or.java @@ -0,0 +1,59 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * 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 + * + * http://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. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Places the predicate of the annotated filter field in the filter's + * disjunction instead of its conjunction. + * + *

The predicates of all {@code @Or} fields are combined with {@code OR}, and + * the result is combined with {@code AND} with the predicates of every other + * field: + * + *

+ * WHERE <other fields> AND (<@Or fields>)
+ * 
+ * + *

There is a single disjunction per filter: every {@code @Or} field joins + * the same one, including fields inherited from a superclass. Fields whose value + * contributes no predicate (for instance, a {@code null} value) are left out of + * it, and when no {@code @Or} field contributes a predicate the disjunction is + * omitted, so it never restricts the results on its own. + * + *

Associations traversed by an {@code @Or} field are joined with a left + * join, so rows with a {@code null} association can still match through + * another branch of the disjunction. + * + *

Must be paired with {@link Attribute} and cannot be used on a + * {@link Attribute#manual() manual} field. On a {@link From} / {@link To} pair, + * either both fields or neither carry {@code @Or}. Groups of disjunctions, or + * matching one value against several attributes, are built in a DAO hook. + * + * @see Attribute + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Or { +} diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/WhenNull.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/WhenNull.java index acdb75a..e695bb1 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/WhenNull.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/WhenNull.java @@ -30,11 +30,13 @@ *

The default policy for any field carrying {@link Attribute} is * {@link Policy#SKIP}: a null field contributes no predicate. Use this * annotation with {@link Policy#IS_NULL} to instead emit a - * {@code attribute IS NULL} predicate. + * {@code attribute IS NULL} predicate. Associations along a nested path are + * then left-joined, so {@code @Attribute("city.name")} matches both a city with + * a {@code null} name and a {@code null} city. * *

{@code @WhenNull} requires {@link Attribute} on the same field and is not - * allowed alongside {@link From} or {@link To}, since range bounds have no - * sensible {@code IS_NULL} semantics. + * allowed alongside {@link From}, {@link To}, {@link Like} or {@link In}, since + * none of those have sensible {@code IS_NULL} semantics. * * @see Attribute */ diff --git a/specs/base-filter.md b/specs/base-filter.md index 2ad9600..945cfd8 100644 --- a/specs/base-filter.md +++ b/specs/base-filter.md @@ -26,8 +26,9 @@ This spec covers the abstract class, the initial annotation set, the criteria-bu ### Non-goals (v1) -- Operator-specific annotations (`@Like`, `@ILike`, `@GreaterThan`, `@In`, `@IsNull`, etc.). The first cut ships only *metadata* annotations that describe the role of the field; operator-specific annotations will be revisited once the abstraction proves out. -- Boolean composition annotations (`@Or`, `@Not`). +- Operator annotations beyond `@Like` and `@In` (e.g. `@NotEqual`, `@Not`, a standalone `@IsNotNull`). Relational comparisons are covered by `@From` / `@To`. +- Boolean composition beyond the single `@Or` disjunction: no named or nested groups. A filter needing them builds the predicate in a DAO hook. +- Matching one filter value against several attributes (e.g. a search box over name and email). That is custom logic, built in a DAO hook. - Pluggable annotation processors (registering third-party annotations). Extensibility is via DAO hook methods only. - Reusing or wrapping `QuerySpec`, `Constraint`, or `ConstraintTransformer`. The new pipeline is independent. - Replacing `findById` / `findAll` / `save` / `update` / `delete`. Only the filter-shaped methods are touched. @@ -48,8 +49,7 @@ public abstract class BaseFilter { public enum Order { ASC, DESC } - @Singular("order") - private Map orders; // initialized to LinkedHashMap; never null + private Map orders; // insertion-ordered; getOrders() never returns null private Integer firstResult; // null or >= 0 private Integer maxResult; // null or >= 0 @@ -57,6 +57,8 @@ public abstract class BaseFilter { // Convenience mutators kept on the POJO surface public BaseFilter addOrder(String attribute); // defaults to ASC public BaseFilter addOrder(String attribute, Order direction); + public BaseFilter setOrders(Map orders); // copies the argument + public Map getOrders(); // unmodifiable view } ``` @@ -83,14 +85,15 @@ Notes on the dual-style design: - Lombok's `@SuperBuilder` is the simplest way to produce a typed builder that correctly composes with inheritance — subclasses opt in by adding `@SuperBuilder` and their builder inherits the base fields automatically. The existing codebase already relies on Lombok (`@Getter`/`@Setter`/`@Accessors(chain=true)` on `QuerySpec`), so this matches established conventions. - The chainable POJO setters (via `@Accessors(chain = true)`) are kept so callers who already work with mutable filters — including code wired through frameworks that prefer no-arg construction + property binding (e.g. JSON deserialization, query-param binding) — don't pay any extra ceremony. -- The inner `BaseFilterBuilder` is declared explicitly so that paging validation and a per-entry `.addOrder(...)` adder are part of the builder API; Lombok fills in the rest (fields, `self()`, `build()`, the subclass plumbing). The built `orders` map is a `LinkedHashMap` (mutable, insertion-ordered) so subsequent POJO-style `addOrder(...)` calls still work after `build()`. +- The inner `BaseFilterBuilder` is declared explicitly so that paging validation, a per-entry `.addOrder(...)` adder and a copying `.orders(Map)` setter are part of the builder API; Lombok fills in the rest (fields, `self()`, `build()`, the subclass plumbing). +- The `orders` map is never shared. Maps passed to `setOrders(...)` or the builder's `orders(...)` are copied, both `addOrder(...)` variants copy before writing, and `getOrders()` returns an unmodifiable view — so a filter built through `toBuilder()`, or built twice from the same builder, is independent of the others. POJO-style `addOrder(...)` still works after `build()`. - `toBuilder = true` lets callers rebuild a tweaked copy of an existing filter (`f.toBuilder().maxResult(10).build()`), useful for paging. - The sort-order attribute string follows the same dotted-path convention as `@Attribute` (see §3.2) so callers can sort across joins. - Pagination validators throw `IllegalArgumentException` on negative values, matching `QuerySpec`. Validation lives in both the POJO setters and the corresponding builder methods (`firstResult(...)` / `maxResult(...)` on `BaseFilterBuilder`), so both construction styles enforce the same invariants. ### 3.2 Annotations (in `backend-core-model`, package `com.flowingcode.backendcore.model.filter`) -All annotations are field-level (`@Target(ElementType.FIELD)`), retained at runtime, and purely *metadata* — they describe how a filter field relates to the entity, not the operator to apply. +All annotations are field-level (`@Target(ElementType.FIELD)`) and retained at runtime. `@Attribute` maps the field to an entity attribute; at most one *operator* annotation (`@From`, `@To`, `@Like`, `@In`) chooses the predicate, which defaults to equality; `@WhenNull` and `@Or` adjust how the predicate is produced and combined. #### `@Attribute` @@ -105,9 +108,9 @@ public @interface Attribute { } ``` -- Maps a filter field to one or more entity attributes via a dotted path. Path traversal follows the same `split("\\.")` + auto-join convention used today in `ConstraintTransformerJpaImpl`, including the inner-join-by-default behavior. +- Maps a filter field to one entity attribute via a dotted path. Path traversal follows the same `split("\\.")` + auto-join convention used today in `ConstraintTransformerJpaImpl`; see §4.1 for the join types. - A filter field with `@Attribute` and no role annotation defaults to **equality** (`cb.equal(...)`). -- `manual = true` is the escape hatch for predicates that don't fit the declarative model. The processor records the field (so the value-accessor helper can read it) but emits no predicate; the DAO's `customizePredicates` hook is responsible for the constraint. On a manual field the `value()` is informational — the processor never resolves it — but stays useful as documentation of which entity attribute the hook is expected to target. `manual = true` cannot combine with `@From`, `@To`, or `@WhenNull`, since none of those have meaning when the predicate is hand-built. +- `manual = true` is the escape hatch for predicates that don't fit the declarative model. The processor emits no predicate for the field; the DAO's `customizePredicates` hook is responsible for the constraint, reading the value through the filter's accessor (§5.1). On a manual field the `value()` is informational — the processor never resolves it — but stays useful as documentation of which entity attribute the hook is expected to target. `manual = true` cannot combine with `@From`, `@To`, `@Like`, `@In`, `@WhenNull` or `@Or`, since none of those have meaning when the predicate is hand-built. #### `@From` and `@To` @@ -148,8 +151,51 @@ public @interface WhenNull { ``` - Per-field override for null handling. Default is `SKIP` (no predicate emitted), so most filter fields don't need this annotation. -- `IS_NULL` makes a null field emit `cb.isNull(...)` against the resolved attribute path. -- Only meaningful on fields that also carry `@Attribute`. On `@From` / `@To`, `SKIP` is the only sensible policy and the processor must reject `IS_NULL` with a clear error. +- `IS_NULL` makes a null field emit `cb.isNull(...)` against the resolved attribute path. The associations on the path are left-joined, so `@Attribute("city.name")` matches both a city with a null name and a null city. +- Only meaningful on plain equality fields that also carry `@Attribute`; the processor rejects `@WhenNull` alongside `@From`, `@To`, `@Like` or `@In`. + +#### `@Like` + +```java +@Retention(RUNTIME) @Target(FIELD) +public @interface Like { + boolean ignoreCase() default false; + Match match() default Match.CONTAINS; + + enum Match { RAW, CONTAINS, STARTS_WITH, ENDS_WITH } +} +``` + +- Matches a `String` field with `LIKE` instead of equality. A non-`String` field is a configuration error. +- `CONTAINS` (default), `STARTS_WITH` and `ENDS_WITH` wrap the value with `%` and escape the `%` / `_` wildcards and the `\` escape character in it, so the value always matches literally. `RAW` passes the value through as the pattern, wildcards included — the behavior of the legacy `ConstraintBuilder.like(...)`. +- `ignoreCase = true` compares `lower(attribute)` with the value lower-cased in `Locale.ROOT`; it replaces the legacy `iLike(...)`. +- A null value is skipped. + +#### `@In` + +```java +@Retention(RUNTIME) @Target(FIELD) +public @interface In { + EmptyPolicy whenEmpty() default EmptyPolicy.SKIP; + + enum EmptyPolicy { SKIP, MATCH_NONE } +} +``` + +- Matches a `Collection` field with `attribute IN (...)`. A non-`Collection` field is a configuration error. +- A null value is skipped. An empty collection is skipped by default, treating an empty selection as "no criterion" like `null`; `MATCH_NONE` emits a predicate that is always false instead. + +#### `@Or` + +```java +@Retention(RUNTIME) @Target(FIELD) +public @interface Or { } +``` + +- Places the field's predicate in the filter's single disjunction instead of its conjunction: `WHERE AND (<@Or fields>)`. +- Every `@Or` field of the filter, inherited ones included, joins the same disjunction. Fields that contribute no predicate (e.g. a null value) are left out; when none contributes, the disjunction is omitted — an empty disjunction would be false and match no rows. +- Associations on the path of an `@Or` field are left-joined, so a row with a null association can still match through another disjunct. +- Requires `@Attribute`, cannot be used on a `manual` field, and on a `@From` / `@To` pair must be on both fields or neither. Named or nested groups are out of scope (§2); they are built in a DAO hook. ### 3.3 DAO surface changes (in `backend-core-data` and `backend-core-data-impl`) @@ -178,18 +224,29 @@ class BaseFilterJpaProcessor, K extends Serializable> For each call, the processor: 1. **Reflects** the `BaseFilter` subclass to discover annotated fields. Reflection results (field handles, attribute paths, role classification, null policy) are **cached per filter class** in a static map to avoid re-walking the class on every query. -2. **Validates** the annotations at first encounter (range-pair consistency, `@From`/`@To` requires `@Attribute`, `@WhenNull(IS_NULL)` requires `@Attribute` and forbids `@From`/`@To`, no duplicate single-value `@Attribute` on the same path, range pairs share the exact same attribute path). +2. **Validates** the annotations at first encounter: every other annotation requires `@Attribute`; `manual` fields take no other annotation; at most one operator (`@From`, `@To`, `@Like`, `@In`) per field; `@WhenNull` only on equality fields; `@Like` requires a `String` field and `@In` a `Collection`; `@Or` is on both fields of a range pair or neither; no two fields share an attribute path unless they form a `@From`/`@To` pair. 3. **Builds predicates** by walking the cached field metadata: - Single-attribute fields → equality. + - `@Like` → `LIKE`, `@In` → `IN`, as described in §3.2. - `@From`-only / `@To`-only → comparison predicate honoring the field's `inclusive` flag (`>` / `>=` / `<` / `<=`). - `@From` + `@To` on the same `@Attribute` path → `cb.between(...)` only when both values are non-null **and** both bounds are inclusive; otherwise two ANDed comparison predicates with each side's `inclusive` setting honored independently. When only one side is non-null, emit just that side's comparison. - Null values are skipped unless `@WhenNull(IS_NULL)` is present. -4. **Resolves attribute paths** via the same auto-joining strategy used today in `ConstraintTransformerJpaImpl` (split on `.`, reuse existing joins when the join type matches). The path/join helper should be **extracted into a shared utility** so both the legacy transformer and the new processor consume it, but the new processor does not depend on `Constraint` / `ConstraintTransformer`. + - Predicates of `@Or` fields are combined with `OR`; the result, the other predicates and the predicates from the `customizePredicates` hook are combined with `AND`. +4. **Resolves attribute paths** with `AttributePathResolver`, a package-private utility modeled on the path/join helper of `ConstraintTransformerJpaImpl` (split on `.`, auto-join). It is new code rather than an extraction: the deprecated transformer is left untouched, and the new processor does not depend on `Constraint` / `ConstraintTransformer`. Join types are described in §4.1. 5. **Applies sort orders** from `BaseFilter.getOrders()` in insertion order. 6. **Calls the DAO hook methods** (see §5) so subclasses can mutate the in-progress `CriteriaQuery`. 7. **Applies pagination** via `firstResult` / `maxResult` on the `TypedQuery`. -The pipeline is implemented twice in shape (once for the `T` result query, once for the `Long` count query), but the predicate assembly is shared. `filterWithSingleResult` runs the standard list query and enforces single-result semantics, matching the existing `FilterProcesor` behavior. +The pipeline is implemented twice in shape (once for the `T` result query, once for the `Long` count query), but the predicate assembly is shared. `filterWithSingleResult` builds the predicates without sort orders or paging and runs the query with `getSingleResult()`, returning an empty `Optional` for no match and throwing `IllegalStateException` for more than one. + +### 4.1 Join types + +Associations along a path are joined once per query: a join already present on the same attribute is reused whatever its type. The join type is chosen by the first resolution that creates it: + +- **Inner** for equality, comparison, `LIKE` and `IN` predicates of non-`@Or` fields. These conjuncts reject null, so every result row has the association anyway. As a consequence, such a predicate on a nested path never matches rows whose association is null. +- **Left** where an inner join would drop the rows the query is meant to return: `IS NULL` predicates from `@WhenNull(IS_NULL)`, predicates of `@Or` fields, and sort orders (sorting must not change the result set, nor make `filter` disagree with `count`). + +Reusing a join across types is safe because the declarative predicates are always combined as a conjunction: an inner join is only requested for a null-rejecting conjunct, and a left join behaves as an inner join under such a conjunct. The join type is inferred rather than configurable, so there is no join-type attribute on the annotations. ## 5. Extensibility — DAO hook methods @@ -202,27 +259,31 @@ default Collection customizePredicates( return Collections.emptyList(); } -/** Last-chance hook to mutate the CriteriaQuery (joins, projections, group by, distinct, etc.) before execution. */ +/** Last-chance hook to mutate the CriteriaQuery (distinct, extra roots or joins, etc.) before execution. */ default void customizeCriteria( BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, Root root) { // no-op } ``` -Both hooks are called once per query (filter, count, single-result). They receive the same `CriteriaQuery` instance that the processor is building, so subclasses can call `cq.distinct(true)`, add `LEFT JOIN FETCH`-style joins (within Criteria limits), or attach predicates that the annotation model can't express (e.g. correlated subqueries, function calls). +Both hooks are called once per query (filter, count, single-result). They receive the same `CriteriaQuery` instance that the processor is building, so subclasses can call `cq.distinct(true)`, add `LEFT JOIN FETCH`-style joins (within Criteria limits), or attach predicates that the annotation model can't express (e.g. correlated subqueries, function calls, `OR` groups, one value matched against several attributes). + +`customizeCriteria` must keep the query's selection — the entity root, or its count — since every query kind returns entities or a single count; replacing it (e.g. with a projection) fails with `IllegalStateException`. It must not add a `GROUP BY` to the count query either, which would return one count per group; the hook can recognize the count query by `cq.getResultType() == Long.class`. A `distinct(true)` set on the count query is honored as `COUNT(DISTINCT root)`, so the count agrees with the distinct list. Why hooks and not annotation processors: it keeps the abstraction surface small and predictable, and it routes customization through the DAO — the layer that already owns the entity-shaped logic — instead of fanning custom behavior across filter classes. -### 5.1 Value-accessor helper +### 5.1 Reading filter values in hooks -Hooks frequently need to read filter field values to build predicates by hand. To avoid forcing every implementer to maintain its own reflection logic, the DAO base exposes: +Hooks receive the filter as `BaseFilter` and read the values they need through the concrete filter class's accessors: ```java -default Object getFilterFieldValue(BaseFilter filter, String fieldName); -default V getFilterFieldValue(BaseFilter filter, String fieldName, Class type); +if (filter instanceof PersonFilter f && f.getSearch() != null) { + return List.of(cb.like(root.get("name"), "%" + f.getSearch() + "%")); +} +return List.of(); ``` -Both overloads are backed by the same cached reflection used to build the declarative predicates, so the lookup is essentially a map read. The helper works on any field declared on the filter (annotated or not); it is especially useful for fields marked `@Attribute(manual = true)`, where the declarative path has been intentionally skipped. +Filters must therefore provide accessors for every field a hook consumes, typically the fields marked `@Attribute(manual = true)`. The declarative fields need none: the processor reads them by reflection. There is deliberately no name-based lookup helper: a cast is checked by the compiler, so renaming a field breaks the hook at compile time rather than at run time, and the `instanceof` check is needed anyway when a DAO receives more than one filter class. Logic shared by several filter classes is expressed through a common superclass or interface (`filter instanceof HasTenant t`). ## 6. Usage example @@ -249,8 +310,20 @@ public class PersonFilter extends BaseFilter { @Attribute("deletedAt") @WhenNull(WhenNull.Policy.IS_NULL) private Instant deletedAt; // null → deletedAt IS NULL; non-null → equality - @Attribute(value = "nickname", manual = true) - private String nicknameLike; // processor skips this; hook builds the LIKE + @Attribute("lastName") @Like(ignoreCase = true) + private String lastName; // lower(lastName) LIKE '%:v%', wildcards in :v escaped + + @Attribute("status") @In + private Set statuses; // null or empty → skipped; else status IN (:v) + + @Attribute("email") @Or + private String email; // with nickname: ... AND (email = :e OR nickname = :n) + + @Attribute("nickname") @Or + private String nickname; + + @Attribute(value = "name", manual = true) + private String search; // processor skips this; hook matches name or nickname } // DAO with a manual predicate @@ -260,9 +333,12 @@ class PersonDao implements JpaDaoSupport { @Override public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, Root root) { - String pattern = getFilterFieldValue(filter, "nicknameLike", String.class); - return pattern == null ? List.of() - : List.of(cb.like(root.get("nickname"), "%" + pattern + "%")); + if (!(filter instanceof PersonFilter f) || f.getSearch() == null) { + return List.of(); + } + String pattern = "%" + f.getSearch() + "%"; + return List.of(cb.or(cb.like(root.get("name"), pattern), + cb.like(root.get("nickname"), pattern))); } } @@ -288,14 +364,14 @@ With both bounds set on `birthDate`, the processor emits `birthDate >= :from AND - All `QuerySpec`-typed DAO method overloads on `QueryDao` and `ConversionJpaDaoSupport` are marked `@Deprecated(forRemoval = false)` with javadoc pointing at the `BaseFilter` overloads. - `QuerySpec`, the `Constraint` hierarchy (`Constraint`, `ConstraintBuilder`, every concrete `Attribute*Constraint` and `DisjunctionConstraint` / `NegatedConstraint` / `RelationalConstraint`), and the transformer types (`ConstraintTransformer`, `ConstraintTransformerJpaImpl`, `ConstraintTransformerException`) are also marked `@Deprecated(forRemoval = false)` in this change. Nothing is scheduled for removal yet — downstream code can keep compiling and running, just with deprecation warnings. -- Internally, the path/join helper inside `ConstraintTransformerJpaImpl` is **extracted to a non-deprecated utility** so the new processor can consume it without depending on the deprecated transformer. The deprecated transformer keeps working by delegating to that utility. +- The new processor resolves paths with its own non-deprecated `AttributePathResolver`, modeled on the path/join helper of `ConstraintTransformerJpaImpl`, so it does not depend on the deprecated transformer. The transformer itself is left unchanged. - A follow-up issue tracks: (a) eventually flipping the deprecations to `forRemoval = true` once downstream usage is gone, (b) removing the deprecated DAO method overloads and the constraint types in a future major version. ## 8. Module placement -- `backend-core-model` — `BaseFilter`, `@Attribute`, `@From`, `@To`, `@WhenNull`. Package: `com.flowingcode.backendcore.model.filter`. +- `backend-core-model` — `BaseFilter`, `@Attribute`, `@From`, `@To`, `@Like`, `@In`, `@WhenNull`, `@Or`. Package: `com.flowingcode.backendcore.model.filter`. - `backend-core-data` — new method signatures on `QueryDao`. -- `backend-core-data-impl` — `BaseFilterJpaProcessor`, default-method implementations on `ConversionJpaDaoSupport`, shared path/join utility extracted from `ConstraintTransformerJpaImpl`. +- `backend-core-data-impl` — `BaseFilterJpaProcessor`, default-method implementations on `ConversionJpaDaoSupport`, and the package-private `AttributePathResolver`. ## 9. Open questions @@ -306,8 +382,8 @@ With both bounds set on `birthDate`, the processor emits `birthDate >= :from AND ## 10. Out of scope / follow-ups -- Operator-specific annotations (`@Like`, `@ILike`, `@In`, `@IsNull` as a standalone, `@Gt`/`@Lt`, etc.). Revisit once usage patterns emerge. -- Boolean composition (`@Or` groups, `@Not`). +- Further operator annotations (`@NotEqual`, `@IsNotNull` as a standalone, etc.). Revisit once usage patterns emerge. +- `@Not`, and `@Or` groups beyond the single disjunction. - Pluggable / user-registered annotation processors. - Removal of `QuerySpec` and friends. - Projection / returned-attributes support equivalent to `QuerySpec.returnedAttributes`.