Skip to content

feat(library): JCR query builder for JavaScript modules - #796

Draft
romain-pm wants to merge 10 commits into
mainfrom
feat/jcr-query-builder
Draft

romain-pm wants to merge 10 commits into
mainfrom
feat/jcr-query-builder

Conversation

@romain-pm

@romain-pm romain-pm commented Sep 20, 2026 •

Copy link
Copy Markdown

What

A typed query builder for Jahia JavaScript modules, modeled on javax.jcr.query.qom.QueryObjectModel.

const events = getNodesByJCRQuery(currentNode.getSession(),
  from("jnt:event", "e")
    .where(({ e }) => e.isDescendantOf(site).and(e.prop("startDate").ge(date(today))))
    .orderBy(({ e }) => e.prop("startDate").desc())
    .limit(10));

Why

Modules concatenate JCR-SQL2 strings today, with no type safety, no escaping guarantee, and no signal about what a query costs. Two library entry points also run unbounded by default.

The goal is performance by default, so the design rests on two rules. A query binds to the Query Object Model, which is the level Jahia hands to its Lucene translation, and a limit reaches setLimit before the query runs, so a page costs a page. Constructs the query engine evaluates in memory are reachable only through a name that ends in Slow, which puts the cost in the code that writes it.

What changed

  • Query builder in javascript-modules-library/src/query: the model types, the factory mirroring QueryObjectModelFactory, the fluent facade, the interop sink and the execution entry. A builder reaches execution only after limit() or the explicit unboundedSlow(), enforced by the type state and by a runtime check, so the rule holds whatever language the caller wrote.
  • Seams: getNodesByJCRQuery and useJCRQuery accept a built query, which carries its own limit. String callers keep today's behaviour. The useJCRQuery form without a limit is deprecated in favour of one that requires it.
  • Type generation: the generator now emits the Query Object Model types, which were blocked.
  • Docs: a querying guide, a README group, and a decision record for binding to the model rather than to a generated string.

256 unit tests and a Cypress suite that runs against a live Jahia.

Verified on a real instance

Run against Jahia 8.2.3.2 with the Jackrabbit fork the design assumes. Measurement corrected the design four times, and each correction is in the code and the documentation:

  • A join returns one row per match, not deduplicated nodes. A plain SQL2 string behaves the same, so this is Jahia's behaviour rather than the sink's. Callers deduplicate.
  • LENGTH over an internationalised property matches nothing, because the rewrite moves the property to a translation subnode.
  • Bind variables fail on this path, which is why the sink inlines literals.
  • Pattern matching escapes the two wildcards and the escape character, but not a letter or digit, where Jackrabbit passes the backslash through. The methods escape exactly those three and no more.

The pattern surface is named after the measured capability rather than an assumed one: startsWith, endsWith and contains build and escape a pattern, like takes a raw one, and fullText is the JCR full-text search, which is a different language with stemming. There is no regular expression anywhere in the JCR query surface, and the guide says so.

Question for the reviewers

This change removes three entries from the type generator blacklist: javax.jcr.query.Query, javax.jcr.query.qom.QueryObjectModel and javax.jcr.query.qom.QueryObjectModelFactory. I could not find what put them there. If a product decision is behind them, say so and the approach changes.

Known gap, left open deliberately

Both seams return JCRNodeWrapper[] for every query, so select() shapes the statement and a caller still reads properties with string keys. A typed rows seam that carries column aliases into the result type is the natural follow-up. It is out of scope here.

Notes

JCR-QUERY-BUILDER-PLAN.md at the repository root is the specification this was built from, kept for review. It should be removed before merge.

The library exposes a typed model of the JCR Query Object Model, the
factory that builds it, and the checks that report what Jahia can push
down to Lucene.

Constructs that Jackrabbit evaluates in memory carry a Slow suffix, so
the cost of a query is visible in the code that writes it. The fluent
facade and the execution sink follow in the next change.
@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

🦜 Chachalog

javascript-modules minor
  • Modules can build JCR queries with a typed builder instead of concatenating JCR-SQL2 strings. (feat(library): JCR query builder for JavaScript modules #796)

    The builder mirrors javax.jcr.query.qom.QueryObjectModel, which is the level Jahia hands to its Lucene translation. Constructs that the query engine evaluates in memory, such as joins and LENGTH, carry a Slow suffix, so the cost of a query is visible where it is written.

    getNodesByJCRQuery and useJCRQuery accept a built query, which carries its own limit and offset. The useJCRQuery form that takes a statement and no limit is deprecated, because it returns every node the query matches.

Create a new entry online or run npx chachalog@0.5.6 prompt to create a new entry locally.

@pkg-pr-new

pkg-pr-new Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

yarn add https://pkg.pr.new/@jahia/create-module@796.tgz
yarn add https://pkg.pr.new/@jahia/javascript-modules-library@796.tgz
yarn add https://pkg.pr.new/@jahia/vite-plugin@796.tgz

commit: 67e1710

The type generator skipped the Query Object Model, so a module could not
call the factory that Jahia hands to its Lucene translation. The blocked
entries are now generated, together with the query row types that a
count query reads.

A subtype only inherits a member that its parent type emitted, so the
five execution methods are whitelisted on the parent interface as well.
The patch script now fails when one of its search patterns matches no
file, which is how five dead patterns went unnoticed.
The fluent facade builds a query, and the sink hands it to Jahia as a
Query Object Model. A query carries its own limit, which the execution
entry applies before the query runs, so a page costs a page.

The type state makes the limit mandatory: a builder reaches execution
only after limit() or the explicit unboundedSlow(). The two library
entry points accept a built query, and the hook form without a limit is
deprecated in favour of one that requires it.

Verified against Jahia 8.2.3.2 with the Jackrabbit fork. Three claims
the design rested on were wrong and are corrected here: a join returns
one row per match rather than deduplicated nodes, LENGTH over an
internationalised property matches nothing, and bind variables do fail
on this path, which is why the sink inlines them.
@github-actions

Copy link
Copy Markdown

📝 Documentation Guidelines

Thank you for contributing to our documentation! To ensure your contributions meet our standards, please review these resources:

This comment is posted automatically when changes are detected in the docs/ folder.

A comparison against the query libraries front-end developers use found
that the mandatory limit was a compile-time rule only. A JavaScript
caller, a cast, or the deprecated hook form all reached execution with
no limit. Execution now refuses a query that carries neither a limit nor
the explicit unbounded marker, so the rule holds for every caller.

The error a developer meets when they forget names the fix, rather than
reporting an unassignable string.

Diagnostics no longer report an unconditional finding, so an empty list
means a clean query. The two costly calls that reported nothing, the
unbounded marker and an inner join, now each report one.

Full-text search is called fullText, because contains means a substring
match in the libraries developers arrive from. Everyday predicates that
had to be written by hand are now methods: in, between, startsWith,
isNull and notExists. The prefix search escapes the pattern wildcards.
Three spellings reached a JCR session across the query guide, the test
module and the hook. The hook used the main resource, which is the page
root rather than the node being rendered.

All three now read currentNode.getSession(), which is the call the
engine already uses to build the server context session.
The prefix search escaped wildcards on the developer's behalf, and no
one had run that escaping against a repository. A matrix of probes on
Jahia 8.2.3.2 settled seventeen questions about it.

The escape holds for the two wildcards and for the escape character
itself, so a literal prefix is a promise the engine keeps. It does not
hold for a letter or a digit, where Jackrabbit passes the backslash
through, so exactly three characters are escaped and no more.

The measured capability is a pattern match over the whole value, which
is wider than a prefix. The surface now says so: startsWith, endsWith
and contains build the pattern for you, and like takes one when you
want the wildcards yourself. Full-text search keeps its own name,
because it is a different language with stemming.

There is no regular expression anywhere in the JCR query surface, and
the guide now says that rather than leaving it to be discovered.

isNull is removed. The JCR tests whether a property exists, so
notExists is the honest name and the only one.
A single-selector query had to name a selector it never refers to by
name, and the callback then destructured that name. The alias now falls
away and the callback receives the reference itself.

    from("jnt:news").where((n) => n.isDescendantOf(site)).limit(10)

The query it builds is unchanged, because the model already defaults a
missing selector name to the node type.

A join still names both sides, since a join condition needs two
references to tell apart. Calling it without an alias is a compile
error that says to add one, in the same way the missing limit does.

Every single-selector sample and test moves to the shorter form. The
aliased form keeps its coverage, because it stays supported.
Jahia has two ways to search content and they read different stores.
Full text reads the Lucene index, which is lowercased, accent folded
and stemmed. A pattern reads the raw stored value. A query written for
one and run against the other returns less and reports nothing.

The GraphQL nodesByCriteria API names these two the other way round to
this builder, so a developer arriving from it writes contains and loses
accent folding, stemming and case insensitivity at once. Both methods
now name the collision, and the guide carries a translation table.

diagnose() now reports a full text expression that cannot parse, which
otherwise fails at execution and takes the clauses beside it down. The
rules come from measured pairs on 8.2.3.2, so an expression that runs
is not reported: a trailing minus is fatal while a minus followed by a
space is not.

Twenty four tests pin the distinction from both directions: what each
method builds, and what each one finds on a live repository.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant