diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java
new file mode 100644
index 0000000..c519cd6
--- /dev/null
+++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java
@@ -0,0 +1,116 @@
+/*-
+ * #%L
+ * Commons Backend - Data Access Layer Implementations
+ * %%
+ * Copyright (C) 2020 - 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.backendcore.dao.jpa;
+
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.Optional;
+
+import jakarta.persistence.criteria.Expression;
+import jakarta.persistence.criteria.From;
+import jakarta.persistence.criteria.Join;
+import jakarta.persistence.criteria.JoinType;
+
+/**
+ * Resolves a dotted attribute path on a JPA {@code From} root into a leaf
+ * {@code Expression}, auto-joining associations along the way and reusing
+ * existing joins when one is already present on the same attribute and join
+ * type.
+ *
+ *
Instances are not thread-safe: a new resolver should be created per
+ * {@code CriteriaQuery}.
+ */
+public class AttributePathResolver {
+
+ private final From, ?> root;
+
+ private JoinType currentJoinType = JoinType.INNER;
+
+ public AttributePathResolver(From, ?> root) {
+ this.root = Objects.requireNonNull(root, "root");
+ }
+
+ /** Returns the join type currently used when creating new joins. */
+ public JoinType getCurrentJoinType() {
+ return currentJoinType;
+ }
+
+ /** Sets the join type used for newly created joins by subsequent resolutions. */
+ public void setCurrentJoinType(JoinType joinType) {
+ this.currentJoinType = Objects.requireNonNull(joinType, "joinType");
+ }
+
+ /**
+ * Resolves {@code attributePath} into an {@code Expression} of the leaf
+ * attribute on the root, auto-joining as needed.
+ */
+ public Expression> resolve(String attributePath) {
+ return resolve(attributePath, Object.class);
+ }
+
+ /**
+ * Resolves {@code attributePath} and verifies the leaf attribute's Java type
+ * is assignable to {@code expectedType}.
+ *
+ * @throws ClassCastException if the leaf attribute type isn't compatible
+ */
+ @SuppressWarnings("unchecked")
+ public Expression resolve(String attributePath, Class expectedType) {
+ Objects.requireNonNull(attributePath, "attributePath");
+ String[] path = attributePath.split("\\.");
+ String attributeName = path[path.length - 1];
+ String[] joinPath = Arrays.copyOf(path, path.length - 1);
+ Expression> expression = traverse(root, joinPath).get(attributeName);
+ boxed(expression.getJavaType()).asSubclass(expectedType);
+ return (Expression) expression;
+ }
+
+ private From, ?> traverse(From, ?> source, String[] path) {
+ From, ?> from = source;
+ for (String name : path) {
+ from = join(from, name);
+ }
+ return from;
+ }
+
+ @SuppressWarnings("rawtypes")
+ private From, ?> join(From, ?> source, String attributeName) {
+ Optional existing = source.getJoins().stream()
+ .map(j -> (Join) j)
+ .filter(j -> j.getAttribute().getName().equals(attributeName))
+ .filter(j -> j.getJoinType() == currentJoinType)
+ .findFirst();
+ return existing.orElseGet(() -> source.join(attributeName, currentJoinType));
+ }
+
+ private static Class> boxed(Class> type) {
+ if (type.isPrimitive()) {
+ if (type == boolean.class) return Boolean.class;
+ if (type == int.class) return Integer.class;
+ if (type == long.class) return Long.class;
+ if (type == byte.class) return Byte.class;
+ if (type == short.class) return Short.class;
+ if (type == char.class) return Character.class;
+ if (type == float.class) return Float.class;
+ if (type == double.class) return Double.class;
+ }
+ return type;
+ }
+}
diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/BaseFilterJpaProcessor.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/BaseFilterJpaProcessor.java
new file mode 100644
index 0000000..8b11b66
--- /dev/null
+++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/BaseFilterJpaProcessor.java
@@ -0,0 +1,484 @@
+/*-
+ * #%L
+ * Commons Backend - Data Access Layer Implementations
+ * %%
+ * Copyright (C) 2020 - 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.backendcore.dao.jpa;
+
+import java.io.Serializable;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+import com.flowingcode.backendcore.model.Identifiable;
+import com.flowingcode.backendcore.model.filter.Attribute;
+import com.flowingcode.backendcore.model.filter.BaseFilter;
+import com.flowingcode.backendcore.model.filter.From;
+import com.flowingcode.backendcore.model.filter.To;
+import com.flowingcode.backendcore.model.filter.WhenNull;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.TypedQuery;
+import jakarta.persistence.criteria.CriteriaBuilder;
+import jakarta.persistence.criteria.CriteriaQuery;
+import jakarta.persistence.criteria.Expression;
+import jakarta.persistence.criteria.Predicate;
+import jakarta.persistence.criteria.Root;
+
+/**
+ * Builds and executes JPA {@code CriteriaQuery} from a {@link BaseFilter}.
+ *
+ * The processor reflects on the filter class once (per JVM), caches the
+ * resulting metadata, and produces predicates honoring {@link Attribute},
+ * {@link com.flowingcode.backendcore.model.filter.From} /
+ * {@link com.flowingcode.backendcore.model.filter.To} ranges and
+ * {@link WhenNull} null-handling policies. DAO subclasses contribute
+ * non-declarative predicates and other {@code CriteriaQuery} mutations through
+ * the supplied {@link Hooks}.
+ *
+ *
Instances are not thread-safe. Create a new instance per query
+ * invocation.
+ */
+class BaseFilterJpaProcessor, K extends Serializable> {
+
+ /** DAO-supplied callbacks that augment the declaratively built criteria. */
+ interface Hooks {
+ Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb,
+ CriteriaQuery> cq, Root root);
+
+ void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery> cq,
+ Root root);
+ }
+
+ private static final ConcurrentMap, FilterMetadata> METADATA_CACHE =
+ new ConcurrentHashMap<>();
+
+ private final EntityManager em;
+ private final Class persistentClass;
+ private final Hooks hooks;
+
+ static , K extends Serializable> BaseFilterJpaProcessor of(
+ EntityManager em, Class persistentClass, Hooks hooks) {
+ return new BaseFilterJpaProcessor<>(em, persistentClass, hooks);
+ }
+
+ private BaseFilterJpaProcessor(EntityManager em, Class persistentClass, Hooks hooks) {
+ this.em = em;
+ this.persistentClass = persistentClass;
+ this.hooks = hooks;
+ }
+
+ List filter(BaseFilter filter) {
+ CriteriaBuilder cb = em.getCriteriaBuilder();
+ CriteriaQuery cq = cb.createQuery(persistentClass);
+ Root root = cq.from(persistentClass);
+ cq.select(root);
+ applyPredicates(filter, cb, cq, root);
+ applyOrders(filter, cb, cq, root);
+ hooks.customizeCriteria(filter, cb, cq, root);
+ TypedQuery query = em.createQuery(cq);
+ applyPaging(query, filter);
+ return query.getResultList();
+ }
+
+ Optional filterWithSingleResult(BaseFilter filter) {
+ List result = filter(filter);
+ if (result.size() > 1) {
+ throw new IllegalStateException("Current filter returned more than one result");
+ }
+ return result.stream().findFirst();
+ }
+
+ long count(BaseFilter filter) {
+ CriteriaBuilder cb = em.getCriteriaBuilder();
+ CriteriaQuery cq = cb.createQuery(Long.class);
+ Root root = cq.from(persistentClass);
+ cq.select(cb.count(root));
+ applyPredicates(filter, cb, cq, root);
+ hooks.customizeCriteria(filter, cb, cq, root);
+ return em.createQuery(cq).getSingleResult();
+ }
+
+ // ----- query assembly -----
+
+ private void applyPredicates(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery> cq,
+ Root root) {
+ FilterMetadata metadata = metadataFor(filter.getClass());
+ AttributePathResolver resolver = new AttributePathResolver(root);
+
+ List predicates = new ArrayList<>();
+ for (FieldHandler handler : metadata.handlers) {
+ Predicate predicate;
+ try {
+ predicate = handler.toPredicate(filter, cb, resolver);
+ } catch (IllegalAccessException e) {
+ throw new IllegalStateException(
+ "Cannot read field " + handler.describe() + " on " + filter.getClass(), e);
+ }
+ if (predicate != null) {
+ predicates.add(predicate);
+ }
+ }
+
+ Collection extra = hooks.customizePredicates(filter, cb, cq, root);
+ if (extra != null && !extra.isEmpty()) {
+ predicates.addAll(extra);
+ }
+
+ if (!predicates.isEmpty()) {
+ cq.where(predicates.toArray(new Predicate[0]));
+ }
+ }
+
+ private void applyOrders(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq,
+ Root root) {
+ Map orders = filter.getOrders();
+ if (orders == null || orders.isEmpty()) {
+ return;
+ }
+ AttributePathResolver resolver = new AttributePathResolver(root);
+ List jpaOrders = new ArrayList<>(orders.size());
+ for (Entry e : orders.entrySet()) {
+ Expression> expr = resolver.resolve(e.getKey());
+ jpaOrders.add(e.getValue() == BaseFilter.Order.ASC ? cb.asc(expr) : cb.desc(expr));
+ }
+ cq.orderBy(jpaOrders);
+ }
+
+ private void applyPaging(TypedQuery query, BaseFilter filter) {
+ if (filter.getFirstResult() != null) {
+ query.setFirstResult(filter.getFirstResult());
+ }
+ if (filter.getMaxResult() != null) {
+ query.setMaxResults(filter.getMaxResult());
+ }
+ }
+
+ // ----- metadata reflection + caching -----
+
+ private static FilterMetadata metadataFor(Class extends BaseFilter> filterClass) {
+ return METADATA_CACHE.computeIfAbsent(filterClass, BaseFilterJpaProcessor::buildMetadata);
+ }
+
+ private static FilterMetadata buildMetadata(Class extends BaseFilter> filterClass) {
+ // Index every declared field in the hierarchy by name so the value-accessor
+ // helper can read any of them, including unannotated ones. Closer-to-leaf
+ // declarations win on name shadowing.
+ Map fieldsByName = new LinkedHashMap<>();
+ List annotated = new ArrayList<>();
+ Class> c = filterClass;
+ while (c != null && c != Object.class) {
+ for (Field f : c.getDeclaredFields()) {
+ if (!fieldsByName.containsKey(f.getName())) {
+ f.setAccessible(true);
+ fieldsByName.put(f.getName(), f);
+ }
+ if (c != BaseFilter.class && (f.isAnnotationPresent(Attribute.class)
+ || f.isAnnotationPresent(From.class) || f.isAnnotationPresent(To.class)
+ || f.isAnnotationPresent(WhenNull.class))) {
+ annotated.add(f);
+ }
+ }
+ c = c.getSuperclass();
+ }
+
+ // Validate per-field constraints and group declarative fields by attribute path.
+ Map> byPath = new HashMap<>();
+ for (Field f : annotated) {
+ Attribute attr = f.getAnnotation(Attribute.class);
+ From from = f.getAnnotation(From.class);
+ To to = f.getAnnotation(To.class);
+ WhenNull whenNull = f.getAnnotation(WhenNull.class);
+
+ if ((from != null || to != null || whenNull != null) && attr == null) {
+ throw new IllegalStateException("Field " + describe(f)
+ + " has @From/@To/@WhenNull but no @Attribute");
+ }
+ if (attr != null && attr.manual()
+ && (from != null || to != null || whenNull != null)) {
+ throw new IllegalStateException("Field " + describe(f)
+ + " is @Attribute(manual=true); it cannot combine with @From, @To or @WhenNull");
+ }
+ if (from != null && to != null) {
+ throw new IllegalStateException("Field " + describe(f)
+ + " cannot be both @From and @To");
+ }
+ if (whenNull != null && (from != null || to != null)) {
+ throw new IllegalStateException("Field " + describe(f)
+ + " cannot combine @WhenNull with @From or @To");
+ }
+ // Manual fields are tracked in fieldsByName already; skip declarative grouping.
+ if (attr.manual()) {
+ continue;
+ }
+ byPath.computeIfAbsent(attr.value(), k -> new ArrayList<>()).add(f);
+ }
+
+ List handlers = new ArrayList<>();
+ for (Entry> entry : byPath.entrySet()) {
+ String path = entry.getKey();
+ List group = entry.getValue();
+ handlers.add(buildHandler(path, group));
+ }
+ return new FilterMetadata(handlers, Collections.unmodifiableMap(fieldsByName));
+ }
+
+ private static FieldHandler buildHandler(String path, List group) {
+ if (group.size() == 1) {
+ Field f = group.get(0);
+ From from = f.getAnnotation(From.class);
+ To to = f.getAnnotation(To.class);
+ if (from != null) {
+ return new LowerBoundHandler(path, f, from.inclusive());
+ }
+ if (to != null) {
+ return new UpperBoundHandler(path, f, to.inclusive());
+ }
+ WhenNull whenNull = f.getAnnotation(WhenNull.class);
+ WhenNull.Policy policy = whenNull != null ? whenNull.value() : WhenNull.Policy.SKIP;
+ return new EqualityHandler(path, f, policy);
+ }
+ if (group.size() == 2) {
+ Field a = group.get(0);
+ Field b = group.get(1);
+ From fromA = a.getAnnotation(From.class);
+ To toA = a.getAnnotation(To.class);
+ From fromB = b.getAnnotation(From.class);
+ To toB = b.getAnnotation(To.class);
+ Field lower = null;
+ Field upper = null;
+ boolean lowerInclusive = true;
+ boolean upperInclusive = true;
+ if (fromA != null && toB != null) {
+ lower = a;
+ upper = b;
+ lowerInclusive = fromA.inclusive();
+ upperInclusive = toB.inclusive();
+ } else if (fromB != null && toA != null) {
+ lower = b;
+ upper = a;
+ lowerInclusive = fromB.inclusive();
+ upperInclusive = toA.inclusive();
+ }
+ if (lower != null) {
+ return new RangePairHandler(path, lower, upper, lowerInclusive, upperInclusive);
+ }
+ }
+ throw new IllegalStateException("Attribute path \"" + path
+ + "\" is mapped by multiple filter fields that do not form a @From/@To pair: "
+ + describe(group));
+ }
+
+ private static String describe(Field f) {
+ return f.getDeclaringClass().getSimpleName() + "." + f.getName();
+ }
+
+ private static String describe(List fields) {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < fields.size(); i++) {
+ if (i > 0) sb.append(", ");
+ sb.append(describe(fields.get(i)));
+ }
+ return sb.toString();
+ }
+
+ // ----- handler types -----
+
+ /**
+ * Reads {@code fieldName} on the given {@code filter} using the cached
+ * reflection metadata. Intended to be called from DAO hooks that need to
+ * consume the value of a field marked {@code @Attribute(manual=true)} (or any
+ * other field on the filter) without re-doing reflection.
+ *
+ * @throws IllegalArgumentException if the filter class has no field with that
+ * name
+ */
+ static Object readField(BaseFilter filter, String fieldName) {
+ FilterMetadata metadata = metadataFor(filter.getClass());
+ Field f = metadata.fieldsByName.get(fieldName);
+ if (f == null) {
+ throw new IllegalArgumentException("Filter " + filter.getClass().getSimpleName()
+ + " has no field named: " + fieldName);
+ }
+ try {
+ return f.get(filter);
+ } catch (IllegalAccessException e) {
+ throw new IllegalStateException(
+ "Cannot read field " + describe(f) + " on " + filter.getClass(), e);
+ }
+ }
+
+ private static final class FilterMetadata {
+ final List handlers;
+ final Map fieldsByName;
+
+ FilterMetadata(List handlers, Map fieldsByName) {
+ this.handlers = List.copyOf(handlers);
+ this.fieldsByName = fieldsByName;
+ }
+ }
+
+ private abstract static class FieldHandler {
+ final String attributePath;
+
+ FieldHandler(String attributePath) {
+ this.attributePath = attributePath;
+ }
+
+ abstract Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb,
+ AttributePathResolver resolver) throws IllegalAccessException;
+
+ abstract String describe();
+ }
+
+ private static final class EqualityHandler extends FieldHandler {
+ private final Field field;
+ private final WhenNull.Policy nullPolicy;
+
+ EqualityHandler(String path, Field field, WhenNull.Policy nullPolicy) {
+ super(path);
+ this.field = field;
+ this.nullPolicy = nullPolicy;
+ }
+
+ @Override
+ Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolver resolver)
+ throws IllegalAccessException {
+ Object value = field.get(filter);
+ if (value == null) {
+ return nullPolicy == WhenNull.Policy.IS_NULL ? cb.isNull(resolver.resolve(attributePath))
+ : null;
+ }
+ return cb.equal(resolver.resolve(attributePath), value);
+ }
+
+ @Override
+ String describe() {
+ return BaseFilterJpaProcessor.describe(field);
+ }
+ }
+
+ private static final class LowerBoundHandler extends FieldHandler {
+ private final Field field;
+ private final boolean inclusive;
+
+ LowerBoundHandler(String path, Field field, boolean inclusive) {
+ super(path);
+ this.field = field;
+ this.inclusive = inclusive;
+ }
+
+ @Override
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolver resolver)
+ throws IllegalAccessException {
+ Object value = field.get(filter);
+ if (value == null) return null;
+ Expression expr = resolver.resolve(attributePath, Comparable.class);
+ return inclusive ? cb.greaterThanOrEqualTo(expr, (Comparable) value)
+ : cb.greaterThan(expr, (Comparable) value);
+ }
+
+ @Override
+ String describe() {
+ return BaseFilterJpaProcessor.describe(field);
+ }
+ }
+
+ private static final class UpperBoundHandler extends FieldHandler {
+ private final Field field;
+ private final boolean inclusive;
+
+ UpperBoundHandler(String path, Field field, boolean inclusive) {
+ super(path);
+ this.field = field;
+ this.inclusive = inclusive;
+ }
+
+ @Override
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolver resolver)
+ throws IllegalAccessException {
+ Object value = field.get(filter);
+ if (value == null) return null;
+ Expression expr = resolver.resolve(attributePath, Comparable.class);
+ return inclusive ? cb.lessThanOrEqualTo(expr, (Comparable) value)
+ : cb.lessThan(expr, (Comparable) value);
+ }
+
+ @Override
+ String describe() {
+ return BaseFilterJpaProcessor.describe(field);
+ }
+ }
+
+ private static final class RangePairHandler extends FieldHandler {
+ private final Field lower;
+ private final Field upper;
+ private final boolean lowerInclusive;
+ private final boolean upperInclusive;
+
+ RangePairHandler(String path, Field lower, Field upper, boolean lowerInclusive,
+ boolean upperInclusive) {
+ super(path);
+ this.lower = lower;
+ this.upper = upper;
+ this.lowerInclusive = lowerInclusive;
+ this.upperInclusive = upperInclusive;
+ }
+
+ @Override
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolver resolver)
+ throws IllegalAccessException {
+ Object lo = lower.get(filter);
+ Object hi = upper.get(filter);
+ if (lo == null && hi == null) return null;
+ Expression expr = resolver.resolve(attributePath, Comparable.class);
+
+ if (lo != null && hi != null && lowerInclusive && upperInclusive) {
+ return cb.between(expr, (Comparable) lo, (Comparable) hi);
+ }
+
+ List parts = new ArrayList<>(2);
+ if (lo != null) {
+ parts.add(lowerInclusive ? cb.greaterThanOrEqualTo(expr, (Comparable) lo)
+ : cb.greaterThan(expr, (Comparable) lo));
+ }
+ if (hi != null) {
+ parts.add(upperInclusive ? cb.lessThanOrEqualTo(expr, (Comparable) hi)
+ : cb.lessThan(expr, (Comparable) hi));
+ }
+ return parts.size() == 1 ? parts.get(0) : cb.and(parts.toArray(new Predicate[0]));
+ }
+
+ @Override
+ String describe() {
+ return BaseFilterJpaProcessor.describe(lower) + " + "
+ + BaseFilterJpaProcessor.describe(upper);
+ }
+ }
+}
diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java
index 593dda9..1e22039 100644
--- a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java
+++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java
@@ -49,7 +49,12 @@
* JPA/Criteria implementation of {@link ConstraintTransformer}.
*
* Instances are not thread-safe. A new instance must be created for each query.
+ *
+ * @deprecated Part of the legacy {@code QuerySpec}-based filter API. New code
+ * should use {@link com.flowingcode.backendcore.model.filter.BaseFilter}
+ * and the DAO {@code filter(BaseFilter)} overloads.
*/
+@Deprecated(since = "1.2.0", forRemoval = false)
@RequiredArgsConstructor
public class ConstraintTransformerJpaImpl extends ConstraintTransformer {
diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConversionJpaDaoSupport.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConversionJpaDaoSupport.java
index a2ab5e4..b7ffd2a 100644
--- a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConversionJpaDaoSupport.java
+++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConversionJpaDaoSupport.java
@@ -22,6 +22,8 @@
import java.io.Serializable;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
+import java.util.Collection;
+import java.util.Collections;
import java.util.List;
import java.util.Map.Entry;
import java.util.Objects;
@@ -38,6 +40,7 @@
import com.flowingcode.backendcore.dao.CrudDao;
import com.flowingcode.backendcore.model.Identifiable;
import com.flowingcode.backendcore.model.QuerySpec;
+import com.flowingcode.backendcore.model.filter.BaseFilter;
public interface ConversionJpaDaoSupport, K extends Serializable>
extends CrudDao {
@@ -101,17 +104,23 @@ default List findAll() {
}
@Override
+ @Deprecated(since = "1.2.0", forRemoval = false)
+ @SuppressWarnings("deprecation")
default long count(QuerySpec filter) {
return FilterProcesor.of(getEntityManager(), getPersistentClass()).count(filter);
}
@Override
+ @Deprecated(since = "1.2.0", forRemoval = false)
+ @SuppressWarnings("deprecation")
default List filter(QuerySpec filter) {
return FilterProcesor.of(getEntityManager(), getPersistentClass()).filter(filter).stream()
.map(this::convertFrom).collect(Collectors.toList());
}
-
+
@Override
+ @Deprecated(since = "1.2.0", forRemoval = false)
+ @SuppressWarnings("deprecation")
default Optional filterWithSingleResult(QuerySpec filter) {
List filtered = FilterProcesor.of(getEntityManager(), getPersistentClass()).filter(filter);
if (filtered.size()>1) {
@@ -121,6 +130,93 @@ default Optional filterWithSingleResult(QuerySpec filter) {
.map(this::convertFrom).findAny();
}
+ @Override
+ default List filter(BaseFilter filter) {
+ return baseFilterProcessor().filter(filter).stream()
+ .map(this::convertFrom).collect(Collectors.toList());
+ }
+
+ @Override
+ default Optional filterWithSingleResult(BaseFilter filter) {
+ return baseFilterProcessor().filterWithSingleResult(filter).map(this::convertFrom);
+ }
+
+ @Override
+ default long count(BaseFilter filter) {
+ return baseFilterProcessor().count(filter);
+ }
+
+ /**
+ * Hook for adding non-declarative predicates to a {@link BaseFilter}-driven
+ * query. Predicates returned here are ANDed with those derived from the
+ * filter's annotations.
+ *
+ * Defaults to no extra predicates.
+ */
+ default Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb,
+ CriteriaQuery> cq, Root root) {
+ return Collections.emptyList();
+ }
+
+ /**
+ * Last-chance hook to mutate the in-progress {@code CriteriaQuery} (e.g.
+ * {@code distinct}, projections, group-by). Called once per filter, count,
+ * and single-result query after predicates have been applied.
+ *
+ * Defaults to a no-op.
+ */
+ default void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery> cq,
+ Root root) {
+ // no-op
+ }
+
+ /**
+ * Reads the value of the given field on {@code filter} without forcing the
+ * hook to do its own reflection. Backed by the same cached metadata used to
+ * build the declarative predicates.
+ *
+ * Useful from {@link #customizePredicates} when consuming a field
+ * declared as {@code @Attribute(manual = true)}, but works for any field
+ * declared on the filter (annotated or not).
+ *
+ * @throws IllegalArgumentException if the filter class has no field with the
+ * given name
+ */
+ default Object getFilterFieldValue(BaseFilter filter, String fieldName) {
+ return BaseFilterJpaProcessor.readField(filter, fieldName);
+ }
+
+ /**
+ * Typed convenience overload of {@link #getFilterFieldValue(BaseFilter, String)};
+ * casts the value through {@code type} so the caller doesn't have to.
+ *
+ * @throws IllegalArgumentException if the filter class has no such field
+ * @throws ClassCastException if the stored value is not assignable to
+ * {@code type}
+ */
+ default V getFilterFieldValue(BaseFilter filter, String fieldName, Class type) {
+ Object value = getFilterFieldValue(filter, fieldName);
+ return value == null ? null : type.cast(value);
+ }
+
+ private BaseFilterJpaProcessor baseFilterProcessor() {
+ final ConversionJpaDaoSupport self = this;
+ return BaseFilterJpaProcessor.of(getEntityManager(), getPersistentClass(),
+ new BaseFilterJpaProcessor.Hooks() {
+ @Override
+ public Collection customizePredicates(BaseFilter filter,
+ CriteriaBuilder cb, CriteriaQuery> cq, Root root) {
+ return self.customizePredicates(filter, cb, cq, root);
+ }
+
+ @Override
+ public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb,
+ CriteriaQuery> cq, Root root) {
+ self.customizeCriteria(filter, cb, cq, root);
+ }
+ });
+ }
+
static class FilterProcesor, K extends Serializable> {
private Class persistentClass;
diff --git a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java
new file mode 100644
index 0000000..5ee2f5e
--- /dev/null
+++ b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java
@@ -0,0 +1,246 @@
+/*-
+ * #%L
+ * Commons Backend - Data Access Layer Implementations
+ * %%
+ * Copyright (C) 2020 - 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.backendcore.dao.jpa;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.EntityManagerFactory;
+import jakarta.persistence.Persistence;
+import jakarta.persistence.criteria.CriteriaBuilder;
+import jakarta.persistence.criteria.CriteriaQuery;
+import jakarta.persistence.criteria.Predicate;
+import jakarta.persistence.criteria.Root;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import com.flowingcode.backendcore.model.filter.Attribute;
+import com.flowingcode.backendcore.model.filter.BaseFilter;
+import com.flowingcode.backendcore.model.filter.From;
+import com.flowingcode.backendcore.model.impl.Person;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+import lombok.experimental.SuperBuilder;
+
+class BaseFilterDaoHookTest {
+
+ @Getter
+ @Setter
+ @Accessors(chain = true)
+ @NoArgsConstructor
+ @SuperBuilder
+ static class PersonFilter extends BaseFilter {
+ // Empty: this suite exercises the DAO hooks, not the annotation pipeline.
+ }
+
+ static class HookDao implements JpaDaoSupport {
+
+ final EntityManagerFactory emf;
+ final AtomicInteger predicatesCalls = new AtomicInteger();
+ final AtomicInteger criteriaCalls = new AtomicInteger();
+ final AtomicBoolean restrictByName = new AtomicBoolean();
+
+ HookDao(EntityManagerFactory emf) {
+ this.emf = emf;
+ }
+
+ @Override
+ public EntityManager getEntityManager() {
+ return emf.createEntityManager();
+ }
+
+ @Override
+ public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb,
+ CriteriaQuery> cq, Root root) {
+ predicatesCalls.incrementAndGet();
+ if (!restrictByName.get()) {
+ return Collections.emptyList();
+ }
+ List ps = new ArrayList<>();
+ ps.add(cb.equal(root.get("name"), "John"));
+ return ps;
+ }
+
+ @Override
+ public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery> cq,
+ Root root) {
+ criteriaCalls.incrementAndGet();
+ }
+ }
+
+ private HookDao dao;
+
+ @BeforeEach
+ void setUp() {
+ EntityManagerFactory emf = Persistence.createEntityManagerFactory("person");
+ EntityManager em = emf.createEntityManager();
+ em.getTransaction().begin();
+ for (String name : new String[] {"John", "Jane", "Alice", "John"}) {
+ Person p = new Person();
+ p.setName(name);
+ p.setLastName("Doe");
+ em.persist(p);
+ }
+ em.getTransaction().commit();
+ em.close();
+ dao = new HookDao(emf);
+ }
+
+ @Test
+ void hooksFireOnFilterAndCount() {
+ PersonFilter f = PersonFilter.builder().build();
+ dao.predicatesCalls.set(0);
+ dao.criteriaCalls.set(0);
+
+ dao.filter(f);
+ dao.count(f);
+
+ assertEquals(2, dao.predicatesCalls.get(),
+ "customizePredicates should fire on filter() and count()");
+ assertEquals(2, dao.criteriaCalls.get(),
+ "customizeCriteria should fire on filter() and count()");
+ }
+
+ @Test
+ void hookFiresOnFilterWithSingleResult() {
+ // Restrict to a name with exactly one match so the call succeeds.
+ dao.restrictByName.set(true);
+ // The data has two "John"s, so override the restriction inline.
+ HookDao d = new HookDao(dao.emf) {
+ @Override
+ public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb,
+ CriteriaQuery> cq, Root root) {
+ predicatesCalls.incrementAndGet();
+ return List.of(cb.equal(root.get("name"), "Jane"));
+ }
+ };
+ d.filterWithSingleResult(PersonFilter.builder().build());
+ assertEquals(1, d.predicatesCalls.get());
+ assertEquals(1, d.criteriaCalls.get());
+ }
+
+ @Test
+ void predicateHookRestrictsResults() {
+ PersonFilter f = PersonFilter.builder().build();
+
+ long unrestricted = dao.count(f);
+ assertEquals(4, unrestricted);
+
+ dao.restrictByName.set(true);
+ long restricted = dao.count(f);
+ assertEquals(2, restricted, "predicate hook should restrict to two Johns");
+
+ List people = dao.filter(f);
+ assertEquals(2, people.size());
+ assertTrue(people.stream().allMatch(p -> "John".equals(p.getName())));
+ }
+
+ @Getter
+ @Setter
+ @Accessors(chain = true)
+ @NoArgsConstructor
+ @SuperBuilder
+ static class ManualPersonFilter extends BaseFilter {
+
+ // Manual field: processor records it but never produces a predicate. The
+ // hook consumes the value via getFilterFieldValue and builds the LIKE.
+ @Attribute(value = "name", manual = true)
+ private String nameLike;
+ }
+
+ @Test
+ void manualField_isSkipped_andHookUsesHelperToBuildPredicate() {
+ HookDao d = new HookDao(dao.emf) {
+ @Override
+ public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb,
+ CriteriaQuery> cq, Root root) {
+ predicatesCalls.incrementAndGet();
+ String pattern = getFilterFieldValue(filter, "nameLike", String.class);
+ return pattern == null ? Collections.emptyList()
+ : List.of(cb.like(root.get("name"), pattern));
+ }
+ };
+
+ // No pattern set → no extra predicate → all four people returned.
+ assertEquals(4, d.count(ManualPersonFilter.builder().build()));
+
+ // Pattern set → hook builds the LIKE → matches the two "John"s.
+ ManualPersonFilter f = ManualPersonFilter.builder().nameLike("Jo%").build();
+ assertEquals(2, d.count(f));
+ List results = d.filter(f);
+ assertEquals(2, results.size());
+ assertTrue(results.stream().allMatch(p -> p.getName().startsWith("Jo")));
+ }
+
+ @Getter
+ @Setter
+ @Accessors(chain = true)
+ @NoArgsConstructor
+ @SuperBuilder
+ static class BadManualFilter extends BaseFilter {
+ // Invalid: @Attribute(manual=true) cannot combine with @From / @To / @WhenNull.
+ @Attribute(value = "birthDay", manual = true) @From
+ private java.util.Date birthDayFrom;
+ }
+
+ @Test
+ void manualField_rejectsRangeAnnotations() {
+ BadManualFilter bad = BadManualFilter.builder().build();
+ IllegalStateException ex = assertThrows(IllegalStateException.class,
+ () -> dao.count(bad));
+ assertTrue(ex.getMessage().contains("manual=true"),
+ "error message should mention manual=true; got: " + ex.getMessage());
+ }
+
+ @Test
+ void getFilterFieldValue_rejectsUnknownField() {
+ assertThrows(IllegalArgumentException.class,
+ () -> dao.getFilterFieldValue(PersonFilter.builder().build(), "noSuchField"));
+ }
+
+ @Test
+ void criteriaHookCanMutateQuery() {
+ // Validate the hook can run cq.* operations against the live query. Applying
+ // distinct on a SELECT-entity query with unique IDs is a no-op result-wise
+ // but a valid Criteria mutation that exercises the hook end-to-end.
+ HookDao d = new HookDao(dao.emf) {
+ @Override
+ public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery> cq,
+ Root root) {
+ cq.distinct(true);
+ }
+ };
+ List people = d.filter(PersonFilter.builder().build());
+ assertEquals(4, people.size());
+ }
+}
diff --git a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java
index d624c5e..abc2e5c 100644
--- a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java
+++ b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java
@@ -45,6 +45,7 @@
import com.flowingcode.backendcore.model.impl.State;
import com.github.javafaker.Faker;
+@SuppressWarnings("deprecation")
class JpaDaoSupportTest {
private PersonCrudDaoImpl dao;
diff --git a/backend-core-data/src/main/java/com/flowingcode/backendcore/dao/QueryDao.java b/backend-core-data/src/main/java/com/flowingcode/backendcore/dao/QueryDao.java
index 5801439..b98deee 100644
--- a/backend-core-data/src/main/java/com/flowingcode/backendcore/dao/QueryDao.java
+++ b/backend-core-data/src/main/java/com/flowingcode/backendcore/dao/QueryDao.java
@@ -23,6 +23,7 @@
import java.util.Optional;
import com.flowingcode.backendcore.model.QuerySpec;
+import com.flowingcode.backendcore.model.filter.BaseFilter;
public interface QueryDao {
@@ -30,10 +31,41 @@ public interface QueryDao {
List findAll();
+ /**
+ * @deprecated Use {@link #filter(BaseFilter)} with a {@link BaseFilter}
+ * subclass.
+ */
+ @Deprecated(since = "1.2.0", forRemoval = false)
List filter(QuerySpec filter);
+ /**
+ * @deprecated Use {@link #filterWithSingleResult(BaseFilter)} with a
+ * {@link BaseFilter} subclass.
+ */
+ @Deprecated(since = "1.2.0", forRemoval = false)
Optional filterWithSingleResult(QuerySpec filter);
+ /**
+ * @deprecated Use {@link #count(BaseFilter)} with a {@link BaseFilter}
+ * subclass.
+ */
+ @Deprecated(since = "1.2.0", forRemoval = false)
long count(QuerySpec filter);
+ /**
+ * Returns the entities that match the given {@code filter}. Annotations on
+ * the filter fields drive the generated JPA Criteria.
+ */
+ List filter(BaseFilter filter);
+
+ /**
+ * Returns the single entity matching the given {@code filter}, if any.
+ *
+ * @throws IllegalStateException if more than one entity matches
+ */
+ Optional filterWithSingleResult(BaseFilter filter);
+
+ /** Returns the number of entities matching the given {@code filter}. */
+ long count(BaseFilter filter);
+
}
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java
index 5ae1d30..5b88fc2 100644
--- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java
@@ -22,6 +22,11 @@
import com.flowingcode.backendcore.model.constraints.DisjunctionConstraint;
import com.flowingcode.backendcore.model.constraints.NegatedConstraint;
+/**
+ * @deprecated Part of the legacy {@link QuerySpec}-based filter API. New code
+ * should use {@link com.flowingcode.backendcore.model.filter.BaseFilter}.
+ */
+@Deprecated(since = "1.2.0", forRemoval = false)
public interface Constraint {
default Constraint not() {
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintBuilder.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintBuilder.java
index a61780e..d39357d 100644
--- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintBuilder.java
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintBuilder.java
@@ -36,6 +36,11 @@
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
+/**
+ * @deprecated Part of the legacy {@link QuerySpec}-based filter API. New code
+ * should use {@link com.flowingcode.backendcore.model.filter.BaseFilter}.
+ */
+@Deprecated(since = "1.2.0", forRemoval = false)
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
public class ConstraintBuilder {
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java
index 8c2213f..aa1456f 100644
--- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java
@@ -37,8 +37,11 @@
* and one or more of the {@code transform*Constraint} methods for providing the actual representations for the underlying database technology.
*
* @param The type of the implementation-specific representation of the constraint.
- * @author Javier Godoy / Flowing Code
+ * @author Javier Godoy / Flowing Code
+ * @deprecated Part of the legacy {@link QuerySpec}-based filter API. New code
+ * should use {@link com.flowingcode.backendcore.model.filter.BaseFilter}.
*/
+@Deprecated(since = "1.2.0", forRemoval = false)
public abstract class ConstraintTransformer implements Function {
/**Return an implementation-specific representation of the constraint.
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformerException.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformerException.java
index 3d80407..bcb4ce3 100644
--- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformerException.java
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformerException.java
@@ -21,9 +21,12 @@
/**
* Thrown by {@link ConstraintTransformer} when the {@link QuerySpec} contains an unsupported {@link Constraint}.
- *
+ *
* @author Javier Godoy / Flowing Code
+ * @deprecated Part of the legacy {@link QuerySpec}-based filter API. New code
+ * should use {@link com.flowingcode.backendcore.model.filter.BaseFilter}.
*/
+@Deprecated(since = "1.2.0", forRemoval = false)
public class ConstraintTransformerException extends RuntimeException {
private static final long serialVersionUID = 1L;
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/QuerySpec.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/QuerySpec.java
index dc63cfe..329fdd9 100644
--- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/QuerySpec.java
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/QuerySpec.java
@@ -30,6 +30,14 @@
import lombok.Setter;
import lombok.experimental.Accessors;
+/**
+ * @deprecated Use {@link com.flowingcode.backendcore.model.filter.BaseFilter}
+ * and the annotation-driven filter API instead. This type, the
+ * {@link Constraint} hierarchy and the {@link ConstraintTransformer}
+ * support are retained for backwards compatibility but slated for
+ * removal in a future major version.
+ */
+@Deprecated(since = "1.2.0", forRemoval = false)
@Accessors(chain=true)
public class QuerySpec {
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeBetweenConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeBetweenConstraint.java
index 5096e51..92106da 100644
--- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeBetweenConstraint.java
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeBetweenConstraint.java
@@ -26,6 +26,7 @@
import lombok.NonNull;
import lombok.experimental.FieldDefaults;
+@Deprecated(since = "1.2.0", forRemoval = false)
@Getter
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class AttributeBetweenConstraint implements AttributeConstraint {
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeConstraint.java
index a47378b..8897fde 100644
--- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeConstraint.java
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeConstraint.java
@@ -21,6 +21,7 @@
import com.flowingcode.backendcore.model.Constraint;
+@Deprecated(since = "1.2.0", forRemoval = false)
public interface AttributeConstraint extends Constraint {
String getAttribute();
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeILikeConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeILikeConstraint.java
index ec2a1cd..62fead0 100644
--- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeILikeConstraint.java
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeILikeConstraint.java
@@ -25,6 +25,7 @@
import lombok.RequiredArgsConstructor;
import lombok.experimental.FieldDefaults;
+@Deprecated(since = "1.2.0", forRemoval = false)
@Getter
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeInConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeInConstraint.java
index aed5f21..7729e16 100644
--- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeInConstraint.java
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeInConstraint.java
@@ -27,6 +27,7 @@
import lombok.RequiredArgsConstructor;
import lombok.experimental.FieldDefaults;
+@Deprecated(since = "1.2.0", forRemoval = false)
@Getter
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeLikeConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeLikeConstraint.java
index a343c19..3946cf7 100644
--- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeLikeConstraint.java
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeLikeConstraint.java
@@ -25,6 +25,7 @@
import lombok.RequiredArgsConstructor;
import lombok.experimental.FieldDefaults;
+@Deprecated(since = "1.2.0", forRemoval = false)
@Getter
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeNullConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeNullConstraint.java
index 99b355d..cf0812e 100644
--- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeNullConstraint.java
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeNullConstraint.java
@@ -6,6 +6,7 @@
import lombok.RequiredArgsConstructor;
import lombok.experimental.FieldDefaults;
+@Deprecated(since = "1.2.0", forRemoval = false)
@Getter
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeRelationalConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeRelationalConstraint.java
index da3292a..ea2c416 100644
--- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeRelationalConstraint.java
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeRelationalConstraint.java
@@ -25,6 +25,7 @@
import lombok.RequiredArgsConstructor;
import lombok.experimental.FieldDefaults;
+@Deprecated(since = "1.2.0", forRemoval = false)
@Getter
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java
index 7cc1859..725a182 100644
--- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java
@@ -29,6 +29,7 @@
import lombok.experimental.FieldDefaults;
/** A constraint that is satisfied when any of its member constraints is satisfied (logical OR). */
+@Deprecated(since = "1.2.0", forRemoval = false)
@Getter
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/NegatedConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/NegatedConstraint.java
index 3630eda..af75332 100644
--- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/NegatedConstraint.java
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/NegatedConstraint.java
@@ -27,6 +27,7 @@
import lombok.RequiredArgsConstructor;
import lombok.experimental.FieldDefaults;
+@Deprecated(since = "1.2.0", forRemoval = false)
@Getter
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/RelationalConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/RelationalConstraint.java
index 6663723..30f38e6 100644
--- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/RelationalConstraint.java
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/RelationalConstraint.java
@@ -21,6 +21,7 @@
import com.flowingcode.backendcore.model.Constraint;
+@Deprecated(since = "1.2.0", forRemoval = false)
public interface RelationalConstraint extends Constraint {
String EQ = "=";
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Attribute.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Attribute.java
new file mode 100644
index 0000000..a6f60d7
--- /dev/null
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Attribute.java
@@ -0,0 +1,71 @@
+/*-
+ * #%L
+ * Commons Backend - Model
+ * %%
+ * Copyright (C) 2020 - 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.backendcore.model.filter;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Maps a filter field to an entity attribute.
+ *
+ * The value is a dotted attribute path on the target entity (e.g.
+ * {@code "city.state.name"}). Path traversal auto-joins associations using
+ * inner joins, reusing existing joins when one is already present on the same
+ * attribute and join type.
+ *
+ *
A filter field carrying only {@code @Attribute} is interpreted as an
+ * equality predicate against the resolved attribute. Pair it with {@link From}
+ * or {@link To} to express range comparisons, or with {@link WhenNull} to
+ * control how a null field value is handled.
+ *
+ *
Set {@link #manual()} to {@code true} when the predicate for the field is
+ * built by hand in a DAO hook (e.g.
+ * {@code ConversionJpaDaoSupport#customizePredicates}). The processor will skip
+ * declarative predicate building for the field but still track it so the
+ * field's value can be retrieved via
+ * {@code ConversionJpaDaoSupport#getFilterFieldValue}. Manual fields cannot
+ * combine with {@link From}, {@link To} or {@link WhenNull}, since none of
+ * those have meaning when the predicate is hand-built.
+ *
+ * @see From
+ * @see To
+ * @see WhenNull
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface Attribute {
+
+ /**
+ * Dotted attribute path on the target entity (e.g. {@code "city.state.name"}).
+ * On a {@link #manual() manual} field the value is informational — the
+ * processor never resolves it — and is still useful as documentation of
+ * which entity attribute the hook is expected to target.
+ */
+ String value();
+
+ /**
+ * When {@code true}, the processor records the field for value lookup but
+ * does not generate a declarative predicate. The caller is responsible for
+ * producing the predicate in a DAO hook.
+ */
+ boolean manual() default false;
+}
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java
new file mode 100644
index 0000000..7961d7a
--- /dev/null
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java
@@ -0,0 +1,174 @@
+/*-
+ * #%L
+ * Commons Backend - Model
+ * %%
+ * Copyright (C) 2020 - 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.backendcore.model.filter;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+import lombok.experimental.SuperBuilder;
+
+/**
+ * Base class for annotation-driven filters consumed by the DAO layer.
+ *
+ *
Filter subclasses declare typed fields annotated with {@link Attribute}
+ * and (optionally) {@link From} / {@link To} / {@link WhenNull}. The DAO base
+ * reflects on the populated instance to build a JPA {@code CriteriaQuery}.
+ *
+ *
Two construction styles are supported and produce equivalent state:
+ *
+ *
{@code
+ * // POJO style
+ * PersonFilter f = new PersonFilter()
+ * .setName("Ada")
+ * .setBirthDateFrom(LocalDate.of(1990, 1, 1));
+ * f.addOrder("name");
+ * f.setMaxResult(50);
+ *
+ * // Builder style (Lombok @SuperBuilder)
+ * PersonFilter f = PersonFilter.builder()
+ * .name("Ada")
+ * .birthDateFrom(LocalDate.of(1990, 1, 1))
+ * .addOrder("name", BaseFilter.Order.ASC)
+ * .maxResult(50)
+ * .build();
+ * }
+ *
+ * Pagination validators ({@link #setFirstResult(Integer)} /
+ * {@link #setMaxResult(Integer)}) reject negative values from the POJO path,
+ * and the builder's {@code firstResult(...)} / {@code maxResult(...)} methods
+ * apply the same checks before {@code build()} returns, so both styles enforce
+ * the same invariants.
+ */
+@Getter
+@Setter
+@Accessors(chain = true)
+@NoArgsConstructor
+@SuperBuilder(toBuilder = true)
+public abstract class BaseFilter {
+
+ /** Sort direction for an ordering entry. */
+ public enum Order {
+ ASC, DESC
+ }
+
+ private Map orders;
+
+ private Integer firstResult;
+
+ private Integer maxResult;
+
+ /** Adds an ascending order on {@code attribute}. */
+ public BaseFilter addOrder(String attribute) {
+ return addOrder(attribute, Order.ASC);
+ }
+
+ /** Adds an order on {@code attribute} with the given {@code direction}. */
+ public BaseFilter addOrder(String attribute, Order direction) {
+ if (this.orders == null) {
+ this.orders = new LinkedHashMap<>();
+ }
+ this.orders.put(attribute, direction);
+ return this;
+ }
+
+ /**
+ * Returns the configured sort orders, preserving insertion order. Never
+ * {@code null}; an empty map indicates no ordering.
+ */
+ public Map getOrders() {
+ return orders == null ? Collections.emptyMap() : orders;
+ }
+
+ /**
+ * Sets the position of the first result to retrieve (numbered from 0).
+ *
+ * @param firstResult the position, or {@code null} to clear
+ * @throws IllegalArgumentException if the argument is negative
+ */
+ public BaseFilter setFirstResult(Integer firstResult) {
+ validateNonNegative(firstResult, "firstResult");
+ this.firstResult = firstResult;
+ return this;
+ }
+
+ /**
+ * Sets the maximum number of results to retrieve.
+ *
+ * @param maxResult the cap, or {@code null} for no cap
+ * @throws IllegalArgumentException if the argument is negative
+ */
+ public BaseFilter setMaxResult(Integer maxResult) {
+ validateNonNegative(maxResult, "maxResult");
+ this.maxResult = maxResult;
+ return this;
+ }
+
+ private static void validateNonNegative(Integer value, String name) {
+ if (value != null && value < 0) {
+ throw new IllegalArgumentException(name + " must be >= 0");
+ }
+ }
+
+ /**
+ * Inner builder declared explicitly so that paging validation and the
+ * per-entry order adder are available on the builder API. Lombok's
+ * {@code @SuperBuilder} fills in the rest (field accumulation, {@code self()},
+ * {@code build()}, the subclass plumbing).
+ */
+ public abstract static class BaseFilterBuilder> {
+
+ /** Adds an ascending order on {@code attribute}. */
+ public B addOrder(String attribute) {
+ return addOrder(attribute, Order.ASC);
+ }
+
+ /** Adds an order on {@code attribute} with the given {@code direction}. */
+ public B addOrder(String attribute, Order direction) {
+ if (this.orders == null) {
+ this.orders = new LinkedHashMap<>();
+ }
+ this.orders.put(attribute, direction);
+ return self();
+ }
+
+ /**
+ * @throws IllegalArgumentException if {@code firstResult} is negative
+ */
+ public B firstResult(Integer firstResult) {
+ validateNonNegative(firstResult, "firstResult");
+ this.firstResult = firstResult;
+ return self();
+ }
+
+ /**
+ * @throws IllegalArgumentException if {@code maxResult} is negative
+ */
+ public B maxResult(Integer maxResult) {
+ validateNonNegative(maxResult, "maxResult");
+ this.maxResult = maxResult;
+ return self();
+ }
+ }
+}
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/From.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/From.java
new file mode 100644
index 0000000..9848b1b
--- /dev/null
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/From.java
@@ -0,0 +1,56 @@
+/*-
+ * #%L
+ * Commons Backend - Model
+ * %%
+ * Copyright (C) 2020 - 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.backendcore.model.filter;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a filter field as the lower bound of a range comparison on its
+ * {@link Attribute}.
+ *
+ * When {@link #inclusive()} is {@code true} (default) the predicate is
+ * {@code attribute >= value}; when {@code false}, it becomes
+ * {@code attribute > value}.
+ *
+ *
If another field on the same filter declares the same {@code @Attribute}
+ * value and carries a {@link To} annotation, the two fields form a range. With
+ * both bounds non-null and both inclusive, the processor emits a single
+ * {@code BETWEEN} predicate; otherwise two ANDed comparison predicates are
+ * emitted honoring each side's {@code inclusive} setting.
+ *
+ *
Must be paired with {@link Attribute}; otherwise the processor reports a
+ * configuration error.
+ *
+ * @see Attribute
+ * @see To
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface From {
+
+ /**
+ * When {@code true} (default), the lower bound is inclusive ({@code >=}).
+ * When {@code false}, the bound is strict ({@code >}).
+ */
+ boolean inclusive() default true;
+}
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/To.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/To.java
new file mode 100644
index 0000000..f3d25d2
--- /dev/null
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/To.java
@@ -0,0 +1,56 @@
+/*-
+ * #%L
+ * Commons Backend - Model
+ * %%
+ * Copyright (C) 2020 - 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.backendcore.model.filter;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a filter field as the upper bound of a range comparison on its
+ * {@link Attribute}.
+ *
+ *
When {@link #inclusive()} is {@code true} (default) the predicate is
+ * {@code attribute <= value}; when {@code false}, it becomes
+ * {@code attribute < value}.
+ *
+ *
If another field on the same filter declares the same {@code @Attribute}
+ * value and carries a {@link From} annotation, the two fields form a range.
+ * With both bounds non-null and both inclusive, the processor emits a single
+ * {@code BETWEEN} predicate; otherwise two ANDed comparison predicates are
+ * emitted honoring each side's {@code inclusive} setting.
+ *
+ *
Must be paired with {@link Attribute}; otherwise the processor reports a
+ * configuration error.
+ *
+ * @see Attribute
+ * @see From
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface To {
+
+ /**
+ * When {@code true} (default), the upper bound is inclusive ({@code <=}).
+ * When {@code false}, the bound is strict ({@code <}).
+ */
+ boolean inclusive() default true;
+}
diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/WhenNull.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/WhenNull.java
new file mode 100644
index 0000000..acdb75a
--- /dev/null
+++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/WhenNull.java
@@ -0,0 +1,56 @@
+/*-
+ * #%L
+ * Commons Backend - Model
+ * %%
+ * Copyright (C) 2020 - 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.backendcore.model.filter;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Overrides how a {@code null} value on the annotated filter field is handled.
+ *
+ *
The default policy for any field carrying {@link Attribute} is
+ * {@link Policy#SKIP}: a null field contributes no predicate. Use this
+ * annotation with {@link Policy#IS_NULL} to instead emit a
+ * {@code attribute IS NULL} predicate.
+ *
+ *
{@code @WhenNull} requires {@link Attribute} on the same field and is not
+ * allowed alongside {@link From} or {@link To}, since range bounds have no
+ * sensible {@code IS_NULL} semantics.
+ *
+ * @see Attribute
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface WhenNull {
+
+ Policy value();
+
+ /** Policy applied to a null filter field. */
+ enum Policy {
+
+ /** Emit no predicate for this field when its value is {@code null}. */
+ SKIP,
+
+ /** Emit an {@code IS NULL} predicate when the field value is {@code null}. */
+ IS_NULL
+ }
+}
diff --git a/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java
new file mode 100644
index 0000000..1b3bd41
--- /dev/null
+++ b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java
@@ -0,0 +1,148 @@
+/*-
+ * #%L
+ * Commons Backend - Model
+ * %%
+ * Copyright (C) 2020 - 2026 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.backendcore.model.filter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertIterableEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.time.LocalDate;
+import java.util.Arrays;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+import lombok.experimental.SuperBuilder;
+
+import org.junit.jupiter.api.Test;
+
+class BaseFilterTest {
+
+ @Getter
+ @Setter
+ @Accessors(chain = true)
+ @NoArgsConstructor
+ @SuperBuilder(toBuilder = true)
+ static class SampleFilter extends BaseFilter {
+
+ @Attribute("name")
+ private String name;
+
+ @Attribute("birthDate") @From
+ private LocalDate birthDateFrom;
+
+ @Attribute("birthDate") @To(inclusive = false)
+ private LocalDate birthDateTo;
+ }
+
+ @Test
+ void pojo_chainableSetters_carryAllState() {
+ SampleFilter f = new SampleFilter()
+ .setName("Ada")
+ .setBirthDateFrom(LocalDate.of(1990, 1, 1))
+ .setBirthDateTo(LocalDate.of(2000, 1, 1));
+ f.addOrder("name").setMaxResult(50).setFirstResult(10);
+
+ assertEquals("Ada", f.getName());
+ assertEquals(LocalDate.of(1990, 1, 1), f.getBirthDateFrom());
+ assertEquals(LocalDate.of(2000, 1, 1), f.getBirthDateTo());
+ assertEquals(50, f.getMaxResult());
+ assertEquals(10, f.getFirstResult());
+ assertIterableEquals(Arrays.asList("name"), f.getOrders().keySet());
+ assertEquals(BaseFilter.Order.ASC, f.getOrders().get("name"));
+ }
+
+ @Test
+ void builder_setsState() {
+ SampleFilter f = SampleFilter.builder()
+ .name("Ada")
+ .birthDateFrom(LocalDate.of(1990, 1, 1))
+ .addOrder("name", BaseFilter.Order.DESC)
+ .addOrder("birthDate", BaseFilter.Order.ASC)
+ .firstResult(0)
+ .maxResult(25)
+ .build();
+
+ assertEquals("Ada", f.getName());
+ assertEquals(LocalDate.of(1990, 1, 1), f.getBirthDateFrom());
+ assertNull(f.getBirthDateTo());
+ assertEquals(0, f.getFirstResult());
+ assertEquals(25, f.getMaxResult());
+ assertIterableEquals(Arrays.asList("name", "birthDate"), f.getOrders().keySet());
+ assertEquals(BaseFilter.Order.DESC, f.getOrders().get("name"));
+ assertEquals(BaseFilter.Order.ASC, f.getOrders().get("birthDate"));
+ }
+
+ @Test
+ void orders_preserveInsertionOrder() {
+ SampleFilter f = new SampleFilter();
+ f.addOrder("c").addOrder("a").addOrder("b", BaseFilter.Order.DESC);
+
+ assertIterableEquals(Arrays.asList("c", "a", "b"), f.getOrders().keySet());
+ assertEquals(BaseFilter.Order.ASC, f.getOrders().get("c"));
+ assertEquals(BaseFilter.Order.DESC, f.getOrders().get("b"));
+ }
+
+ @Test
+ void emptyFilter_hasEmptyOrders_andNullPaging() {
+ SampleFilter f = new SampleFilter();
+ assertEquals(0, f.getOrders().size());
+ assertNull(f.getFirstResult());
+ assertNull(f.getMaxResult());
+ }
+
+ @Test
+ void firstResult_rejectsNegativeFromPojo() {
+ SampleFilter f = new SampleFilter();
+ assertThrows(IllegalArgumentException.class, () -> f.setFirstResult(-1));
+ }
+
+ @Test
+ void maxResult_rejectsNegativeFromPojo() {
+ SampleFilter f = new SampleFilter();
+ assertThrows(IllegalArgumentException.class, () -> f.setMaxResult(-1));
+ }
+
+ @Test
+ void firstResult_rejectsNegativeFromBuilder() {
+ assertThrows(IllegalArgumentException.class,
+ () -> SampleFilter.builder().firstResult(-1).build());
+ }
+
+ @Test
+ void maxResult_rejectsNegativeFromBuilder() {
+ assertThrows(IllegalArgumentException.class,
+ () -> SampleFilter.builder().maxResult(-1).build());
+ }
+
+ @Test
+ void toBuilder_producesIndependentCopy() {
+ SampleFilter original = SampleFilter.builder().name("Ada").maxResult(50).build();
+ SampleFilter tweaked = original.toBuilder().maxResult(10).build();
+
+ assertEquals(50, original.getMaxResult());
+ assertEquals(10, tweaked.getMaxResult());
+ assertEquals("Ada", tweaked.getName());
+ assertNotSame(original, tweaked);
+ }
+}
diff --git a/specs/base-filter.md b/specs/base-filter.md
new file mode 100644
index 0000000..2ad9600
--- /dev/null
+++ b/specs/base-filter.md
@@ -0,0 +1,313 @@
+# BaseFilter — Annotation-driven JPA Criteria filtering
+
+Status: Draft
+Branch: `base-filter`
+Author: Leo
+Module: `backend-core-model` (abstraction + annotations), `backend-core-data-impl` (JPA processor + DAO hooks)
+
+## 1. Motivation
+
+The current filtering abstraction (`QuerySpec` + `Constraint` hierarchy + `ConstraintTransformer`) is imperative and verbose: callers must construct `Constraint` objects, register them on a `QuerySpec`, and let `ConstraintTransformerJpaImpl` turn them into JPA `Predicate`s. It works, but every new query surface in a downstream project ends up reimplementing the same boilerplate.
+
+`BaseFilter` replaces that with a *declarative* model: application developers define a POJO whose fields describe the filterable criteria, annotate the fields with lightweight metadata, and pass the populated instance to the DAO. The DAO base implementation reflects on the filter, builds a JPA `CriteriaQuery`, and runs it. The existing `QuerySpec`-based DAO methods — and the supporting `QuerySpec` / `Constraint` / `ConstraintTransformer` types — are deprecated as part of this change but **not** marked for removal; downstream consumers can migrate at their own pace.
+
+This spec covers the abstract class, the initial annotation set, the criteria-building pipeline in the DAO base, and the extensibility surface.
+
+## 2. Goals and non-goals
+
+### Goals
+
+- A single abstract `BaseFilter` class that downstream filters extend.
+- Filter fields describe *what entity attribute they map to* and *what semantic role* the field plays (single value, lower bound, upper bound). The operator is inferred from the metadata, not declared per field.
+- Built-in support for sort orders, first result, and max result, mirroring `QuerySpec`'s pagination semantics.
+- Two equally supported construction styles on every filter: a **POJO style** (no-arg constructor + chainable setters) and a **Builder style** (typed, inheritance-aware builder, populated by `Filter.builder()...build()`).
+- A JPA `CriteriaQuery` builder living in the DAO base, with well-defined hook methods so subclasses can add predicates/joins/projections that don't fit the declarative model.
+- Coexist with `QuerySpec`-based DAO methods during a deprecation window.
+
+### Non-goals (v1)
+
+- Operator-specific annotations (`@Like`, `@ILike`, `@GreaterThan`, `@In`, `@IsNull`, etc.). The first cut ships only *metadata* annotations that describe the role of the field; operator-specific annotations will be revisited once the abstraction proves out.
+- Boolean composition annotations (`@Or`, `@Not`).
+- Pluggable annotation processors (registering third-party annotations). Extensibility is via DAO hook methods only.
+- Reusing or wrapping `QuerySpec`, `Constraint`, or `ConstraintTransformer`. The new pipeline is independent.
+- Replacing `findById` / `findAll` / `save` / `update` / `delete`. Only the filter-shaped methods are touched.
+
+## 3. Public API surface
+
+### 3.1 `BaseFilter` (in `backend-core-model`)
+
+```java
+package com.flowingcode.backendcore.model.filter;
+
+@Getter
+@Setter
+@Accessors(chain = true)
+@SuperBuilder(toBuilder = true)
+@NoArgsConstructor
+public abstract class BaseFilter {
+
+ public enum Order { ASC, DESC }
+
+ @Singular("order")
+ private Map orders; // initialized to LinkedHashMap; never null
+
+ private Integer firstResult; // null or >= 0
+ private Integer maxResult; // null or >= 0
+
+ // Convenience mutators kept on the POJO surface
+ public BaseFilter addOrder(String attribute); // defaults to ASC
+ public BaseFilter addOrder(String attribute, Order direction);
+}
+```
+
+Two construction styles are supported, and both populate the same underlying state:
+
+```java
+// POJO style — no-arg ctor + chainable setters
+PersonFilter f = new PersonFilter()
+ .setName("Ada")
+ .setBirthDateFrom(LocalDate.of(1990, 1, 1));
+f.addOrder("name");
+f.setMaxResult(50);
+
+// Builder style — Lombok @SuperBuilder
+PersonFilter f = PersonFilter.builder()
+ .name("Ada")
+ .birthDateFrom(LocalDate.of(1990, 1, 1))
+ .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 and a per-entry `.addOrder(...)` adder are part of the builder API; Lombok fills in the rest (fields, `self()`, `build()`, the subclass plumbing). The built `orders` map is a `LinkedHashMap` (mutable, insertion-ordered) so subsequent POJO-style `addOrder(...)` calls still work after `build()`.
+- `toBuilder = true` lets callers rebuild a tweaked copy of an existing filter (`f.toBuilder().maxResult(10).build()`), useful for paging.
+- The sort-order attribute string follows the same dotted-path convention as `@Attribute` (see §3.2) so callers can sort across joins.
+- Pagination validators throw `IllegalArgumentException` on negative values, matching `QuerySpec`. Validation lives in both the POJO setters and the corresponding builder methods (`firstResult(...)` / `maxResult(...)` on `BaseFilterBuilder`), so both construction styles enforce the same invariants.
+
+### 3.2 Annotations (in `backend-core-model`, package `com.flowingcode.backendcore.model.filter`)
+
+All annotations are field-level (`@Target(ElementType.FIELD)`), retained at runtime, and purely *metadata* — they describe how a filter field relates to the entity, not the operator to apply.
+
+#### `@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 or more entity attributes via a dotted path. Path traversal follows the same `split("\\.")` + auto-join convention used today in `ConstraintTransformerJpaImpl`, including the inner-join-by-default behavior.
+- A filter field with `@Attribute` and no role annotation defaults to **equality** (`cb.equal(...)`).
+- `manual = true` is the escape hatch for predicates that don't fit the declarative model. The processor records the field (so the value-accessor helper can read it) but emits no predicate; the DAO's `customizePredicates` hook is responsible for the constraint. On a manual field the `value()` is informational — the processor never resolves it — but stays useful as documentation of which entity attribute the hook is expected to target. `manual = true` cannot combine with `@From`, `@To`, or `@WhenNull`, since none of those have meaning when the predicate is hand-built.
+
+#### `@From` and `@To`
+
+```java
+@Retention(RUNTIME) @Target(FIELD)
+public @interface From {
+ /** When true (default), the lower bound is inclusive (>=); when false, strict (>). */
+ boolean inclusive() default true;
+}
+
+@Retention(RUNTIME) @Target(FIELD)
+public @interface To {
+ /** When true (default), the upper bound is inclusive (<=); when false, strict (<). */
+ boolean inclusive() default true;
+}
+```
+
+- `@From` marks the field as the **lower bound** of a range comparison on its `@Attribute`; `inclusive` controls whether the comparison is `>=` (default) or `>`.
+- `@To` marks the field as the **upper bound**; `inclusive` controls `<=` (default) or `<`.
+- Single-sided behavior:
+ - `@From` alone → `>=` or `>` depending on `inclusive`.
+ - `@To` alone → `<=` or `<` depending on `inclusive`.
+- Paired behavior — when the same `@Attribute("x")` value appears on two fields, one with `@From` and the other with `@To`:
+ - **Both bounds inclusive and both values non-null** → emit a single `cb.between(...)` predicate (BETWEEN is inclusive on both sides in JPA/SQL).
+ - **Any bound exclusive, or mixed inclusivity** → fall back to two predicates ANDed together (`> lower AND < upper`, `>= lower AND < upper`, etc.). The BETWEEN optimization is dropped because JPA `between` cannot express exclusivity.
+ - **Only one side non-null** → emit the single available comparison using that side's `inclusive` setting; the other side contributes nothing.
+- `@From` / `@To` without `@Attribute` is a configuration error and must fail fast at startup or on first use, with a clear message.
+
+#### `@WhenNull`
+
+```java
+@Retention(RUNTIME) @Target(FIELD)
+public @interface WhenNull {
+ Policy value();
+
+ enum Policy { SKIP, IS_NULL }
+}
+```
+
+- Per-field override for null handling. Default is `SKIP` (no predicate emitted), so most filter fields don't need this annotation.
+- `IS_NULL` makes a null field emit `cb.isNull(...)` against the resolved attribute path.
+- Only meaningful on fields that also carry `@Attribute`. On `@From` / `@To`, `SKIP` is the only sensible policy and the processor must reject `IS_NULL` with a clear error.
+
+### 3.3 DAO surface changes (in `backend-core-data` and `backend-core-data-impl`)
+
+Add overloads to `QueryDao` and `ConversionJpaDaoSupport` that take a `BaseFilter`:
+
+```java
+// backend-core-data, com.flowingcode.backendcore.dao.QueryDao
+List filter(BaseFilter filter);
+Optional filterWithSingleResult(BaseFilter filter);
+long count(BaseFilter filter);
+```
+
+The existing `QuerySpec` overloads stay but are annotated `@Deprecated(forRemoval = false)` with javadoc pointing at `BaseFilter`. Removal is deferred to a future major version.
+
+`ConversionJpaDaoSupport` provides default implementations that delegate to a `BaseFilterJpaProcessor` (see §4) and convert results via the existing `convertFrom`. `JpaDaoSupport` inherits transparently.
+
+## 4. Criteria-building pipeline
+
+A new package-private (or `protected`-accessible) class lives alongside the existing `FilterProcesor`:
+
+```java
+// backend-core-data-impl
+class BaseFilterJpaProcessor, K extends Serializable> { ... }
+```
+
+For each call, the processor:
+
+1. **Reflects** the `BaseFilter` subclass to discover annotated fields. Reflection results (field handles, attribute paths, role classification, null policy) are **cached per filter class** in a static map to avoid re-walking the class on every query.
+2. **Validates** the annotations at first encounter (range-pair consistency, `@From`/`@To` requires `@Attribute`, `@WhenNull(IS_NULL)` requires `@Attribute` and forbids `@From`/`@To`, no duplicate single-value `@Attribute` on the same path, range pairs share the exact same attribute path).
+3. **Builds predicates** by walking the cached field metadata:
+ - Single-attribute fields → equality.
+ - `@From`-only / `@To`-only → comparison predicate honoring the field's `inclusive` flag (`>` / `>=` / `<` / `<=`).
+ - `@From` + `@To` on the same `@Attribute` path → `cb.between(...)` only when both values are non-null **and** both bounds are inclusive; otherwise two ANDed comparison predicates with each side's `inclusive` setting honored independently. When only one side is non-null, emit just that side's comparison.
+ - Null values are skipped unless `@WhenNull(IS_NULL)` is present.
+4. **Resolves attribute paths** via the same auto-joining strategy used today in `ConstraintTransformerJpaImpl` (split on `.`, reuse existing joins when the join type matches). The path/join helper should be **extracted into a shared utility** so both the legacy transformer and the new processor consume it, but the new processor does not depend on `Constraint` / `ConstraintTransformer`.
+5. **Applies sort orders** from `BaseFilter.getOrders()` in insertion order.
+6. **Calls the DAO hook methods** (see §5) so subclasses can mutate the in-progress `CriteriaQuery`.
+7. **Applies pagination** via `firstResult` / `maxResult` on the `TypedQuery`.
+
+The pipeline is implemented twice in shape (once for the `T` result query, once for the `Long` count query), but the predicate assembly is shared. `filterWithSingleResult` runs the standard list query and enforces single-result semantics, matching the existing `FilterProcesor` behavior.
+
+## 5. Extensibility — DAO hook methods
+
+Customization happens on the DAO base, not on the filter. `ConversionJpaDaoSupport` (and by inheritance `JpaDaoSupport`) exposes hook methods that default to no-ops:
+
+```java
+/** Add predicates that don't fit the declarative model. Return null or empty to add nothing. */
+default Collection customizePredicates(
+ BaseFilter filter, CriteriaBuilder cb, CriteriaQuery> cq, Root root) {
+ return Collections.emptyList();
+}
+
+/** Last-chance hook to mutate the CriteriaQuery (joins, projections, group by, distinct, etc.) before execution. */
+default void customizeCriteria(
+ BaseFilter filter, CriteriaBuilder cb, CriteriaQuery> cq, Root root) {
+ // no-op
+}
+```
+
+Both hooks are called once per query (filter, count, single-result). They receive the same `CriteriaQuery` instance that the processor is building, so subclasses can call `cq.distinct(true)`, add `LEFT JOIN FETCH`-style joins (within Criteria limits), or attach predicates that the annotation model can't express (e.g. correlated subqueries, function calls).
+
+Why hooks and not annotation processors: it keeps the abstraction surface small and predictable, and it routes customization through the DAO — the layer that already owns the entity-shaped logic — instead of fanning custom behavior across filter classes.
+
+### 5.1 Value-accessor helper
+
+Hooks frequently need to read filter field values to build predicates by hand. To avoid forcing every implementer to maintain its own reflection logic, the DAO base exposes:
+
+```java
+default Object getFilterFieldValue(BaseFilter filter, String fieldName);
+default V getFilterFieldValue(BaseFilter filter, String fieldName, Class type);
+```
+
+Both overloads are backed by the same cached reflection used to build the declarative predicates, so the lookup is essentially a map read. The helper works on any field declared on the filter (annotated or not); it is especially useful for fields marked `@Attribute(manual = true)`, where the declarative path has been intentionally skipped.
+
+## 6. Usage example
+
+```java
+@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(value = "nickname", manual = true)
+ private String nicknameLike; // processor skips this; hook builds the LIKE
+}
+
+// DAO with a manual predicate
+class PersonDao implements JpaDaoSupport {
+ // ... getEntityManager() ...
+
+ @Override
+ public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb,
+ CriteriaQuery> cq, Root root) {
+ String pattern = getFilterFieldValue(filter, "nicknameLike", String.class);
+ return pattern == null ? List.of()
+ : List.of(cb.like(root.get("nickname"), "%" + pattern + "%"));
+ }
+}
+
+// POJO style
+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.
+- Internally, the path/join helper inside `ConstraintTransformerJpaImpl` is **extracted to a non-deprecated utility** so the new processor can consume it without depending on the deprecated transformer. The deprecated transformer keeps working by delegating to that utility.
+- A follow-up issue tracks: (a) eventually flipping the deprecations to `forRemoval = true` once downstream usage is gone, (b) removing the deprecated DAO method overloads and the constraint types in a future major version.
+
+## 8. Module placement
+
+- `backend-core-model` — `BaseFilter`, `@Attribute`, `@From`, `@To`, `@WhenNull`. Package: `com.flowingcode.backendcore.model.filter`.
+- `backend-core-data` — new method signatures on `QueryDao`.
+- `backend-core-data-impl` — `BaseFilterJpaProcessor`, default-method implementations on `ConversionJpaDaoSupport`, shared path/join utility extracted from `ConstraintTransformerJpaImpl`.
+
+## 9. Open questions
+
+1. **Sort order via annotation.** Should a filter class be able to declare a default sort via annotation (e.g. `@DefaultSort("createdAt DESC")`)? Out of scope for v1; callers use `addOrder`.
+2. **Validation timing.** Should the per-class reflection/validation run eagerly at startup (e.g. via a CDI extension or a Spring `BeanPostProcessor`) or lazily on first use? Spec assumes lazy with caching. Eager validation can be added later without API changes.
+3. **Field discovery.** Inherited fields from a deeper hierarchy (filter extending filter) should be supported. Confirm whether non-public fields require `setAccessible(true)` allowances in target deployments.
+4. **Builder validation hookup.** 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
+
+- Operator-specific annotations (`@Like`, `@ILike`, `@In`, `@IsNull` as a standalone, `@Gt`/`@Lt`, etc.). Revisit once usage patterns emerge.
+- Boolean composition (`@Or` groups, `@Not`).
+- Pluggable / user-registered annotation processors.
+- Removal of `QuerySpec` and friends.
+- Projection / returned-attributes support equivalent to `QuerySpec.returnedAttributes`.