Q Class / Specification Guide
1. Overview
ElastiCORE automatically generates Q classes from entity definitions. A Q class wraps the JPA Specification pattern and serves as the core tool for building type-safe dynamic queries.
What Q Classes Provide
- Type-safe condition builder: Validates field names and types at compile time.
- JoinSpec: An advanced query builder supporting joins, projections, and subqueries.
- UpdateSpec: Performs bulk updates based on WHERE conditions.
- DeleteSpec: Performs bulk deletes based on WHERE conditions.
- checkEmpty pattern: Automatically ignores null or empty values to concisely handle optional conditions.
Q classes are useful in any dynamic search scenario that uses Spring Data JPA's JpaSpecificationExecutor. They are especially powerful when search conditions vary dynamically based on user input.
2. Q Class Structure
How It Is Generated
ElastiCORE automatically generates a domain-specific Q.java file according to the q namespace configured in env.yml. An inner class is created for each entity, containing its field information and condition methods.
Auto-Generated Q Class Structure
public class Q {
// Singleton instances per entity
public static $Article<Article> Article = new $Article<>();
public static $User<User> User = new $User<>();
public static $Category<Category> Category = new $Category<>();
/**
* Q class for the Article entity
*/
public static class $Article<T> {
// ═══ Scalar fields ═══
// FieldInfo getters - used in JoinSpec for JOIN, SELECT, ORDER BY, etc.
public final FieldInfo<T> getId() { ... }
public final FieldInfo<T> getTitle() { ... }
public final FieldInfo<T> getContent() { ... }
public final FieldInfo<T> getCreatedAt() { ... }
// String constants - used in JPQL or native queries
public final String id = "id";
public final String title = "title";
public final String content = "content";
public final String createdAt = "createdAt";
// Condition methods - return Specification<T>
public Specification<T> id(Op op, Object value) { ... }
public Specification<T> id(Op op, Object value, boolean checkEmpty) { ... }
public Specification<T> title(Op op, Object value) { ... }
public Specification<T> title(Op op, Object value, boolean checkEmpty) { ... }
// ═══ Association fields (nested navigation) ═══
// @ManyToOne, @OneToOne, etc. association fields
public $Category<T> category() { ... } // Q.Article.category().name(Op.EQ, "Tech")
public $User<T> author() { ... } // Q.Article.author().email(Op.LIKE, "@gmail.com")
// FieldInfo getters for association fields
public final FieldInfo<T> getCategory() { ... }
public final FieldInfo<T> getAuthor() { ... }
public final FieldInfo<T> getComments() { ... }
// ═══ Factory methods ═══
public static JoinSpec<Article> joinSpec() { ... }
public static UpdateSpec<Article> updateSpec() { ... }
public static DeleteSpec<Article> deleteSpec() { ... }
}
}
Key Components Summary
| Component | Return Type | Purpose | Example |
|---|---|---|---|
getField() | FieldInfo<T> | Used in JOIN, SELECT, ORDER BY | Q.Article.getTitle() |
field (string constant) | String | Used in JPQL or native queries | Q.Article.title → "title" |
field(Op, value) | Specification<T> | Creates a WHERE condition | Q.Article.title(Op.LIKE, "keyword") |
field(Op, value, checkEmpty) | Specification<T> | Optional WHERE condition | Q.Article.title(Op.LIKE, keyword, true) |
relation() | $RelatedEntity<T> | Nested path navigation | Q.Article.category().name(Op.EQ, "Tech") |
joinSpec() | JoinSpec<T> | Creates an advanced query builder | Q.Article.joinSpec() |
updateSpec() | UpdateSpec<T> | Creates a bulk update builder | Q.Article.updateSpec() |
deleteSpec() | DeleteSpec<T> | Creates a bulk delete builder | Q.Article.deleteSpec() |
3. Operators (Op)
ElastiCORE provides 15 comparison operators. All operators are defined in the Op enum.
| Operator | Description | SQL Mapping | Example |
|---|---|---|---|
Op.EQ | Equals | = value | Q.User.status(Op.EQ, "ACTIVE") |
Op.NEQ | Not equals | != value | Q.User.status(Op.NEQ, "DELETED") |
Op.LIKE | Contains | LIKE '%value%' | Q.User.name(Op.LIKE, "hong") |
Op.NOT_LIKE | Does not contain | NOT LIKE '%value%' | Q.User.name(Op.NOT_LIKE, "test") |
Op.STARTS_WITH | Starts with | LIKE 'value%' | Q.User.name(Op.STARTS_WITH, "Kim") |
Op.ENDS_WITH | Ends with | LIKE '%value' | Q.User.email(Op.ENDS_WITH, "@gmail.com") |
Op.GT | Greater than | > value | Q.User.age(Op.GT, 20) |
Op.GTE | Greater than or equal | >= value | Q.User.age(Op.GTE, 18) |
Op.LT | Less than | < value | Q.User.age(Op.LT, 65) |
Op.LTE | Less than or equal | <= value | Q.User.age(Op.LTE, 60) |
Op.IN | Included in | IN (values) | Q.User.role(Op.IN, roleList) |
Op.NOT_IN | Not included in | NOT IN (values) | Q.User.status(Op.NOT_IN, excludeList) |
Op.IS_NULL | Is null | IS NULL | Q.User.deletedAt(Op.IS_NULL, null) |
Op.IS_NOT_NULL | Is not null | IS NOT NULL | Q.User.email(Op.IS_NOT_NULL, null) |
Op.BETWEEN | Range | BETWEEN a AND b | Q.User.age(Op.BETWEEN, List.of(18, 65)) |
Op.LIKEautomatically wraps the value with%on both sides. You do not need to add%manually.Op.IS_NULLandOp.IS_NOT_NULLignore the value parameter. By convention, passnull.Op.BETWEENrequires a List with exactly 2 elements as the value.Op.INandOp.NOT_INaccept aCollectiontype value.
4. Basic Usage
AND / OR Combinations
Condition methods on Q classes return Specification<T>, which can be freely combined with .and() and .or().
// AND combination - all conditions must be satisfied simultaneously
Specification<User> spec = Q.User.name(Op.LIKE, "홍길동")
.and(Q.User.status(Op.EQ, "ACTIVE"))
.and(Q.User.age(Op.GTE, 20));
// OR combination - at least one condition must be satisfied
Specification<User> spec2 = Q.User.name(Op.LIKE, "홍")
.or(Q.User.email(Op.LIKE, "hong"));
// AND + OR compound condition
// (status = 'ACTIVE' AND age >= 18) OR (role = 'ADMIN')
Specification<User> activeAdults = Q.User.status(Op.EQ, "ACTIVE")
.and(Q.User.age(Op.GTE, 18));
Specification<User> admins = Q.User.role(Op.EQ, "ADMIN");
Specification<User> spec3 = Specification.where(activeAdults).or(admins);
Optional Conditions (checkEmpty Pattern)
In practice, search forms mostly have optional inputs. Using checkEmpty=true automatically ignores the condition when the value is null, an empty string (""), or an empty collection.
// checkEmpty=true: ignores the condition when value is null, "", or empty collection (returns conjunction)
Specification<User> spec = Q.User.name(Op.LIKE, searchDTO.getName(), true)
.and(Q.User.status(Op.EQ, searchDTO.getStatus(), true))
.and(Q.User.age(Op.GTE, searchDTO.getMinAge(), true));
When checkEmpty=false (the default) and a null value is passed, an IS NULL comparison is performed. To avoid unintended results, always use checkEmpty=true for optional conditions.
Comparison of checkEmpty pattern benefits:
// ❌ Old approach - code gets verbose with repeated if statements
Specification<User> spec = Specification.where(null);
if (dto.getName() != null && !dto.getName().isEmpty()) {
spec = spec.and(Q.User.name(Op.LIKE, dto.getName()));
}
if (dto.getStatus() != null && !dto.getStatus().isEmpty()) {
spec = spec.and(Q.User.status(Op.EQ, dto.getStatus()));
}
if (dto.getMinAge() != null) {
spec = spec.and(Q.User.age(Op.GTE, dto.getMinAge()));
}
// ✅ checkEmpty pattern - clean chaining
Specification<User> spec = Q.User.name(Op.LIKE, dto.getName(), true)
.and(Q.User.status(Op.EQ, dto.getStatus(), true))
.and(Q.User.age(Op.GTE, dto.getMinAge(), true));
Practical Search Service Example
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
/**
* 사용자 검색 - 모든 조건은 선택적
*/
public Page<User> searchUsers(UserSearchDTO dto) {
Specification<User> spec = Q.User.name(Op.LIKE, dto.getName(), true)
.and(Q.User.status(Op.EQ, dto.getStatus(), true))
.and(Q.User.role(Op.IN, dto.getRoles(), true))
.and(Q.User.createdAt(Op.GTE, dto.getStartDate(), true))
.and(Q.User.createdAt(Op.LTE, dto.getEndDate(), true))
.and(Q.User.email(Op.ENDS_WITH, dto.getEmailDomain(), true));
return userRepository.findAll(spec, dto.getPageable());
}
/**
* 활성 사용자 수 조회
*/
public long countActiveUsers() {
Specification<User> spec = Q.User.status(Op.EQ, "ACTIVE")
.and(Q.User.deletedAt(Op.IS_NULL, null));
return userRepository.count(spec);
}
/**
* 이메일로 사용자 존재 여부 확인
*/
public boolean existsByEmail(String email) {
Specification<User> spec = Q.User.email(Op.EQ, email);
return userRepository.count(spec) > 0;
}
}
5. JoinSpec - Advanced Query Builder
JoinSpec is the most powerful query builder provided by ElastiCORE. It allows you to compose complex query requirements — including JOINs, projections, subqueries, and sorting — in a type-safe manner.
JoinSpec<T> implements Specification<T>, so it can be passed directly to Spring Data JPA's findAll(Specification, Pageable) method.
Basic Usage
JoinSpec<Article> spec = Q.Article.joinSpec()
.where(Q.Article.title(Op.LIKE, "ElastiCORE"))
.orderByDesc(Q.Article.getCreatedAt());
List<Article> articles = articleRepository.findAll(spec);
JOIN
Various join types can be composed via method chaining.
JoinSpec<Article> spec = Q.Article.joinSpec()
.join(Q.Article.getCategory()) // INNER JOIN - excludes articles without a category
.leftJoin(Q.Article.getAuthor()) // LEFT JOIN - includes articles even without an author
.fetchJoin(Q.Article.getComments()) // LEFT JOIN FETCH - eager load comments (prevents N+1)
.where(Q.Article.category().name(Op.EQ, "Tech"));
| Join Method | SQL Mapping | Use Case |
|---|---|---|
join() | INNER JOIN | When the associated entity must exist |
leftJoin() | LEFT JOIN | When results should include rows without an associated entity |
fetchJoin() | LEFT JOIN FETCH | Eager loading to prevent N+1 problems |
When using fetchJoin() together with pagination, Hibernate performs paging in memory (HHH-000104 warning). For large datasets, consider using @BatchSize or separating into two queries. ElastiCORE automatically ignores fetchJoin in count queries to prevent the HHH-7413 error.
Nested Navigation
You can traverse deep paths along associations. Required JOINs are automatically generated internally.
// Navigating the path: comment → article → category → name
JoinSpec<Comment> spec = Q.Comment.joinSpec()
.leftJoin(Q.Comment.getArticle())
.where(Q.Comment.article().category().name(Op.EQ, "Tech"));
// Navigating the path: order → customer → address → city
JoinSpec<Order> spec2 = Q.Order.joinSpec()
.leftJoin(Q.Order.getCustomer())
.where(Q.Order.customer().address().city(Op.EQ, "서울"));
Cross Join
Performs a join between entities that have no direct association. The join condition must be specified with link().
JoinSpec<Order> spec = Q.Order.joinSpec()
.crossJoin(Product.class, "p")
.link(Q.Order.getProductId(), Q.Product.getId()).strict()
.where(Q.Product.price(Op.GT, 10000));
Calling link() before crossJoin() will throw an IllegalStateException. Always follow the order: crossJoin() → link(). .strict() means the link condition must match exactly.
Projection (SELECT)
You can select only specific columns. This is useful when mapping results to a DTO or returning them as a Map.
// Select specific fields
JoinSpec<Article> spec = Q.Article.joinSpec()
.select(Q.Article.getTitle(), Q.Article.getCreatedAt())
.select(Q.Article.category().getName(), "categoryName") // with alias
.distinct();
// Retrieve results as a Map
List<Map<String, Object>> results = spec.findAllAsMap(entityManager);
// Result: [{"title": "ElastiCORE 소개", "createdAt": ..., "categoryName": "Tech"}, ...]
// Map directly to a DTO
List<ArticleSummaryDTO> dtos = spec.findAll(entityManager, ArticleSummaryDTO.class);
Scalar Subquery (Subquery in SELECT clause)
You can include aggregate subqueries in the SELECT clause.
SubQuery<Long> commentCount = SubQuery.count(Comment.class)
.where(Q.Comment.articleId(Op.EQ, /* correlated */));
JoinSpec<Article> spec = Q.Article.joinSpec()
.select(Q.Article.getTitle())
.select(Q.Article.getCreatedAt())
.selectSubquery("commentCount", commentCount);
List<Map<String, Object>> results = spec.findAllAsMap(entityManager);
// Result: [{"title": "ElastiCORE 소개", "createdAt": ..., "commentCount": 15}, ...]
ORDER BY
Add sort conditions via chaining. Priority is determined by the order in which conditions are added.
JoinSpec<Article> spec = Q.Article.joinSpec()
.orderByDesc(Q.Article.getCreatedAt()) // Primary sort: creation date descending
.orderByAsc(Q.Article.getTitle()); // Secondary sort: title ascending
Execution Methods
JoinSpec can be executed in two ways.
Direct execution using EntityManager:
// Retrieve as Map (select() required)
List<Map<String, Object>> maps = spec.findAllAsMap(em);
// Retrieve with DTO mapping
List<ArticleDTO> dtos = spec.findAll(em, ArticleDTO.class);
// DTO + pagination
Page<ArticleDTO> page = spec.findAll(em, ArticleDTO.class, pageable);
// Count query
long count = spec.count(em);
Execution through Spring Data Repository:
// JoinSpec implements Specification<T> and can be passed directly to the Repository
List<Article> list = articleRepository.findAll(spec);
Page<Article> page = articleRepository.findAll(spec, pageable);
long count = articleRepository.count(spec);
JoinSpec Comprehensive Example
@Service
@RequiredArgsConstructor
public class ArticleService {
private final EntityManager em;
private final ArticleRepository articleRepository;
/**
* 카테고리별 게시글 목록 조회 (작성자 정보 포함)
*/
public Page<ArticleListDTO> getArticlesByCategory(
String categoryName, String keyword, Pageable pageable) {
JoinSpec<Article> spec = Q.Article.joinSpec()
.leftJoin(Q.Article.getAuthor())
.join(Q.Article.getCategory())
.select(Q.Article.getId())
.select(Q.Article.getTitle())
.select(Q.Article.getCreatedAt())
.select(Q.Article.author().getName(), "authorName")
.select(Q.Article.category().getName(), "categoryName")
.where(Q.Article.category().name(Op.EQ, categoryName))
.where(Q.Article.title(Op.LIKE, keyword, true))
.where(Q.Article.status(Op.EQ, "PUBLISHED"))
.orderByDesc(Q.Article.getCreatedAt());
return spec.findAll(em, ArticleListDTO.class, pageable);
}
/**
* 댓글이 많은 인기 게시글 조회
*/
public List<Article> getPopularArticles(int minCommentCount) {
SubQuery<Long> commentCount = SubQuery.count(Comment.class)
.where(Q.Comment.articleId(Op.EQ, /* correlated */));
JoinSpec<Article> spec = Q.Article.joinSpec()
.fetchJoin(Q.Article.getAuthor())
.where(Q.Article.status(Op.EQ, "PUBLISHED"))
.orderByDesc(Q.Article.getCreatedAt());
return articleRepository.findAll(spec);
}
}
6. SubQuery
Subqueries are used to construct queries nested inside a main query. ElastiCORE supports various subquery patterns including IN, NOT IN, EXISTS, and NOT EXISTS.
IN / NOT IN Subquery
// Retrieve only orders from active users
SubQuery<Long> activeUserIds = SubQuery.select(Q.User.getId())
.where(Q.User.status(Op.EQ, "ACTIVE"));
JoinSpec<Order> spec = Q.Order.joinSpec()
.subquery(Q.Order.getUserId(), SubQuery.Op.IN, activeUserIds);
// Retrieve products not belonging to certain categories
SubQuery<Long> excludeCategoryIds = SubQuery.select(Q.Category.getId())
.where(Q.Category.type(Op.EQ, "HIDDEN"));
JoinSpec<Product> spec2 = Q.Product.joinSpec()
.subquery(Q.Product.getCategoryId(), SubQuery.Op.NOT_IN, excludeCategoryIds);
EXISTS / NOT EXISTS Subquery
Use a correlated subquery to check for the existence of related data.
// Retrieve only articles that have comments
SubQuery<Integer> hasComments = SubQuery.from(Comment.class)
.correlate(Q.Comment.getArticleId(), Q.Article.getId());
JoinSpec<Article> spec = Q.Article.joinSpec()
.exists(hasComments);
// Retrieve only articles with no comments
JoinSpec<Article> spec2 = Q.Article.joinSpec()
.notExists(hasComments);
// Retrieve customers with no orders in the last 30 days
SubQuery<Integer> recentOrders = SubQuery.from(Order.class)
.correlate(Q.Order.getCustomerId(), Q.Customer.getId())
.where(Q.Order.createdAt(Op.GTE, LocalDateTime.now().minusDays(30)));
JoinSpec<Customer> inactiveCustomers = Q.Customer.joinSpec()
.notExists(recentOrders);
Aggregate Function Subquery
You can use aggregate functions inside a subquery.
// Maximum product price per category
SubQuery<Number> maxPrice = SubQuery.max(Q.Product.getPrice())
.where(Q.Product.categoryId(Op.EQ, categoryId));
// Total amount per order
SubQuery<Number> totalAmount = SubQuery.sum(Q.OrderItem.getAmount())
.where(Q.OrderItem.orderId(Op.EQ, orderId));
// Order count per user
SubQuery<Long> orderCount = SubQuery.count(Order.class)
.where(Q.Order.userId(Op.EQ, userId));
7. UpdateSpec - Bulk Update
UpdateSpec builds bulk updates using the JPA Criteria API in a type-safe manner. Because it executes a SQL UPDATE statement directly without loading entities one by one, it is efficient for processing large amounts of data.
Basic Update
// Mark users who have not logged in for 30 days as inactive
int updated = Q.User.updateSpec()
.set(Q.User.getStatus(), "INACTIVE")
.set(Q.User.getUpdatedAt(), LocalDateTime.now())
.where(Q.User.lastLoginAt(Op.LT, LocalDateTime.now().minusDays(30)))
.execute(entityManager);
System.out.println(updated + "명의 사용자가 비활성 처리되었습니다.");
Increment / Decrement
// Increment view count by 1
Q.Product.updateSpec()
.increment(Q.Product.getViewCount(), 1)
.where(Q.Product.id(Op.EQ, productId))
.execute(entityManager);
// Decrease stock
Q.Product.updateSpec()
.increment(Q.Product.getStock(), -orderQuantity)
.where(Q.Product.id(Op.EQ, productId))
.execute(entityManager);
Copy Values from DTO
Automatically maps non-null fields of a DTO object to the UPDATE SET clause.
// Update only non-null fields from updateDTO
Q.User.updateSpec()
.setFromDTO(updateDTO)
.where(Q.User.id(Op.EQ, userId))
.execute(entityManager);
Copy Between Fields
// Restore discount price to the original price
Q.Product.updateSpec()
.set(Q.Product.getDiscountPrice(), Q.Product.getOriginalPrice())
.where(Q.Product.status(Op.EQ, "SALE_END"))
.execute(entityManager);
Using Expressions
You can use JPA Criteria Builder expressions directly to perform complex operations.
// Increase price by 10%
Q.Product.updateSpec()
.set(Q.Product.getPrice(), (root, cb) ->
cb.prod(root.get("price"), 1.1))
.where(Q.Product.categoryId(Op.EQ, categoryId))
.execute(entityManager);
// Add a prefix to a name
Q.Category.updateSpec()
.set(Q.Category.getName(), (root, cb) ->
cb.concat("[아카이브] ", root.get("name")))
.where(Q.Category.status(Op.EQ, "ARCHIVED"))
.execute(entityManager);
Updating All Records
When updating all records, you must explicitly call whereAll() for safety.
// Set password expiration flag for all users
Q.User.updateSpec()
.set(Q.User.getPasswordExpired(), true)
.whereAll()
.execute(entityManager);
Calling execute() without where() throws an IllegalArgumentException. If you intend to update all records, you must explicitly call whereAll(). This is a safety mechanism to prevent accidentally modifying all data.
8. DeleteSpec - Bulk Delete
DeleteSpec builds bulk deletes in a type-safe manner. The same safety mechanisms as UpdateSpec apply.
Conditional Delete
// Delete user data that has been withdrawn for more than 1 year
int deleted = Q.User.deleteSpec()
.where(Q.User.status(Op.EQ, "WITHDRAWN"))
.where(Q.User.withdrawnAt(Op.LT, LocalDateTime.now().minusYears(1)))
.execute(entityManager);
System.out.println(deleted + "건의 사용자 데이터가 삭제되었습니다.");
Count Before Deleting
You can check the number of affected records before actually performing the delete.
// Check number of records to be deleted
long count = Q.User.deleteSpec()
.where(Q.User.status(Op.EQ, "WITHDRAWN"))
.where(Q.User.withdrawnAt(Op.LT, LocalDateTime.now().minusYears(1)))
.count(entityManager);
log.info("삭제 대상: {}건", count);
// Execute delete after confirmation
if (count > 0 && count < MAX_DELETE_THRESHOLD) {
Q.User.deleteSpec()
.where(Q.User.status(Op.EQ, "WITHDRAWN"))
.where(Q.User.withdrawnAt(Op.LT, LocalDateTime.now().minusYears(1)))
.execute(entityManager);
}
Delete All Records
whereAll() is also required when deleting all records.
// Delete all temporary logs
Q.TempLog.deleteSpec()
.whereAll()
.execute(entityManager);
Compound Condition Delete Example
// Clean up expired sessions
Q.Session.deleteSpec()
.where(Q.Session.expiredAt(Op.LT, LocalDateTime.now()))
.execute(entityManager);
// Delete notifications from a specific period
Q.Notification.deleteSpec()
.where(Q.Notification.isRead(Op.EQ, true))
.where(Q.Notification.createdAt(Op.LT, LocalDateTime.now().minusMonths(3)))
.execute(entityManager);
9. Safety Mechanisms and Precautions
ElastiCORE has various built-in safety mechanisms to prevent data loss or runtime errors caused by mistakes.
| Situation | Behavior |
|---|---|
Calling execute() on UpdateSpec without set() | Throws IllegalStateException — you must specify the fields to update |
Calling execute() on UpdateSpec/DeleteSpec without where() | Throws IllegalArgumentException — you must explicitly call whereAll() |
Calling findAllAsMap() on JoinSpec without select() | Throws IllegalStateException — you must specify which columns to retrieve |
fetchJoin() used in a count query | Automatically ignored (Hibernate HHH-7413 compatibility handling) |
null or empty value with checkEmpty=true | Returns conjunction() — the condition is ignored and always evaluates to true |
Calling link() before crossJoin() | Throws IllegalStateException — must follow the order crossJoin() → link() |
Passing fewer than 2 elements to Op.BETWEEN | Throws IllegalArgumentException — requires a List with exactly 2 elements |
UpdateSpec and DeleteSpec execute JPQL UPDATE/DELETE directly, so they are not synchronized with the persistence context (first-level cache). If you need to query the affected entities in the same transaction after a bulk operation, call entityManager.flush() and entityManager.clear() to refresh the cache.
Q.User.updateSpec()
.set(Q.User.getStatus(), "INACTIVE")
.where(Q.User.lastLoginAt(Op.LT, thirtyDaysAgo))
.execute(entityManager);
// Clear the persistence context after the bulk operation
entityManager.flush();
entityManager.clear();
// Subsequent queries will fetch the latest data from the DB
User user = userRepository.findById(userId).orElseThrow();
10. Q Class Generation Configuration
env.yml Configuration
Specifying the q namespace in the env.yml file causes the Q class to be generated at that package path.
q: io.domain.q
The above configuration generates the io.domain.q.Q class. If you have multiple domains, you can specify a separate namespace in each domain's env.yml.
Generation Command
# Q class is automatically generated during the Gradle build
./gradlew elcore
The generated Q class is located at the specified package path and must be regenerated whenever the entity definition changes.
After adding or changing fields in an entity, always run ./gradlew elcore to regenerate the Q class. The Q class must be up to date for the IDE's auto-complete feature to recognize the latest fields.
Repository Configuration
To use Specification queries with Q classes, the Repository must extend JpaSpecificationExecutor.
public interface UserRepository
extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
// Automatically provides Specification-based findAll, count, etc.
}
Injecting EntityManager for JoinSpec Execution
To use EntityManager-based execution methods such as findAllAsMap() and findAll(em, DTO.class) in JoinSpec, inject EntityManager into your service.
@Service
@RequiredArgsConstructor
public class ArticleService {
private final EntityManager entityManager;
private final ArticleRepository articleRepository;
// JoinSpec + EntityManager usage
public List<ArticleDTO> getArticles() {
JoinSpec<Article> spec = Q.Article.joinSpec()
.select(Q.Article.getTitle(), Q.Article.getCreatedAt())
.orderByDesc(Q.Article.getCreatedAt());
return spec.findAll(entityManager, ArticleDTO.class);
}
// Basic Specification + Repository usage
public Page<Article> searchArticles(String keyword, Pageable pageable) {
Specification<Article> spec = Q.Article.title(Op.LIKE, keyword, true);
return articleRepository.findAll(spec, pageable);
}
}