Skip to content

OAK-12360: Support per-property analyzers in Lucene index definitions - #3089

Draft
bhabegger wants to merge 10 commits into
apache:trunkfrom
oak-indexing:issue/OAK-12360
Draft

OAK-12360: Support per-property analyzers in Lucene index definitions#3089
bhabegger wants to merge 10 commits into
apache:trunkfrom
oak-indexing:issue/OAK-12360

Conversation

@bhabegger

Copy link
Copy Markdown
Contributor

OAK-12360

Problem

Today, an oak:index Lucene definition can declare a custom analyzer only under analyzers/default. Any other analyzer configured under analyzers/<name> is silently ignored — there's no way to apply a different tokenizer/stemmer/stopword-set to individual properties (e.g. a language-specific analyzer for one field while keeping the default for the rest of the index).

Change

Adds a new optional analyzer string property on a property definition:

indexRules/<nodeType>/properties/<propName>/analyzer = "<name>"

referencing a sibling node analyzers/<name>, alongside the existing analyzers/default. LuceneIndexDefinition.createAnalyzer() builds a PerFieldAnalyzerWrapper entry for every analyzed property with a resolving analyzer reference, keyed by that property's actual Lucene field name (full:<pname> for index format V2+, <pname> for legacy V1) — the same name LuceneDocumentMaker writes documents under, so index-time and query-time (both already resolve through LuceneIndexDefinition.getAnalyzer()) stay consistent automatically.

Properties that don't set analyzer are completely unaffected — fully backward compatible, no feature toggle needed since the change is purely additive/opt-in.

Error handling: a property's analyzer reference that doesn't resolve to an existing analyzers/<name> node logs a warning and falls back to the default analyzer for that property only, rather than failing the index build — consistent with the existing convention for other dangling references in IndexDefinition (e.g. an aggregate rule referencing a missing property).

Known limitations (explicitly out of scope for this PR)

  • The aggregated :fulltext field (used by CONTAINS(*, ...)) collects raw text from every nodeScopeIndex=true property into one shared field, re-analyzed with a single analyzer — per-property analyzers can't differentiate text once merged into :fulltext.
  • Regular-expression property definitions — since the rule matches a different concrete property per node, there's no single property to attach a custom analyzer to.

Both fall back to the default analyzer with a logged warning and are documented as known limitations in oak-doc/src/site/markdown/query/lucene.md.

Out of scope

  • Elasticsearch provider (oak-search-elastic) — tracked separately.

Testing

New tests in PropertyDefinitionTest and LuceneIndexDefinitionTest cover: config parsing, the happy path (custom analyzer applied to the declared property's own field while a sibling property keeps the default), a dangling analyzer reference, regexp property definitions, and the :fulltext aggregate-field limitation. Full oak-search and oak-lucene module suites pass with no regressions.

Marked as draft while an internal CI pipeline runs against the branch.

bhabegger and others added 10 commits August 14, 2026 18:24
…(known limitation)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add a new subsection to the Analyzers section documenting the per-property
analyzer feature. Documents the syntax, backward compatibility, and explicitly
calls out the two known limitations (aggregated fulltext field and regexp properties).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ertyAnalyzers

The pd.isRegexp check in the main getProperties() loop could never fire —
regexp property definitions are routed into IndexingRule.namePatterns, never
into propDefinitions, so this branch duplicated the (correct, live) warning
already emitted by the getNamePatternsProperties() pass below it. Flagged
in final review as a minor; removing it since there's no reason to keep
dead code around.
…yzers

The existing LuceneIndexDefinitionTest coverage drives Analyzer.tokenStream()
directly on the constructed Analyzer object - it verifies createAnalyzer()
builds the right PerFieldAnalyzerWrapper, but never exercises the actual
write path (LuceneDocumentMaker -> IndexWriter) or query path (CONTAINS()
via LucenePropertyIndex). Add a test in LuceneFullTextAnalyzerTest, following
the existing whitespace-tokenizer pattern in FullTextAnalyzerCommonTest, that
commits real content and asserts real query results differ per property
based on its declared analyzer. Verified red->green by temporarily
short-circuiting collectPerPropertyAnalyzers() to return an empty map: the
case-sensitive assertion on "title" failed as expected, confirming the test
is genuinely sensitive to the feature rather than passing for a trivial
reason.
The existing :fulltext locking test in LuceneIndexDefinitionTest only
proves PerFieldAnalyzerWrapper's map never gets a :fulltext key - it never
exercises the real nodeScopeIndex -> :fulltext write path. Add a test that
commits real content with nodeScopeIndex + a custom per-property analyzer,
and asserts jcr:contains(@title, ...) is case-sensitive (custom analyzer)
while jcr:contains(., ...) - the aggregated field - stays case-insensitive
(default analyzer), through the real indexing/query pipeline.
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