Skip to content

docs(search-api): fix the VTL examples and document JSON output - #37544

Merged
jdcmsd merged 4 commits into
mainfrom
issue-37520-search-api-migration-vtl-fixes
Sep 15, 2026
Merged

jdcmsd merged 4 commits into
mainfrom
issue-37520-search-api-migration-vtl-fixes

Conversation

@fabrizzio-dotCMS

@fabrizzio-dotCMS fabrizzio-dotCMS commented Sep 14, 2026

Copy link
Copy Markdown
Member

What

Fixes the Velocity section of docs/backend/SEARCH_API_MIGRATION.md, whose examples could
not be followed as written, and adds a section answering the question the guide raises but
never resolves: if .toString() no longer returns JSON, what does a template use instead?

Why

Every snippet in section 3 fails:

In the doc Problem Correct
$ESContent.raw($query) The viewtool is registered under the key estool (toolbox.xml). $ESContent resolves to nothing $estool.raw($query)
$raw.hits().hits() Java call syntax. Velocity resolves properties through bean getters $raw.hits.hits
$hit.id() Same $hit.id
hits().totalHits().value() Listed under a Velocity heading, but it is Java — and wrong Java $raw.hits.totalHits.value

The Java example in section 1 was also wrong: SearchHits declares its record components
as getHits / getTotalHits — deliberately, so Velocity can resolve them as properties —
which means there is no hits() or totalHits() method on it. raw.hits().hits() does not
compile; it is raw.hits().getHits().

And aggregations() was recommended with no caveat. Its own javadoc says it returns
"first-level terms aggregations… only aggregations that have buckets are included" — it
silently drops nested aggregations and top_hits. The full tree is getAggregations(),
reached from VTL as $raw.aggregations.

Changes

  • Section 3 renamed to $estool, with every snippet rewritten in real Velocity.
  • The accessor table now has two columns, VTL and Java, instead of mixing the dialects under
    a Velocity heading. Both flavours of aggregations are listed, with the flattened one
    marked lossy.
  • A note on why SearchHits looks the way it does, so the naming stops surprising people
    in both languages.
  • New JSON output section: $json.generate(...) still produces JSON in the neutral
    shape — a path kept working on purpose (ContentSearchResponse.getAggregations() is
    deliberately not @JsonIgnore so the reflective JSON builder still sees it, issue $json.generate() reflection-based navigation of aggregation results silently breaks after #36026 records migration #36435)
    — while Elasticsearch's wire format is gone by design. Only consumers that parse that
    specific envelope need restructuring. Includes a reduced-payload example and a
    three-case summary table.
  • Documents that the query is lowercased before execution
    (StringUtils.lowercaseStringExceptMatchingTokens), so an aggregation declared "tagAgg"
    comes back keyed tagagg — a #foreach over the wrong key renders nothing rather than
    failing.

Verification

Every accessor in the changed text was checked against the source: ESContentTool,
ContentSearchResponse, SearchHits, TotalHits, SearchHit, AggregationBucket,
JSONTool, and the viewtool keys in toolbox.xml.

Docs only — no code changes.

Related: #37520 (the audit/rewrite tool, which uses these rules as its spec).

🤖 Generated with Claude Code

This PR fixes: #37520

This PR fixes: #37520

The Velocity section of the search API migration guide could not be followed
as written:

- The viewtool is registered as `estool`, not `ESContent`, so every snippet
  started with a variable that resolves to nothing.
- The examples used Java call syntax (`$raw.hits().hits()`, `$hit.id()`).
  Velocity resolves properties through bean getters, so these are
  `$raw.hits.hits` and `$hit.id` in a template.
- The Java example was wrong too: `SearchHits` declares its record components
  as `getHits`/`getTotalHits` (so Velocity can see them as properties), which
  makes the Java calls `hits().getHits()` and `hits().getTotalHits()`, not
  `hits().hits()`.
- `aggregations()` was recommended without noting it returns the flattened
  first-level-terms view, silently dropping nested aggregations and top_hits.
  `$raw.aggregations` is the full tree.

Adds a "JSON output" section answering what replaces `.toString()`: JSON is
still available through `$json.generate(...)` in the neutral shape — a path
deliberately kept working (issue #36435) — while Elasticsearch's own wire
format is gone for good, so only consumers that parse that specific envelope
have to be restructured.

Also documents that the query is lowercased before it runs, so aggregation
names come back lowercased.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @fabrizzio-dotCMS's task in 3m 8s —— View job


Review — docs(search-api): VTL examples & JSON output

Docs-only PR. I verified every accessor and behavioral claim in the changed text against source, and rechecked the two review comments.

  • Diffed both files against branch base
  • Verified VTL/Java accessors against SearchHits, ContentSearchResponse, TotalHits, SearchHit
  • Verified viewtool keys (estool, json) and JSONTool.generate(Object)
  • Verified esSearch/esSearchRaw engine-routing claim
  • Verified OS_INDEX_REPLICAS fallback
  • Rechecked reviewer comments

Verification results

Every claim in the diff holds against the code:

Doc claim Source
SearchHits has getHits()/getTotalHits(), no hits()/totalHits() SearchHits.java:42-45 (record components named getHits/getTotalHits)
$raw.hits.totalHits.valuehits().getTotalHits().value() TotalHits.java:35,52 (value() + getValue() alias)
$raw.aggregationsgetAggregations() = full tree; aggregations() = flattened first-level terms, drops nested/top_hits ContentSearchResponse.java:75-77,126-128,157-168
$hit.id/index/score/sourceAsMap/sortValues SearchHit.java:74-103 (bean getters)
$raw.tookInMillistookMillis() ContentSearchResponse.java:58,110 (getTookInMillis() alias)
viewtool keys estool / json dotCMS/src/main/webapp/WEB-INF/toolbox.xml:301,241
$json.generate(...) builds JSON reflectively from bean getters; getAggregations() deliberately not @JsonIgnore (#36435) JSONTool.java:407-412, ContentSearchResponse.java:42-54,114-128
OS_INDEX_REPLICAS falls back to ES_INDEX_REPLICAS OSIndexProperty.java:116
esSearch/esRaw delegate straight to APILocator.getEsSearchAPI(), bypassing the phase router (→ diverge Phase 2, fail Phase 3) ESContentTool.java:99-112ESContentletAPIImpl.java:354-363

The reduced-payload example (#set($out = {...}) / $out.items.add(...)) is valid Velocity — map/array literals are mutable LinkedHashMap/ArrayList. The #json-output-replacing-tostring anchor link resolves to the <a id> at line 200.

Resolved (reviewer comments)

  • docs/backend/OPENSEARCH_MIGRATION_RUNBOOK.md:454-456 (jdcmsd) — Step 0.2 no longer says templates "break in Phase 2." The latest commit (6a23ae13) rewrote it to "do not break in Phase 2 … fail outright in Phase 3," matching the plugin table (:501, :1877) and the esRaw row (:1793). Grep confirms no stale "break at Phase 2" claim survives for legacy templates/plugins.
  • docs/backend/SEARCH_API_MIGRATION.md:196 (lowercasing periodization) — author decided to leave the version note out, with sound reasoning: for the esRawraw migration path there is no cross-version behavior change (old and new behave identically at any given build), so dating it would wrongly implicate the migration. That's a release-note matter (Search queries are lowercased end to end, so case-sensitive matching is impossible and quoted phrases are split #37522), not a migration-guide one. Agreed.

New Issues

No issues found. The rewrites are accurate and internally consistent, and both requested changes are addressed.

· issue-37520-search-api-migration-vtl-fixes

Four fixes to the migration runbook, each verified against the source:

- Neutral response accessors. `SearchHits` declares its record components as
  `getHits`/`getTotalHits` — deliberately, so Velocity resolves them as bean
  properties — so `hits().hits()` and `hits().totalHits()` do not exist. The
  Java calls are `hits().getHits()` and `hits().getTotalHits().value()`. Also
  swaps `aggregations()` for `getAggregations()`: the former returns the
  flattened first-level-terms view and drops nested aggregations and top_hits.

- When esSearch / esRaw break. Both delegate to APILocator.getEsSearchAPI(),
  the legacy Elasticsearch client, in every phase — they never reach the phase
  router. So they do not fail in Phase 2: they keep answering from
  Elasticsearch while the rest of the site reads OpenSearch, which is a silent
  divergence rather than a crash. They fail at Phase 3. Corrected in the
  $estool table, both plugin-grading tables, and the Timing section, whose
  conclusion now reads that neither a clean Phase 1 nor a clean Phase 2 says
  anything about plugin readiness.

- OS_INDEX_REPLICAS. It is declared with a fallback to ES_INDEX_REPLICAS
  (OSIndexProperty), so the claim that OpenSearch "does not inherit dotCMS's
  implicit default" was wrong. The advice to set it stands; the reason is that
  it otherwise inherits the old cluster's value, or pins nothing.

- Site Search crawl. Stage 3.9 warned that an incremental crawl can leave a
  dynamically-mapped copy, while R12 already documented the gate that prevents
  exactly that. The warning now points at R12 and keeps the instruction: run
  the full crawl yourself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fabrizzio-dotCMS

Copy link
Copy Markdown
Member Author

Pushed a second commit (3931917c52) extending this to OPENSEARCH_MIGRATION_RUNBOOK.md, which carried the same class of errors plus two of its own:

  • Neutral response accessors — same fix as the migration guide: hits().getHits() / hits().getTotalHits().value(), and getAggregations() instead of the flattened aggregations().
  • When esSearch / esRaw break — the runbook said Phase 2, in four places. Both delegate to APILocator.getEsSearchAPI() in every phase and never reach the phase router, so in Phase 2 they keep answering from Elasticsearch while the rest of the site reads OpenSearch: a silent divergence, not a crash. They fail at Phase 3. The Timing section now says that neither a clean Phase 1 nor a clean Phase 2 tells you anything about plugin readiness.
  • OS_INDEX_REPLICAS — it is declared with a fallback to ES_INDEX_REPLICAS (OSIndexProperty), so "OpenSearch does not inherit dotCMS's implicit default" was wrong. The advice to set it stands, with the real reason.
  • A contradiction on Site Search — Stage 3.9 warned about incremental crawls leaving a dynamically-mapped copy while R12 documented the gate that prevents it. Reconciled.

One known issue deliberately left alone: the runbook describes the Phase 2 read fallback as covering every read, which is not true in main today — ESContentFactoryImpl picks its provider directly and never goes through PhaseRouter. That is what #37413 / #37500 fixes, so the wording is correct once that merges and I would rather not churn it twice.

Review feedback on #37544:

- The header said the guide was for "plugin and integration developers", but
  section 3 is written for template authors — more visible now that the
  section has grown. It now names both audiences and says which sections
  belong to each.

- Section 4's "before" example cast the result of `esSearch(...)` to
  `ESSearchResults`, which is already its return type. Removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fabrizzio-dotCMS

Copy link
Copy Markdown
Member Author

Both review notes applied in 553ae3c7a6:

  • Header vs. audience — it now names both audiences and says which sections belong to each: Java callers get 1, 2, 4 and 5; template authors get section 3, which is self-contained and written entirely in Velocity. The closing line also distinguishes the two failure modes, since they are not alike: Java fails to compile, templates fail silently.
  • Redundant cast — dropped; esSearch(...) already returns ESSearchResults.

On the lowercasing note: the fact is right and worth recording. esSearch has normalised the query since c4e2d072cc (2023-04-19), but esSearchRaw only gained it in 7f8eee8586 (2026-06-16, #36170 — "normalize query in raw() search path"). So on a build older than June the raw path did not lowercase. Agreed the present-tense wording is accurate for anything current, so leaving the text as is.

@jdcmsd jdcmsd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two comments attached.

Comment thread docs/backend/OPENSEARCH_MIGRATION_RUNBOOK.md Outdated
Comment thread docs/backend/SEARCH_API_MIGRATION.md
Follow-up to the previous commit, which corrected the plugin tables and R7's
timing section but left four other places still saying customisations "break
at Phase 2" — so the runbook contradicted itself.

The same reasoning applies to templates as to plugins: `$estool.esSearch` and
`esRaw` reach `APILocator.getEsSearchAPI()` in every phase and never touch the
phase router. They do not break in Phase 2. While the two indices agree they
render exactly as before, which means walking the site in Stage 4 proves
nothing about them; they fail in Stage 5, when Elasticsearch is gone.

That changes what Stage 4 is actually risky for. Its real exposure is the
supported path — content pulls, the admin search, URL maps, REST and GraphQL —
which does switch engine there, so any difference between the indices surfaces:
results in a different order where the query gave no explicit sort, or short
results where the copy is incomplete. The stage heading and the Phase 2
description now say that, with the legacy methods called out separately as the
deferred, symptomless risk they are.

Also moves the template/plugin deadline from "before Stage 4" to "before
Stage 5", with the reason: fix them while Elasticsearch is still live and old
and new output can be compared side by side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@jdcmsd jdcmsd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all comments addressed

@jdcmsd
jdcmsd added this pull request to the merge queue Sep 15, 2026
Merged via the queue into main with commit ef9f538 Sep 15, 2026
56 checks passed
@jdcmsd
jdcmsd deleted the issue-37520-search-api-migration-vtl-fixes branch September 15, 2026 13:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Documentation PR changes documentation files

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

ES→OS: endpoint to audit and rewrite VTL that calls the deprecated $estool.esSearch / $estool.esRaw

2 participants