From 13520b0cb92fcc12dd9d078f1b1c4131f9e7524a Mon Sep 17 00:00:00 2001 From: Eric Rizzo Date: Wed, 17 Jun 2026 13:33:18 -0400 Subject: [PATCH] Added SpecificationProjects to help make projecting Specification query results easier, more expressive. --- README.md | 43 +++++- docs/usage-guide.md | 63 ++++++++ pom.xml | 10 ++ .../expresspecs/SpecificationProjections.java | 145 ++++++++++++++++++ .../java/expresspecs/example/CustomerDTO.java | 17 ++ .../expresspecs/example/CustomersService.java | 18 ++- .../CustomersServiceIntegrationTests.java | 80 ++++++++++ 7 files changed, 367 insertions(+), 9 deletions(-) create mode 100644 src/main/java/expresspecs/SpecificationProjections.java create mode 100644 src/test/java/expresspecs/example/CustomerDTO.java diff --git a/README.md b/README.md index 421b9f1..9903999 100644 --- a/README.md +++ b/README.md @@ -38,19 +38,20 @@ public Specification hasZipCodeOrMinCredit(List 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 @@ -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. @@ -125,13 +152,13 @@ public interface CustomerRepository extends JpaRepository, 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. diff --git a/docs/usage-guide.md b/docs/usage-guide.md index ebe161a..6e0301d 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -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) @@ -334,6 +335,67 @@ public Page findSpecialCustomers(Set 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 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 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` | you want all matches | +| `projectedAs(Type, Pageable)` | `Page` | you want a page, with the usual paging metadata | +| `oneProjectedAs(Type)` | `Optional` | at most one row matches (throws if more than one) | +| `firstProjectedAs(Type)` | `Optional` | several may match and you want the first | +| `streamProjectedAs(Type)` | `Stream` | 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 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 (`true` 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. @@ -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) diff --git a/pom.xml b/pom.xml index 6d9871e..4296974 100755 --- a/pom.xml +++ b/pom.xml @@ -120,6 +120,16 @@ maven-compiler-plugin 3.13.0 + + true org.projectlombok diff --git a/src/main/java/expresspecs/SpecificationProjections.java b/src/main/java/expresspecs/SpecificationProjections.java new file mode 100644 index 0000000..5de0e46 --- /dev/null +++ b/src/main/java/expresspecs/SpecificationProjections.java @@ -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)}. + *

+ * 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. + *

+ * Example: + * + *
{@code
+	 * List dtos = repository.findBy(isActive(), projectedAs(CustomerDTO.class));
+	 * }
+ * + * @param the entity type the underlying query operates on. + * @param 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 @NonNull Function, List> projectedAs(@NonNull Class 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)}. + *

+ * 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)}. + *

+ * Example: + * + *
{@code
+	 * Page dtos = repository.findBy(isActive(), projectedAs(CustomerDTO.class, pageable));
+	 * }
+ * + * @param the entity type the underlying query operates on. + * @param 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 @NonNull Function, Page> projectedAs(@NonNull Class 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)}. + *

+ * 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. + *

+ * Example: + * + *
{@code
+	 * Optional dto = repository.findBy(hasEmail(email), oneProjectedAs(CustomerDTO.class));
+	 * }
+ * + * @param the entity type the underlying query operates on. + * @param 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 @NonNull Function, Optional> oneProjectedAs(@NonNull Class 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)}. + *

+ * 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. + *

+ * Example: + * + *
{@code
+	 * Optional dto = repository.findBy(isActive(), firstProjectedAs(CustomerDTO.class));
+	 * }
+ * + * @param the entity type the underlying query operates on. + * @param 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 @NonNull Function, Optional> firstProjectedAs(@NonNull Class 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)}. + *

+ * 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. + *

+ * Example: + * + *
{@code
+	 * try (Stream dtos = repository.findBy(isActive(), streamProjectedAs(CustomerDTO.class))) {
+	 *     dtos.forEach(...);
+	 * }
+	 * }
+ * + * @param the entity type the underlying query operates on. + * @param 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 @NonNull Function, Stream> streamProjectedAs(@NonNull Class projectedType) { + return q -> q.as(projectedType).stream(); + } + +} diff --git a/src/test/java/expresspecs/example/CustomerDTO.java b/src/test/java/expresspecs/example/CustomerDTO.java new file mode 100644 index 0000000..173ec4f --- /dev/null +++ b/src/test/java/expresspecs/example/CustomerDTO.java @@ -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; +} diff --git a/src/test/java/expresspecs/example/CustomersService.java b/src/test/java/expresspecs/example/CustomersService.java index e29dc2e..df051ca 100644 --- a/src/test/java/expresspecs/example/CustomersService.java +++ b/src/test/java/expresspecs/example/CustomersService.java @@ -4,17 +4,21 @@ import static expresspecs.BasicSpecifications.isTrue; import static expresspecs.BasicSpecifications.where; import static expresspecs.RangeSpecifications.atLeast; +import static expresspecs.SpecificationProjections.projectedAs; import static expresspecs.StringSpecifications.containsIgnoreCase; import static expresspecs.example.CustomerSpecifications.hasAnyOrders; import static expresspecs.example.CustomerSpecifications.isActive; import static expresspecs.example.CustomerSpecifications.nameContainsIgnoreCase; import java.util.List; - +import org.jspecify.annotations.NonNull; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import expresspecs.BasicSpecifications; +import expresspecs.SpecificationProjections; import lombok.RequiredArgsConstructor; /** @@ -59,4 +63,16 @@ public List findActiveCustomersWithOrdersByName(String partialName) { .and(nameContainsIgnoreCase(partialName)) .and(hasAnyOrders())); } + + /** + * Returns a page of active customers as DTOs. This method demonstrates two things: + *
    + *
  • Using the {@link SpecificationProjections#projectedAs(Class, Pageable)} helper to return a projection instead + * of the entities.
  • + *
  • Paging and sorting with Specifications. + *
+ */ + public Page getActiveCustomers(@NonNull Pageable page) { + return repository.findBy(isActive(), projectedAs(CustomerDTO.class, page)); + } } diff --git a/src/test/java/expresspecs/example/CustomersServiceIntegrationTests.java b/src/test/java/expresspecs/example/CustomersServiceIntegrationTests.java index 3ac3869..a69dae8 100644 --- a/src/test/java/expresspecs/example/CustomersServiceIntegrationTests.java +++ b/src/test/java/expresspecs/example/CustomersServiceIntegrationTests.java @@ -1,11 +1,20 @@ package expresspecs.example; +import static expresspecs.SpecificationProjections.firstProjectedAs; +import static expresspecs.SpecificationProjections.oneProjectedAs; +import static expresspecs.SpecificationProjections.streamProjectedAs; +import static expresspecs.example.CustomerSpecifications.isActive; +import static expresspecs.example.CustomerSpecifications.nameIs; import static org.assertj.core.api.Assertions.assertThat; import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.transaction.annotation.Transactional; import lombok.Getter; @@ -41,4 +50,75 @@ void findActiveCustomersByName() { assertThat(results).containsExactly(alice); } + + @Test + void getActiveCustomers() { + Customer alice = Customer.builder().name("Alice").isActive(true).build(); + Customer bob = Customer.builder().name("Bob").isActive(true).build(); + Customer charlie = Customer.builder().name("Charlie").isActive(false).build(); + + persistAndFlush(alice); + persistAndFlush(bob); + persistAndFlush(charlie); + + Page page = customersService.getActiveCustomers(PageRequest.ofSize(10)); + + assertThat(page.getTotalElements()).isEqualTo(2); + + assertThat(page.getContent()) + .extracting(CustomerDTO::getName) + .containsExactlyInAnyOrder(alice.getName(), bob.getName()); + + assertThat(page.getContent()) + .extracting(CustomerDTO::getId) + .containsExactlyInAnyOrder(alice.getId(), bob.getId()); + + assertThat(page.getContent()).allSatisfy(dto -> assertThat(dto.isActive()).isTrue()); + } + + @Test + void oneProjectedAs_returnsTheSingleMatch() { + persistAndFlush(Customer.builder().name("Alice").isActive(true).build()); + persistAndFlush(Customer.builder().name("Bob").isActive(true).build()); + + Optional result = repo.findBy(nameIs("Alice"), oneProjectedAs(CustomerDTO.class)); + + assertThat(result).isPresent(); + assertThat(result.get().getName()).isEqualTo("Alice"); + } + + @Test + void oneProjectedAs_isEmptyWhenNothingMatches() { + persistAndFlush(Customer.builder().name("Alice").isActive(true).build()); + + Optional result = repo.findBy(nameIs("Nobody"), oneProjectedAs(CustomerDTO.class)); + + assertThat(result).isEmpty(); + } + + @Test + void firstProjectedAs_returnsAMatchWhenSeveralExist() { + persistAndFlush(Customer.builder().name("Alice").isActive(true).build()); + persistAndFlush(Customer.builder().name("Bob").isActive(true).build()); + persistAndFlush(Customer.builder().name("Charlie").isActive(false).build()); + + Optional result = repo.findBy(isActive(), firstProjectedAs(CustomerDTO.class)); + + assertThat(result).isPresent(); + assertThat(result.get().getName()).isIn("Alice", "Bob"); + assertThat(result.get().isActive()).isTrue(); + } + + @Test + void streamProjectedAs_streamsAllMatches() { + persistAndFlush(Customer.builder().name("Alice").isActive(true).build()); + persistAndFlush(Customer.builder().name("Bob").isActive(true).build()); + persistAndFlush(Customer.builder().name("Charlie").isActive(false).build()); + + try (Stream stream = repo.findBy(isActive(), streamProjectedAs(CustomerDTO.class))) { + assertThat(stream) + .extracting(CustomerDTO::getName) + .containsExactlyInAnyOrder("Alice", "Bob"); + } + } }