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

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

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

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

Instances are not thread-safe. Create a new instance per query + * invocation. + */ +class BaseFilterJpaProcessor, K extends Serializable> { + + /** DAO-supplied callbacks that augment the declaratively built criteria. */ + interface Hooks { + Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb, + CriteriaQuery cq, Root root); + + void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root); + } + + /** Annotations that choose the operator of a field's predicate; at most one per field. */ + private static final List> OPERATORS = + List.of(From.class, To.class, Like.class, In.class); + + /** Annotations that are only meaningful on a declarative {@code @Attribute} field. */ + private static final List> MODIFIERS = + List.of(From.class, To.class, Like.class, In.class, WhenNull.class, Or.class); + + private static final ConcurrentMap, List> HANDLER_CACHE = + new ConcurrentHashMap<>(); + + private final EntityManager em; + private final Class persistentClass; + private final Hooks hooks; + + static , K extends Serializable> BaseFilterJpaProcessor of( + EntityManager em, Class persistentClass, Hooks hooks) { + return new BaseFilterJpaProcessor<>(em, persistentClass, hooks); + } + + private BaseFilterJpaProcessor(EntityManager em, Class persistentClass, Hooks hooks) { + this.em = em; + this.persistentClass = persistentClass; + this.hooks = hooks; + } + + List filter(BaseFilter filter) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(persistentClass); + Root root = cq.from(persistentClass); + cq.select(root); + applyPredicates(filter, cb, cq, root); + applyOrders(filter, cb, cq, root); + hooks.customizeCriteria(filter, cb, cq, root); + requireSelection(cq, root); + TypedQuery query = em.createQuery(cq); + applyPaging(query, filter); + return query.getResultList(); + } + + Optional filterWithSingleResult(BaseFilter filter) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(persistentClass); + Root root = cq.from(persistentClass); + cq.select(root); + applyPredicates(filter, cb, cq, root); + hooks.customizeCriteria(filter, cb, cq, root); + requireSelection(cq, root); + try { + return Optional.of(em.createQuery(cq).getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } catch (NonUniqueResultException e) { + throw new IllegalStateException("Current filter returned more than one result", e); + } + } + + long count(BaseFilter filter) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Long.class); + Root root = cq.from(persistentClass); + Expression count = cb.count(root); + cq.select(count); + applyPredicates(filter, cb, cq, root); + hooks.customizeCriteria(filter, cb, cq, root); + requireSelection(cq, count); + if (!cq.getGroupList().isEmpty()) { + throw new IllegalStateException( + "customizeCriteria must not add a GROUP BY to a count query, which would return" + + " one count per group; check CriteriaQuery.getResultType() to skip it"); + } + if (cq.isDistinct()) { + // SELECT DISTINCT COUNT(x) still counts duplicates; count distinct roots instead. + cq.distinct(false); + cq.select(cb.countDistinct(root)); + } + return em.createQuery(cq).getSingleResult(); + } + + private static void requireSelection(CriteriaQuery cq, Selection expected) { + if (cq.getSelection() != expected) { + throw new IllegalStateException("customizeCriteria must not replace the query selection"); + } + } + + // ----- query assembly ----- + + private void applyPredicates(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + AttributePathResolver resolver = new AttributePathResolver(root); + + List conjunction = new ArrayList<>(); + List disjunction = new ArrayList<>(); + for (FieldHandler handler : handlersFor(filter.getClass())) { + Predicate predicate; + try { + predicate = handler.toPredicate(filter, cb, resolver); + } catch (IllegalAccessException e) { + throw new IllegalStateException( + "Cannot read field " + handler.describe() + " on " + filter.getClass(), e); + } + if (predicate != null) { + (handler.or ? disjunction : conjunction).add(predicate); + } + } + // A disjunction without disjuncts is false, so it is only added when some + // @Or field contributed a predicate; otherwise it would match no rows. + if (!disjunction.isEmpty()) { + conjunction.add(cb.or(disjunction.toArray(new Predicate[0]))); + } + + Collection extra = hooks.customizePredicates(filter, cb, cq, root); + if (extra != null && !extra.isEmpty()) { + conjunction.addAll(extra); + } + + if (!conjunction.isEmpty()) { + cq.where(conjunction.toArray(new Predicate[0])); + } + } + + private void applyOrders(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + Map orders = filter.getOrders(); + if (orders.isEmpty()) { + return; + } + AttributePathResolver resolver = new AttributePathResolver(root); + List jpaOrders = new ArrayList<>(orders.size()); + for (Entry e : orders.entrySet()) { + // Left join: sorting must not drop rows whose association is null. + Expression expr = resolver.resolve(e.getKey(), JoinType.LEFT); + jpaOrders.add(e.getValue() == BaseFilter.Order.ASC ? cb.asc(expr) : cb.desc(expr)); + } + cq.orderBy(jpaOrders); + } + + private void applyPaging(TypedQuery query, BaseFilter filter) { + if (filter.getFirstResult() != null) { + query.setFirstResult(filter.getFirstResult()); + } + if (filter.getMaxResult() != null) { + query.setMaxResults(filter.getMaxResult()); + } + } + + // ----- metadata reflection + caching ----- + + private static List handlersFor(Class filterClass) { + return HANDLER_CACHE.computeIfAbsent(filterClass, BaseFilterJpaProcessor::buildHandlers); + } + + private static List buildHandlers(Class filterClass) { + // Collect the annotated fields of every class between the filter and + // BaseFilter. A field shadowed by a subclass field of the same name is still + // a filter field of its own. + List annotated = new ArrayList<>(); + for (Class c = filterClass; c != BaseFilter.class; c = c.getSuperclass()) { + for (Field f : c.getDeclaredFields()) { + if (f.isAnnotationPresent(Attribute.class) || !annotationsOn(f, MODIFIERS).isEmpty()) { + annotated.add(f); + } + } + } + + // Validate per-field constraints and group declarative fields by attribute path. + Map> byPath = new HashMap<>(); + for (Field f : annotated) { + validate(f); + Attribute attr = f.getAnnotation(Attribute.class); + // Manual fields are read by the DAO hook through the filter's accessors. + if (attr.manual()) { + continue; + } + f.setAccessible(true); + byPath.computeIfAbsent(attr.value(), k -> new ArrayList<>()).add(f); + } + + List handlers = new ArrayList<>(); + for (Entry> entry : byPath.entrySet()) { + String path = entry.getKey(); + List group = entry.getValue(); + handlers.add(buildHandler(path, group)); + } + return List.copyOf(handlers); + } + + private static void validate(Field f) { + Attribute attr = f.getAnnotation(Attribute.class); + List modifiers = annotationsOn(f, MODIFIERS); + List operators = annotationsOn(f, OPERATORS); + + if (attr == null) { + throw new IllegalStateException("Field " + describe(f) + " has " + + String.join(", ", modifiers) + " but no @Attribute"); + } + if (attr.manual() && !modifiers.isEmpty()) { + throw new IllegalStateException("Field " + describe(f) + + " is @Attribute(manual=true); it cannot combine with " + + String.join(", ", modifiers)); + } + if (operators.size() > 1) { + throw new IllegalStateException("Field " + describe(f) + " cannot combine " + + String.join(" and ", operators) + "; use at most one of @From, @To, @Like or @In"); + } + if (f.isAnnotationPresent(WhenNull.class) && !operators.isEmpty()) { + throw new IllegalStateException("Field " + describe(f) + + " cannot combine @WhenNull with " + operators.get(0)); + } + if (f.isAnnotationPresent(Like.class) && f.getType() != String.class) { + throw new IllegalStateException("Field " + describe(f) + + " is @Like, so its type must be String; found " + f.getType().getName()); + } + if (f.isAnnotationPresent(In.class) && !Collection.class.isAssignableFrom(f.getType())) { + throw new IllegalStateException("Field " + describe(f) + + " is @In, so its type must be a Collection; found " + f.getType().getName()); + } + } + + private static List annotationsOn(Field f, List> types) { + return types.stream() + .filter(f::isAnnotationPresent) + .map(type -> "@" + type.getSimpleName()) + .collect(Collectors.toList()); + } + + private static FieldHandler buildHandler(String path, List group) { + if (group.size() == 1) { + Field f = group.get(0); + boolean or = f.isAnnotationPresent(Or.class); + From from = f.getAnnotation(From.class); + if (from != null) { + return new LowerBoundHandler(path, or, f, from.inclusive()); + } + To to = f.getAnnotation(To.class); + if (to != null) { + return new UpperBoundHandler(path, or, f, to.inclusive()); + } + Like like = f.getAnnotation(Like.class); + if (like != null) { + return new LikeHandler(path, or, f, like.ignoreCase(), like.match()); + } + In in = f.getAnnotation(In.class); + if (in != null) { + return new InHandler(path, or, f, in.whenEmpty()); + } + WhenNull whenNull = f.getAnnotation(WhenNull.class); + WhenNull.Policy policy = whenNull != null ? whenNull.value() : WhenNull.Policy.SKIP; + return new EqualityHandler(path, or, f, policy); + } + if (group.size() == 2) { + Field a = group.get(0); + Field b = group.get(1); + From fromA = a.getAnnotation(From.class); + To toA = a.getAnnotation(To.class); + From fromB = b.getAnnotation(From.class); + To toB = b.getAnnotation(To.class); + Field lower = null; + Field upper = null; + boolean lowerInclusive = true; + boolean upperInclusive = true; + if (fromA != null && toB != null) { + lower = a; + upper = b; + lowerInclusive = fromA.inclusive(); + upperInclusive = toB.inclusive(); + } else if (fromB != null && toA != null) { + lower = b; + upper = a; + lowerInclusive = fromB.inclusive(); + upperInclusive = toA.inclusive(); + } + if (lower != null) { + boolean or = lower.isAnnotationPresent(Or.class); + if (or != upper.isAnnotationPresent(Or.class)) { + throw new IllegalStateException("Range fields " + describe(group) + + " on attribute path \"" + path + "\" must either both carry @Or or neither"); + } + return new RangePairHandler(path, or, lower, upper, lowerInclusive, upperInclusive); + } + } + throw new IllegalStateException("Attribute path \"" + path + + "\" is mapped by multiple filter fields that do not form a @From/@To pair: " + + describe(group)); + } + + private static String describe(Field f) { + return f.getDeclaringClass().getSimpleName() + "." + f.getName(); + } + + private static String describe(List fields) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < fields.size(); i++) { + if (i > 0) sb.append(", "); + sb.append(describe(fields.get(i))); + } + return sb.toString(); + } + + // ----- handler types ----- + + private abstract static class FieldHandler { + final String attributePath; + final boolean or; + + FieldHandler(String attributePath, boolean or) { + this.attributePath = attributePath; + this.or = or; + } + + /** + * Join type for the path of a predicate that rejects {@code null}: a left + * join inside the disjunction, so that a row with a {@code null} + * association can still match through another disjunct, and an inner join + * otherwise. + */ + JoinType joinType() { + return or ? JoinType.LEFT : JoinType.INNER; + } + + abstract Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, + AttributePathResolver resolver) throws IllegalAccessException; + + abstract String describe(); + } + + private static final class EqualityHandler extends FieldHandler { + private final Field field; + private final WhenNull.Policy nullPolicy; + + EqualityHandler(String path, boolean or, Field field, WhenNull.Policy nullPolicy) { + super(path, or); + this.field = field; + this.nullPolicy = nullPolicy; + } + + @Override + Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolver resolver) + throws IllegalAccessException { + Object value = field.get(filter); + if (value == null) { + // IS NULL must also match rows whose association is null, which an + // inner join would drop before the predicate is evaluated. + return nullPolicy == WhenNull.Policy.IS_NULL + ? cb.isNull(resolver.resolve(attributePath, JoinType.LEFT)) + : null; + } + return cb.equal(resolver.resolve(attributePath, joinType()), value); + } + + @Override + String describe() { + return BaseFilterJpaProcessor.describe(field); + } + } + + private static final class LowerBoundHandler extends FieldHandler { + private final Field field; + private final boolean inclusive; + + LowerBoundHandler(String path, boolean or, Field field, boolean inclusive) { + super(path, or); + this.field = field; + this.inclusive = inclusive; + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolver resolver) + throws IllegalAccessException { + Object value = field.get(filter); + if (value == null) return null; + Expression expr = resolver.resolve(attributePath, Comparable.class, joinType()); + return inclusive ? cb.greaterThanOrEqualTo(expr, (Comparable) value) + : cb.greaterThan(expr, (Comparable) value); + } + + @Override + String describe() { + return BaseFilterJpaProcessor.describe(field); + } + } + + private static final class UpperBoundHandler extends FieldHandler { + private final Field field; + private final boolean inclusive; + + UpperBoundHandler(String path, boolean or, Field field, boolean inclusive) { + super(path, or); + this.field = field; + this.inclusive = inclusive; + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolver resolver) + throws IllegalAccessException { + Object value = field.get(filter); + if (value == null) return null; + Expression expr = resolver.resolve(attributePath, Comparable.class, joinType()); + return inclusive ? cb.lessThanOrEqualTo(expr, (Comparable) value) + : cb.lessThan(expr, (Comparable) value); + } + + @Override + String describe() { + return BaseFilterJpaProcessor.describe(field); + } + } + + private static final class RangePairHandler extends FieldHandler { + private final Field lower; + private final Field upper; + private final boolean lowerInclusive; + private final boolean upperInclusive; + + RangePairHandler(String path, boolean or, Field lower, Field upper, boolean lowerInclusive, + boolean upperInclusive) { + super(path, or); + this.lower = lower; + this.upper = upper; + this.lowerInclusive = lowerInclusive; + this.upperInclusive = upperInclusive; + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolver resolver) + throws IllegalAccessException { + Object lo = lower.get(filter); + Object hi = upper.get(filter); + if (lo == null && hi == null) return null; + Expression expr = resolver.resolve(attributePath, Comparable.class, joinType()); + + if (lo != null && hi != null && lowerInclusive && upperInclusive) { + return cb.between(expr, (Comparable) lo, (Comparable) hi); + } + + List parts = new ArrayList<>(2); + if (lo != null) { + parts.add(lowerInclusive ? cb.greaterThanOrEqualTo(expr, (Comparable) lo) + : cb.greaterThan(expr, (Comparable) lo)); + } + if (hi != null) { + parts.add(upperInclusive ? cb.lessThanOrEqualTo(expr, (Comparable) hi) + : cb.lessThan(expr, (Comparable) hi)); + } + return parts.size() == 1 ? parts.get(0) : cb.and(parts.toArray(new Predicate[0])); + } + + @Override + String describe() { + return BaseFilterJpaProcessor.describe(lower) + " + " + + BaseFilterJpaProcessor.describe(upper); + } + } + + private static final class LikeHandler extends FieldHandler { + private static final char ESCAPE = '\\'; + + private final Field field; + private final boolean ignoreCase; + private final Like.Match match; + + LikeHandler(String path, boolean or, Field field, boolean ignoreCase, Like.Match match) { + super(path, or); + this.field = field; + this.ignoreCase = ignoreCase; + this.match = match; + } + + @Override + Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolver resolver) + throws IllegalAccessException { + String value = (String) field.get(filter); + if (value == null) return null; + Expression expr = resolver.resolve(attributePath, String.class, joinType()); + if (ignoreCase) { + expr = cb.lower(expr); + value = value.toLowerCase(Locale.ROOT); + } + if (match == Like.Match.RAW) { + return cb.like(expr, value); + } + return cb.like(expr, pattern(value), ESCAPE); + } + + private String pattern(String value) { + String escaped = escape(value); + return switch (match) { + case STARTS_WITH -> escaped + "%"; + case ENDS_WITH -> "%" + escaped; + case CONTAINS, RAW -> "%" + escaped + "%"; + }; + } + + /** Escapes the wildcards so that {@code value} is matched literally. */ + private static String escape(String value) { + StringBuilder sb = new StringBuilder(value.length()); + for (char ch : value.toCharArray()) { + if (ch == ESCAPE || ch == '%' || ch == '_') { + sb.append(ESCAPE); + } + sb.append(ch); + } + return sb.toString(); + } + + @Override + String describe() { + return BaseFilterJpaProcessor.describe(field); + } + } + + private static final class InHandler extends FieldHandler { + private final Field field; + private final In.EmptyPolicy whenEmpty; + + InHandler(String path, boolean or, Field field, In.EmptyPolicy whenEmpty) { + super(path, or); + this.field = field; + this.whenEmpty = whenEmpty; + } + + @Override + Predicate toPredicate(BaseFilter filter, CriteriaBuilder cb, AttributePathResolver resolver) + throws IllegalAccessException { + Collection values = (Collection) field.get(filter); + if (values == null) return null; + if (values.isEmpty()) { + // A disjunction without disjuncts is false: it matches no rows. + return whenEmpty == In.EmptyPolicy.MATCH_NONE ? cb.disjunction() : null; + } + return resolver.resolve(attributePath, joinType()).in(values); + } + + @Override + String describe() { + return BaseFilterJpaProcessor.describe(field); + } + } +} diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java index 593dda9..1e22039 100644 --- a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java +++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java @@ -49,7 +49,12 @@ * JPA/Criteria implementation of {@link ConstraintTransformer}. * *

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

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

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

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

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

Defaults to a no-op. + */ + default void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + // no-op + } + + private BaseFilterJpaProcessor baseFilterProcessor() { + final ConversionJpaDaoSupport self = this; + return BaseFilterJpaProcessor.of(getEntityManager(), getPersistentClass(), + new BaseFilterJpaProcessor.Hooks() { + @Override + public Collection customizePredicates(BaseFilter filter, + CriteriaBuilder cb, CriteriaQuery cq, Root root) { + return self.customizePredicates(filter, cb, cq, root); + } + + @Override + public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, + CriteriaQuery cq, Root root) { + self.customizeCriteria(filter, cb, cq, root); + } + }); + } + static class FilterProcesor, K extends Serializable> { private Class persistentClass; diff --git a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java new file mode 100644 index 0000000..b5edcf8 --- /dev/null +++ b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterDaoHookTest.java @@ -0,0 +1,321 @@ +/*- + * #%L + * Commons Backend - Data Access Layer Implementations + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.backendcore.dao.jpa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.Persistence; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.flowingcode.backendcore.model.filter.Attribute; +import com.flowingcode.backendcore.model.filter.BaseFilter; +import com.flowingcode.backendcore.model.filter.From; +import com.flowingcode.backendcore.model.impl.City; +import com.flowingcode.backendcore.model.impl.Person; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.SuperBuilder; + +class BaseFilterDaoHookTest { + + @Getter + @Setter + @Accessors(chain = true) + @NoArgsConstructor + @SuperBuilder + static class PersonFilter extends BaseFilter { + // Empty: this suite exercises the DAO hooks, not the annotation pipeline. + } + + static class HookDao implements JpaDaoSupport { + + final EntityManager em; + final AtomicInteger predicatesCalls = new AtomicInteger(); + final AtomicInteger criteriaCalls = new AtomicInteger(); + final AtomicBoolean restrictByName = new AtomicBoolean(); + + HookDao(EntityManager em) { + this.em = em; + } + + @Override + public EntityManager getEntityManager() { + return em; + } + + // Tests subclass this DAO anonymously, and the default reflective lookup only + // inspects the interfaces implemented directly by the runtime class. + @Override + public Class getPersistentClass() { + return Person.class; + } + + @Override + public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb, + CriteriaQuery cq, Root root) { + predicatesCalls.incrementAndGet(); + if (!restrictByName.get()) { + return Collections.emptyList(); + } + List ps = new ArrayList<>(); + ps.add(cb.equal(root.get("name"), "John")); + return ps; + } + + @Override + public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + criteriaCalls.incrementAndGet(); + } + } + + private HookDao dao; + private EntityManagerFactory emf; + private EntityManager em; + + @BeforeEach + void setUp() { + emf = Persistence.createEntityManagerFactory("person"); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + for (String name : new String[] {"John", "Jane", "Alice", "John"}) { + Person p = new Person(); + p.setName(name); + p.setLastName("Doe"); + em.persist(p); + } + em.getTransaction().commit(); + em.close(); + // One EntityManager for all the DAOs of a test, like one per transaction under Spring. + this.em = emf.createEntityManager(); + dao = new HookDao(this.em); + } + + @AfterEach + void tearDown() { + if (em != null && em.isOpen()) { + em.close(); + } + if (emf != null && emf.isOpen()) { + emf.close(); + } + } + + @Test + void hooksFireOnFilterAndCount() { + PersonFilter f = PersonFilter.builder().build(); + dao.predicatesCalls.set(0); + dao.criteriaCalls.set(0); + + dao.filter(f); + dao.count(f); + + assertEquals(2, dao.predicatesCalls.get(), + "customizePredicates should fire on filter() and count()"); + assertEquals(2, dao.criteriaCalls.get(), + "customizeCriteria should fire on filter() and count()"); + } + + @Test + void hookFiresOnFilterWithSingleResult() { + // Restrict to a name with exactly one match so the call succeeds. + dao.restrictByName.set(true); + // The data has two "John"s, so override the restriction inline. + HookDao d = new HookDao(em) { + @Override + public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb, + CriteriaQuery cq, Root root) { + predicatesCalls.incrementAndGet(); + return List.of(cb.equal(root.get("name"), "Jane")); + } + }; + d.filterWithSingleResult(PersonFilter.builder().build()); + assertEquals(1, d.predicatesCalls.get()); + assertEquals(1, d.criteriaCalls.get()); + } + + @Test + void predicateHookRestrictsResults() { + PersonFilter f = PersonFilter.builder().build(); + + long unrestricted = dao.count(f); + assertEquals(4, unrestricted); + + dao.restrictByName.set(true); + long restricted = dao.count(f); + assertEquals(2, restricted, "predicate hook should restrict to two Johns"); + + List people = dao.filter(f); + assertEquals(2, people.size()); + assertTrue(people.stream().allMatch(p -> "John".equals(p.getName()))); + } + + @Getter + @Setter + @Accessors(chain = true) + @NoArgsConstructor + @SuperBuilder + static class ManualPersonFilter extends BaseFilter { + + // Manual field: the processor never produces a predicate for it. The hook + // reads the value through the getter and builds the predicate itself. + @Attribute(value = "name", manual = true) + private String nameLike; + } + + @Test + void manualField_isSkipped_andHookReadsItThroughTheGetter() { + HookDao d = new HookDao(em) { + @Override + public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb, + CriteriaQuery cq, Root root) { + predicatesCalls.incrementAndGet(); + if (filter instanceof ManualPersonFilter f && f.getNameLike() != null) { + return List.of(cb.like(root.get("name"), f.getNameLike())); + } + return Collections.emptyList(); + } + }; + + // No pattern set → no extra predicate → all four people returned. + assertEquals(4, d.count(ManualPersonFilter.builder().build())); + + // Pattern set → hook builds the LIKE → matches the two "John"s. + ManualPersonFilter f = ManualPersonFilter.builder().nameLike("Jo%").build(); + assertEquals(2, d.count(f)); + List results = d.filter(f); + assertEquals(2, results.size()); + assertTrue(results.stream().allMatch(p -> p.getName().startsWith("Jo"))); + } + + @Getter + @Setter + @Accessors(chain = true) + @NoArgsConstructor + @SuperBuilder + static class BadManualFilter extends BaseFilter { + // Invalid: @Attribute(manual=true) cannot combine with @From / @To / @WhenNull. + @Attribute(value = "birthDay", manual = true) @From + private java.util.Date birthDayFrom; + } + + @Test + void manualField_rejectsRangeAnnotations() { + BadManualFilter bad = BadManualFilter.builder().build(); + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> dao.count(bad)); + assertTrue(ex.getMessage().contains("manual=true"), + "error message should mention manual=true; got: " + ex.getMessage()); + } + + @Test + void criteriaHookCanMutateQuery() { + // Validate the hook can run cq.* operations against the live query. Applying + // distinct on a SELECT-entity query with unique IDs is a no-op result-wise + // but a valid Criteria mutation that exercises the hook end-to-end. + HookDao d = new HookDao(em) { + @Override + public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + cq.distinct(true); + } + }; + List people = d.filter(PersonFilter.builder().build()); + assertEquals(4, people.size()); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + void criteriaHookCannotReplaceSelection() { + HookDao d = new HookDao(em) { + @Override + public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + ((CriteriaQuery) cq).select(root.get("name")); + } + }; + PersonFilter f = PersonFilter.builder().build(); + + assertThrows(IllegalStateException.class, () -> d.filter(f)); + assertThrows(IllegalStateException.class, () -> d.filterWithSingleResult(f)); + assertThrows(IllegalStateException.class, () -> d.count(f)); + } + + @Test + void criteriaHookCannotGroupCountQuery() { + HookDao d = new HookDao(em) { + @Override + public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + cq.groupBy(root.get("name")); + } + }; + assertThrows(IllegalStateException.class, () -> d.count(PersonFilter.builder().build())); + } + + @Test + void distinctFromCriteriaHook_countsDistinctEntities() { + // A second root yields one row per (person, city) pair; distinct collapses + // them back to the four people in both the list and the count query. + EntityManager tx = emf.createEntityManager(); + tx.getTransaction().begin(); + for (String name : new String[] {"Rome", "Paris"}) { + City city = new City(); + city.setName(name); + tx.persist(city); + } + tx.getTransaction().commit(); + tx.close(); + + HookDao d = new HookDao(em) { + @Override + public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + cq.from(City.class); + cq.distinct(true); + } + }; + PersonFilter f = PersonFilter.builder().build(); + + assertEquals(4, d.filter(f).size()); + assertEquals(4, d.count(f)); + } +} diff --git a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterPipelineTest.java b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterPipelineTest.java new file mode 100644 index 0000000..9614dde --- /dev/null +++ b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/BaseFilterPipelineTest.java @@ -0,0 +1,388 @@ +/*- + * #%L + * Commons Backend - Data Access Layer Implementations + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.backendcore.dao.jpa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.Persistence; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.flowingcode.backendcore.model.filter.Attribute; +import com.flowingcode.backendcore.model.filter.BaseFilter; +import com.flowingcode.backendcore.model.filter.From; +import com.flowingcode.backendcore.model.filter.In; +import com.flowingcode.backendcore.model.filter.Like; +import com.flowingcode.backendcore.model.filter.Or; +import com.flowingcode.backendcore.model.filter.To; +import com.flowingcode.backendcore.model.filter.WhenNull; +import com.flowingcode.backendcore.model.impl.City; +import com.flowingcode.backendcore.model.impl.Person; + +import lombok.Setter; +import lombok.experimental.Accessors; + +/** End-to-end coverage of the annotation pipeline against an in-memory database. */ +class BaseFilterPipelineTest { + + static class PersonDao implements JpaDaoSupport { + + final EntityManager em; + final AtomicInteger rootJoins = new AtomicInteger(-1); + + PersonDao(EntityManager em) { + this.em = em; + } + + @Override + public EntityManager getEntityManager() { + return em; + } + + @Override + public void customizeCriteria(BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, + Root root) { + rootJoins.set(root.getJoins().size()); + } + } + + private EntityManagerFactory emf; + private EntityManager em; + private PersonDao dao; + + @BeforeEach + void setUp() { + emf = Persistence.createEntityManagerFactory("person"); + EntityManager tx = emf.createEntityManager(); + tx.getTransaction().begin(); + City laPlata = new City(); + laPlata.setName("La Plata"); + laPlata.setPopulation(700_000); + tx.persist(laPlata); + City unnamed = new City(); + tx.persist(unnamed); + persist(tx, "John", "Doe", laPlata); + persist(tx, "Jane", "Doe", unnamed); + persist(tx, "Alice", "Smith", null); + persist(tx, "johnny", "smith", null); + persist(tx, "Ann_B", "Roe", null); + persist(tx, "AnnXB", "Roe", null); + tx.getTransaction().commit(); + tx.close(); + + em = emf.createEntityManager(); + dao = new PersonDao(em); + } + + private static void persist(EntityManager tx, String name, String lastName, City city) { + Person p = new Person(); + p.setName(name); + p.setLastName(lastName); + p.setCity(city); + tx.persist(p); + } + + @AfterEach + void tearDown() { + if (em != null && em.isOpen()) { + em.close(); + } + if (emf != null && emf.isOpen()) { + emf.close(); + } + } + + /** Asserts the people matched by {@code filter}, and that {@code count()} agrees. */ + private void assertMatches(BaseFilter filter, String... expectedNames) { + List actual = dao.filter(filter).stream() + .map(Person::getName) + .sorted() + .collect(Collectors.toList()); + assertEquals(Stream.of(expectedNames).sorted().collect(Collectors.toList()), actual); + assertEquals(expectedNames.length, dao.count(filter), "count() must agree with filter()"); + } + + private static final String[] EVERYONE = {"John", "Jane", "Alice", "johnny", "Ann_B", "AnnXB"}; + + // ----- @Like ----- + + @Setter + @Accessors(chain = true) + static class LikeFilter extends BaseFilter { + @Attribute("name") @Like + private String nameContains; + + @Attribute("lastName") @Like(ignoreCase = true, match = Like.Match.STARTS_WITH) + private String lastNameStartsIgnoringCase; + + @Attribute("city.name") @Like(match = Like.Match.ENDS_WITH) + private String cityEnds; + } + + @Setter + @Accessors(chain = true) + static class RawLikeFilter extends BaseFilter { + @Attribute("name") @Like(match = Like.Match.RAW) + private String namePattern; + + @Attribute("lastName") @Like(match = Like.Match.STARTS_WITH) + private String lastNameStarts; + } + + @Test + void like_containsByDefault_andIsCaseSensitive() { + assertMatches(new LikeFilter().setNameContains("Joh"), "John"); + } + + @Test + void like_ignoreCase_lowerCasesBothSides() { + assertMatches(new LikeFilter().setLastNameStartsIgnoringCase("SMI"), "Alice", "johnny"); + assertMatches(new RawLikeFilter().setLastNameStarts("Smi"), "Alice"); + } + + @Test + void like_endsWith_onNestedPath() { + assertMatches(new LikeFilter().setCityEnds("Plata"), "John"); + } + + @Test + void like_escapesWildcardsInTheValue() { + // Unescaped, "_" would also match "AnnXB" and "%" would match everyone. + assertMatches(new LikeFilter().setNameContains("n_B"), "Ann_B"); + assertMatches(new LikeFilter().setNameContains("%")); + } + + @Test + void like_raw_keepsWildcards() { + assertMatches(new RawLikeFilter().setNamePattern("Ann_B"), "Ann_B", "AnnXB"); + } + + @Test + void like_nullValue_isSkipped() { + assertMatches(new LikeFilter(), EVERYONE); + } + + // ----- @In ----- + + @Setter + @Accessors(chain = true) + static class InFilter extends BaseFilter { + @Attribute("lastName") @In + private List lastNames; + + @Attribute("name") @In(whenEmpty = In.EmptyPolicy.MATCH_NONE) + private List names; + } + + @Test + void in_matchesAnyElement() { + assertMatches(new InFilter().setLastNames(List.of("Doe", "Roe")), "John", "Jane", "Ann_B", "AnnXB"); + } + + @Test + void in_emptyCollection_isSkippedByDefault() { + assertMatches(new InFilter().setLastNames(List.of()), EVERYONE); + } + + @Test + void in_emptyCollection_matchesNoneWhenConfigured() { + assertMatches(new InFilter().setNames(List.of())); + } + + @Test + void in_nullValue_isSkipped() { + assertMatches(new InFilter(), EVERYONE); + } + + // ----- @Or ----- + + @Setter + @Accessors(chain = true) + static class OrFilter extends BaseFilter { + @Attribute("city.name") @Or + private String cityName; + + @Attribute("name") @Or + private String name; + + @Attribute("lastName") + private String lastName; + } + + @Test + void or_matchesThroughAnyDisjunct_includingRowsWithANullAssociation() { + // Alice has no city: an inner join on city would drop her before the OR runs. + assertMatches(new OrFilter().setCityName("La Plata").setName("Alice"), "John", "Alice"); + } + + @Test + void or_disjunctionIsAndedWithTheOtherFields() { + assertMatches(new OrFilter().setCityName("La Plata").setName("Alice").setLastName("Smith"), + "Alice"); + } + + @Test + void or_withoutAnyValue_doesNotRestrict() { + // An empty disjunction is false; it must be omitted rather than match nothing. + assertMatches(new OrFilter(), EVERYONE); + } + + @Test + void or_singleValue_actsAsThatPredicate() { + assertMatches(new OrFilter().setName("Alice"), "Alice"); + } + + // ----- join types ----- + + @Setter + @Accessors(chain = true) + static class CityNameFilter extends BaseFilter { + @Attribute("city.name") @WhenNull(WhenNull.Policy.IS_NULL) + private String cityName; + } + + @Test + void isNull_onNestedPath_matchesNullAssociations() { + // Jane's city has no name; the other four have no city at all. + assertMatches(new CityNameFilter(), "Jane", "Alice", "johnny", "Ann_B", "AnnXB"); + } + + @Test + void isNullPolicy_withValue_isPlainEquality() { + assertMatches(new CityNameFilter().setCityName("La Plata"), "John"); + } + + @Test + void orderingByNestedPath_doesNotDropRows() { + OrFilter filter = new OrFilter(); + filter.addOrder("city.name"); + assertMatches(filter, EVERYONE); + } + + @Setter + @Accessors(chain = true) + static class SharedJoinFilter extends BaseFilter { + @Attribute("city.name") + private String cityName; + + @Attribute("city.population") @Or + private Integer population; + + @Attribute("name") @Or + private String name; + } + + @Test + void associationUsedByAndAndOrFields_isJoinedOnce() { + SharedJoinFilter filter = new SharedJoinFilter() + .setCityName("La Plata").setPopulation(700_000).setName("Nobody"); + filter.addOrder("city.name"); + assertMatches(filter, "John"); + assertEquals(1, dao.rootJoins.get(), "city must be joined once"); + } + + // ----- field discovery ----- + + static class NameFilter extends BaseFilter { + @Attribute("name") + private String name; + } + + static class ShadowingFilter extends NameFilter { + @Attribute("lastName") + private String name; + } + + @Test + void annotatedFieldShadowedBySubclassField_isStillReadable() { + assertMatches(new ShadowingFilter(), EVERYONE); + } + + // ----- configuration errors ----- + + static class LikeOnNonString extends BaseFilter { + @Attribute("city.population") @Like + private Integer population; + } + + static class InOnNonCollection extends BaseFilter { + @Attribute("name") @In + private String name; + } + + static class LikeWithFrom extends BaseFilter { + @Attribute("name") @Like @From + private String name; + } + + static class InWithWhenNull extends BaseFilter { + @Attribute("lastName") @In @WhenNull(WhenNull.Policy.IS_NULL) + private List lastNames; + } + + static class OrWithoutAttribute extends BaseFilter { + @Or + private String name; + } + + static class OrOnManualField extends BaseFilter { + @Attribute(value = "name", manual = true) @Or + private String name; + } + + static class OrOnOneSideOfRange extends BaseFilter { + @Attribute("city.population") @From @Or + private Integer populationFrom; + + @Attribute("city.population") @To + private Integer populationTo; + } + + private void assertRejected(BaseFilter filter, String expectedInMessage) { + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> dao.count(filter)); + assertTrue(ex.getMessage().contains(expectedInMessage), + "message should mention " + expectedInMessage + "; got: " + ex.getMessage()); + } + + @Test + void invalidConfigurations_areRejected() { + assertRejected(new LikeOnNonString(), "must be String"); + assertRejected(new InOnNonCollection(), "must be a Collection"); + assertRejected(new LikeWithFrom(), "cannot combine @From and @Like"); + assertRejected(new InWithWhenNull(), "cannot combine @WhenNull with @In"); + assertRejected(new OrWithoutAttribute(), "has @Or but no @Attribute"); + assertRejected(new OrOnManualField(), "manual=true"); + assertRejected(new OrOnOneSideOfRange(), "must either both carry @Or or neither"); + } +} diff --git a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java index d624c5e..abc2e5c 100644 --- a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java +++ b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java @@ -45,6 +45,7 @@ import com.flowingcode.backendcore.model.impl.State; import com.github.javafaker.Faker; +@SuppressWarnings("deprecation") class JpaDaoSupportTest { private PersonCrudDaoImpl dao; diff --git a/backend-core-data/src/main/java/com/flowingcode/backendcore/dao/QueryDao.java b/backend-core-data/src/main/java/com/flowingcode/backendcore/dao/QueryDao.java index 5801439..054fd10 100644 --- a/backend-core-data/src/main/java/com/flowingcode/backendcore/dao/QueryDao.java +++ b/backend-core-data/src/main/java/com/flowingcode/backendcore/dao/QueryDao.java @@ -23,6 +23,7 @@ import java.util.Optional; import com.flowingcode.backendcore.model.QuerySpec; +import com.flowingcode.backendcore.model.filter.BaseFilter; public interface QueryDao { @@ -30,10 +31,78 @@ public interface QueryDao { List findAll(); + /** + * @deprecated Use {@link #filter(BaseFilter)} with a {@link BaseFilter} + * subclass. + */ + @Deprecated(since = "1.2.0", forRemoval = false) List filter(QuerySpec filter); + /** + * @deprecated Use {@link #filterWithSingleResult(BaseFilter)} with a + * {@link BaseFilter} subclass. + */ + @Deprecated(since = "1.2.0", forRemoval = false) Optional filterWithSingleResult(QuerySpec filter); + /** + * @deprecated Use {@link #count(BaseFilter)} with a {@link BaseFilter} + * subclass. + */ + @Deprecated(since = "1.2.0", forRemoval = false) long count(QuerySpec filter); + /** + * Returns the entities that match the given {@code filter}. Annotations on + * the filter fields drive the generated JPA Criteria. + * + *

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

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

The default implementation throws {@link UnsupportedOperationException} + * so that existing {@code QueryDao} implementors keep compiling when + * upgrading. DAOs built on {@code JpaDaoSupport} / + * {@code ConversionJpaDaoSupport} override it with the actual behavior; + * standalone implementors should override it too. + * + * @throws UnsupportedOperationException if the implementation does not + * support {@link BaseFilter}-based queries + */ + default long count(BaseFilter filter) { + throw new UnsupportedOperationException( + "count(BaseFilter) is not implemented by this QueryDao"); + } + } diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java index 5ae1d30..5b88fc2 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java @@ -22,6 +22,11 @@ import com.flowingcode.backendcore.model.constraints.DisjunctionConstraint; import com.flowingcode.backendcore.model.constraints.NegatedConstraint; +/** + * @deprecated Part of the legacy {@link QuerySpec}-based filter API. New code + * should use {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ +@Deprecated(since = "1.2.0", forRemoval = false) public interface Constraint { default Constraint not() { diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintBuilder.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintBuilder.java index a61780e..d39357d 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintBuilder.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintBuilder.java @@ -36,6 +36,11 @@ import lombok.NonNull; import lombok.RequiredArgsConstructor; +/** + * @deprecated Part of the legacy {@link QuerySpec}-based filter API. New code + * should use {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ +@Deprecated(since = "1.2.0", forRemoval = false) @RequiredArgsConstructor(access = AccessLevel.PROTECTED) public class ConstraintBuilder { diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java index 8c2213f..aa1456f 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java @@ -37,8 +37,11 @@ * and one or more of the {@code transform*Constraint} methods for providing the actual representations for the underlying database technology. * * @param The type of the implementation-specific representation of the constraint. - * @author Javier Godoy / Flowing Code + * @author Javier Godoy / Flowing Code + * @deprecated Part of the legacy {@link QuerySpec}-based filter API. New code + * should use {@link com.flowingcode.backendcore.model.filter.BaseFilter}. */ +@Deprecated(since = "1.2.0", forRemoval = false) public abstract class ConstraintTransformer implements Function { /**Return an implementation-specific representation of the constraint. diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformerException.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformerException.java index 3d80407..bcb4ce3 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformerException.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformerException.java @@ -21,9 +21,12 @@ /** * Thrown by {@link ConstraintTransformer} when the {@link QuerySpec} contains an unsupported {@link Constraint}. - * + * * @author Javier Godoy / Flowing Code + * @deprecated Part of the legacy {@link QuerySpec}-based filter API. New code + * should use {@link com.flowingcode.backendcore.model.filter.BaseFilter}. */ +@Deprecated(since = "1.2.0", forRemoval = false) public class ConstraintTransformerException extends RuntimeException { private static final long serialVersionUID = 1L; diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/QuerySpec.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/QuerySpec.java index dc63cfe..329fdd9 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/QuerySpec.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/QuerySpec.java @@ -30,6 +30,14 @@ import lombok.Setter; import lombok.experimental.Accessors; +/** + * @deprecated Use {@link com.flowingcode.backendcore.model.filter.BaseFilter} + * and the annotation-driven filter API instead. This type, the + * {@link Constraint} hierarchy and the {@link ConstraintTransformer} + * support are retained for backwards compatibility but slated for + * removal in a future major version. + */ +@Deprecated(since = "1.2.0", forRemoval = false) @Accessors(chain=true) public class QuerySpec { diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeBetweenConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeBetweenConstraint.java index 5096e51..d3512aa 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeBetweenConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeBetweenConstraint.java @@ -26,6 +26,12 @@ import lombok.NonNull; import lombok.experimental.FieldDefaults; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ +@Deprecated(since = "1.2.0", forRemoval = false) @Getter @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class AttributeBetweenConstraint implements AttributeConstraint { diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeConstraint.java index a47378b..49d6488 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeConstraint.java @@ -21,6 +21,12 @@ import com.flowingcode.backendcore.model.Constraint; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ +@Deprecated(since = "1.2.0", forRemoval = false) public interface AttributeConstraint extends Constraint { String getAttribute(); diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeILikeConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeILikeConstraint.java index ec2a1cd..019e721 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeILikeConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeILikeConstraint.java @@ -25,6 +25,12 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ +@Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeInConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeInConstraint.java index aed5f21..1d693bd 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeInConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeInConstraint.java @@ -27,6 +27,12 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ +@Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeLikeConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeLikeConstraint.java index a343c19..fb0a168 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeLikeConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeLikeConstraint.java @@ -25,6 +25,12 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ +@Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeNullConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeNullConstraint.java index 99b355d..c9127d2 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeNullConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeNullConstraint.java @@ -6,6 +6,12 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ +@Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeRelationalConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeRelationalConstraint.java index da3292a..74b17f9 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeRelationalConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/AttributeRelationalConstraint.java @@ -25,6 +25,12 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ +@Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java index 7cc1859..825fc7d 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java @@ -28,7 +28,14 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; -/** A constraint that is satisfied when any of its member constraints is satisfied (logical OR). */ +/** + * A constraint that is satisfied when any of its member constraints is satisfied (logical OR). + * + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ +@Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor(access = AccessLevel.PRIVATE) @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/NegatedConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/NegatedConstraint.java index 3630eda..87f0cc0 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/NegatedConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/NegatedConstraint.java @@ -27,6 +27,12 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ +@Deprecated(since = "1.2.0", forRemoval = false) @Getter @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/RelationalConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/RelationalConstraint.java index 6663723..e9b46e1 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/RelationalConstraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/RelationalConstraint.java @@ -21,6 +21,12 @@ import com.flowingcode.backendcore.model.Constraint; +/** + * @deprecated Part of the legacy {@link com.flowingcode.backendcore.model.QuerySpec}-based + * filter API. New code should use + * {@link com.flowingcode.backendcore.model.filter.BaseFilter}. + */ +@Deprecated(since = "1.2.0", forRemoval = false) public interface RelationalConstraint extends Constraint { String EQ = "="; diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Attribute.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Attribute.java new file mode 100644 index 0000000..2383889 --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Attribute.java @@ -0,0 +1,80 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Maps a filter field to an entity attribute. + * + *

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

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

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

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

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

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

Pagination validators ({@link #setFirstResult(Integer)} / + * {@link #setMaxResult(Integer)}) reject negative values from the POJO path, + * and the builder's {@code firstResult(...)} / {@code maxResult(...)} methods + * apply the same checks before {@code build()} returns, so both styles enforce + * the same invariants. + * + *

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

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

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

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

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

Must be paired with {@link Attribute}, and cannot combine with + * {@link From}, {@link To}, {@link Like} or {@link WhenNull}. + * + * @see Attribute + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface In { + + /** How an empty collection is handled. Defaults to {@link EmptyPolicy#SKIP}. */ + EmptyPolicy whenEmpty() default EmptyPolicy.SKIP; + + /** Policy applied to an empty collection. */ + enum EmptyPolicy { + + /** Emit no predicate, as for a {@code null} field value. */ + SKIP, + + /** Emit a predicate that matches no rows. */ + MATCH_NONE + } +} diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Like.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Like.java new file mode 100644 index 0000000..3265212 --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Like.java @@ -0,0 +1,72 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Matches the {@link Attribute} of a {@code String} filter field with a + * {@code LIKE} predicate instead of equality. + * + *

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

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

A {@code null} field value contributes no predicate. Must be paired with + * {@link Attribute}, and cannot combine with {@link From}, {@link To}, + * {@link In} or {@link WhenNull}. + * + * @see Attribute + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Like { + + /** When {@code true}, the comparison ignores case. Defaults to {@code false}. */ + boolean ignoreCase() default false; + + /** Where the field value must occur in the attribute. Defaults to {@link Match#CONTAINS}. */ + Match match() default Match.CONTAINS; + + /** Position of the field value within the matched attribute. */ + enum Match { + + /** The field value is used as the pattern verbatim, wildcards included. */ + RAW, + + /** The attribute contains the field value. */ + CONTAINS, + + /** The attribute starts with the field value. */ + STARTS_WITH, + + /** The attribute ends with the field value. */ + ENDS_WITH + } +} diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Or.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Or.java new file mode 100644 index 0000000..b1a165a --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/Or.java @@ -0,0 +1,59 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Places the predicate of the annotated filter field in the filter's + * disjunction instead of its conjunction. + * + *

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

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

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

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

Must be paired with {@link Attribute} and cannot be used on a + * {@link Attribute#manual() manual} field. On a {@link From} / {@link To} pair, + * either both fields or neither carry {@code @Or}. Groups of disjunctions, or + * matching one value against several attributes, are built in a DAO hook. + * + * @see Attribute + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Or { +} diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/To.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/To.java new file mode 100644 index 0000000..f3d25d2 --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/filter/To.java @@ -0,0 +1,56 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a filter field as the upper bound of a range comparison on its + * {@link Attribute}. + * + *

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

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

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

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

{@code @WhenNull} requires {@link Attribute} on the same field and is not + * allowed alongside {@link From}, {@link To}, {@link Like} or {@link In}, since + * none of those have sensible {@code IS_NULL} semantics. + * + * @see Attribute + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface WhenNull { + + Policy value(); + + /** Policy applied to a null filter field. */ + enum Policy { + + /** Emit no predicate for this field when its value is {@code null}. */ + SKIP, + + /** Emit an {@code IS NULL} predicate when the field value is {@code null}. */ + IS_NULL + } +} diff --git a/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java new file mode 100644 index 0000000..6aeafc7 --- /dev/null +++ b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/filter/BaseFilterTest.java @@ -0,0 +1,214 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.backendcore.model.filter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.LocalDate; +import java.time.Month; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.SuperBuilder; + +import org.junit.jupiter.api.Test; + +class BaseFilterTest { + + @Getter + @Setter + @Accessors(chain = true) + @NoArgsConstructor + @SuperBuilder(toBuilder = true) + static class SampleFilter extends BaseFilter { + + @Attribute("name") + private String name; + + @Attribute("birthDate") @From + private LocalDate birthDateFrom; + + @Attribute("birthDate") @To(inclusive = false) + private LocalDate birthDateTo; + } + + @Test + void pojo_chainableSetters_carryAllState() { + SampleFilter f = new SampleFilter() + .setName("Ada") + .setBirthDateFrom(LocalDate.of(1990, Month.JANUARY, 1)) + .setBirthDateTo(LocalDate.of(2000, Month.JANUARY, 1)); + f.addOrder("name").setMaxResult(50).setFirstResult(10); + + assertEquals("Ada", f.getName()); + assertEquals(LocalDate.of(1990, Month.JANUARY, 1), f.getBirthDateFrom()); + assertEquals(LocalDate.of(2000, Month.JANUARY, 1), f.getBirthDateTo()); + assertEquals(50, f.getMaxResult()); + assertEquals(10, f.getFirstResult()); + assertIterableEquals(Arrays.asList("name"), f.getOrders().keySet()); + assertEquals(BaseFilter.Order.ASC, f.getOrders().get("name")); + } + + @Test + void builder_setsState() { + SampleFilter f = SampleFilter.builder() + .name("Ada") + .birthDateFrom(LocalDate.of(1990, Month.JANUARY, 1)) + .addOrder("name", BaseFilter.Order.DESC) + .addOrder("birthDate", BaseFilter.Order.ASC) + .firstResult(0) + .maxResult(25) + .build(); + + assertEquals("Ada", f.getName()); + assertEquals(LocalDate.of(1990, Month.JANUARY, 1), f.getBirthDateFrom()); + assertNull(f.getBirthDateTo()); + assertEquals(0, f.getFirstResult()); + assertEquals(25, f.getMaxResult()); + assertIterableEquals(Arrays.asList("name", "birthDate"), f.getOrders().keySet()); + assertEquals(BaseFilter.Order.DESC, f.getOrders().get("name")); + assertEquals(BaseFilter.Order.ASC, f.getOrders().get("birthDate")); + } + + @Test + void orders_preserveInsertionOrder() { + SampleFilter f = new SampleFilter(); + f.addOrder("c").addOrder("a").addOrder("b", BaseFilter.Order.DESC); + + assertIterableEquals(Arrays.asList("c", "a", "b"), f.getOrders().keySet()); + assertEquals(BaseFilter.Order.ASC, f.getOrders().get("c")); + assertEquals(BaseFilter.Order.DESC, f.getOrders().get("b")); + } + + @Test + void emptyFilter_hasEmptyOrders_andNullPaging() { + SampleFilter f = new SampleFilter(); + assertEquals(0, f.getOrders().size()); + assertNull(f.getFirstResult()); + assertNull(f.getMaxResult()); + } + + @Test + void firstResult_rejectsNegativeFromPojo() { + SampleFilter f = new SampleFilter(); + assertThrows(IllegalArgumentException.class, () -> f.setFirstResult(-1)); + } + + @Test + void maxResult_rejectsNegativeFromPojo() { + SampleFilter f = new SampleFilter(); + assertThrows(IllegalArgumentException.class, () -> f.setMaxResult(-1)); + } + + @Test + void firstResult_rejectsNegativeFromBuilder() { + assertThrows(IllegalArgumentException.class, + () -> SampleFilter.builder().firstResult(-1).build()); + } + + @Test + void maxResult_rejectsNegativeFromBuilder() { + assertThrows(IllegalArgumentException.class, + () -> SampleFilter.builder().maxResult(-1).build()); + } + + @Test + void toBuilder_producesIndependentCopy() { + SampleFilter original = SampleFilter.builder().name("Ada").maxResult(50).build(); + SampleFilter tweaked = original.toBuilder().maxResult(10).build(); + + assertEquals(50, original.getMaxResult()); + assertEquals(10, tweaked.getMaxResult()); + assertEquals("Ada", tweaked.getName()); + assertNotSame(original, tweaked); + } + + @Test + void toBuilder_ordersAreIsolatedFromOriginal() { + SampleFilter original = SampleFilter.builder().addOrder("a").build(); + SampleFilter tweaked = original.toBuilder() + .addOrder("b", BaseFilter.Order.DESC) + .build(); + + assertIterableEquals(Arrays.asList("a"), original.getOrders().keySet(), + "adding an order on the clone builder must not mutate the original"); + assertIterableEquals(Arrays.asList("a", "b"), tweaked.getOrders().keySet()); + assertEquals(BaseFilter.Order.DESC, tweaked.getOrders().get("b")); + } + + @Test + void toBuilder_instanceAddOrderOnCopy_doesNotLeakIntoOriginal() { + SampleFilter original = SampleFilter.builder().name("Ada").addOrder("name").build(); + SampleFilter copy = original.toBuilder().build(); + copy.addOrder("birthDate", BaseFilter.Order.DESC); + + assertIterableEquals(Arrays.asList("name"), original.getOrders().keySet()); + assertIterableEquals(Arrays.asList("name", "birthDate"), copy.getOrders().keySet()); + } + + @Test + void builderOrdersSetter_copiesCallerMap() { + Map caller = new LinkedHashMap<>(Map.of("name", BaseFilter.Order.ASC)); + SampleFilter f = SampleFilter.builder().orders(caller).build(); + caller.put("sneaky", BaseFilter.Order.DESC); + + assertIterableEquals(Arrays.asList("name"), f.getOrders().keySet()); + } + + @Test + void pojoOrdersSetter_copiesCallerMap() { + Map caller = new LinkedHashMap<>(Map.of("name", BaseFilter.Order.ASC)); + SampleFilter f = new SampleFilter(); + f.setOrders(caller); + caller.put("sneaky", BaseFilter.Order.DESC); + + assertIterableEquals(Arrays.asList("name"), f.getOrders().keySet()); + } + + @Test + void getOrders_isUnmodifiable_whetherSetOrNot() { + SampleFilter unset = new SampleFilter(); + SampleFilter set = SampleFilter.builder().addOrder("name").build(); + + assertThrows(UnsupportedOperationException.class, + () -> unset.getOrders().put("injected", BaseFilter.Order.DESC)); + assertThrows(UnsupportedOperationException.class, + () -> set.getOrders().put("injected", BaseFilter.Order.DESC)); + } + + @Test + void filtersBuiltFromSameBuilder_doNotShareOrders() { + SampleFilter.SampleFilterBuilder builder = SampleFilter.builder().addOrder("name"); + SampleFilter first = builder.build(); + SampleFilter second = builder.build(); + first.addOrder("birthDate"); + + assertIterableEquals(Arrays.asList("name"), second.getOrders().keySet()); + } +} diff --git a/specs/base-filter.md b/specs/base-filter.md new file mode 100644 index 0000000..945cfd8 --- /dev/null +++ b/specs/base-filter.md @@ -0,0 +1,389 @@ +# BaseFilter — Annotation-driven JPA Criteria filtering + +Status: Draft +Branch: `base-filter` +Author: Leo +Module: `backend-core-model` (abstraction + annotations), `backend-core-data-impl` (JPA processor + DAO hooks) + +## 1. Motivation + +The current filtering abstraction (`QuerySpec` + `Constraint` hierarchy + `ConstraintTransformer`) is imperative and verbose: callers must construct `Constraint` objects, register them on a `QuerySpec`, and let `ConstraintTransformerJpaImpl` turn them into JPA `Predicate`s. It works, but every new query surface in a downstream project ends up reimplementing the same boilerplate. + +`BaseFilter` replaces that with a *declarative* model: application developers define a POJO whose fields describe the filterable criteria, annotate the fields with lightweight metadata, and pass the populated instance to the DAO. The DAO base implementation reflects on the filter, builds a JPA `CriteriaQuery`, and runs it. The existing `QuerySpec`-based DAO methods — and the supporting `QuerySpec` / `Constraint` / `ConstraintTransformer` types — are deprecated as part of this change but **not** marked for removal; downstream consumers can migrate at their own pace. + +This spec covers the abstract class, the initial annotation set, the criteria-building pipeline in the DAO base, and the extensibility surface. + +## 2. Goals and non-goals + +### Goals + +- A single abstract `BaseFilter` class that downstream filters extend. +- Filter fields describe *what entity attribute they map to* and *what semantic role* the field plays (single value, lower bound, upper bound). The operator is inferred from the metadata, not declared per field. +- Built-in support for sort orders, first result, and max result, mirroring `QuerySpec`'s pagination semantics. +- Two equally supported construction styles on every filter: a **POJO style** (no-arg constructor + chainable setters) and a **Builder style** (typed, inheritance-aware builder, populated by `Filter.builder()...build()`). +- A JPA `CriteriaQuery` builder living in the DAO base, with well-defined hook methods so subclasses can add predicates/joins/projections that don't fit the declarative model. +- Coexist with `QuerySpec`-based DAO methods during a deprecation window. + +### Non-goals (v1) + +- Operator annotations beyond `@Like` and `@In` (e.g. `@NotEqual`, `@Not`, a standalone `@IsNotNull`). Relational comparisons are covered by `@From` / `@To`. +- Boolean composition beyond the single `@Or` disjunction: no named or nested groups. A filter needing them builds the predicate in a DAO hook. +- Matching one filter value against several attributes (e.g. a search box over name and email). That is custom logic, built in a DAO hook. +- Pluggable annotation processors (registering third-party annotations). Extensibility is via DAO hook methods only. +- Reusing or wrapping `QuerySpec`, `Constraint`, or `ConstraintTransformer`. The new pipeline is independent. +- Replacing `findById` / `findAll` / `save` / `update` / `delete`. Only the filter-shaped methods are touched. + +## 3. Public API surface + +### 3.1 `BaseFilter` (in `backend-core-model`) + +```java +package com.flowingcode.backendcore.model.filter; + +@Getter +@Setter +@Accessors(chain = true) +@SuperBuilder(toBuilder = true) +@NoArgsConstructor +public abstract class BaseFilter { + + public enum Order { ASC, DESC } + + private Map orders; // insertion-ordered; getOrders() never returns null + + private Integer firstResult; // null or >= 0 + private Integer maxResult; // null or >= 0 + + // Convenience mutators kept on the POJO surface + public BaseFilter addOrder(String attribute); // defaults to ASC + public BaseFilter addOrder(String attribute, Order direction); + public BaseFilter setOrders(Map orders); // copies the argument + public Map getOrders(); // unmodifiable view +} +``` + +Two construction styles are supported, and both populate the same underlying state: + +```java +// POJO style — no-arg ctor + chainable setters +PersonFilter f = new PersonFilter() + .setName("Ada") + .setBirthDateFrom(LocalDate.of(1990, 1, 1)); +f.addOrder("name"); +f.setMaxResult(50); + +// Builder style — Lombok @SuperBuilder +PersonFilter f = PersonFilter.builder() + .name("Ada") + .birthDateFrom(LocalDate.of(1990, 1, 1)) + .addOrder("name", BaseFilter.Order.ASC) + .maxResult(50) + .build(); +``` + +Notes on the dual-style design: + +- Lombok's `@SuperBuilder` is the simplest way to produce a typed builder that correctly composes with inheritance — subclasses opt in by adding `@SuperBuilder` and their builder inherits the base fields automatically. The existing codebase already relies on Lombok (`@Getter`/`@Setter`/`@Accessors(chain=true)` on `QuerySpec`), so this matches established conventions. +- The chainable POJO setters (via `@Accessors(chain = true)`) are kept so callers who already work with mutable filters — including code wired through frameworks that prefer no-arg construction + property binding (e.g. JSON deserialization, query-param binding) — don't pay any extra ceremony. +- The inner `BaseFilterBuilder` is declared explicitly so that paging validation, a per-entry `.addOrder(...)` adder and a copying `.orders(Map)` setter are part of the builder API; Lombok fills in the rest (fields, `self()`, `build()`, the subclass plumbing). +- The `orders` map is never shared. Maps passed to `setOrders(...)` or the builder's `orders(...)` are copied, both `addOrder(...)` variants copy before writing, and `getOrders()` returns an unmodifiable view — so a filter built through `toBuilder()`, or built twice from the same builder, is independent of the others. POJO-style `addOrder(...)` still works after `build()`. +- `toBuilder = true` lets callers rebuild a tweaked copy of an existing filter (`f.toBuilder().maxResult(10).build()`), useful for paging. +- The sort-order attribute string follows the same dotted-path convention as `@Attribute` (see §3.2) so callers can sort across joins. +- Pagination validators throw `IllegalArgumentException` on negative values, matching `QuerySpec`. Validation lives in both the POJO setters and the corresponding builder methods (`firstResult(...)` / `maxResult(...)` on `BaseFilterBuilder`), so both construction styles enforce the same invariants. + +### 3.2 Annotations (in `backend-core-model`, package `com.flowingcode.backendcore.model.filter`) + +All annotations are field-level (`@Target(ElementType.FIELD)`) and retained at runtime. `@Attribute` maps the field to an entity attribute; at most one *operator* annotation (`@From`, `@To`, `@Like`, `@In`) chooses the predicate, which defaults to equality; `@WhenNull` and `@Or` adjust how the predicate is produced and combined. + +#### `@Attribute` + +```java +@Retention(RUNTIME) @Target(FIELD) +public @interface Attribute { + /** Dotted attribute path on the target entity, e.g. "city.state.name". */ + String value(); + + /** When true, predicate building is the hook's responsibility. */ + boolean manual() default false; +} +``` + +- Maps a filter field to one entity attribute via a dotted path. Path traversal follows the same `split("\\.")` + auto-join convention used today in `ConstraintTransformerJpaImpl`; see §4.1 for the join types. +- A filter field with `@Attribute` and no role annotation defaults to **equality** (`cb.equal(...)`). +- `manual = true` is the escape hatch for predicates that don't fit the declarative model. The processor emits no predicate for the field; the DAO's `customizePredicates` hook is responsible for the constraint, reading the value through the filter's accessor (§5.1). On a manual field the `value()` is informational — the processor never resolves it — but stays useful as documentation of which entity attribute the hook is expected to target. `manual = true` cannot combine with `@From`, `@To`, `@Like`, `@In`, `@WhenNull` or `@Or`, since none of those have meaning when the predicate is hand-built. + +#### `@From` and `@To` + +```java +@Retention(RUNTIME) @Target(FIELD) +public @interface From { + /** When true (default), the lower bound is inclusive (>=); when false, strict (>). */ + boolean inclusive() default true; +} + +@Retention(RUNTIME) @Target(FIELD) +public @interface To { + /** When true (default), the upper bound is inclusive (<=); when false, strict (<). */ + boolean inclusive() default true; +} +``` + +- `@From` marks the field as the **lower bound** of a range comparison on its `@Attribute`; `inclusive` controls whether the comparison is `>=` (default) or `>`. +- `@To` marks the field as the **upper bound**; `inclusive` controls `<=` (default) or `<`. +- Single-sided behavior: + - `@From` alone → `>=` or `>` depending on `inclusive`. + - `@To` alone → `<=` or `<` depending on `inclusive`. +- Paired behavior — when the same `@Attribute("x")` value appears on two fields, one with `@From` and the other with `@To`: + - **Both bounds inclusive and both values non-null** → emit a single `cb.between(...)` predicate (BETWEEN is inclusive on both sides in JPA/SQL). + - **Any bound exclusive, or mixed inclusivity** → fall back to two predicates ANDed together (`> lower AND < upper`, `>= lower AND < upper`, etc.). The BETWEEN optimization is dropped because JPA `between` cannot express exclusivity. + - **Only one side non-null** → emit the single available comparison using that side's `inclusive` setting; the other side contributes nothing. +- `@From` / `@To` without `@Attribute` is a configuration error and must fail fast at startup or on first use, with a clear message. + +#### `@WhenNull` + +```java +@Retention(RUNTIME) @Target(FIELD) +public @interface WhenNull { + Policy value(); + + enum Policy { SKIP, IS_NULL } +} +``` + +- Per-field override for null handling. Default is `SKIP` (no predicate emitted), so most filter fields don't need this annotation. +- `IS_NULL` makes a null field emit `cb.isNull(...)` against the resolved attribute path. The associations on the path are left-joined, so `@Attribute("city.name")` matches both a city with a null name and a null city. +- Only meaningful on plain equality fields that also carry `@Attribute`; the processor rejects `@WhenNull` alongside `@From`, `@To`, `@Like` or `@In`. + +#### `@Like` + +```java +@Retention(RUNTIME) @Target(FIELD) +public @interface Like { + boolean ignoreCase() default false; + Match match() default Match.CONTAINS; + + enum Match { RAW, CONTAINS, STARTS_WITH, ENDS_WITH } +} +``` + +- Matches a `String` field with `LIKE` instead of equality. A non-`String` field is a configuration error. +- `CONTAINS` (default), `STARTS_WITH` and `ENDS_WITH` wrap the value with `%` and escape the `%` / `_` wildcards and the `\` escape character in it, so the value always matches literally. `RAW` passes the value through as the pattern, wildcards included — the behavior of the legacy `ConstraintBuilder.like(...)`. +- `ignoreCase = true` compares `lower(attribute)` with the value lower-cased in `Locale.ROOT`; it replaces the legacy `iLike(...)`. +- A null value is skipped. + +#### `@In` + +```java +@Retention(RUNTIME) @Target(FIELD) +public @interface In { + EmptyPolicy whenEmpty() default EmptyPolicy.SKIP; + + enum EmptyPolicy { SKIP, MATCH_NONE } +} +``` + +- Matches a `Collection` field with `attribute IN (...)`. A non-`Collection` field is a configuration error. +- A null value is skipped. An empty collection is skipped by default, treating an empty selection as "no criterion" like `null`; `MATCH_NONE` emits a predicate that is always false instead. + +#### `@Or` + +```java +@Retention(RUNTIME) @Target(FIELD) +public @interface Or { } +``` + +- Places the field's predicate in the filter's single disjunction instead of its conjunction: `WHERE AND (<@Or fields>)`. +- Every `@Or` field of the filter, inherited ones included, joins the same disjunction. Fields that contribute no predicate (e.g. a null value) are left out; when none contributes, the disjunction is omitted — an empty disjunction would be false and match no rows. +- Associations on the path of an `@Or` field are left-joined, so a row with a null association can still match through another disjunct. +- Requires `@Attribute`, cannot be used on a `manual` field, and on a `@From` / `@To` pair must be on both fields or neither. Named or nested groups are out of scope (§2); they are built in a DAO hook. + +### 3.3 DAO surface changes (in `backend-core-data` and `backend-core-data-impl`) + +Add overloads to `QueryDao` and `ConversionJpaDaoSupport` that take a `BaseFilter`: + +```java +// backend-core-data, com.flowingcode.backendcore.dao.QueryDao +List filter(BaseFilter filter); +Optional filterWithSingleResult(BaseFilter filter); +long count(BaseFilter filter); +``` + +The existing `QuerySpec` overloads stay but are annotated `@Deprecated(forRemoval = false)` with javadoc pointing at `BaseFilter`. Removal is deferred to a future major version. + +`ConversionJpaDaoSupport` provides default implementations that delegate to a `BaseFilterJpaProcessor` (see §4) and convert results via the existing `convertFrom`. `JpaDaoSupport` inherits transparently. + +## 4. Criteria-building pipeline + +A new package-private (or `protected`-accessible) class lives alongside the existing `FilterProcesor`: + +```java +// backend-core-data-impl +class BaseFilterJpaProcessor, K extends Serializable> { ... } +``` + +For each call, the processor: + +1. **Reflects** the `BaseFilter` subclass to discover annotated fields. Reflection results (field handles, attribute paths, role classification, null policy) are **cached per filter class** in a static map to avoid re-walking the class on every query. +2. **Validates** the annotations at first encounter: every other annotation requires `@Attribute`; `manual` fields take no other annotation; at most one operator (`@From`, `@To`, `@Like`, `@In`) per field; `@WhenNull` only on equality fields; `@Like` requires a `String` field and `@In` a `Collection`; `@Or` is on both fields of a range pair or neither; no two fields share an attribute path unless they form a `@From`/`@To` pair. +3. **Builds predicates** by walking the cached field metadata: + - Single-attribute fields → equality. + - `@Like` → `LIKE`, `@In` → `IN`, as described in §3.2. + - `@From`-only / `@To`-only → comparison predicate honoring the field's `inclusive` flag (`>` / `>=` / `<` / `<=`). + - `@From` + `@To` on the same `@Attribute` path → `cb.between(...)` only when both values are non-null **and** both bounds are inclusive; otherwise two ANDed comparison predicates with each side's `inclusive` setting honored independently. When only one side is non-null, emit just that side's comparison. + - Null values are skipped unless `@WhenNull(IS_NULL)` is present. + - Predicates of `@Or` fields are combined with `OR`; the result, the other predicates and the predicates from the `customizePredicates` hook are combined with `AND`. +4. **Resolves attribute paths** with `AttributePathResolver`, a package-private utility modeled on the path/join helper of `ConstraintTransformerJpaImpl` (split on `.`, auto-join). It is new code rather than an extraction: the deprecated transformer is left untouched, and the new processor does not depend on `Constraint` / `ConstraintTransformer`. Join types are described in §4.1. +5. **Applies sort orders** from `BaseFilter.getOrders()` in insertion order. +6. **Calls the DAO hook methods** (see §5) so subclasses can mutate the in-progress `CriteriaQuery`. +7. **Applies pagination** via `firstResult` / `maxResult` on the `TypedQuery`. + +The pipeline is implemented twice in shape (once for the `T` result query, once for the `Long` count query), but the predicate assembly is shared. `filterWithSingleResult` builds the predicates without sort orders or paging and runs the query with `getSingleResult()`, returning an empty `Optional` for no match and throwing `IllegalStateException` for more than one. + +### 4.1 Join types + +Associations along a path are joined once per query: a join already present on the same attribute is reused whatever its type. The join type is chosen by the first resolution that creates it: + +- **Inner** for equality, comparison, `LIKE` and `IN` predicates of non-`@Or` fields. These conjuncts reject null, so every result row has the association anyway. As a consequence, such a predicate on a nested path never matches rows whose association is null. +- **Left** where an inner join would drop the rows the query is meant to return: `IS NULL` predicates from `@WhenNull(IS_NULL)`, predicates of `@Or` fields, and sort orders (sorting must not change the result set, nor make `filter` disagree with `count`). + +Reusing a join across types is safe because the declarative predicates are always combined as a conjunction: an inner join is only requested for a null-rejecting conjunct, and a left join behaves as an inner join under such a conjunct. The join type is inferred rather than configurable, so there is no join-type attribute on the annotations. + +## 5. Extensibility — DAO hook methods + +Customization happens on the DAO base, not on the filter. `ConversionJpaDaoSupport` (and by inheritance `JpaDaoSupport`) exposes hook methods that default to no-ops: + +```java +/** Add predicates that don't fit the declarative model. Return null or empty to add nothing. */ +default Collection customizePredicates( + BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, Root root) { + return Collections.emptyList(); +} + +/** Last-chance hook to mutate the CriteriaQuery (distinct, extra roots or joins, etc.) before execution. */ +default void customizeCriteria( + BaseFilter filter, CriteriaBuilder cb, CriteriaQuery cq, Root root) { + // no-op +} +``` + +Both hooks are called once per query (filter, count, single-result). They receive the same `CriteriaQuery` instance that the processor is building, so subclasses can call `cq.distinct(true)`, add `LEFT JOIN FETCH`-style joins (within Criteria limits), or attach predicates that the annotation model can't express (e.g. correlated subqueries, function calls, `OR` groups, one value matched against several attributes). + +`customizeCriteria` must keep the query's selection — the entity root, or its count — since every query kind returns entities or a single count; replacing it (e.g. with a projection) fails with `IllegalStateException`. It must not add a `GROUP BY` to the count query either, which would return one count per group; the hook can recognize the count query by `cq.getResultType() == Long.class`. A `distinct(true)` set on the count query is honored as `COUNT(DISTINCT root)`, so the count agrees with the distinct list. + +Why hooks and not annotation processors: it keeps the abstraction surface small and predictable, and it routes customization through the DAO — the layer that already owns the entity-shaped logic — instead of fanning custom behavior across filter classes. + +### 5.1 Reading filter values in hooks + +Hooks receive the filter as `BaseFilter` and read the values they need through the concrete filter class's accessors: + +```java +if (filter instanceof PersonFilter f && f.getSearch() != null) { + return List.of(cb.like(root.get("name"), "%" + f.getSearch() + "%")); +} +return List.of(); +``` + +Filters must therefore provide accessors for every field a hook consumes, typically the fields marked `@Attribute(manual = true)`. The declarative fields need none: the processor reads them by reflection. There is deliberately no name-based lookup helper: a cast is checked by the compiler, so renaming a field breaks the hook at compile time rather than at run time, and the `instanceof` check is needed anyway when a DAO receives more than one filter class. Logic shared by several filter classes is expressed through a common superclass or interface (`filter instanceof HasTenant t`). + +## 6. Usage example + +```java +@Getter +@Setter +@Accessors(chain = true) +@SuperBuilder(toBuilder = true) +@NoArgsConstructor +public class PersonFilter extends BaseFilter { + + @Attribute("name") + private String name; // null → skipped; non-null → name = :v + + @Attribute("birthDate") @From + private LocalDate birthDateFrom; // null → skipped; non-null → birthDate >= :v + + @Attribute("birthDate") @To(inclusive = false) + private LocalDate birthDateToExclusive; // pairs with birthDateFrom; strict upper bound + + @Attribute("address.city.name") + private String cityName; // auto-joins address → city, equality on name + + @Attribute("deletedAt") @WhenNull(WhenNull.Policy.IS_NULL) + private Instant deletedAt; // null → deletedAt IS NULL; non-null → equality + + @Attribute("lastName") @Like(ignoreCase = true) + private String lastName; // lower(lastName) LIKE '%:v%', wildcards in :v escaped + + @Attribute("status") @In + private Set statuses; // null or empty → skipped; else status IN (:v) + + @Attribute("email") @Or + private String email; // with nickname: ... AND (email = :e OR nickname = :n) + + @Attribute("nickname") @Or + private String nickname; + + @Attribute(value = "name", manual = true) + private String search; // processor skips this; hook matches name or nickname +} + +// DAO with a manual predicate +class PersonDao implements JpaDaoSupport { + // ... getEntityManager() ... + + @Override + public Collection customizePredicates(BaseFilter filter, CriteriaBuilder cb, + CriteriaQuery cq, Root root) { + if (!(filter instanceof PersonFilter f) || f.getSearch() == null) { + return List.of(); + } + String pattern = "%" + f.getSearch() + "%"; + return List.of(cb.or(cb.like(root.get("name"), pattern), + cb.like(root.get("nickname"), pattern))); + } +} + +// POJO style +PersonFilter f1 = new PersonFilter() + .setBirthDateFrom(LocalDate.of(1990, 1, 1)); +f1.addOrder("name"); +f1.setMaxResult(50); + +// Builder style +PersonFilter f2 = PersonFilter.builder() + .birthDateFrom(LocalDate.of(1990, 1, 1)) + .addOrder("name", BaseFilter.Order.ASC) + .maxResult(50) + .build(); + +List people = personDao.filter(f2); +``` + +With both bounds set on `birthDate`, the processor emits `birthDate >= :from AND birthDate < :to` rather than a `BETWEEN`, because the upper bound is exclusive. + +## 7. Deprecation plan for `QuerySpec` + +- All `QuerySpec`-typed DAO method overloads on `QueryDao` and `ConversionJpaDaoSupport` are marked `@Deprecated(forRemoval = false)` with javadoc pointing at the `BaseFilter` overloads. +- `QuerySpec`, the `Constraint` hierarchy (`Constraint`, `ConstraintBuilder`, every concrete `Attribute*Constraint` and `DisjunctionConstraint` / `NegatedConstraint` / `RelationalConstraint`), and the transformer types (`ConstraintTransformer`, `ConstraintTransformerJpaImpl`, `ConstraintTransformerException`) are also marked `@Deprecated(forRemoval = false)` in this change. Nothing is scheduled for removal yet — downstream code can keep compiling and running, just with deprecation warnings. +- The new processor resolves paths with its own non-deprecated `AttributePathResolver`, modeled on the path/join helper of `ConstraintTransformerJpaImpl`, so it does not depend on the deprecated transformer. The transformer itself is left unchanged. +- A follow-up issue tracks: (a) eventually flipping the deprecations to `forRemoval = true` once downstream usage is gone, (b) removing the deprecated DAO method overloads and the constraint types in a future major version. + +## 8. Module placement + +- `backend-core-model` — `BaseFilter`, `@Attribute`, `@From`, `@To`, `@Like`, `@In`, `@WhenNull`, `@Or`. Package: `com.flowingcode.backendcore.model.filter`. +- `backend-core-data` — new method signatures on `QueryDao`. +- `backend-core-data-impl` — `BaseFilterJpaProcessor`, default-method implementations on `ConversionJpaDaoSupport`, and the package-private `AttributePathResolver`. + +## 9. Open questions + +1. **Sort order via annotation.** Should a filter class be able to declare a default sort via annotation (e.g. `@DefaultSort("createdAt DESC")`)? Out of scope for v1; callers use `addOrder`. +2. **Validation timing.** Should the per-class reflection/validation run eagerly at startup (e.g. via a CDI extension or a Spring `BeanPostProcessor`) or lazily on first use? Spec assumes lazy with caching. Eager validation can be added later without API changes. +3. **Field discovery.** Inherited fields from a deeper hierarchy (filter extending filter) should be supported. Confirm whether non-public fields require `setAccessible(true)` allowances in target deployments. +4. **Builder validation hookup.** Resolved during implementation: the inner `BaseFilterBuilder` is declared explicitly with `@SuperBuilder` filling in the missing parts, and the `firstResult(...)` / `maxResult(...)` builder methods carry the same validation as the POJO setters. + +## 10. Out of scope / follow-ups + +- Further operator annotations (`@NotEqual`, `@IsNotNull` as a standalone, etc.). Revisit once usage patterns emerge. +- `@Not`, and `@Or` groups beyond the single disjunction. +- Pluggable / user-registered annotation processors. +- Removal of `QuerySpec` and friends. +- Projection / returned-attributes support equivalent to `QuerySpec.returnedAttributes`.