Restore ToScopedNode() wrap in TypedElementSearchIndexer, lost in 2022 - #5737
Draft
Brendan Kowitz (brendankowitz) wants to merge 1 commit into
Draft
Restore ToScopedNode() wrap in TypedElementSearchIndexer, lost in 2022#5737Brendan Kowitz (brendankowitz) wants to merge 1 commit into
Brendan Kowitz (brendankowitz) wants to merge 1 commit into
Conversation
TypedElementSearchIndexer invokes compiled FHIRPath expressions directly against a bare ITypedElement. Hl7.FhirPath's own Select()/Scalar() extension methods always wrap their input in ToScopedNode() first, and before #2763 (Aug 2022, "SearchIndexer uses its own Expression Cache") this indexer did too, calling resource.Select(...) like everything else. That commit switched to caching CompiledExpression and invoking the delegate directly for performance; nothing in it mentions resolve() or contained resources, so losing the wrap was a side effect, not a decision. ScopedNode is what lets resolve() find a contained resource or a sibling Bundle entry from inside the instance, before falling back to the external IReferenceToElementResolver - LightweightReferenceToElement Resolver, which parses reference strings and never had access to the instance being indexed, so a contained "#p1" reference could never resolve through it. resolve() itself has therefore never seen a contained resource during indexing since that commit, silently. This does not change what gets persisted for any shipped search parameter: every resolve() usage in the generated R4/R4B/R5 definitions is shaped `.where(resolve() is X)` for type filtering only, and ResourceReferenceToReferenceSearchValueConverter drops '#'/'urn:' references before they'd reach a SearchIndexEntry regardless of whether resolve() succeeds. The fix protects a narrower, real case: a custom search parameter that navigates past resolve() into the resolved resource's own data, where the previous behavior silently returned nothing instead of throwing or logging. The wrap is idempotent, so this doesn't double-wrap where extension methods already applied it upstream (e.g. composite parameters walking already-ScopedNode children). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brendan Kowitz (brendankowitz)
marked this pull request as draft
August 19, 2026 23:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What broke, and when
TypedElementSearchIndexerinvokes compiled FHIRPath expressions directly against a bareITypedElement. Every one ofHl7.FhirPath's ownSelect()/Scalar()extension methods wraps its input inToScopedNode()before evaluating — and before #2763 ("SearchIndexer uses its own Expression Cache", Aug 2022), this indexer did too:That change is purely a caching optimization — nothing in its description mentions
resolve()or contained resources — but hand-rolling the compiled-delegate invocation dropped theToScopedNode()wrap as an undocumented side effect.Why it matters
ScopedNodeis what lets FHIRPath'sresolve()find a contained resource (#id) or a sibling Bundle entry from inside the instance, before falling back to the externalIReferenceToElementResolver. This server's resolver,LightweightReferenceToElementResolver, only parses the reference string — it never had access to the instance being indexed, and its regex requiresResourceType/ResourceId, so#p1never matches and it returnsnull. Without the wrap,resolve()against a contained resource has therefore returned nothing during indexing since this commit — silently, four years ago.Scope: this does not change any shipped search parameter's output
I checked before writing this fix. Every
resolve()occurrence in the generated R4/R4B/R5search-parameters.json(76 across those three files; Stu3 has none) is shaped.where(resolve() is X)— pure type filtering, never navigation into the resolved resource's data:And even where the fix changes which elements pass that
.where()filter,ResourceReferenceToReferenceSearchValueConverter.Convertdrops#/urn:-prefixed references unconditionally before they'd ever reach aSearchIndexEntry:So no reindex is implied for any built-in search parameter. The fix protects a narrower, real case: a custom search parameter that navigates past
resolve()into the resolved resource's own data (e.g.Encounter.participant.actor.resolve().name.family), where the previous behavior silently returned an empty result instead of the contained resource's value.The fix
Two call sites, wrapped at the point of invocation rather than reverting the 2022 caching:
ToScopedNode()is idempotent (node as ScopedNode ?? new ScopedNode(node)), so this doesn't double-wrap where an extension method already applied it upstream — e.g. a composite parameter's child expressions run againstrootObjects that came out of the firstToScopedNode()-wrapped evaluation and are alreadyScopedNodes.Testing
New test constructs an
Encounterwith a containedPractitioner, a synthetic non-Reference search parameter ({participant path}.resolve().name.family), and anIReferenceToElementResolvermock returningnull— so external resolution definitely cannot be what succeeds. Verified it actually pins the regression: fails against the pre-fix code (Assert.Single() Failure: The collection was empty), passes with the fix, on all four FHIR versions (R4/R4B/R5/Stu3).TypedElementSearchIndexerTests, all four versions — new test passes, confirmed to fail without the fixMicrosoft.Health.Fhir.R4.Core.UnitTestsfull suite — 1723/1723Microsoft.Health.Fhir.Core.UnitTestsfull suite — 1044/1044Context
Found while investigating ADR 2608 (the Ignixa FHIRPath migration seam, not yet merged) — landing this independently first means that PR's
TypedElementSearchIndexerdiff is a pure provider-abstraction change with no bundled behavior fix.🤖 Generated with Claude Code