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..edce1ca
--- /dev/null
+++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java
@@ -0,0 +1,117 @@
+/*-
+ * #%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.
+ *
+ *
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}.
+ */
+class AttributePathResolver {
+
+ private final From, ?> root;
+
+ AttributePathResolver(From, ?> root) {
+ this.root = Objects.requireNonNull(root, "root");
+ }
+
+ /**
+ * Resolves {@code attributePath} into an {@code Expression} of the leaf
+ * attribute on the root, creating missing joins with {@code joinType}.
+ */
+ Expression> resolve(String attributePath, JoinType joinType) {
+ return resolve(attributePath, Object.class, joinType);
+ }
+
+ /**
+ * 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")
+ 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 + "\"");
+ }
+ String[] path = attributePath.split("\\.");
+ String attributeName = path[path.length - 1];
+ String[] joinPath = Arrays.copyOf(path, path.length - 1);
+ Expression> expression = traverse(root, joinPath, joinType).get(attributeName);
+ boxed(expression.getJavaType()).asSubclass(expectedType);
+ return (Expression) expression;
+ }
+
+ private From, ?> traverse(From, ?> source, String[] path, JoinType joinType) {
+ From, ?> from = source;
+ for (String name : path) {
+ from = join(from, name, joinType);
+ }
+ return from;
+ }
+
+ @SuppressWarnings("rawtypes")
+ 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))
+ .findFirst();
+ return existing.orElseGet(() -> source.join(attributeName, joinType));
+ }
+
+ 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..ea3dbdf
--- /dev/null
+++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/BaseFilterJpaProcessor.java
@@ -0,0 +1,624 @@
+/*-
+ * #%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.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+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;
+
+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;
+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 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.
+ */
+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);
+ }
+
+ /** 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;
+ 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);
+ requireSelection(cq, root);
+ TypedQuery query = em.createQuery(cq);
+ applyPaging(query, filter);
+ return query.getResultList();
+ }
+
+ Optional filterWithSingleResult(BaseFilter filter) {
+ 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);
+ requireSelection(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);
+ }
+ }
+
+ long count(BaseFilter filter) {
+ CriteriaBuilder cb = em.getCriteriaBuilder();
+ CriteriaQuery cq = cb.createQuery(Long.class);
+ Root root = cq.from(persistentClass);
+ 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) {
+ AttributePathResolver resolver = new AttributePathResolver(root);
+
+ List conjunction = new ArrayList<>();
+ List disjunction = new ArrayList<>();
+ for (FieldHandler handler : handlersFor(filter.getClass())) {
+ 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) {
+ (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()) {
+ conjunction.addAll(extra);
+ }
+
+ 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.isEmpty()) {
+ return;
+ }
+ AttributePathResolver resolver = new AttributePathResolver(root);
+ List jpaOrders = new ArrayList<>(orders.size());
+ for (Entry e : orders.entrySet()) {
+ // 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);
+ }
+
+ 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 List handlersFor(Class extends BaseFilter> filterClass) {
+ return HANDLER_CACHE.computeIfAbsent(filterClass, BaseFilterJpaProcessor::buildHandlers);
+ }
+
+ private static List buildHandlers(Class extends BaseFilter> 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<>();
+ for (Class> c = filterClass; c != BaseFilter.class; c = c.getSuperclass()) {
+ for (Field f : c.getDeclaredFields()) {
+ if (f.isAnnotationPresent(Attribute.class) || !annotationsOn(f, MODIFIERS).isEmpty()) {
+ annotated.add(f);
+ }
+ }
+ }
+
+ // 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);
+ // 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);
+ }
+
+ List handlers = new ArrayList<>();
+ for (Entry> entry : byPath.entrySet()) {
+ String path = entry.getKey();
+ List group = entry.getValue();
+ handlers.add(buildHandler(path, group));
+ }
+ 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);
+ if (from != null) {
+ return new LowerBoundHandler(path, or, f, from.inclusive());
+ }
+ To to = f.getAnnotation(To.class);
+ if (to != null) {
+ 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, or, 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) {
+ 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
+ + "\" 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 -----
+
+ private abstract static class FieldHandler {
+ final String attributePath;
+ final boolean or;
+
+ 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,
+ 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, boolean or, Field field, WhenNull.Policy nullPolicy) {
+ super(path, or);
+ 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) {
+ // 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, joinType()), 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, boolean or, Field field, boolean inclusive) {
+ super(path, or);
+ 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, joinType());
+ 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, boolean or, Field field, boolean inclusive) {
+ super(path, or);
+ 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, joinType());
+ 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, boolean or, Field lower, Field upper, boolean lowerInclusive,
+ boolean upperInclusive) {
+ super(path, or);
+ 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, joinType());
+
+ 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);
+ }
+ }
+
+ 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/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..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
@@ -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 {
@@ -100,18 +103,34 @@ 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")
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")
default List filter(QuerySpec filter) {
return FilterProcesor.of(getEntityManager(), getPersistentClass()).filter(filter).stream()
.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")
default Optional filterWithSingleResult(QuerySpec filter) {
List filtered = FilterProcesor.of(getEntityManager(), getPersistentClass()).filter(filter);
if (filtered.size()>1) {
@@ -121,6 +140,87 @@ 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, 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(...)}.
+ *
+ * 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) {
+ return Collections.emptyList();
+ }
+
+ /**
+ * Last-chance hook to mutate the in-progress {@code CriteriaQuery} (e.g.
+ * {@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.
+ */
+ default void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery> cq,
+ Root root) {
+ // no-op
+ }
+
+ 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..b5edcf8
--- /dev/null
+++ b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java
@@ -0,0 +1,321 @@
+/*-
+ * #%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.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.impl.City;
+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 EntityManager em;
+ final AtomicInteger predicatesCalls = new AtomicInteger();
+ final AtomicInteger criteriaCalls = new AtomicInteger();
+ final AtomicBoolean restrictByName = new AtomicBoolean();
+
+ HookDao(EntityManager em) {
+ this.em = em;
+ }
+
+ @Override
+ public EntityManager getEntityManager() {
+ 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
+ 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;
+ private EntityManagerFactory emf;
+ private EntityManager em;
+
+ @BeforeEach
+ void setUp() {
+ 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();
+ // 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();
+ }
+ }
+
+ @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(em) {
+ @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: 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_andHookReadsItThroughTheGetter() {
+ HookDao d = new HookDao(em) {
+ @Override
+ public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb,
+ CriteriaQuery> cq, Root root) {
+ predicatesCalls.incrementAndGet();
+ if (filter instanceof ManualPersonFilter f && f.getNameLike() != null) {
+ return List.of(cb.like(root.get("name"), f.getNameLike()));
+ }
+ return Collections.emptyList();
+ }
+ };
+
+ // 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 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(em) {
+ @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());
+ }
+
+ @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-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..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
@@ -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,78 @@ 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.
+ *
+ * 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 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
+ */
+ 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}.
+ *
+ * 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/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..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,12 @@
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)
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..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,12 @@
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 {
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..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,12 @@
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
@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..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,12 @@
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
@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..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,12 @@
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
@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..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,12 @@
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
@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..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,12 @@
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
@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..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,14 @@
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)
@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..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,12 @@
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
@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..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,12 @@
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 {
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..2383889
--- /dev/null
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Attribute.java
@@ -0,0 +1,80 @@
+/*-
+ * #%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, 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, 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 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)
+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 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/BaseFilter.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java
new file mode 100644
index 0000000..e7a5f0e
--- /dev/null
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java
@@ -0,0 +1,208 @@
+/*-
+ * #%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.
+ *
+ *
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
+@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) {
+ // 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;
+ }
+
+ /**
+ * 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() : 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);
+ }
+
+ /**
+ * 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) {
+ // 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 = 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
+ */
+ 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/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/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..e695bb1
--- /dev/null
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/WhenNull.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;
+
+/**
+ * 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. 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}, {@link To}, {@link Like} or {@link In}, since
+ * none of those have 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..6aeafc7
--- /dev/null
+++ b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java
@@ -0,0 +1,214 @@
+/*-
+ * #%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.time.Month;
+import java.util.Arrays;
+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;
+
+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, 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, 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());
+ assertEquals(BaseFilter.Order.ASC, f.getOrders().get("name"));
+ }
+
+ @Test
+ void builder_setsState() {
+ SampleFilter f = SampleFilter.builder()
+ .name("Ada")
+ .birthDateFrom(LocalDate.of(1990, Month.JANUARY, 1))
+ .addOrder("name", BaseFilter.Order.DESC)
+ .addOrder("birthDate", BaseFilter.Order.ASC)
+ .firstResult(0)
+ .maxResult(25)
+ .build();
+
+ assertEquals("Ada", f.getName());
+ assertEquals(LocalDate.of(1990, Month.JANUARY, 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);
+ }
+
+ @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"));
+ }
+
+ @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());
+ }
+}
diff --git a/specs/base-filter.md b/specs/base-filter.md
new file mode 100644
index 0000000..945cfd8
--- /dev/null
+++ b/specs/base-filter.md
@@ -0,0 +1,389 @@
+# 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 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.
+
+## 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 }
+
+ private Map orders; // insertion-ordered; getOrders() never returns 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);
+ public BaseFilter setOrders(Map orders); // copies the argument
+ public Map getOrders(); // unmodifiable view
+}
+```
+
+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))
+ .addOrder("name", BaseFilter.Order.ASC)
+ .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.
+- 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)`) 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`
+
+```java
+@Retention(RUNTIME) @Target(FIELD)
+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 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 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`
+
+```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. 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`)
+
+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: 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.
+ - 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` 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
+
+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 (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, `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 Reading filter values in hooks
+
+Hooks receive the filter as `BaseFilter` and read the values they need through the concrete filter class's accessors:
+
+```java
+if (filter instanceof PersonFilter f && f.getSearch() != null) {
+ return List.of(cb.like(root.get("name"), "%" + f.getSearch() + "%"));
+}
+return List.of();
+```
+
+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
+
+```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
+
+ @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
+class PersonDao implements JpaDaoSupport {
+ // ... getEntityManager() ...
+
+ @Override
+ public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb,
+ CriteriaQuery> cq, Root root) {
+ 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)));
+ }
+}
+
+// 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))
+ .addOrder("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.
+- 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`, `@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`, and the package-private `AttributePathResolver`.
+
+## 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.** 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
+
+- 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`.