RangeSpecifications.java

package expresspecs;

import static expresspecs.BasicSpecifications.unrestricted;

import org.jspecify.annotations.NonNull;
import org.springframework.data.jpa.domain.Specification;

import jakarta.persistence.criteria.Path;
import lombok.experimental.UtilityClass;

/**
 * Predicate factories for Range-based JPA Specifications, such as comparisons and bounds.
 */
@UtilityClass
public class RangeSpecifications {

	/**
	 * Creates a specification that matches entities where the specified property is strictly less than {@code value},
	 * or an {@linkplain BasicSpecifications#unrestricted() unrestricted specification} if {@code value} is {@code null}.
	 *
	 * @param <T>          The entity type being queried.
	 * @param <C>          The comparable type of the property.
	 * @param propertyPath Dot-delimited property path to compare.
	 * @param value        Value to compare against.
	 * @see PropertyPath#from(String)
	 */
	public static <T, C extends Comparable<? super C>> @NonNull Specification<T> lessThan(String propertyPath, C value) {
		return lessThan(PropertyPath.from(propertyPath), value);
	}

	/**
	 * Creates a specification that matches entities where the specified property is strictly less than {@code value},
	 * or an {@linkplain BasicSpecifications#unrestricted() unrestricted specification} if {@code value} is {@code null}.
	 *
	 * @param <T>          The entity type being queried.
	 * @param <C>          The comparable type of the property.
	 * @param propertyPath Resolved property path to compare.
	 * @param value        Value to compare against.
	 */
	public static <T, C extends Comparable<? super C>> @NonNull Specification<T> lessThan(PropertyPath propertyPath, C value) {
		if (value == null) {
			return unrestricted();
		}
		return (root, query, cb) -> {
			Path<C> path = propertyPath.asPath(root);
			return cb.lessThan(path, value);
		};
	}

	/**
	 * Creates a specification that matches entities where the specified property is strictly greater than {@code value},
	 * or an {@linkplain BasicSpecifications#unrestricted() unrestricted specification} if {@code value} is {@code null}.
	 *
	 * @param <T>          The entity type being queried.
	 * @param <C>          The comparable type of the property.
	 * @param propertyPath Dot-delimited property path to compare.
	 * @param value        Value to compare against.
	 * @see PropertyPath#from(String)
	 */
	public static <T, C extends Comparable<? super C>> @NonNull Specification<T> greaterThan(String propertyPath, C value) {
		return greaterThan(PropertyPath.from(propertyPath), value);
	}

	/**
	 * Creates a specification that matches entities where the specified property is strictly greater than {@code value},
	 * or an {@linkplain BasicSpecifications#unrestricted() unrestricted specification} if {@code value} is {@code null}.
	 *
	 * @param <T>          The entity type being queried.
	 * @param <C>          The comparable type of the property.
	 * @param propertyPath Resolved property path to compare.
	 * @param value        Value to compare against.
	 */
	public static <T, C extends Comparable<? super C>> @NonNull Specification<T> greaterThan(PropertyPath propertyPath, C value) {
		if (value == null) {
			return unrestricted();
		}
		return (root, query, cb) -> {
			Path<C> path = propertyPath.asPath(root);
			return cb.greaterThan(path, value);
		};
	}

	/**
	 * Creates a specification that matches entities where the specified property is greater than or equal to
	 * {@code value}, or an {@linkplain BasicSpecifications#unrestricted() unrestricted specification} if
	 * {@code value} is {@code null}.
	 *
	 * @param <T>          The entity type being queried.
	 * @param <C>          The comparable type of the property.
	 * @param propertyPath Dot-delimited property path to compare.
	 * @param value        Value to compare against.
	 * @see PropertyPath#from(String)
	 */
	public static <T, C extends Comparable<? super C>> @NonNull Specification<T> atLeast(String propertyPath, C value) {
		return atLeast(PropertyPath.from(propertyPath), value);
	}

	/**
	 * Creates a specification that matches entities where the specified property is greater than or equal to
	 * {@code value}, or an {@linkplain BasicSpecifications#unrestricted() unrestricted specification} if
	 * {@code value} is {@code null}.
	 *
	 * @param <T>          The entity type being queried.
	 * @param <C>          The comparable type of the property.
	 * @param propertyPath Resolved property path to compare.
	 * @param value        Value to compare against.
	 */
	public static <T, C extends Comparable<? super C>> @NonNull Specification<T> atLeast(PropertyPath propertyPath, C value) {
		if (value == null) {
			return unrestricted();
		}
		return (root, query, cb) -> {
			Path<C> path = propertyPath.asPath(root);
			return cb.greaterThanOrEqualTo(path, value);
		};
	}

	/**
	 * Creates a specification that matches entities where the specified property is less than or equal to
	 * {@code value}, or an {@linkplain BasicSpecifications#unrestricted() unrestricted specification} if
	 * {@code value} is {@code null}.
	 *
	 * @param <T>          The entity type being queried.
	 * @param <C>          The comparable type of the property.
	 * @param propertyPath Dot-delimited property path to compare.
	 * @param value        Value to compare against.
	 * @see PropertyPath#from(String)
	 */
	public static <T, C extends Comparable<? super C>> @NonNull Specification<T> atMost(String propertyPath, C value) {
		return atMost(PropertyPath.from(propertyPath), value);
	}

	/**
	 * Creates a specification that matches entities where the specified property is less than or equal to
	 * {@code value}, or an {@linkplain BasicSpecifications#unrestricted() unrestricted specification} if
	 * {@code value} is {@code null}.
	 *
	 * @param <T>          The entity type being queried.
	 * @param <C>          The comparable type of the property.
	 * @param propertyPath Resolved property path to compare.
	 * @param value        Value to compare against.
	 */
	public static <T, C extends Comparable<? super C>> @NonNull Specification<T> atMost(PropertyPath propertyPath, C value) {
		if (value == null) {
			return unrestricted();
		}
		return (root, query, cb) -> {
			Path<C> path = propertyPath.asPath(root);
			return cb.lessThanOrEqualTo(path, value);
		};
	}

	/**
	 * Creates a specification that matches entities where the specified property is greater than or
	 * equal to {@code startInclusive} and strictly less than {@code endExclusive}.
	 *
	 * <p>Unlike other factories in this class, {@code null} bounds are not permitted and will throw
	 * {@link IllegalArgumentException}. This is intentional: because this method delegates to
	 * {@link #atLeast} and {@link #lessThan}, a null bound would silently produce a one-sided range
	 * rather than the two-sided range the caller requested, hiding a subtle behavioral change in the
	 * resulting query. If only one bound is needed, call {@link #atLeast} or {@link #lessThan} directly.
	 *
	 * @param <T>            The entity type being queried.
	 * @param <C>            The comparable type of the property.
	 * @param propertyPath   Dot-delimited property path to compare.
	 * @param startInclusive The inclusive lower bound; must not be {@code null}.
	 * @param endExclusive   The exclusive upper bound; must not be {@code null}.
	 * @throws IllegalArgumentException if either bound is {@code null}.
	 * @see PropertyPath#from(String)
	 */
	public static <T, C extends Comparable<? super C>> @NonNull Specification<T> between(
			String propertyPath, C startInclusive, C endExclusive) {
		return between(PropertyPath.from(propertyPath), startInclusive, endExclusive);
	}

	/**
	 * Creates a specification that matches entities where the specified property is greater than or
	 * equal to {@code startInclusive} and strictly less than {@code endExclusive}.
	 *
	 * <p>Unlike other factories in this class, {@code null} bounds are not permitted and will throw
	 * {@link IllegalArgumentException}. This is intentional: because this method delegates to
	 * {@link #atLeast} and {@link #lessThan}, a null bound would silently produce a one-sided range
	 * rather than the two-sided range the caller requested, hiding a subtle behavioral change in the
	 * resulting query. If only one bound is needed, call {@link #atLeast} or {@link #lessThan} directly.
	 *
	 * @param <T>            The entity type being queried.
	 * @param <C>            The comparable type of the property.
	 * @param propertyPath   Resolved property path to compare.
	 * @param startInclusive The inclusive lower bound; must not be {@code null}.
	 * @param endExclusive   The exclusive upper bound; must not be {@code null}.
	 * @throws IllegalArgumentException if either bound is {@code null}.
	 */
	public static <T, C extends Comparable<? super C>> @NonNull Specification<T> between(
			PropertyPath propertyPath, C startInclusive, C endExclusive) {
		if (startInclusive == null || endExclusive == null) {
			throw new IllegalArgumentException("between() requires non-null bounds; use atLeast() or lessThan() for a one-sided range");
		}
		Specification<T> afterStart = atLeast(propertyPath, startInclusive);
		Specification<T> beforeEnd = lessThan(propertyPath, endExclusive);

		return afterStart.and(beforeEnd);
	}

}