refactor(search): split SearchHit into a lean content shape and a Site Search one (#36360) - #36899
Conversation
…e Search one (#36360) Content search results are the hot path and should stay as small as possible; highlight fragments are a Site Search concern that no other caller reads. Rather than every hit carrying a field only one caller uses, SearchHit becomes a sealed interface over two records: - ContentSearchHit — six components, no highlight state at all - SiteSearchHit — the same six plus the highlight fragments Highlights are read through the interface's highlightsFor(field), which defaults to an empty list, so a caller holding a plain SearchHit never has to know or ask which shape it got. Nothing outside the domain package changed: the enterprise consumer now resolves highlightsFor through invokeinterface, and the ten SearchHits.from / SearchHit::from call sites compile untouched. Callers never choose the shape — Builder.build() picks it from the data, returning the lean record unless the engine actually returned fragments. That keeps the decision in one place instead of threading a "do I want highlights" flag down through rawSearch -> ContentSearchResponse.from -> SearchHits.from, which would otherwise be required: Site Search and content search reach the neutral layer through the very same factory. Deserialization of the interface targets ContentSearchHit via @JsonDeserialize. Nothing serializes a Site Search hit today, and the alternative -- polymorphic @JsonTypeInfo -- would add a type discriminator to a JSON shape the query caches depend on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Tick the box to add this pull request to the merge queue (same as
|
|
Claude finished @fabrizzio-dotCMS's task in 1m 27s —— View job Claude finished @fabrizzio-dotCMS's task in 4m 47s —— View job Code Review — SearchHit sealed split
New IssuesNo new bugs found. The logic is sound and well-tested:
Notes (non-blocking)
Both are trade-offs already documented in the PR body and comments, not defects. No code change requested from me. |
|
Closing: I built this to address @wezell's cohesion point on #36886, then measured what it actually bought and concluded it should not land. Full reasoning in #36886 (comment). Short version: the shared contract still carries The branch The one piece worth keeping landed on #36886 instead: two |
…rip (#36360) The first cut of the split pinned deserialization of the interface to ContentSearchHit, because an interface is not instantiable on its own. That made a serialized SiteSearchHit come back without its fragments and without an error — a silent loss, and the same failure mode that already cost us two follow-up commits on #36026. Nothing serializes a Site Search hit today, but a landmine that only goes off later is worse than one that fails loudly now. Replace it with a @JsonCreator static factory that rebuilds through Builder.build(). The serialized form already carries the `highlights` key when there were fragments, so the same rule that picks the shape coming from the engine picks it coming from JSON — no polymorphic type discriminator, so the JSON shape the query caches depend on is unchanged. Covered both ways: a highlighted hit round-trips back as SiteSearchHit with its fragments, and a content hit stays lean instead of quietly widening into the Site Search shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reopened — @wezell is good with the shape: public sealed interface SearchHit permits ContentSearchHit, SiteSearchHit {
// ... the six neutral accessors ...
default List<String> highlightsFor(String field) { return List.of(); }
}One thing changed since I closed it. My objection had two parts: that the cohesion gain was small, which is a judgement call and yours to make, and that the first cut introduced a new silent-data-loss path, which was a real defect rather than a trade-off. Deserialization no longer pins the interface to Still worth flagging, since it is a trade-off rather than a bug: |
|
On the Record --> Interface change - we should check our customer plugin who leverages the base ES classes to make sure we don't break it. |
… null (#36360) getOrDefault substitutes its default only when the key is absent, so a highlight map carrying the field as an explicit null value came back as null — and OSSiteSearchAPI turns the result straight into an array, so it would NPE there rather than at the source. Two unvalidated inputs can produce that shape: the OpenSearch adapter passes Hit.highlight() through as-is, and a `"highlights": {"content": null}` payload survives deserialization. The Elasticsearch adapter is already safe (it only puts non-null, non-empty fragment lists). Fixed in the accessor rather than by stripping null values in the canonical constructor, which would copy the map on every Site Search hit for a case a null check handles in constant time. Pre-existing on the single-record shape too, not introduced by the split — but this PR owns the accessor, so it is the place to close it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c9941c5
into
issue-36360-phase-sweep-fixes
Summary
Follow-up to @wezell's review on #36886: content search results should stay small and fast, and
highlight fragments are a Site Search concern no other caller reads. Instead of every hit carrying
a field only one caller uses,
SearchHitbecomes a sealed interface over two records.Stacked on #36886 (base is
issue-36360-phase-sweep-fixes, notmain) — the field this splitsonly exists on that branch. Retarget to
mainonce #36886 merges.The shape
ContentSearchHit— six components, no highlight state at all. What every content query produces.SiteSearchHit— the same six plus the fragments, overridinghighlightsFor.The
defaultis what makes it work: the concern is declared once on the contract, costs zero bytesper instance, and a caller holding a plain
SearchHitnever has to know or ask which shape it got.Callers never choose the shape
Builder.build()picks it from the data — the lean record unless the engine actually returnedfragments. That matters because Site Search and content search reach the neutral layer through the
same factory (
ContentSearchResponse.from, since Site Search reads viarawSearchafter #36398),so a "do I want highlights" flag would have to be threaded through
rawSearch→ContentSearchResponse.from→SearchHits.from→ the hit adapter. Deciding from the response keepsit in one place. Since the only queries that request highlighting are the two Site Search ones, a
content hit is always the lean shape.
Blast radius: none outside the domain package
./mvnw compile -pl :dotcms-corepasses with zero changes anywhere else — the tenSearchHits.from/SearchHit::fromcall sites,ContentSearchResponse,Aggregation,ESContentResourcePortletandOSSiteSearchAPIall compile untouched. Verified the enterpriseconsumer really does resolve through the contract:
Jackson
An interface is not instantiable, so deserialization goes through a
@JsonCreatorstatic factory thatrebuilds via
Builder.build(). The serialized form already carries thehighlightskey when there werefragments, so the same rule that picks the shape coming from the engine picks it coming from JSON — no
polymorphic
@JsonTypeInfo, so the JSON shape the query caches depend on is unchanged.The first cut of this PR instead pinned deserialization to
ContentSearchHit, which made a serializedSiteSearchHitcome back without its fragments and without an error. Nothing serializes one today, butthat is a silent-loss landmine of the same class as #36026, so it is fixed rather than documented.
Side benefit:
ContentSearchHitno longer serializes an empty"highlights":{}.Velocity
No template reads highlights (zero
.vtlreferences in the repo), and Site Search results are notexposed through a viewtool at all — those templates consume
SiteSearchResult, which this does nottouch. The at-risk surface is the content hit accessors reachable as
$hit.id/$hit.sourceAsMap/$hit.sortValues, and those are guarded byContentSearchToolTest, which evaluates real customer VTLthrough the dotCMS Velocity engine.
Testing
All green against this branch. 25 unit + 83 integration tests, covering every family that consumes
the neutral hit:
SearchHitTestsort()unwrap, highlights, JSON round-trip both ways, field mapped tonullAggregationDomainTestSearchHit/SearchHitsContentSearchToolTest$hit.id/.index/.sourceAsMap/.sortValuesonsearch()andraw(), plus the nestedtop_hitswalkESContentResourcePortletTest/api/es/searchand/api/es/rawwire shapeContentletIndexAPIImplTestESContentFactoryImplTestsearchHitson the Elasticsearch factory pathVerified the ITs ran against the refactor rather than a stale artifact — the integration module resolves
dotcms-corefrom~/.m2, so core was reinstalled first and the installed jar carriesSearchHitas aninterface alongside the two records.
Coverage gap, stated plainly: the runs above are phase 0, where Site Search reads are served by
ESSiteSearchAPI— which uses Elasticsearch's ownSearchHitand never touches the neutral type. SoSiteSearchHitend-to-end (OSSiteSearchAPI→ neutral hit → fragments on the result) is currentlycovered by unit tests only. Exercising it needs a phase 2/3 run, where OpenSearch serves reads.
Breaking Changes
None at the source level.
SearchHitchanges from a record to a sealed interface, so anythingcompiled against the concrete type would need recompiling — nothing in the repo does, and the type is
internal to the neutral search layer.
🤖 Generated with Claude Code
This PR fixes: #36360
This PR fixes: #36360