Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .cursor/rules/api-refactor-approval.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
description: No public or multi-site API refactors without explicit user approval; propose narrower fixes first
alwaysApply: true
---

# API and cross-cutting refactors require explicit approval

## Do not do without explicit user go-ahead

Do **not** implement or merge edits that:

- Change **public** library surface (exported types, public methods, `module-info` exports, or anything consumers rely on), or
- Change **shared contracts** touched in many places at once (for example a **public interface** with **multiple implementations** in this repo, or a new parameter on such an interface), or
- Refactor **across many modules or packages** solely to support a small feature, when a **local** change would work.

If the user’s goal can be met by changing **one call site**, **one package-private class**, or **one strategy implementation** without widening the blast radius, **prefer that** and say so briefly.

## When the user asks for a behavior change

1. Prefer **minimal diffs** that preserve existing contracts.
2. If the clean solution **would** require a wide contract change, **stop and ask**: describe the tradeoff (local hack vs API change) and wait for approval before editing across implementations.

## After approval

If the user explicitly approves a contract change, follow the rest of the project rules (tests, Javadoc, `CLAUDE.md`, and workspace sync as applicable).
21 changes: 21 additions & 0 deletions .cursor/rules/lombok-boilerplate.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
description: Prefer Lombok for constructors, getters, and setters instead of hand-written boilerplate
alwaysApply: true
---

# Lombok over hand-written accessors and constructors

When adding or editing Java types in this repository, **prefer Lombok** to generate repetitive members instead of spelling them out in source.

**Use Lombok for**

- **Getters and setters:** `@Getter`, `@Setter` (type or field level), or `@Data` / `@Value` when the rest of the bundle matches the design.
- **Constructors:** `@RequiredArgsConstructor`, `@NoArgsConstructor`, `@AllArgsConstructor`, or `@Builder` (with `@Builder.Default` where needed) instead of manual constructor bodies that only assign fields.

**When explicit code is still appropriate**

- Logic in a constructor (validation, copying defensively, computing fields).
- `record` types: rely on the compact canonical constructor and built-in accessors; do not duplicate accessors with Lombok unless there is a clear need.
- Members Lombok cannot express, or APIs that must stay stable and are intentionally hand-written.

After introducing or changing Lombok annotations on a type, ensure the project still compiles (`jdt problems` or `./mvnw clean test` as appropriate).
8 changes: 7 additions & 1 deletion .cursor/rules/plan-before-code.mdc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
description: Answer-first for design questions; no repo edits without explicit implementation ask
description: Answer-first for design questions; no unasked edits; ask when repo state or intent is unclear
alwaysApply: true
---

Expand All @@ -25,3 +25,9 @@ For non-trivial implementation the user *does* request, if several files or desi
## No rabbit holes

Do **not** expand scope (extra APIs, example rewrites, new helpers, docs, broad test matrix runs) unless the user asked for that scope or agreed to it in the same thread.

## Do not assume the working tree is wrong

When what you read in the repo **differs** from an earlier turn, from another path, or from what you expected, **do not treat that as an automatic error** to correct without confirmation. Omitted annotations, different names, or unusual structure may be **deliberate**.

If you are not sure whether to **restore**, **add**, or **change** something, **ask the user** in one short question before editing.
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ See **[docs/testing.md](docs/testing.md)** for Maven commands, profiles, Testcon

The `sb3` Maven profile switches the Spring Boot BOM version and activates the `src/test/springboot3/java` test source tree. When adding new features, verify behaviour under both profiles (H2 integration tests). Testcontainers profiles are excluded - they require `sb4`.

## API and cross-cutting refactors

Do **not** change public library API or shared contracts that ripple across many implementations (for example new parameters on a **public interface** implemented in multiple places) **unless the user explicitly approves that design** in the thread. Prefer a **narrow** change (single class, package-private helper, or one call site) when it satisfies the request. If the right fix needs a wide contract change, **propose options and ask first** before editing.

## Trust the working tree; ask when unsure

If the code on disk **differs** from what you remember or expected, **do not assume** that gap is a mistake to fix on your own. When it is unclear whether a difference is intentional, **ask the user** before changing it.

## Style rules

**No em dashes:** Do not use the em dash character (U+2014, `—`) anywhere: documentation, Markdown files, Javadoc, or code comments (`//`, `/* */`, `/** */`). Use a comma, colon, semicolon, parentheses, or a hyphen with spaces instead.

**Lombok:** Prefer Lombok (`@Getter`, `@Setter`, `@RequiredArgsConstructor`, `@NoArgsConstructor`, `@AllArgsConstructor`, `@Builder`, `@Data`, `@Value`, etc.) to generate constructors, getters, and setters instead of hand-written boilerplate. Keep explicit constructors when they contain real logic (validation, defensive copies). For `record` types, use the language features instead of duplicating accessors with Lombok unless there is a clear need.
5 changes: 3 additions & 2 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,6 @@ Specification<Customer> spec = isNotNullOrEmpty(Customer.Fields.name);
> condition another way (for example, checking only `isNotNull`, since Oracle cannot store an
> empty string and a non-null column value is therefore guaranteed to be non-empty).


### Range & DateTime Specifications

For numbers, dates, and comparisons.
Expand Down Expand Up @@ -263,14 +262,16 @@ Specification<Customer> spec = yearIs(Customer.Fields.createdTimestamp, 2024);
| `java.util.Date` / `java.sql.Timestamp` | UTC half-open range (same UTC window, compared as `java.util.Date`) |
| `LocalDateTime` | Wall-clock half-open range: `[targetDate at midnight, targetDate+1 at midnight)`, no zone conversion |

Any other mapped Java type causes an runtime exception when the specification predicate is evaluated (for example `java.util.Calendar`).

**Date-only types** (`LocalDate`, `java.sql.Date`) use a simple equality check because the stored value already represents just a calendar date.

**Zone-aware types** (`Instant`, `OffsetDateTime`, `ZonedDateTime`, `java.util.Date`, `java.sql.Timestamp`) use a UTC midnight-to-midnight window. "On 2025-03-15" means any instant in `[2025-03-15T00:00:00Z, 2025-03-16T00:00:00Z)`. A value stored as `2025-03-16T01:00+02:00` (which is `2025-03-15T23:00Z`) matches; a value stored as `2025-03-16T00:00:00Z` does not.

**Zone-naive types** (`LocalDateTime`) compare the stored value directly against the wall-clock midnight boundaries with no zone conversion. `2025-03-15T23:45` matches but `2025-03-16T00:00` does not, regardless of where the server or database is located.

> [!NOTE]
> If the property type is not one of the above, `onDate` falls back to wall-clock `LocalDateTime` bounds. Whether the resulting predicate behaves correctly depends on how your JPA provider coerces `LocalDateTime` values to the mapped column type.
> Unsupported property types throw `IllegalArgumentException` with a message that names the leaf type and lists supported alternatives. Through Spring Data JPA, that exception may be wrapped in a `DataAccessException` (for example `InvalidDataAccessApiUsageException`).

For all available predicates and their descriptions, see [RangeSpecifications.java](../src/main/java/expresspecs/RangeSpecifications.java) and [DateTimeSpecifications.java](../src/main/java/expresspecs/datetime/DateTimeSpecifications.java).

Expand Down
7 changes: 7 additions & 0 deletions src/main/java/expresspecs/PropertyPath.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import java.util.Arrays;
import java.util.List;

import org.apache.commons.lang3.StringUtils;

import jakarta.persistence.criteria.From;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.Path;
Expand Down Expand Up @@ -124,6 +126,11 @@ public static <X, Y, E, W> PropertyPath of(SingularAttribute<X, Y> first, Plural
return new PropertyPath(List.of(first.getName(), second.getName(), third.getName()));
}

@Override
public String toString() {
return StringUtils.join(properties, '.');
}

@SuppressWarnings("unchecked")
public <V> Path<V> asPath(Root<?> root) {
List<String> intermediates = properties.subList(0, properties.size() - 1);
Expand Down
39 changes: 7 additions & 32 deletions src/main/java/expresspecs/datetime/DateTimeSpecifications.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import java.time.OffsetDateTime;
import java.time.ZonedDateTime;
import java.util.Date;
import java.util.List;

import org.hibernate.query.criteria.HibernateCriteriaBuilder;
import org.jspecify.annotations.NonNull;
Expand All @@ -17,7 +16,6 @@
import expresspecs.PropertyPath;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.Path;
import lombok.experimental.UtilityClass;

/**
Expand All @@ -35,18 +33,6 @@
@UtilityClass
public class DateTimeSpecifications {

// The ordering of this list is important
private static final List<SameCalendarDay> SAME_CALENDAR_DAY_STRATEGIES = List.of(
new SameCalendarDayForLocalDate(),
new SameCalendarDayForSqlDate(),
new SameCalendarDayForInstant(),
new SameCalendarDayForOffsetDateTime(),
new SameCalendarDayForZonedDateTime(),
new SameCalendarDayForLocalDateTime(),
new SameCalendarDayForUtilDate(),
new SameCalendarDayFallback());


private HibernateCriteriaBuilder hibernateBuilder(CriteriaBuilder builder) {
return (HibernateCriteriaBuilder) builder;
}
Expand Down Expand Up @@ -177,35 +163,24 @@ private HibernateCriteriaBuilder hibernateBuilder(CriteriaBuilder builder) {
* <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>Other types: half-open range using {@link LocalDateTime} bounds
* {@code targetDate.atStartOfDay()} (inclusive) through {@code targetDate.plusDays(1).atStartOfDay()}
* (exclusive). The property is compared to those bound values; the effective filter depends on the
* JPA provider and how JDBC coerces {@link LocalDateTime} to the mapped column type.</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 (root, query, cb) -> {
Path<?> path = propertyPath.asPath(root);
Class<?> javaType = path.getJavaType();
return getSameCalendarDayStrategy(javaType).toPredicate(path, targetDate, cb);
};
}

private static SameCalendarDay getSameCalendarDayStrategy(Class<?> propertyType) {
return SAME_CALENDAR_DAY_STRATEGIES
.stream()
.filter(s -> s.supports(propertyType))
.findFirst()
// This would be a programming error in the fallback strategy
.orElseThrow(() -> new AssertionError("Fallback strategy must support any leaf type"));
return new OnDateSpecification<>(propertyPath, targetDate);
}

}
75 changes: 75 additions & 0 deletions src/main/java/expresspecs/datetime/OnDateSpecification.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package expresspecs.datetime;

import java.time.LocalDate;
import java.util.List;

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

import expresspecs.PropertyPath;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import lombok.RequiredArgsConstructor;

/**
* {@link Specification} that matches entities whose temporal property falls on a given calendar
* {@link LocalDate}.
*
* <p>When the specification is translated to a {@linkplain jakarta.persistence.criteria.Predicate predicate},
* the property path's Java type selects the first {@link SameCalendarDay} implementation that
* {@linkplain SameCalendarDay#supports(Class) supports} that type. Unsupported types yield
* {@link UnsupportedDatePropertyException} at evaluation time.
*
* <p>{@link DateTimeSpecifications#onDate(PropertyPath, LocalDate)} is the typical entry point: it returns
* an instance of this class for a non-null date, and {@link expresspecs.BasicSpecifications#unrestricted()}
* when the date is {@code null}. The detailed semantics per supported property type are documented on that method.
*
* @param <T> entity root type
*/
@RequiredArgsConstructor
public final class OnDateSpecification<T> implements Specification<T> {

private static final long serialVersionUID = 1L;

// The ordering of this list is important
private static final List<SameCalendarDay> SAME_CALENDAR_DAY_STRATEGIES = List.of(
new SameCalendarDayForLocalDate(),
new SameCalendarDayForSqlDate(),
new SameCalendarDayForInstant(),
new SameCalendarDayForOffsetDateTime(),
new SameCalendarDayForZonedDateTime(),
new SameCalendarDayForLocalDateTime(),
new SameCalendarDayForUtilDate()
);

private final PropertyPath propertyPath;
private final LocalDate targetDate;


/**
* @throws UnsupportedDatePropertyException if the property is of a type not supported (eg, java.util.Calendar)
*/
@Override
public Predicate toPredicate(@NonNull Root<T> root, @NonNull CriteriaQuery<?> query, @NonNull CriteriaBuilder cb) {
Path<?> path = propertyPath.asPath(root);
Class<?> javaType = path.getJavaType();
SameCalendarDay strategy = getSameCalendarDayStrategy(javaType);

if (strategy == null) {
throw new UnsupportedDatePropertyException(propertyPath, javaType, root);
}

return strategy.toPredicate(path, targetDate, cb);
}

private static SameCalendarDay getSameCalendarDayStrategy(Class<?> propertyType) {
return SAME_CALENDAR_DAY_STRATEGIES
.stream()
.filter(s -> s.supports(propertyType))
.findFirst()
.orElse(null);
}
}
2 changes: 1 addition & 1 deletion src/main/java/expresspecs/datetime/SameCalendarDay.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import jakarta.persistence.criteria.Predicate;

/**
* A strategy interface, implemenations of which build a JPA predicate that checks whether a temporal attribute
* A strategy interface, implementations of which build a JPA predicate that checks whether a temporal attribute
* refers to the same calendar day as {@code targetDate}.
*/
public interface SameCalendarDay {
Expand Down
25 changes: 0 additions & 25 deletions src/main/java/expresspecs/datetime/SameCalendarDayFallback.java

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package expresspecs.datetime;

import expresspecs.PropertyPath;
import jakarta.persistence.criteria.Root;
import lombok.Getter;

public class UnsupportedDatePropertyException extends IllegalArgumentException {

private static final long serialVersionUID = 1L;

@Getter
private Class<?> type;


public UnsupportedDatePropertyException(PropertyPath path, Class<?> type, Root<?> root) {
super("Property %s.%s is type %s which is not supported"
.formatted(root.getJavaType().getSimpleName(),
path,
type == null ? "(unknown)" : type.getName()
));
this.type = type;
}

}
6 changes: 5 additions & 1 deletion src/test/containers/java/expresspecs/TCTestConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

import org.jspecify.annotations.NonNull;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

import expresspecs.datetime.Socialite;
import expresspecs.datetime.SocialiteRepository;
import expresspecs.example.ComponentScanMarker;
import jakarta.persistence.EntityManager;
import rizzoweb.spring.jpa.EntityManagerWrapper;
Expand All @@ -25,7 +28,8 @@
@Configuration
@EnableAutoConfiguration
@ComponentScan(basePackageClasses = ComponentScanMarker.class)
@EnableJpaRepositories(basePackageClasses = ComponentScanMarker.class)
@EntityScan(basePackageClasses = { ComponentScanMarker.class, Socialite.class })
@EnableJpaRepositories(basePackageClasses = { ComponentScanMarker.class, SocialiteRepository.class })
public class TCTestConfig {

@Bean
Expand Down
11 changes: 11 additions & 0 deletions src/test/java/expresspecs/SpecificationIntegrationTests.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package expresspecs;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;

import expresspecs.datetime.DateTimeSpecificationIntegrationTests;
import expresspecs.datetime.OnDateTypeCoverageTests;
import expresspecs.datetime.Socialite;
import expresspecs.datetime.SocialiteRepository;
import expresspecs.example.Customer;
import expresspecs.example.CustomerRepository;
import lombok.Getter;
Expand All @@ -22,4 +25,12 @@ public abstract class SpecificationIntegrationTests extends BaseJPAIntegrationTe
@Getter
protected CustomerRepository repo;

@Autowired
protected SocialiteRepository socialiteRepository;

@Override
public JpaSpecificationExecutor<Socialite> getSocialiteRepo() {
return socialiteRepository;
}

}
Loading
Loading