Skip to content

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

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:Asset thay vì type=nt:unstructured vì 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ưới

Ví dụ thực tế — tìm articles theo category:

java
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 lai

Predicate: 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:content

Predicate: tagid

Tìm pages theo cq:tags.

tagid=newssite:category/politics
tagid.property=jcr:content/cq:tags
type=cq:Page

Logic Groups — p.orp.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:

java
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:

java
// (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.limitp.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:

java
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=desc

3. 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=true

Xem XPath tương đương — Trong Debugger, click tab "XPath" để thấy query được dịch thành:

text
/jcr:root/content/newssite/articles//element(*, cq:Page)
[jcr:content/@articleCategory = 'politics']
order by jcr:content/@jcr:lastModified descending

Explain Query Tool — phân tích index sử dụng:

http://localhost:4502/libs/granite/operations/content/diagnosistools/queryPerformance.html

4. 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 query

Từ 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ể

java
// ❌ 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 typeIndex sẽ dùng
Pagescq:PagecqPageLucene
DAM Assetsdam:AssetdamAssetLucene
Genericnt: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:

xml
<!-- 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" → reindex

5. ArticleQueryService hoàn chỉnh cho News Website

java
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 pathpath=/content/newssiteKhông có path
Dùng type cụ thểtype=cq:Pagetype=nt:unstructured
Paginationp.limit=10, p.offset=0p.limit=-1 trong servlet
Performance với result lớnp.guessTotal=trueresult.getTotalMatches()
Tìm trong property conproperty=jcr:content/titleQuery trực tiếp trên node cha
IndexTạo custom index cho property query thường xuyênĐể traversal
DebugDùng QueryBuilder Debugger trước khi codeViế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.

AEM 6.5 On-Premise Developer Notes