JCR Query Builder trong AEM 6.5
1. Nền tảng lý thuyết
QueryBuilder là gì?
QueryBuilder là một framework do Adobe phát triển để viết các query đơn giản và hiệu quả trong AEM. Nó sử dụng JCR XPath bên dưới và OAK Query Engine để thực thi.
AEM chuyển đổi QueryBuilder queries thành XPath rồi gửi đến Query Engine, Query Engine lại chuyển sang JCR-SQL2 vì XPath và JCR SQL đã bị deprecated từ AEM 6.0 khi Jackrabbit nâng cấp lên OAK.
Khi nào dùng QueryBuilder vs JCR-SQL2?
Sử dụng QueryBuilder được khuyến nghị mạnh khi cần sanitize input — ví dụ trong servlet nơi user có thể tùy chỉnh query qua request parameter.
Khi query không cần sanitize, resourceResolver.findResources() có thể được sử dụng để query content theo cách đơn giản hơn, tương tự câu lệnh SQL truyền thống.
2 API truy cập JCR
Khi làm việc với JCR nodes trong code, có 2 API chính: Sling Resource API (khuyến nghị) — abstraction cấp cao làm việc với Resource objects và ResourceResolver, portable, testable, và là cách idiomatic trong AEM. JCR Node API — javax.jcr API cấp thấp hơn, làm việc trực tiếp với Node, Session và Property objects. Chỉ dùng khi Sling API không expose được tính năng cần thiết như versioning, ordering, workspace operations.
2. Cú pháp Predicates quan trọng
Cách truyền predicates — 3 cách trong Java
@Reference
private QueryBuilder queryBuilder;
// Cách 1: HashMap (phổ biến nhất, đơn giản nhất)
Map<String, String> map = new HashMap<>();
map.put("path", "/content/newssite");
map.put("type", "cq:Page");
map.put("p.limit", "10");
Query query = queryBuilder.createQuery(PredicateGroup.create(map), session);
// Cách 2: PredicateGroup API (cho query phức tạp)
PredicateGroup group = new PredicateGroup();
group.add(new Predicate("mypath", "path").set("path", "/content/newssite"));
group.add(new Predicate("mytype", "type").set("type", "cq:Page"));
Query query = queryBuilder.createQuery(group, session);
// Cách 3: Từ HTTP request parameters (dùng trong servlet)
Session session = request.getResourceResolver().adaptTo(Session.class);
PredicateGroup root = PredicateGroup.create(request.getParameterMap());
Query query = queryBuilder.createQuery(root, session);
// Lấy kết quả — chuẩn cho mọi cách
SearchResult result = query.getResult();
List<Hit> hits = result.getHits();
for (Hit hit : hits) {
Resource resource = hit.getResource(); // null check bắt buộc
String path = hit.getPath();
}Predicate: path
Giới hạn tìm kiếm trong một path cụ thể — luôn luôn phải có để tránh traversal toàn bộ repository.
path=/content/newssite/articles
path.exact=true → chỉ match đúng path đó, không bao gồm con
path.flat=true → chỉ tìm direct children
path.self=true → bao gồm cả node gốc trong kết quảPredicate: type
Giới hạn node type — bắt buộc phải có để Oak chọn đúng index.
type=cq:Page → trang AEM
type=dam:Asset → asset trong DAM
type=nt:file → file node
type=nt:unstructured → generic node (tránh dùng — không có index tốt)⚠️ Lưu ý quan trọng: Nên dùng
type=dam:Assetthay vìtype=nt:unstructuredvì nó sẽ sử dụng index khác phù hợp hơn (damAssetLucene), tránh traversal warning.
Predicate: property
Tìm theo giá trị property JCR.
property=jcr:content/cq:template
property.value=/conf/newssite/settings/wcm/templates/article-page
# Nhiều giá trị — OR (mặc định)
property=jcr:content/cq:tags
property.1_value=newssite:category/politics
property.2_value=newssite:category/technology
# Nhiều giá trị — AND
property.and=true
property.1_value=breaking
property.2_value=featured
# Các operations
property.operation=like → jcr:like (LIKE SQL)
property.operation=not → NOT EXISTS
property.operation=exists → property phải tồn tại
property.operation=unequals → giá trị khác với value
# Độ sâu tìm kiếm
property.depth=2 → tìm trong 2 levels con bên dướiVí dụ thực tế — tìm articles theo category:
map.put("path", "/content/newssite/articles");
map.put("type", "cq:Page");
map.put("property", "jcr:content/articleCategory");
map.put("property.value", "politics");
map.put("property.operation", "equals");Predicate: daterange
Tìm theo khoảng thời gian với giá trị tuyệt đối.
daterange.property=jcr:content/cq:lastModified
daterange.lowerBound=2024-01-01T00:00:00.000+07:00
daterange.upperBound=2024-12-31T23:59:59.999+07:00
daterange.lowerOperation=>=
daterange.upperOperation=<=Predicate: relativedaterange
Tìm theo khoảng thời gian tương đối so với thời điểm hiện tại — rất hữu ích cho "articles trong 7 ngày gần nhất".
Sử dụng predicate relativedaterange để query theo khoảng thời gian tương đối. Ví dụ lấy tất cả assets được modified trong 5 ngày qua, hoặc events sẽ xảy ra trong 5 ngày tới bằng cách dùng upperBound=5d.
# Articles được publish trong 7 ngày gần nhất
relativedaterange.property=jcr:content/cq:lastModified
relativedaterange.lowerBound=-7d
# Cú pháp thời gian: 1s=1 giây, 2m=2 phút, 3h=3 giờ
# 4d=4 ngày, 5w=5 tuần, 6M=6 tháng, 7y=7 năm
# Dấu - = quá khứ, không có dấu = tương laiPredicate: nodename
Tìm theo tên node với wildcard.
nodename=article-* → bất kỳ ký tự hoặc không có ký tự sau "article-"
nodename=article-? → đúng 1 ký tự bất kỳ sau "article-"
nodename=article-[12] → chỉ "article-1" hoặc "article-2"Predicate: fulltext
Tìm kiếm full-text trong toàn bộ nội dung node.
fulltext=breaking news
fulltext.relPath=jcr:content → chỉ tìm trong jcr:contentPredicate: tagid
Tìm pages theo cq:tags.
tagid=newssite:category/politics
tagid.property=jcr:content/cq:tags
type=cq:PageLogic Groups — p.or và p.and
Dùng group.p.or=true để kết hợp các predicates trong nhóm với OR thay vì AND mặc định.
Ví dụ: Tìm articles thuộc category politics HOẶC technology:
map.put("path", "/content/newssite/articles");
map.put("type", "cq:Page");
// Group OR: category là politics HOẶC technology
map.put("group.p.or", "true");
map.put("group.1_property", "jcr:content/articleCategory");
map.put("group.1_property.value", "politics");
map.put("group.2_property", "jcr:content/articleCategory");
map.put("group.2_property.value", "technology");Ví dụ phức tạp hơn — AND giữa các group, OR trong group:
// (category=politics OR category=technology) AND status=published
map.put("path", "/content/newssite/articles");
map.put("type", "cq:Page");
// Group 1: OR categories
map.put("1_group.p.or", "true");
map.put("1_group.1_property", "jcr:content/articleCategory");
map.put("1_group.1_property.value", "politics");
map.put("1_group.2_property", "jcr:content/articleCategory");
map.put("1_group.2_property.value", "technology");
// Group 2: AND status
map.put("2_group.property", "jcr:content/articleStatus");
map.put("2_group.property.value", "published");Pagination — p.limit và p.offset
p.limit=10 → lấy 10 kết quả (page size)
p.offset=20 → bỏ qua 20 kết quả đầu (trang thứ 3 nếu limit=10)
p.limit=-1 → lấy TẤT CẢ kết quả (dùng cẩn thận — nguy hiểm performance)Ví dụ phân trang trong ArticleQueryService:
public List<Resource> getArticlesByPage(ResourceResolver resolver,
String category,
int pageNumber,
int pageSize) {
Session session = resolver.adaptTo(Session.class);
Map<String, String> map = new HashMap<>();
map.put("path", "/content/newssite/articles");
map.put("type", "cq:Page");
map.put("property", "jcr:content/articleCategory");
map.put("property.value", category);
map.put("orderby", "@jcr:content/jcr:lastModified");
map.put("orderby.sort", "desc");
map.put("p.limit", String.valueOf(pageSize));
map.put("p.offset", String.valueOf(pageNumber * pageSize));
map.put("p.guessTotal", "true"); // tối ưu performance
Query query = queryBuilder.createQuery(PredicateGroup.create(map), session);
SearchResult result = query.getResult();
List<Resource> articles = new ArrayList<>();
for (Hit hit : result.getHits()) {
try {
articles.add(hit.getResource());
} catch (RepositoryException e) {
LOG.error("Error getting resource from hit", e);
}
}
return articles;
}Sorting — orderby
orderby=@jcr:content/jcr:lastModified → sort theo property
orderby.sort=desc → desc hoặc asc
orderby.case=ignore → case insensitive (AEM 6.2+)
# Sort theo nhiều field
1_orderby=@jcr:content/articleCategory
2_orderby=@jcr:content/jcr:lastModified
2_orderby.sort=desc3. Query Builder Debugger
Query Builder debugger URL: http://localhost:4502/libs/cq/search/content/querydebug.html
Tool này cho phép:
- Viết và test query trực tiếp trên AEM
- Xem kết quả JSON trả về
- Xem XPath query được generate
- Kiểm tra index nào được sử dụng
Ví dụ query trong Debugger cho News Website:
# Lấy 10 articles mới nhất thuộc category politics
path=/content/newssite/articles
type=cq:Page
property=jcr:content/articleCategory
property.value=politics
orderby=@jcr:content/jcr:lastModified
orderby.sort=desc
p.limit=10
p.offset=0
p.guessTotal=trueXem XPath tương đương — Trong Debugger, click tab "XPath" để thấy query được dịch thành:
/jcr:root/content/newssite/articles//element(*, cq:Page)
[jcr:content/@articleCategory = 'politics']
order by jcr:content/@jcr:lastModified descendingExplain Query Tool — phân tích index sử dụng:
http://localhost:4502/libs/granite/operations/content/diagnosistools/queryPerformance.html4. Oak Indexing — Đặc thù AEM 6.5 On-Premise
Tại sao cần quan tâm đến Index?
Khác với Jackrabbit 2, Oak không index content mặc định. Custom indexes phải được tạo thủ công khi cần thiết, giống như với các relational database truyền thống. Nếu không có index cho một query cụ thể, nhiều nodes có thể bị traversed — query vẫn chạy nhưng rất chậm.
Khi Oak gặp một query không có index, một WARN level log message sẽ được in ra: Traversed 1000 nodes with filter... consider creating an index or changing the query
Traversal Warning — Dấu hiệu nguy hiểm
*WARN* Traversed 1000 nodes with filter Filter(query=select * from [cq:Page]...)
consider creating an index or changing the queryTừ AEM 6.3, khi traversal đạt 100,000 nodes, query sẽ fail và throw exception. Nếu cần query trả về nhiều hơn 100,000 records, cần tăng giới hạn: -Doak.queryLimitInMemory=500000 -Doak.queryLimitReads=100000
Cách tránh Traversal Warning
Rule 1: Luôn có path + type cụ thể
// ❌ SAI — traversal toàn bộ repository
map.put("type", "nt:unstructured");
map.put("property", "articleStatus");
map.put("property.value", "published");
// ✅ ĐÚNG — giới hạn path và type
map.put("path", "/content/newssite/articles");
map.put("type", "cq:Page");
map.put("property", "jcr:content/articleStatus");
map.put("property.value", "published");Rule 2: Dùng đúng node type để tận dụng OOTB index
| Query về | Dùng type | Index sẽ dùng |
|---|---|---|
| Pages | cq:Page | cqPageLucene |
| DAM Assets | dam:Asset | damAssetLucene |
| Generic | nt:unstructured | ❌ Không có index — traversal |
Custom Oak Index cho News Website
Khi cần query theo articleCategory hoặc articleStatus, cần tạo custom index:
<!-- ui.apps/src/main/content/jcr_root/oak:index/newssite-article-index/.content.xml -->
<jcr:root
jcr:primaryType="oak:QueryIndexDefinition"
type="lucene"
async="async"
reindex="{Boolean}false">
<indexRules jcr:primaryType="nt:unstructured">
<cq:Page jcr:primaryType="nt:unstructured">
<properties jcr:primaryType="nt:unstructured">
<articleCategory
jcr:primaryType="nt:unstructured"
name="jcr:content/articleCategory"
propertyIndex="{Boolean}true"/>
<articleStatus
jcr:primaryType="nt:unstructured"
name="jcr:content/articleStatus"
propertyIndex="{Boolean}true"/>
<publishDate
jcr:primaryType="nt:unstructured"
name="jcr:content/publishDate"
propertyIndex="{Boolean}true"
ordered="Date"/>
</properties>
</cq:Page>
</indexRules>
</jcr:root>Sau khi deploy, trigger reindex tại:
http://localhost:4502/system/console/jmx → search "IndexStats" → reindex5. ArticleQueryService hoàn chỉnh cho News Website
package com.mysite.core.services.impl;
import com.day.cq.search.PredicateGroup;
import com.day.cq.search.Query;
import com.day.cq.search.QueryBuilder;
import com.day.cq.search.result.Hit;
import com.day.cq.search.result.SearchResult;
import com.mysite.core.services.ArticleQueryService;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import java.util.*;
@Component(service = ArticleQueryService.class)
public class ArticleQueryServiceImpl implements ArticleQueryService {
private static final Logger LOG = LoggerFactory.getLogger(ArticleQueryServiceImpl.class);
private static final String ARTICLES_ROOT = "/content/newssite/articles";
@Reference
private QueryBuilder queryBuilder;
@Reference
private ResourceResolverFactory resolverFactory;
@Override
public List<Resource> getLatestArticles(ResourceResolver resolver, int limit) {
Map<String, String> map = new HashMap<>();
map.put("path", ARTICLES_ROOT);
map.put("type", "cq:Page");
map.put("property", "jcr:content/articleStatus");
map.put("property.value", "published");
map.put("orderby", "@jcr:content/publishDate");
map.put("orderby.sort", "desc");
map.put("p.limit", String.valueOf(limit));
map.put("p.guessTotal", "true");
return executeQuery(resolver, map);
}
@Override
public List<Resource> getArticlesByCategory(ResourceResolver resolver,
String category, int limit) {
Map<String, String> map = new HashMap<>();
map.put("path", ARTICLES_ROOT);
map.put("type", "cq:Page");
map.put("property", "jcr:content/articleCategory");
map.put("property.value", category);
map.put("orderby", "@jcr:content/publishDate");
map.put("orderby.sort", "desc");
map.put("p.limit", String.valueOf(limit));
map.put("p.guessTotal", "true");
return executeQuery(resolver, map);
}
@Override
public List<Resource> searchArticles(ResourceResolver resolver,
String keyword, int limit, int offset) {
Map<String, String> map = new HashMap<>();
map.put("path", ARTICLES_ROOT);
map.put("type", "cq:Page");
map.put("fulltext", keyword);
map.put("fulltext.relPath", "jcr:content");
map.put("orderby", "@jcr:score");
map.put("orderby.sort", "desc");
map.put("p.limit", String.valueOf(limit));
map.put("p.offset", String.valueOf(offset));
map.put("p.guessTotal", "true");
return executeQuery(resolver, map);
}
@Override
public List<Resource> getRecentArticles(ResourceResolver resolver,
int daysBack, int limit) {
Map<String, String> map = new HashMap<>();
map.put("path", ARTICLES_ROOT);
map.put("type", "cq:Page");
map.put("relativedaterange.property", "jcr:content/publishDate");
map.put("relativedaterange.lowerBound", "-" + daysBack + "d");
map.put("orderby", "@jcr:content/publishDate");
map.put("orderby.sort", "desc");
map.put("p.limit", String.valueOf(limit));
return executeQuery(resolver, map);
}
private List<Resource> executeQuery(ResourceResolver resolver,
Map<String, String> predicates) {
Session session = resolver.adaptTo(Session.class);
if (session == null) {
LOG.error("Cannot adapt ResourceResolver to Session");
return Collections.emptyList();
}
List<Resource> results = new ArrayList<>();
try {
Query query = queryBuilder.createQuery(
PredicateGroup.create(predicates), session);
SearchResult result = query.getResult();
for (Hit hit : result.getHits()) {
try {
Resource resource = hit.getResource();
if (resource != null) {
results.add(resource);
}
} catch (RepositoryException e) {
LOG.warn("Error getting resource from hit: {}", e.getMessage());
}
}
} catch (Exception e) {
LOG.error("Query execution failed: {}", e.getMessage(), e);
}
return results;
}
}6. Best Practices tổng hợp
| Rule | Đúng ✅ | Sai ❌ |
|---|---|---|
| Luôn giới hạn path | path=/content/newssite | Không có path |
| Dùng type cụ thể | type=cq:Page | type=nt:unstructured |
| Pagination | p.limit=10, p.offset=0 | p.limit=-1 trong servlet |
| Performance với result lớn | p.guessTotal=true | result.getTotalMatches() |
| Tìm trong property con | property=jcr:content/title | Query trực tiếp trên node cha |
| Index | Tạo custom index cho property query thường xuyên | Để traversal |
| Debug | Dùng QueryBuilder Debugger trước khi code | Viết code rồi mới test |
| ResourceResolver | Đóng bằng try-with-resources | Để leak |
Vì queries có thể là một trong những operations tốn kém nhất trong AEM, nên tránh chạy query trong components. Nhiều queries thực thi mỗi khi render một page có thể làm giảm performance đáng kể của hệ thống.