https://github.com/jwcarman/jpa-utils
A collection of Jakarta Persistence API utilities
https://github.com/jwcarman/jpa-utils
jakarta-persistence javaee jpa
Last synced: 8 months ago
JSON representation
A collection of Jakarta Persistence API utilities
- Host: GitHub
- URL: https://github.com/jwcarman/jpa-utils
- Owner: jwcarman
- License: apache-2.0
- Created: 2025-02-14T03:25:13.000Z (over 1 year ago)
- Default Branch: main
- Last Pushed: 2025-02-25T03:07:01.000Z (over 1 year ago)
- Last Synced: 2025-04-09T09:37:03.859Z (about 1 year ago)
- Topics: jakarta-persistence, javaee, jpa
- Language: Java
- Homepage:
- Size: 47.9 KB
- Stars: 0
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# JPA Utilities
[](https://github.com/jwcarman/jpa-utils/actions/workflows/maven.yml)
[](https://opensource.org/licenses/Apache-2.0)
[='java.version']/text()&label=Java&color=orange)](https://openjdk.org/)
[](https://central.sonatype.com/artifact/org.jwcarman.jpa/jpa-utils)
[](https://sonarcloud.io/summary/new_code?id=jwcarman_jpa-utils)
[](https://sonarcloud.io/summary/new_code?id=jwcarman_jpa-utils)
[](https://sonarcloud.io/summary/new_code?id=jwcarman_jpa-utils)
[](https://sonarcloud.io/summary/new_code?id=jwcarman_jpa-utils)
[](https://sonarcloud.io/summary/new_code?id=jwcarman_jpa-utils)
[](https://sonarcloud.io/summary/new_code?id=jwcarman_jpa-utils)
A collection of utilities for building REST APIs with [Jakarta Persistence API](https://jakarta.ee/specifications/persistence/), featuring framework-agnostic pagination, annotation-driven search, and optional Spring Data JPA integration.
## Table of Contents
- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Core Modules](#core-modules)
- [Base Entity](#base-entity)
- [Pagination](#pagination)
- [Search](#search)
- [Auditing](#auditing)
- [Spring Integration](#spring-integration)
- [REST Controller Integration](#rest-controller-integration)
- [Searchable Repository](#searchable-repository)
- [Error Handling](#error-handling)
- [Security Considerations](#security-considerations)
- [Performance Optimization](#performance-optimization)
- [Complete Example](#complete-example)
- [Requirements](#requirements)
- [Database Compatibility](#database-compatibility)
- [License](#license)
## Features
- **🔑 UUID-based Entity IDs**: Stable, time-based UUID v7 identifiers with optimistic locking
- **📄 Framework-Agnostic Pagination**: Clean architecture design decoupling web layer from domain
- **🔍 Annotation-Driven Search**: Mark fields as `@Searchable` for automatic LIKE query generation
- **🕒 Auditing Support**: Automatic tracking of creation/modification timestamps and users
- **🌱 Spring Integration**: Optional utilities for Spring Data JPA and Spring Web
- **🎯 Type-Safe Sorting**: Enum-based sort field definitions with automatic resolution
- **✨ Clean Architecture**: Proper dependency direction (web → app → domain)
## Installation
Add the dependency to your `pom.xml`:
```xml
org.jwcarman.jpa
jpa-utils
1.0-SNAPSHOT
```
## Quick Start
### 1. Define Your Entity
```java
@Entity
public class Person extends BaseEntity {
@Searchable
private String firstName;
@Searchable
private String lastName;
private String email;
protected Person() {}
public Person(String firstName, String lastName, String email) {
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
}
// Getters and setters...
}
```
### 2. Define Sort Fields
```java
public enum PersonSort implements SortPropertyProvider {
FIRST_NAME("firstName"),
LAST_NAME("lastName"),
EMAIL("email");
private final String sortProperty;
PersonSort(String sortProperty) {
this.sortProperty = sortProperty;
}
@Override
public String getSortProperty() {
return sortProperty;
}
}
```
### 3. Create a Repository
```java
@Repository
public interface PersonRepository extends SearchableRepository {
}
```
### 4. Build a REST Controller
```java
@RestController
@RequestMapping("/api/persons")
public class PersonController {
@Autowired
private PersonService personService;
@GetMapping
public PageDto search(
@RequestParam(required = false) String query,
PageParams pageParams) {
return personService.search(query, pageParams);
}
}
```
### 5. Implement the Service
```java
@Service
public class PersonService {
@Autowired
private PersonRepository personRepository;
public PageDto search(String query, PageSpec pageSpec) {
Pageable pageable = Pageables.pageableOf(pageSpec, PersonSort.class);
Page page = personRepository.search(query, pageable);
return Pages.pageDtoOf(page.map(PersonDto::fromEntity));
}
}
```
**Example Request:**
```
GET /api/persons?query=john&pageIndex=0&pageSize=20&sortBy=LAST_NAME&sortDirection=ASC
```
## Core Modules
### Base Entity
`BaseEntity` provides UUID-based primary keys and optimistic locking for all entities.
#### Key Features
- **UUID v7 Identifiers**: Time-based epoch UUIDs for better database locality
- **Stable IDs**: Generated at construction time, before persistence
- **Optimistic Locking**: Built-in `@Version` field
- **Final equals/hashCode**: Based on UUID for stable behavior
#### Usage
```java
@Entity
public class Product extends BaseEntity {
private String name;
private BigDecimal price;
protected Product() {}
public Product(String name, BigDecimal price) {
this.name = name;
this.price = price;
}
}
```
### Pagination
Framework-agnostic pagination contracts that maintain clean architecture principles.
#### Core Interfaces
**`PageSpec`** - Pagination specification
```java
public interface PageSpec {
Integer pageIndex(); // Zero-based page number
Integer pageSize(); // Items per page
String sortBy(); // Sort field name (e.g., "LAST_NAME")
SortDirection sortDirection(); // ASC or DESC
}
```
**`SortPropertyProvider`** - Maps sort enum constants to JPA properties
```java
public interface SortPropertyProvider {
String getSortProperty(); // Returns JPA property path
}
```
**`PageDto`** - Response containing page data and metadata
```java
public record PageDto(
List data, // Page content
PaginationDto pagination // Metadata (pageIndex, pageSize, totalPages, etc.)
) {}
```
#### Example Sort Enum
```java
public enum ProductSort implements SortPropertyProvider {
NAME("name"),
PRICE("price"),
CREATED_DATE("createdDate"),
CATEGORY_NAME("category.name"); // Nested property
private final String sortProperty;
ProductSort(String sortProperty) {
this.sortProperty = sortProperty;
}
@Override
public String getSortProperty() {
return sortProperty;
}
}
```
### Search
Annotation-driven search functionality using JPA Criteria API.
#### `@Searchable` Annotation
Mark fields that should be included in search queries:
```java
@Entity
public class Article extends BaseEntity {
@Searchable
private String title;
@Searchable
private String summary;
private String content; // Not searchable
// ...
}
```
#### `Searchables` Utility
Automatically builds case-insensitive LIKE predicates for all `@Searchable` String fields:
```java
// Automatically searches across title and summary fields
Specification spec = (root, query, cb) ->
Searchables.createSearchPredicate("spring boot", root, cb);
Page results = articleRepository.findAll(spec, pageable);
```
**Features:**
- Case-insensitive LIKE queries
- Automatic SQL wildcard escaping (`%`, `_`, `\`)
- OR logic across all searchable fields
- Null-safe (returns all results when search term is null/blank)
### Auditing
`AuditableEntity` extends `BaseEntity` with automatic tracking of creation and modification metadata.
#### Fields
- `createdDate` - When the entity was created
- `modifiedDate` - When the entity was last modified
- `createdBy` - Username/identifier who created the entity
- `modifiedBy` - Username/identifier who last modified the entity
#### Usage
```java
@Entity
public class Order extends AuditableEntity {
private BigDecimal total;
protected Order() {}
public Order(BigDecimal total) {
this.total = total;
}
}
```
#### Spring Configuration
Enable auditing and provide user information:
```java
@Configuration
@EnableJpaAuditing
public class AuditConfig {
@Bean
public AuditorAware auditorProvider() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication)
.filter(Authentication::isAuthenticated)
.map(Authentication::getName);
}
}
```
## Spring Integration
### REST Controller Integration
#### Using `PageParams`
`PageParams` is a record that implements `PageSpec` and automatically binds Spring Web query parameters:
```java
@RestController
@RequestMapping("/api/products")
public class ProductController {
@Autowired
private ProductService productService;
@GetMapping
public PageDto getProducts(PageParams pageParams) {
// Spring automatically binds these query parameters:
// - pageIndex
// - pageSize
// - sortBy
// - sortDirection
return productService.findAll(pageParams);
}
}
```
**All parameters are optional:**
```
GET /api/products # Uses defaults
GET /api/products?pageIndex=0&pageSize=10 # Pagination only
GET /api/products?sortBy=PRICE&sortDirection=DESC # Sorting only
GET /api/products?pageIndex=1&pageSize=25&sortBy=NAME&sortDirection=ASC # Full
```
#### Custom Parameter Names
If you need different URL parameter names:
```java
@GetMapping
public PageDto getProducts(
@RequestParam(required = false) Integer page,
@RequestParam(required = false) Integer size,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) SortDirection sortDirection) {
PageParams params = new PageParams(page, size, sortBy, sortDirection);
return productService.findAll(params);
}
// URL: /api/products?page=0&size=10&sortBy=NAME&sortDirection=ASC
```
#### Converting with `Pageables` and `Pages`
**`Pageables`** - Converts `PageSpec` to Spring's `Pageable`:
```java
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
public PageDto findAll(PageSpec pageSpec) {
// Automatically resolves sort field string to ProductSort enum
Pageable pageable = Pageables.pageableOf(pageSpec, ProductSort.class);
// Or with custom default page size
// Pageable pageable = Pageables.pageableOf(pageSpec, ProductSort.class, 50);
Page page = productRepository.findAll(pageable);
return Pages.pageDtoOf(page.map(ProductDto::fromEntity));
}
}
```
**Default Values:**
- Page index: 0 (first page)
- Page size: 20
- Sort: unsorted
- Sort direction: ASC
**`Pages`** - Converts Spring's `Page` to framework-agnostic `PageDto`:
```java
Page springPage = productRepository.findAll(pageable);
PageDto pageDto = Pages.pageDtoOf(
springPage.map(product -> new ProductDto(product))
);
```
### Searchable Repository
`SearchableRepository` combines `JpaRepository` with built-in search support:
```java
@Repository
public interface ProductRepository extends SearchableRepository {
// Inherits search() method automatically
}
```
**Usage in Service:**
```java
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
public PageDto search(String query, PageSpec pageSpec) {
Pageable pageable = Pageables.pageableOf(pageSpec, ProductSort.class);
Page page = productRepository.search(query, pageable);
return Pages.pageDtoOf(page.map(ProductDto::fromEntity));
}
}
```
**Controller:**
```java
@RestController
@RequestMapping("/api/products")
public class ProductController {
@Autowired
private ProductService productService;
@GetMapping
public PageDto search(
@RequestParam(required = false) String query,
PageParams pageParams) {
return productService.search(query, pageParams);
}
}
// Example: GET /api/products?query=laptop&pageIndex=0&pageSize=20&sortBy=PRICE&sortDirection=ASC
```
## Error Handling
### Invalid Sort Fields
When an invalid sort field is provided, `Pageables.pageableOf()` throws `UnknownSortByValueException`. This typically happens when a user provides a sort field name that doesn't match any of your defined enum constants.
**Service Layer Handling:**
```java
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
public PageDto findAll(PageSpec pageSpec) {
try {
Pageable pageable = Pageables.pageableOf(pageSpec, ProductSort.class);
Page page = productRepository.findAll(pageable);
return Pages.pageDtoOf(page.map(ProductDto::fromEntity));
} catch (UnknownSortByValueException e) {
// e.getProvidedValue() - the invalid value the user sent
// e.getExpectedValues() - array of valid enum constant names
throw new BadRequestException("Invalid sort field: " + e.getProvidedValue());
}
}
}
```
**Spring Boot Controller Advice:**
Map the exception to HTTP 400 Bad Request using RFC 7807 `ProblemDetail`:
```java
@RestControllerAdvice
public class UnknownSortByValueExceptionAdvice {
@ExceptionHandler(UnknownSortByValueException.class)
public ProblemDetail handleUnknownSortByValue(UnknownSortByValueException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
```
**Optional: Add custom properties for debugging:**
```java
@RestControllerAdvice
public class UnknownSortByValueExceptionAdvice {
@ExceptionHandler(UnknownSortByValueException.class)
public ProblemDetail handleUnknownSortByValue(UnknownSortByValueException e) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
e.getMessage()
);
problemDetail.setProperty("providedValue", e.getProvidedValue());
problemDetail.setProperty("expectedValues", e.getExpectedValues());
return problemDetail;
}
}
```
**Example Error Response:**
When a user requests `GET /api/products?sortBy=INVALID_FIELD`:
```json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Unknown sort by value \"INVALID_FIELD\", expecting one of NAME, PRICE, CREATED_DATE, CATEGORY_NAME."
}
```
**With optional properties:**
```json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Unknown sort by value \"INVALID_FIELD\", expecting one of NAME, PRICE, CREATED_DATE, CATEGORY_NAME.",
"providedValue": "INVALID_FIELD",
"expectedValues": ["NAME", "PRICE", "CREATED_DATE", "CATEGORY_NAME"]
}
```
**Note:** Sort field matching is case-sensitive. If your enum constant is `LAST_NAME`, the client must send `sortBy=LAST_NAME` (not `last_name` or `lastName`).
## Security Considerations
### Search Term Length Validation
For production applications, validate search term length at the controller level to prevent excessively long LIKE patterns from impacting database performance:
```java
@RestController
@RequestMapping("/api/products")
public class ProductController {
@Autowired
private ProductService productService;
@GetMapping
public PageDto search(
@RequestParam(required = false)
@Size(max = 200, message = "Search term must not exceed 200 characters")
String query,
PageParams pageParams) {
return productService.search(query, pageParams);
}
}
```
Spring Boot will automatically return HTTP 400 Bad Request when the validation fails:
```json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid request content.",
"instance": "/api/products"
}
```
**Recommended Limits:**
- **General search**: 100-200 characters (covers most legitimate search queries)
- **Long-form content search**: 500 characters (for searching articles, descriptions, etc.)
- **Never unlimited**: Always enforce some reasonable maximum
### Page Size Limits
The library automatically clamps page sizes to `Pageables.MAX_PAGE_SIZE` (1000) to prevent memory exhaustion attacks. However, you may want to enforce stricter limits at the service or controller level for your specific use case:
```java
@GetMapping
public PageDto getProducts(
@RequestParam(required = false)
@Min(1)
@Max(100)
Integer pageSize,
PageParams pageParams) {
// Custom validation: max 100 items per page for this endpoint
return productService.findAll(pageParams);
}
```
## Performance Optimization
### Query Plan Caching
The library is already optimized for query plan caching with minimal configuration needed. Hibernate automatically uses bind parameters for String literals (the default `AUTO` mode), which means search queries are already benefiting from query plan reuse.
**What's Already Optimized:**
String literals in search patterns are automatically bound as parameters:
```sql
-- All searches use the same query plan (automatic with Hibernate's AUTO mode)
WHERE LOWER(firstName) LIKE ?1 OR LOWER(lastName) LIKE ?2
-- Parameters: ?1='%john%', ?2='%john%' (or '%jane%', etc.)
```
The library also caches searchable field metadata (via reflection) internally, so field discovery only happens once per entity type.
**Optional: Full BIND Mode (Future-Proofing)**
If you plan to extend the library with numeric or date field searching, you can configure Hibernate to use bind parameters for **all** literal types:
```properties
# Use bind parameters for all literals, including numbers (default: AUTO uses bind for Strings only)
spring.jpa.properties.hibernate.criteria.literal_handling_mode=BIND
# Optional: Adjust query plan cache size (default: 2048)
spring.jpa.properties.hibernate.query.plan_cache_max_size=2048
```
**Hibernate Literal Handling Modes:**
- `AUTO` (default): Uses bind parameters for Strings, inlines numeric values
- `BIND`: Uses bind parameters for all literal types (Strings, numbers, dates)
- `INLINE`: Inlines all literals (not recommended - poor caching, potential SQL injection risk)
**Performance Impact:**
- String-based `@Searchable` searches already benefit from query plan caching (no configuration needed)
- `BIND` mode provides benefits if you add numeric or date field searching in the future
- High-traffic search endpoints will see query compilation overhead reduced by 500%+ compared to inline literals
## Complete Example
### Entity
```java
@Entity
public class Book extends AuditableEntity {
@Searchable
private String title;
@Searchable
private String author;
private String isbn;
@Searchable
private String publisher;
private BigDecimal price;
protected Book() {}
public Book(String title, String author, String isbn,
String publisher, BigDecimal price) {
this.title = title;
this.author = author;
this.isbn = isbn;
this.publisher = publisher;
this.price = price;
}
// Getters and setters...
}
```
### Sort Enum
```java
public enum BookSort implements SortPropertyProvider {
TITLE("title"),
AUTHOR("author"),
PUBLISHER("publisher"),
PRICE("price"),
CREATED("createdDate");
private final String sortProperty;
BookSort(String sortProperty) {
this.sortProperty = sortProperty;
}
@Override
public String getSortProperty() {
return sortProperty;
}
}
```
### Repository
```java
@Repository
public interface BookRepository extends SearchableRepository {
}
```
### Service
```java
@Service
public class BookService {
@Autowired
private BookRepository bookRepository;
public PageDto search(String query, PageSpec pageSpec) {
Pageable pageable = Pageables.pageableOf(pageSpec, BookSort.class);
Page page = bookRepository.search(query, pageable);
return Pages.pageDtoOf(page.map(BookDto::fromEntity));
}
}
```
### Controller
```java
@RestController
@RequestMapping("/api/books")
public class BookController {
@Autowired
private BookService bookService;
@GetMapping
public PageDto search(
@RequestParam(required = false) String query,
PageParams pageParams) {
return bookService.search(query, pageParams);
}
}
```
### Example Requests
```bash
# Get first page of all books, sorted by title
GET /api/books?pageIndex=0&pageSize=20&sortBy=TITLE&sortDirection=ASC
# Search for "spring" in title, author, or publisher
GET /api/books?query=spring&pageIndex=0&pageSize=10
# Get page 2, sorted by price descending
GET /api/books?pageIndex=1&pageSize=25&sortBy=PRICE&sortDirection=DESC
# Search with custom page size
GET /api/books?query=java&pageSize=50
```
### Example Response
```json
{
"data": [
{
"id": "01234567-89ab-cdef-0123-456789abcdef",
"title": "Spring in Action",
"author": "Craig Walls",
"isbn": "978-1617294945",
"publisher": "Manning",
"price": 44.99,
"createdDate": "2024-01-15T10:30:00Z",
"modifiedDate": "2024-01-15T10:30:00Z"
}
],
"pagination": {
"pageIndex": 0,
"pageSize": 20,
"totalElementCount": 127,
"totalPageCount": 7,
"hasNext": true,
"hasPrevious": false
}
}
```
## Requirements
- **Java**: 21 or higher
- **Jakarta Persistence API**: 3.1.0
- **Spring Framework** (optional): 6.2.2 or higher
- **Spring Data JPA** (optional): 2024.1.2 or higher
## Database Compatibility
This library is thoroughly tested against multiple database platforms using [Testcontainers](https://testcontainers.com/) to ensure compatibility across different SQL dialects and implementations.
**Tested Databases:**
- ✅ **PostgreSQL 17.6** - Open source, standards-compliant
- ✅ **MySQL 9.2** - Most widely deployed open source database
- ✅ **MariaDB 11.6** - MySQL fork with enhancements
- ✅ **CockroachDB v25.3** - Distributed SQL, PostgreSQL-compatible
- ✅ **Oracle Database Free 23** - Enterprise standard
- ✅ **H2** - Embedded database for testing
**Test Coverage:**
- 75+ integration tests across 5 database platforms
- Comprehensive validation of pagination, sorting, and search functionality
- SQL wildcard escaping and edge case handling
- Multi-field search across different SQL dialects
All integration tests run automatically in CI/CD to ensure consistent behavior across all supported databases.
## License
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.