Skip to main content

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.
When should you use Q classes?

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

ComponentReturn TypePurposeExample
getField()FieldInfo<T>Used in JOIN, SELECT, ORDER BYQ.Article.getTitle()
field (string constant)StringUsed in JPQL or native queriesQ.Article.title"title"
field(Op, value)Specification<T>Creates a WHERE conditionQ.Article.title(Op.LIKE, "keyword")
field(Op, value, checkEmpty)Specification<T>Optional WHERE conditionQ.Article.title(Op.LIKE, keyword, true)
relation()$RelatedEntity<T>Nested path navigationQ.Article.category().name(Op.EQ, "Tech")
joinSpec()JoinSpec<T>Creates an advanced query builderQ.Article.joinSpec()
updateSpec()UpdateSpec<T>Creates a bulk update builderQ.Article.updateSpec()
deleteSpec()DeleteSpec<T>Creates a bulk delete builderQ.Article.deleteSpec()

3. Operators (Op)

ElastiCORE provides 15 comparison operators. All operators are defined in the Op enum.

OperatorDescriptionSQL MappingExample
Op.EQEquals= valueQ.User.status(Op.EQ, "ACTIVE")
Op.NEQNot equals!= valueQ.User.status(Op.NEQ, "DELETED")
Op.LIKEContainsLIKE '%value%'Q.User.name(Op.LIKE, "hong")
Op.NOT_LIKEDoes not containNOT LIKE '%value%'Q.User.name(Op.NOT_LIKE, "test")
Op.STARTS_WITHStarts withLIKE 'value%'Q.User.name(Op.STARTS_WITH, "Kim")
Op.ENDS_WITHEnds withLIKE '%value'Q.User.email(Op.ENDS_WITH, "@gmail.com")
Op.GTGreater than> valueQ.User.age(Op.GT, 20)
Op.GTEGreater than or equal>= valueQ.User.age(Op.GTE, 18)
Op.LTLess than< valueQ.User.age(Op.LT, 65)
Op.LTELess than or equal<= valueQ.User.age(Op.LTE, 60)
Op.INIncluded inIN (values)Q.User.role(Op.IN, roleList)
Op.NOT_INNot included inNOT IN (values)Q.User.status(Op.NOT_IN, excludeList)
Op.IS_NULLIs nullIS NULLQ.User.deletedAt(Op.IS_NULL, null)
Op.IS_NOT_NULLIs not nullIS NOT NULLQ.User.email(Op.IS_NOT_NULL, null)
Op.BETWEENRangeBETWEEN a AND bQ.User.age(Op.BETWEEN, List.of(18, 65))
Operator usage tips
  • Op.LIKE automatically wraps the value with % on both sides. You do not need to add % manually.
  • Op.IS_NULL and Op.IS_NOT_NULL ignore the value parameter. By convention, pass null.
  • Op.BETWEEN requires a List with exactly 2 elements as the value.
  • Op.IN and Op.NOT_IN accept a Collection type 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));
What happens if you pass null without checkEmpty?

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 implements Specification

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 MethodSQL MappingUse Case
join()INNER JOINWhen the associated entity must exist
leftJoin()LEFT JOINWhen results should include rows without an associated entity
fetchJoin()LEFT JOIN FETCHEager loading to prevent N+1 problems
fetchJoin and Pagination

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));
Caution when using Cross Join

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);
Safety guard: whereAll() is required

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.

SituationBehavior
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 queryAutomatically ignored (Hibernate HHH-7413 compatibility handling)
null or empty value with checkEmpty=trueReturns 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.BETWEENThrows IllegalArgumentException — requires a List with exactly 2 elements
Bulk operations and the persistence context

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.

Development tip

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);
}
}