diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java new file mode 100644 index 0000000..c519cd6 --- /dev/null +++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/AttributePathResolver.java @@ -0,0 +1,116 @@ +/*- + * #%L + * Commons Backend - Data Access Layer Implementations + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.backendcore.dao.jpa; + +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; + +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.From; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.JoinType; + +/** + * Resolves a dotted attribute path on a JPA {@code From} root into a leaf + * {@code Expression}, auto-joining associations along the way and reusing + * existing joins when one is already present on the same attribute and join + * type. + * + *

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

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

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

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

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

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

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

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

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

Set {@link #manual()} to {@code true} when the predicate for the field is + * built by hand in a DAO hook (e.g. + * {@code ConversionJpaDaoSupport#customizePredicates}). The processor will skip + * declarative predicate building for the field but still track it so the + * field's value can be retrieved via + * {@code ConversionJpaDaoSupport#getFilterFieldValue}. Manual fields cannot + * combine with {@link From}, {@link To} or {@link WhenNull}, since none of + * those have meaning when the predicate is hand-built. + * + * @see From + * @see To + * @see WhenNull + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Attribute { + + /** + * Dotted attribute path on the target entity (e.g. {@code "city.state.name"}). + * On a {@link #manual() manual} field the value is informational — the + * processor never resolves it — and is still useful as documentation of + * which entity attribute the hook is expected to target. + */ + String value(); + + /** + * When {@code true}, the processor records the field for value lookup but + * does not generate a declarative predicate. The caller is responsible for + * producing the predicate in a DAO hook. + */ + boolean manual() default false; +} diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java new file mode 100644 index 0000000..7961d7a --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/BaseFilter.java @@ -0,0 +1,174 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.SuperBuilder; + +/** + * Base class for annotation-driven filters consumed by the DAO layer. + * + *

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

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

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

Pagination validators ({@link #setFirstResult(Integer)} / + * {@link #setMaxResult(Integer)}) reject negative values from the POJO path, + * and the builder's {@code firstResult(...)} / {@code maxResult(...)} methods + * apply the same checks before {@code build()} returns, so both styles enforce + * the same invariants. + */ +@Getter +@Setter +@Accessors(chain = true) +@NoArgsConstructor +@SuperBuilder(toBuilder = true) +public abstract class BaseFilter { + + /** Sort direction for an ordering entry. */ + public enum Order { + ASC, DESC + } + + private Map orders; + + private Integer firstResult; + + private Integer maxResult; + + /** Adds an ascending order on {@code attribute}. */ + public BaseFilter addOrder(String attribute) { + return addOrder(attribute, Order.ASC); + } + + /** Adds an order on {@code attribute} with the given {@code direction}. */ + public BaseFilter addOrder(String attribute, Order direction) { + if (this.orders == null) { + this.orders = new LinkedHashMap<>(); + } + this.orders.put(attribute, direction); + return this; + } + + /** + * Returns the configured sort orders, preserving insertion order. Never + * {@code null}; an empty map indicates no ordering. + */ + public Map getOrders() { + return orders == null ? Collections.emptyMap() : orders; + } + + /** + * Sets the position of the first result to retrieve (numbered from 0). + * + * @param firstResult the position, or {@code null} to clear + * @throws IllegalArgumentException if the argument is negative + */ + public BaseFilter setFirstResult(Integer firstResult) { + validateNonNegative(firstResult, "firstResult"); + this.firstResult = firstResult; + return this; + } + + /** + * Sets the maximum number of results to retrieve. + * + * @param maxResult the cap, or {@code null} for no cap + * @throws IllegalArgumentException if the argument is negative + */ + public BaseFilter setMaxResult(Integer maxResult) { + validateNonNegative(maxResult, "maxResult"); + this.maxResult = maxResult; + return this; + } + + private static void validateNonNegative(Integer value, String name) { + if (value != null && value < 0) { + throw new IllegalArgumentException(name + " must be >= 0"); + } + } + + /** + * Inner builder declared explicitly so that paging validation and the + * per-entry order adder are available on the builder API. Lombok's + * {@code @SuperBuilder} fills in the rest (field accumulation, {@code self()}, + * {@code build()}, the subclass plumbing). + */ + public abstract static class BaseFilterBuilder> { + + /** Adds an ascending order on {@code attribute}. */ + public B addOrder(String attribute) { + return addOrder(attribute, Order.ASC); + } + + /** Adds an order on {@code attribute} with the given {@code direction}. */ + public B addOrder(String attribute, Order direction) { + if (this.orders == null) { + this.orders = new LinkedHashMap<>(); + } + this.orders.put(attribute, direction); + return self(); + } + + /** + * @throws IllegalArgumentException if {@code firstResult} is negative + */ + public B firstResult(Integer firstResult) { + validateNonNegative(firstResult, "firstResult"); + this.firstResult = firstResult; + return self(); + } + + /** + * @throws IllegalArgumentException if {@code maxResult} is negative + */ + public B maxResult(Integer maxResult) { + validateNonNegative(maxResult, "maxResult"); + this.maxResult = maxResult; + return self(); + } + } +} diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/From.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/From.java new file mode 100644 index 0000000..9848b1b --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/From.java @@ -0,0 +1,56 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a filter field as the lower bound of a range comparison on its + * {@link Attribute}. + * + *

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

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

Must be paired with {@link Attribute}; otherwise the processor reports a + * configuration error. + * + * @see Attribute + * @see To + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface From { + + /** + * When {@code true} (default), the lower bound is inclusive ({@code >=}). + * When {@code false}, the bound is strict ({@code >}). + */ + boolean inclusive() default true; +} diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/To.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/To.java new file mode 100644 index 0000000..f3d25d2 --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/To.java @@ -0,0 +1,56 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a filter field as the upper bound of a range comparison on its + * {@link Attribute}. + * + *

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

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

Must be paired with {@link Attribute}; otherwise the processor reports a + * configuration error. + * + * @see Attribute + * @see From + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface To { + + /** + * When {@code true} (default), the upper bound is inclusive ({@code <=}). + * When {@code false}, the bound is strict ({@code <}). + */ + boolean inclusive() default true; +} diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/WhenNull.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/WhenNull.java new file mode 100644 index 0000000..acdb75a --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/WhenNull.java @@ -0,0 +1,56 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Overrides how a {@code null} value on the annotated filter field is handled. + * + *

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

{@code @WhenNull} requires {@link Attribute} on the same field and is not + * allowed alongside {@link From} or {@link To}, since range bounds have no + * sensible {@code IS_NULL} semantics. + * + * @see Attribute + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface WhenNull { + + Policy value(); + + /** Policy applied to a null filter field. */ + enum Policy { + + /** Emit no predicate for this field when its value is {@code null}. */ + SKIP, + + /** Emit an {@code IS NULL} predicate when the field value is {@code null}. */ + IS_NULL + } +} diff --git a/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java new file mode 100644 index 0000000..1b3bd41 --- /dev/null +++ b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java @@ -0,0 +1,148 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.LocalDate; +import java.util.Arrays; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.SuperBuilder; + +import org.junit.jupiter.api.Test; + +class BaseFilterTest { + + @Getter + @Setter + @Accessors(chain = true) + @NoArgsConstructor + @SuperBuilder(toBuilder = true) + static class SampleFilter extends BaseFilter { + + @Attribute("name") + private String name; + + @Attribute("birthDate") @From + private LocalDate birthDateFrom; + + @Attribute("birthDate") @To(inclusive = false) + private LocalDate birthDateTo; + } + + @Test + void pojo_chainableSetters_carryAllState() { + SampleFilter f = new SampleFilter() + .setName("Ada") + .setBirthDateFrom(LocalDate.of(1990, 1, 1)) + .setBirthDateTo(LocalDate.of(2000, 1, 1)); + f.addOrder("name").setMaxResult(50).setFirstResult(10); + + assertEquals("Ada", f.getName()); + assertEquals(LocalDate.of(1990, 1, 1), f.getBirthDateFrom()); + assertEquals(LocalDate.of(2000, 1, 1), f.getBirthDateTo()); + assertEquals(50, f.getMaxResult()); + assertEquals(10, f.getFirstResult()); + assertIterableEquals(Arrays.asList("name"), f.getOrders().keySet()); + assertEquals(BaseFilter.Order.ASC, f.getOrders().get("name")); + } + + @Test + void builder_setsState() { + SampleFilter f = SampleFilter.builder() + .name("Ada") + .birthDateFrom(LocalDate.of(1990, 1, 1)) + .addOrder("name", BaseFilter.Order.DESC) + .addOrder("birthDate", BaseFilter.Order.ASC) + .firstResult(0) + .maxResult(25) + .build(); + + assertEquals("Ada", f.getName()); + assertEquals(LocalDate.of(1990, 1, 1), f.getBirthDateFrom()); + assertNull(f.getBirthDateTo()); + assertEquals(0, f.getFirstResult()); + assertEquals(25, f.getMaxResult()); + assertIterableEquals(Arrays.asList("name", "birthDate"), f.getOrders().keySet()); + assertEquals(BaseFilter.Order.DESC, f.getOrders().get("name")); + assertEquals(BaseFilter.Order.ASC, f.getOrders().get("birthDate")); + } + + @Test + void orders_preserveInsertionOrder() { + SampleFilter f = new SampleFilter(); + f.addOrder("c").addOrder("a").addOrder("b", BaseFilter.Order.DESC); + + assertIterableEquals(Arrays.asList("c", "a", "b"), f.getOrders().keySet()); + assertEquals(BaseFilter.Order.ASC, f.getOrders().get("c")); + assertEquals(BaseFilter.Order.DESC, f.getOrders().get("b")); + } + + @Test + void emptyFilter_hasEmptyOrders_andNullPaging() { + SampleFilter f = new SampleFilter(); + assertEquals(0, f.getOrders().size()); + assertNull(f.getFirstResult()); + assertNull(f.getMaxResult()); + } + + @Test + void firstResult_rejectsNegativeFromPojo() { + SampleFilter f = new SampleFilter(); + assertThrows(IllegalArgumentException.class, () -> f.setFirstResult(-1)); + } + + @Test + void maxResult_rejectsNegativeFromPojo() { + SampleFilter f = new SampleFilter(); + assertThrows(IllegalArgumentException.class, () -> f.setMaxResult(-1)); + } + + @Test + void firstResult_rejectsNegativeFromBuilder() { + assertThrows(IllegalArgumentException.class, + () -> SampleFilter.builder().firstResult(-1).build()); + } + + @Test + void maxResult_rejectsNegativeFromBuilder() { + assertThrows(IllegalArgumentException.class, + () -> SampleFilter.builder().maxResult(-1).build()); + } + + @Test + void toBuilder_producesIndependentCopy() { + SampleFilter original = SampleFilter.builder().name("Ada").maxResult(50).build(); + SampleFilter tweaked = original.toBuilder().maxResult(10).build(); + + assertEquals(50, original.getMaxResult()); + assertEquals(10, tweaked.getMaxResult()); + assertEquals("Ada", tweaked.getName()); + assertNotSame(original, tweaked); + } +} diff --git a/specs/base-filter.md b/specs/base-filter.md 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`.