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
43 changes: 35 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,20 @@ public Specification<Customer> hasZipCodeOrMinCredit(List<String> zipCodes, int
Predicate active = cb.isTrue(root.get("isActive"));
Predicate highCredit = cb.greaterThan(root.get("creditLimit"), minCredit);
Predicate zipBranch;

if (CollectionUtils.isEmpty(zipCodes)) {
// IN () is undefined / false; OR reduces to credit branch only
zipBranch = cb.disjunction();
} else {
zipBranch = address.get("zipCode").in(zipCodes);
}

Predicate orPart = cb.or(zipBranch, highCredit);
return cb.and(orPart, active);
};
}
```

*A contrived example, but you get the point.*

## We Can Do Better
Expand Down Expand Up @@ -85,6 +86,32 @@ and sits entirely in your own codebase. See the [Usage Guide](docs/usage-guide.m
- **Spring Boot 3 & 4 Compatible:** A single, consistent API that works seamlessly across major Spring Boot versions.
- **Smart Distinct:** Avoids the tricky Spring Data pagination `count` bugs by only applying `DISTINCT` when a Join is present and it is safe to do so.

## ExpresSpecs vs. Querydsl

[Querydsl](https://querydsl.com) is the most well-known alternative for type-safe dynamic queries with Spring Data JPA, so it's worth being clear about how ExpresSpecs differs and where each one fits.

Querydsl is a full query DSL. An annotation processor generates `Q`-types, a static metamodel for your entities, and you write entire queries, including joins, projections, subqueries, grouping, and ordering, against that metamodel with compile-time checking of the whole query shape. That's a real investment (build-time code generation, a generated source tree to manage), but it pays off for applications with complex reporting-style queries, DTO projections, or query needs that go well beyond a `WHERE` clause.

ExpresSpecs is intentionally narrower. It generates no code and introduces no metamodel; it's a set of static factory methods that build standard Spring Data `Specification` objects, which are themselves just `CriteriaBuilder` predicates under the hood. It targets the single most common pain point: applications with search/filter screens that need to combine many optional criteria, often across relationships, into a `WHERE` clause.

**Choose ExpresSpecs when:**

- Your queries are primarily filtering: combining a handful of optional criteria (equality, ranges, text search, null checks, collection membership) over one or two entity graphs.
- You're already using, or willing to use, Spring Data's `Specification` / `JpaSpecificationExecutor`, and just want to stop writing `CriteriaBuilder` boilerplate.
- You'd rather not add an annotation processor or generated source step to your build.
- You want a small, focused dependency you can drop into an existing repository method.

**Choose Querydsl when:**

- You need complex projections, such as aggregations, computed columns, or DTOs assembled from multiple joined entities, as first-class parts of the query*.
- You need subqueries, grouping/aggregation, or dynamic ordering as first-class parts of the query.
- You want the entire query, not just the predicate, checked at compile time against your schema.
- Your application's query needs go meaningfully beyond filtering.

The two aren't mutually exclusive: it's reasonable to use ExpresSpecs for filter-building in most repositories and reach for Querydsl (or plain JPQL/native queries) for the handful of reporting-style queries that need more.

*Note that simple DTO/interface projections don't require Querydsl: Spring Data JPA `JpaSpecificationExecutor` provides `findBy(Specification, queryFunction)` method. Class `SpecificationProjections` provides common, basic query functions that can be used with that method to maintain expressive, simple syntax when projecting. See [Returning Projections](docs/usage-guide.md#returning-projections) in the Usage Guide for details.

## Getting Started

Add the dependency and extend your repository from Spring's `JpaSpecificationExecutor` - that's all that's strictly required to start using ExpresSpecs.
Expand Down Expand Up @@ -125,13 +152,13 @@ public interface CustomerRepository extends JpaRepository<Customer, Long>, JpaSp
listed below). This table shows what databases the code is *actively verified against* (using [Testcontainers](https://testcontainers.com/)) using the full test
suite on every build:

| Database | Testcontainers Image |
|---|---|
| PostgreSQL | `postgres:17-alpine` |
| MySQL | `mysql:8.4` |
| MariaDB | `mariadb:11.4` |
| Database | Testcontainers Image |
| -------------------- | -------------------------------------------- |
| PostgreSQL | `postgres:17-alpine` |
| MySQL | `mysql:8.4` |
| MariaDB | `mariadb:11.4` |
| Microsoft SQL Server | `mcr.microsoft.com/mssql/server:2022-latest` |
| Oracle | `gvenzl/oracle-free:23-slim-faststart` |
| Oracle | `gvenzl/oracle-free:23-slim-faststart` |

If you want to run the test suite against a different database version, these images can be overridden. See [Overriding Testcontainers images](docs/testing.md#overriding-testcontainers-images) in docs/testing.md for details.

Expand Down
63 changes: 63 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ This guide covers setup, compatibility, typing options, factory usage, edge case
- [`onDate` behavior by property type](#ondate-behavior-by-property-type)
- [Collection Specifications](#collection-specifications)
- [Streamlining Optional Filters](#streamlining-optional-filters)
- [Returning Projections](#returning-projections)
- [The Magic of `smartDistinct`](#the-magic-of-smartdistinct)
- [Complete Example Source Code](#complete-example-source-code)

Expand Down Expand Up @@ -334,6 +335,67 @@ public Page<Customer> findSpecialCustomers(Set<String> zipCodes, Integer minCred
> [!NOTE]
> Most factory methods behave this way, though there are a few exceptions. Consult the Javadoc on each method for details.

## Returning Projections

Sometimes you do not want full entities back, just a few fields shaped into a lightweight DTO or interface view. Spring Data's `JpaSpecificationExecutor` supports this through its `findBy(Specification, queryFunction)` method: the first argument is your Specification (the `WHERE` clause), and the second is a function that shapes the result, including projecting each matching row to a different type.

Written by hand, that second argument is a noisy lambda over Spring Data's fluent query API:

```java
List<CustomerDTO> dtos = repository.findBy(isActive(), q -> q.as(CustomerDTO.class).all());
```

`SpecificationProjections` supplies ready-made query functions so the call reads as a phrase instead. This is the one part of the library that shapes results rather than building predicates:

```java
import static expresspecs.SpecificationProjections.*;

List<CustomerDTO> dtos = repository.findBy(isActive(), projectedAs(CustomerDTO.class));
```

The factory mirrors the most common terminal operations of the fluent query. The plain `projectedAs` forms cover the common multi-result cases; the rarer single-result and streaming cases carry an explicit prefix:

| Function | Returns | Use when |
| ----------------------------- | ---------------- | ------------------------------------------------- |
| `projectedAs(Type)` | `List<Type>` | you want all matches |
| `projectedAs(Type, Pageable)` | `Page<Type>` | you want a page, with the usual paging metadata |
| `oneProjectedAs(Type)` | `Optional<Type>` | at most one row matches (throws if more than one) |
| `firstProjectedAs(Type)` | `Optional<Type>` | several may match and you want the first |
| `streamProjectedAs(Type)` | `Stream<Type>` | you want to stream results lazily |

`streamProjectedAs` returns a `Stream` backed by an open result set, so consume it within the surrounding transaction and close it (for example with a try-with-resources block).

A typical paged service method:

```java
public Page<CustomerDTO> getActiveCustomers(Pageable page) {
return repository.findBy(isActive(), projectedAs(CustomerDTO.class, page));
}
```

The projected type can be a plain class (DTO) or an interface projection; both are supported by Spring Data's `.as(...)`.



There are some limitations/characteristics of Spring Data's projection support itself to be aware of; these are not specific to `SpecificationProjections` or the query functions it provides; they apply to any projection used with `findBy`, no matter how you build the query function:

> [!NOTE]
> A **class-based DTO** binds result columns to its constructor parameters *by name*, which requires
> compiling with the `-parameters` flag (`<parameters>true</parameters>` on the Maven compiler
> plugin, which is the Spring Boot default). Without it, the projection fails at runtime with
> `ConverterNotFoundException`. Java `record` types and interface projections are unaffected, since
> they carry their component/accessor names regardless.

> [!NOTE]
> A class or record DTO projection is **flat**: it maps the root entity's own columns. To project
> fields from an *associated* entity (for example `customer.address.zipCode`), use an interface
> projection, which can traverse associations, either by returning a nested projection interface or
> by flattening with `@Value("#{target.address.zipCode}")`.



For all available functions and their descriptions, see [SpecificationProjections.java](../src/main/java/expresspecs/SpecificationProjections.java).

## The Magic of `smartDistinct`

When you join across a `OneToMany` collection (like searching for a `Customer` who has `orders` placed after a certain date), JPA will often return duplicate `Customer` rows.
Expand Down Expand Up @@ -366,3 +428,4 @@ To see a complete, fully working example of how all these pieces fit together, c
- **Repository:** [CustomerRepository.java](../src/test/java/expresspecs/example/CustomerRepository.java)
- **DSL Factory:** [CustomerSpecifications.java](../src/test/java/expresspecs/example/CustomerSpecifications.java)
- **Service Layer:** [CustomersService.java](../src/test/java/expresspecs/example/CustomersService.java)
- **Projection DTO:** [CustomerDTO.java](../src/test/java/expresspecs/example/CustomerDTO.java)
10 changes: 10 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<!--
Retain method/constructor parameter names in the bytecode. Spring Data's
class-based DTO projections (e.g. findBy(spec, q -> q.as(SomeDto.class)...))
bind result columns to constructor parameters by name. Since Spring Framework
6.1 removed LocalVariableTableParameterNameDiscoverer, those names are only
available when compiled with -parameters; without this, such projections fail
with ConverterNotFoundException. Records are unaffected (they carry component
names regardless), but plain class DTOs require this flag.
-->
<parameters>true</parameters>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
Expand Down
145 changes: 145 additions & 0 deletions src/main/java/expresspecs/SpecificationProjections.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package expresspecs;

import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Stream;

import org.jspecify.annotations.NonNull;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor.SpecificationFluentQuery;

/**
* Factory methods for the projection function argument of
* {@code JpaSpecificationExecutor.findBy(Specification, Function)}. Where the rest of this library
* expresses the {@code WHERE} clause as a {@link org.springframework.data.jpa.domain.Specification},
* these helpers express how the matching rows are projected and shaped into results, replacing the
* noisy fluent-query lambda with a single call that reads as a phrase.
*/
public class SpecificationProjections {

/**
* Builds a query function that returns all matching entities projected as the given type,
* for use with {@code JpaSpecificationExecutor.findBy(Specification, Function)}.
* <p>
* The projection type may be a DTO (closed projection bound by constructor arguments) or an
* interface (open projection), as supported by Spring Data's
* {@link SpecificationFluentQuery#as(Class)}. This lets callers fetch only the columns they need
* instead of full entities, without writing a separate query method.
* </p>
* <b>Example:</b>
*
* <pre>{@code
* List<CustomerDTO> dtos = repository.findBy(isActive(), projectedAs(CustomerDTO.class));
* }</pre>
*
* @param <EntityType> the entity type the underlying query operates on.
* @param <ProjectedType> the type each result is projected into.
* @param projectedType the class to project each matching entity into.
* @return a function that, given the fluent query, returns the projected results as a {@link List}.
*/
public static <EntityType, ProjectedType> @NonNull Function<SpecificationFluentQuery<EntityType>, List<ProjectedType>> projectedAs(@NonNull Class<ProjectedType> projectedType) {
return q -> q.as(projectedType).all();
}

/**
* Builds a query function that returns a page of matching entities projected as the given type,
* for use with {@code JpaSpecificationExecutor.findBy(Specification, Function)}.
* <p>
* This is the paged and sorted counterpart of {@link #projectedAs(Class)}. The supplied
* {@link Pageable} controls page size, offset, and sort order, and the result carries the usual
* pagination metadata. The projection type may be a DTO or an interface, as supported by Spring
* Data's {@link SpecificationFluentQuery#as(Class)}.
* </p>
* <b>Example:</b>
*
* <pre>{@code
* Page<CustomerDTO> dtos = repository.findBy(isActive(), projectedAs(CustomerDTO.class, pageable));
* }</pre>
*
* @param <EntityType> the entity type the underlying query operates on.
* @param <ProjectedType> the type each result is projected into.
* @param projectedType the class to project each matching entity into.
* @param page the paging and sorting information to apply.
* @return a function that, given the fluent query, returns the projected results as a {@link Page}.
*/
public static <EntityType, ProjectedType> @NonNull Function<SpecificationFluentQuery<EntityType>, Page<ProjectedType>> projectedAs(@NonNull Class<ProjectedType> projectedType, @NonNull Pageable page) {
return q -> q.as(projectedType).page(page);
}

/**
* Builds a query function that returns the single matching entity projected as the given type,
* for use with {@code JpaSpecificationExecutor.findBy(Specification, Function)}.
* <p>
* The projection type may be a DTO or an interface, as supported by Spring Data's
* {@link SpecificationFluentQuery#as(Class)}. The result is empty when nothing matches; an
* exception is thrown when more than one row matches.
* </p>
* <b>Example:</b>
*
* <pre>{@code
* Optional<CustomerDTO> dto = repository.findBy(hasEmail(email), oneProjectedAs(CustomerDTO.class));
* }</pre>
*
* @param <EntityType> the entity type the underlying query operates on.
* @param <ProjectedType> the type the result is projected into.
* @param projectedType the class to project the matching entity into.
* @return a function that, given the fluent query, returns the single projected result as an {@link Optional}.
*/
public static <EntityType, ProjectedType> @NonNull Function<SpecificationFluentQuery<EntityType>, Optional<ProjectedType>> oneProjectedAs(@NonNull Class<ProjectedType> projectedType) {
return q -> q.as(projectedType).one();
}

/**
* Builds a query function that returns the first matching entity projected as the given type,
* for use with {@code JpaSpecificationExecutor.findBy(Specification, Function)}.
* <p>
* Unlike {@link #oneProjectedAs(Class)}, this tolerates multiple matches and returns the first,
* so it is typically paired with a sort. The projection type may be a DTO or an interface, as
* supported by Spring Data's {@link SpecificationFluentQuery#as(Class)}. The result is empty when
* nothing matches.
* </p>
* <b>Example:</b>
*
* <pre>{@code
* Optional<CustomerDTO> dto = repository.findBy(isActive(), firstProjectedAs(CustomerDTO.class));
* }</pre>
*
* @param <EntityType> the entity type the underlying query operates on.
* @param <ProjectedType> the type the result is projected into.
* @param projectedType the class to project the matching entity into.
* @return a function that, given the fluent query, returns the first projected result as an {@link Optional}.
*/
public static <EntityType, ProjectedType> @NonNull Function<SpecificationFluentQuery<EntityType>, Optional<ProjectedType>> firstProjectedAs(@NonNull Class<ProjectedType> projectedType) {
return q -> q.as(projectedType).first();
}

/**
* Builds a query function that returns the matching entities projected as the given type as a
* lazily-evaluated {@link Stream}, for use with
* {@code JpaSpecificationExecutor.findBy(Specification, Function)}.
* <p>
* The projection type may be a DTO or an interface, as supported by Spring Data's
* {@link SpecificationFluentQuery#as(Class)}. The returned stream is backed by an open result set
* and must be consumed within the surrounding transaction and closed when done, for example with
* a try-with-resources block.
* </p>
* <b>Example:</b>
*
* <pre>{@code
* try (Stream<CustomerDTO> dtos = repository.findBy(isActive(), streamProjectedAs(CustomerDTO.class))) {
* dtos.forEach(...);
* }
* }</pre>
*
* @param <EntityType> the entity type the underlying query operates on.
* @param <ProjectedType> the type each result is projected into.
* @param projectedType the class to project each matching entity into.
* @return a function that, given the fluent query, returns the projected results as a {@link Stream}.
*/
public static <EntityType, ProjectedType> @NonNull Function<SpecificationFluentQuery<EntityType>, Stream<ProjectedType>> streamProjectedAs(@NonNull Class<ProjectedType> projectedType) {
return q -> q.as(projectedType).stream();
}

}
17 changes: 17 additions & 0 deletions src/test/java/expresspecs/example/CustomerDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package expresspecs.example;

import lombok.AllArgsConstructor;
import lombok.Getter;

/**
* Flat DTO projection of a {@link Customer}, used to demonstrate returning a projection instead of
* full entities via {@link expresspecs.SpecificationProjections}.
*/
@Getter
@AllArgsConstructor
public class CustomerDTO {

private Long id;
private final String name;
private final boolean isActive;
}
Loading
Loading