DateTimeSpecifications.java
package expresspecs.datetime;
import static expresspecs.BasicSpecifications.unrestricted;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZonedDateTime;
import java.util.Date;
import org.hibernate.query.criteria.HibernateCriteriaBuilder;
import org.jspecify.annotations.NonNull;
import org.springframework.data.jpa.domain.Specification;
import expresspecs.PropertyPath;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.Expression;
import lombok.experimental.UtilityClass;
/**
* Predicate factories for Date/Time-based JPA Specifications.
*
* <p>All factory methods accept {@code null} filter values and return an {@linkplain expresspecs.BasicSpecifications#unrestricted()
* unrestricted specification} in that case, making them safe to use without null-checking at the call site.
*
* <p><strong>Hibernate requirement:</strong> Some methods (such as {@link #yearIs}, {@link #monthIs},
* and {@link #dayOfMonthIs}) use the Hibernate-specific {@code HibernateCriteriaBuilder} API to
* generate portable {@code EXTRACT}-based SQL that works across all supported databases. These methods
* will throw {@link ClassCastException} at runtime if the JPA provider is not Hibernate. All other
* methods in this class rely only on the standard JPA Criteria API and are provider-neutral.
*/
@UtilityClass
public class DateTimeSpecifications {
private HibernateCriteriaBuilder hibernateBuilder(CriteriaBuilder builder) {
return (HibernateCriteriaBuilder) builder;
}
/**
* Creates a specification that matches entities where the year of the specified date-time property
* equals {@code year}.
*
* @param <T> The entity type being queried.
* @param propertyPath Dot-delimited property path to compare.
* @param year The year to match.
* @see PropertyPath#from(String)
*/
public static <T> @NonNull Specification<T> yearIs(String propertyPath, Integer year) {
return yearIs(PropertyPath.from(propertyPath), year);
}
/**
* Creates a specification that matches entities where the year of the specified date-time property
* equals {@code year}.
*
* @param <T> The entity type being queried.
* @param propertyPath Resolved property path to compare.
* @param year The year to match.
*/
public static <T> @NonNull Specification<T> yearIs(PropertyPath propertyPath, Integer year) {
if (year == null) {
return unrestricted();
}
return (root, query, cb) -> {
Expression<Integer> yearExpr = hibernateBuilder(cb).year(propertyPath.asPath(root));
return cb.equal(yearExpr, year);
};
}
/**
* Creates a specification that matches entities where the month of the specified date-time property
* equals {@code month}.
*
* @param <T> The entity type being queried.
* @param propertyPath Dot-delimited property path to compare.
* @param month The month to match (1–12).
* @see PropertyPath#from(String)
*/
public static <T> @NonNull Specification<T> monthIs(String propertyPath, Integer month) {
return monthIs(PropertyPath.from(propertyPath), month);
}
/**
* Creates a specification that matches entities where the month of the specified date-time property
* equals {@code month}.
*
* @param <T> The entity type being queried.
* @param propertyPath Resolved property path to compare.
* @param month The month to match (1–12).
*/
public static <T> @NonNull Specification<T> monthIs(PropertyPath propertyPath, Integer month) {
if (month == null) {
return unrestricted();
}
return (root, query, cb) -> {
Expression<Integer> monthExpr = hibernateBuilder(cb).month(propertyPath.asPath(root));
return cb.equal(monthExpr, month);
};
}
/**
* Creates a specification that matches entities where the day of month of the specified date-time
* property equals {@code dayOfMonth}.
*
* @param <T> The entity type being queried.
* @param propertyPath Dot-delimited property path to compare.
* @param dayOfMonth The day of month to match (1–31).
* @see PropertyPath#from(String)
*/
public static <T> @NonNull Specification<T> dayOfMonthIs(String propertyPath, Integer dayOfMonth) {
return dayOfMonthIs(PropertyPath.from(propertyPath), dayOfMonth);
}
/**
* Creates a specification that matches entities where the day of month of the specified date-time
* property equals {@code dayOfMonth}.
*
* @param <T> The entity type being queried.
* @param propertyPath Resolved property path to compare.
* @param dayOfMonth The day of month to match (1–31).
*/
public static <T> @NonNull Specification<T> dayOfMonthIs(PropertyPath propertyPath, Integer dayOfMonth) {
if (dayOfMonth == null) {
return unrestricted();
}
return (root, query, cb) -> {
Expression<Integer> dayExpr = hibernateBuilder(cb).day(propertyPath.asPath(root));
return cb.equal(dayExpr, dayOfMonth);
};
}
/**
* Creates a specification that matches entities where the specified temporal property falls on the
* given {@code targetDate}. How {@code targetDate} is interpreted depends on the leaf property type
* (see {@link #onDate(PropertyPath, LocalDate)}).
*
* @param <T> The entity type being queried.
* @param propertyPath Dot-delimited property path to compare.
* @param targetDate The date to match.
* @see PropertyPath#from(String)
*/
public static <T> @NonNull Specification<T> onDate(String propertyPath, LocalDate targetDate) {
return onDate(PropertyPath.from(propertyPath), targetDate);
}
/**
* Creates a specification that matches entities where the specified temporal property falls on the
* given calendar {@code targetDate}.
*
* <p>Predicate construction walks a fixed-order list of {@link SameCalendarDay} implementations until one
* {@linkplain SameCalendarDay#supports(Class) supports} the leaf property type.
*
* <p><strong>Semantics by leaf property type</strong>
*
* <ul>
* <li>{@link Instant}, {@link OffsetDateTime}, {@link ZonedDateTime}, {@link Date}, and
* {@link java.sql.Timestamp}: values are treated as instants on the UTC timeline. The range is
* {@code [targetDate at 00:00 UTC, targetDate.plusDays(1) at 00:00 UTC)} (half-open).</li>
* <li>{@link LocalDate}: {@linkplain jakarta.persistence.criteria.CriteriaBuilder#equal equal}
* to {@code targetDate}.</li>
* <li>{@link java.sql.Date}: equal to {@link java.sql.Date#valueOf(LocalDate)} for {@code targetDate}.</li>
* <li>{@link LocalDateTime}: half-open range using {@linkplain LocalDate#atStartOfDay() start of day}
* through the following midnight in the <em>same</em> {@link LocalDateTime} calendar (no zone
* conversion).</li>
* <li>Any other leaf property type: {@link UnsupportedDatePropertyException} (a subtype of
* {@link IllegalArgumentException}) when the specification is evaluated, because {@code onDate}
* does not define calendar-day semantics for that type.</li>
* </ul>
*
* @param <T> The entity type being queried.
* @param propertyPath Resolved property path to compare.
* @param targetDate The date to match.
* @throws UnsupportedDatePropertyException if the leaf property type is not one of the supported temporal types
* (see list in this method's description). When the specification is run through Spring Data JPA,
* this exception may be wrapped in a {@link org.springframework.dao.DataAccessException}.
*/
public static <T> @NonNull Specification<T> onDate(PropertyPath propertyPath, LocalDate targetDate) {
if (targetDate == null) {
return unrestricted();
}
return new OnDateSpecification<>(propertyPath, targetDate);
}
}