Skip to content

Restore ToScopedNode() wrap in TypedElementSearchIndexer, lost in 2022 - #5737

Draft
Brendan Kowitz (brendankowitz) wants to merge 1 commit into
mainfrom
personal/bkowitz/fix-indexer-resolve-scoping
Draft

Restore ToScopedNode() wrap in TypedElementSearchIndexer, lost in 2022#5737
Brendan Kowitz (brendankowitz) wants to merge 1 commit into
mainfrom
personal/bkowitz/fix-indexer-resolve-scoping

Conversation

@brendankowitz

Copy link
Copy Markdown
Member

What broke, and when

TypedElementSearchIndexer invokes compiled FHIRPath expressions directly against a bare ITypedElement. Every one of Hl7.FhirPath's own Select()/Scalar() extension methods wraps its input in ToScopedNode() before evaluating — and before #2763 ("SearchIndexer uses its own Expression Cache", Aug 2022), this indexer did too:

-IEnumerable<ITypedElement> rootObjects = resource.Select(searchParameter.Expression, context);
+CompiledExpression expression = _expressions.GetOrAdd(searchParameter.Expression, s => _compiler.Compile(s));
+IEnumerable<ITypedElement> rootObjects = expression.Invoke(resource, context);

That change is purely a caching optimization — nothing in its description mentions resolve() or contained resources — but hand-rolling the compiled-delegate invocation dropped the ToScopedNode() wrap as an undocumented side effect.

Why it matters

ScopedNode is what lets FHIRPath's resolve() find a contained resource (#id) or a sibling Bundle entry from inside the instance, before falling back to the external IReferenceToElementResolver. This server's resolver, LightweightReferenceToElementResolver, only parses the reference string — it never had access to the instance being indexed, and its regex requires ResourceType/ResourceId, so #p1 never matches and it returns null. 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/R5 search-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:

$ python3 -c "... find resolve() not immediately followed by 'is' ..."
(no output — zero counter-examples)

And even where the fix changes which elements pass that .where() filter, ResourceReferenceToReferenceSearchValueConverter.Convert drops #/urn:-prefixed references unconditionally before they'd ever reach a SearchIndexEntry:

// Contained resources will not be searchable.
if (reference.StartsWith('#') || reference.StartsWith("urn:", StringComparison.Ordinal))
{
    yield break;
}

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:

-IEnumerable<ITypedElement> rootObjects = expression.Invoke(resource, context);
+IEnumerable<ITypedElement> rootObjects = expression.Invoke(resource.ToScopedNode(), context);

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 against rootObjects that came out of the first ToScopedNode()-wrapped evaluation and are already ScopedNodes.

Testing

New test constructs an Encounter with a contained Practitioner, a synthetic non-Reference search parameter ({participant path}.resolve().name.family), and an IReferenceToElementResolver mock returning null — 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 fix
  • Microsoft.Health.Fhir.R4.Core.UnitTests full suite — 1723/1723
  • Microsoft.Health.Fhir.Core.UnitTests full suite — 1044/1044

Context

Found while investigating ADR 2608 (the Ignixa FHIRPath migration seam, not yet merged) — landing this independently first means that PR's TypedElementSearchIndexer diff is a pure provider-abstraction change with no bundled behavior fix.

🤖 Generated with Claude Code

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>
@brendankowitz
Brendan Kowitz (brendankowitz) marked this pull request as draft August 19, 2026 23:01
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.

2 participants