Ignixa.FhirPath
A high-performance FHIRPath implementation with visitor pattern architecture, compile-time optimization, and expression caching, implementing the FHIRPath N1 (Normative) specification.
Built using the Superpower parser combinator library (based on Sprache), which provides token-driven parsing with friendly, human-readable error messages for invalid FHIRPath expressions.
Key Features
- Visitor Pattern Architecture - Clean separation between AST structure and operations
- Compile-Time Optimization - Constant folding and short-circuiting, applied only where they provably preserve semantics
- Expression Caching - Parsed ASTs cached for repeated evaluations
- Type Inference - Static analyzer validates expressions before execution
- High Performance - Significant improvements over traditional switch-based evaluators
- Extensible - Custom functions registered via attributes and source generators
- Instance Selectors - Inline object construction (
Coding { code: '8480-6' }) delegated to a host-supplied factory
Installation
dotnet add package Ignixa.FhirPath
dotnet add package Ignixa.Serialization
dotnet add package Ignixa.Specification
Quick Start
using Ignixa.Abstractions;
using Ignixa.FhirPath.Evaluation;
using Ignixa.Serialization;
using Ignixa.Specification.Extensions;
// Parse FHIR JSON
var patientJson = """{"resourceType":"Patient","active":true,"name":[{"given":["Jane"]}]}""";
var schema = FhirVersion.R4.GetSchemaProvider();
var element = JsonSourceNodeFactory.Parse(patientJson).ToElement(schema);
// Evaluate FHIRPath (with automatic caching)
var names = element.Select("name.given");
var isActive = element.IsTrue("active = true");
Compile-Time Optimization
The parser can optimize expressions at compile time when using CompilationOptions:
using Ignixa.FhirPath.Parser;
var parser = new FhirPathParser();
var options = new CompilationOptions { Optimize = true };
// Constant folding
var expr1 = parser.Parse("1 + 1", options); // Optimized to: 2
var expr2 = parser.Parse("'hello' + 'world'", options); // Optimized to: 'helloworld'
// Short-circuit evaluation, matching the three rows the evaluator itself short-circuits
var expr3 = parser.Parse("false and X", options); // Optimized to: false (X not evaluated)
var expr4 = parser.Parse("true or X", options); // Optimized to: true (X not evaluated)
var expr5 = parser.Parse("false implies X", options); // Optimized to: true (X not evaluated)
// Left alone: X is always evaluated, so folding it away would discard an error it may signal
var expr6 = parser.Parse("X and false", options); // Unchanged
var expr7 = parser.Parse("X + 0", options); // Unchanged
var expr8 = parser.Parse("X and true", options); // Unchanged
An optimization may only drop an operand that is itself a literal, or one the evaluator would not have
evaluated either. Every FHIRPath operand can signal an error, so rewriting X and false to false
would turn an expression that throws into one that quietly answers - and a rewrite that returns X in
place of the whole expression changes its type whenever X is not a boolean singleton.
The Select() extension methods automatically use optimized parsing. Manual optimization is only needed when using the parser API directly.
Evaluation Methods
Select
Returns a collection of matching elements:
// Single path
var names = element.Select("name.given");
// Union paths
var identifiers = element.Select("identifier.value | id");
// With predicates
var activeContacts = element.Select("contact.where(active = true)");
Scalar
Returns a single scalar value:
var birthDate = element.Scalar("birthDate");
var age = element.Scalar("age()");
var count = element.Scalar("name.count()");
Scalar() returns the raw boxed .NET value. Don't call .ToString() on it to get display
text — a boolean gives "True" instead of FhirPath's "true", and decimals format per the
current culture. Use Select(expr).AsString() for spec-conformant strings.
AsString
Converts a single-result expression to its FhirPath string representation (the spec's
toString() rules — lowercase booleans, invariant-culture decimals):
var hasAddress = element.Select("address.exists()").AsString(); // "false", not "False"
var birthDate = element.Select("birthDate").AsString();
Returns null if the expression yields empty, multiple values, or the single result has no
primitive value (e.g. a complex/backbone element) — matching Scalar()'s empty/multiple contract.
IsTrue / IsBoolean
Returns boolean evaluation:
// Check if expression evaluates to true
var isActive = element.IsTrue("active = true");
// Check specific boolean value
var isInactive = element.IsBoolean("active", false);
Path Syntax
Navigation
Patient.name // Direct child
Patient.name.family // Nested path
Patient.name[0] // Index access
Patient.contact.name // Through arrays
Filtering
name.where(use = 'official') // Where clause
name.first() // First element
name.last() // Last element
name.exists() // Existence check
name.empty() // Empty check
Operators
birthDate < @2000-01-01 // Date comparison
age > 18 // Numeric comparison
active and deceased.exists().not() // Boolean logic
gender = 'male' or gender = 'female' // Boolean logic
name.family.startsWith('Sm') // String operations
name.family.contains('ith') // String operations
Instance Selectors
An instance selector constructs a new FHIR object inline, using the type name followed by a brace-delimited list of element assignments:
Coding { system: 'http://loinc.org', code: '8480-6' }
Quantity { value: 120, unit: 'mm[Hg]' }
FHIR.Quantity { value: 120 } // Namespace-qualified type name
Coding {} // Empty object
Coding {:} // Empty object, alternate form
Coding { `code`: 'c1' } // Delimited (backtick) element name
Assigned values are themselves FHIRPath expressions, and selectors nest:
(1 | 2 | 3).select(Coding { code: $this.toString() })
Observation { value: Quantity { value: 70 } }
Coding { code: 'TEST'.lower() }
Evaluation semantics:
- An empty input collection produces an empty result.
- An input collection with more than one item is an error.
- An element whose value expression evaluates to an empty collection is omitted from the created object.
- Construction itself is delegated to the host — see Instance Creator for the required wiring.
The static analyzer resolves the declared type against the schema and infers it, so an unknown type name is reported as an analysis error. At runtime an unknown type is simply declined by the creator, producing an empty collection rather than an exception.
FHIRPath object creation is a Standard for Trial Use (STU) section of the specification, not normative. It has no conformance tests in fhir-test-cases, so behaviour the spec leaves open (choice-element naming, repeated assignments, primitive construction) is an Ignixa implementation decision rather than a compliance claim. Those decisions are recorded in docs/features/fhirpath/investigations/instance-creation-delegate.md in the repository.
Functions
See the FHIRPath N1 specification for the complete function reference. Commonly used functions include:
Collection: exists(), empty(), count(), first(), last(), single(), where(), select(), all(), any()
String: contains(), startsWith(), endsWith(), matches(), replace(), substring(), length()
Type: ofType(), as(), is()
FHIR-specific: resolve(), extension(), memberOf()
Compilation & Caching
Automatic Caching
The Select() extension method automatically caches both the parsed AST and compiled delegates:
// First call: parse + compile + cache
var result1 = element.Select("name.family");
// Second call: uses cached compiled delegate
var result2 = element.Select("name.family");
How it works:
- AST Caching: Expression string is parsed once and cached
- Delegate Compilation: AST is compiled to a delegate if the pattern is supported
- Fallback: Complex expressions fall back to interpreter automatically
The caching is automatic and internal - no configuration needed.
Variables & Context
Built-in Variables
%resource // Current resource (set via WithResource())
%rootResource // Root resource (set via WithRootResource())
%context // Evaluation context node, falling back to %resource
%this, %index // Also spelled $this and $index
The specification's fixed constants resolve without any configuration:
%ucum // http://unitsofmeasure.org
%sct // http://snomed.info/sct
%loinc // http://loinc.org
%`vs-[name]` // http://hl7.org/fhir/ValueSet/[name]
%`ext-[name]` // http://hl7.org/fhir/StructureDefinition/[name]
%terminologies is recognised but not supported; referencing it throws, because it needs a terminology
service.
Hyphenated variable names must be quoted
The vs- and ext- families only expand in the backtick-delimited spelling. This is what the FHIR
profile of FHIRPath specifies - it writes both families quoted "to allow - in the name" - and what HAPI
does. A bare %vs-mine is an ordinary variable name, so unless you bind it yourself it fails with
Attempting to access an undefined environment variable: vs-mine:
%`vs-administrative-gender` // http://hl7.org/fhir/ValueSet/administrative-gender
%vs-administrative-gender // error: undefined environment variable
Any name you supply through WithEnvironmentVariable can be read either way, since the quoting rule
applies only to the two specification prefixes.
- binds into a variable name, so subtraction needs whitespace
Ignixa's tokenizer takes - inside an unquoted %name, matching HAPI's lexer, so that names like
%p-inactive - which appear in published text/fhirpath cqf-expression content - read as one name.
The consequence is that a hyphen touching a variable name is part of that name, not an operator:
%a-b // one variable named "a-b"
%a-1 // one variable named "a-1"
%a - b // subtraction: %a minus b
%a -b // subtraction: %a minus b
%a- b // parse error - the name is "a-" and "b" follows it
Write whitespace before the hyphen when you mean subtraction. The failure mode is loud in every case above: an unknown name, or a parse error - never a silently different number.
Custom Variables
EvaluationContext is an immutable record: every With* method returns a new context rather than mutating the existing one.
using Ignixa.FhirPath.Evaluation;
var context = new EvaluationContext()
.WithResource(patientElement) // Sets %resource
.WithEnvironmentVariable("today", todayElement);
var result = element.Select("birthDate < %today", context);
WithEnvironmentVariable also accepts an IEnumerable<IElement> when a variable holds a collection. Object-initializer syntax works too, since the properties are init-only:
var context = new EvaluationContext { Resource = patientElement };
Reference Resolution (resolve())
The resolve() function follows a Reference from one resource to another. Resolution happens in two stages, tried in order:
- In-instance resolution — looks inside the resource under evaluation for a contained resource, a sibling Bundle/Parameters entry, or (when there is something to index) a bare
#. This needs no configuration at all. ElementResolverfallback — runs whenever in-instance resolution does not produce a result and anElementResolveris configured on aFhirEvaluationContext. This is the extension point for genuinely external references (a different server, a database, a cache).
In-instance resolution requires a root to index: RootResource if set, otherwise Resource (the two can differ — for example, validation sets them to different elements while inside a contained resource's own scope). With neither set, there is nothing to index at all, so every reference — including a bare # — falls straight through to the ElementResolver fallback, or to empty if that isn't configured either.
In-instance resolution
No ElementResolver needed. It covers:
- Contained resources by
#id("reference": "#p1"finds the matching entry incontained). - A bare
#— only when an index exists (ResourceorRootResourceis set) — is decided entirely in-instance: it resolves to the containing resource when evaluated from inside one of that container's own contained resources, and to empty at the container's own scope (root, or a Bundle/Parameters entry). See the callout below — this is not the same rule as an unresolved#id. - For a
Bundleroot, sibling entries byfullUrl,Type/id, andType/id/_history/versionId. - For a
Parametersroot, resources nested underparameter/part(at any depth), keyed byType/idonly — Parameters entries have nofullUrl.
using Ignixa.Abstractions;
using Ignixa.FhirPath.Evaluation;
using Ignixa.Serialization.SourceNodes;
using Ignixa.Specification.Extensions;
IFhirSchemaProvider schemaProvider = FhirVersion.R4.GetSchemaProvider();
// subject.reference is "#p1" - Patient p1 is a contained resource, not a separate lookup.
var observationJson = """
{
"resourceType": "Observation",
"id": "obs1",
"status": "final",
"code": { "coding": [ { "system": "http://loinc.org", "code": "1234-5" } ] },
"subject": { "reference": "#p1" },
"contained": [
{ "resourceType": "Patient", "id": "p1" }
]
}
""";
var observation = ResourceJsonNode.Parse(observationJson).ToElement(schemaProvider);
// No ElementResolver configured - Resource is enough for resolve() to find the contained Patient.
var context = new EvaluationContext { Resource = observation };
var isPatient = observation
.Select("Observation.subject.where(resolve() is Patient).exists()", context)
.Single();
// isPatient.Value == true
The same applies to a Bundle root: Bundle.entry.resource.ofType(Observation).subject.resolve() finds a sibling entry by fullUrl or Type/id with no resolver configured, as long as RootResource (or Resource) points at the Bundle.
Bare # versus an unresolved #id
An unresolved #id and an unresolved bare # behave differently, and this is the single most surprising part of the design:
#idthat misses the in-instance index falls through to theElementResolver, exactly like any other unresolved reference.- Bare
#never falls through once an index exists (ResourceorRootResourceis set) — if it doesn't resolve to a containing resource in-instance,resolve()returns empty, and theElementResolveris never consulted, even if one is configured and would return something.
Ignixa follows Firely for this asymmetry: ScopedNodeExtensions.Resolve<T> only short-circuits the exact string "#" — for an unresolved #id it still consults the external resolver. HAPI's FHIRPathEngine.funcResolve disagrees: it short-circuits every #-prefixed reference regardless of the id, so it never consults the host resolver for any fragment. See GivenUnresolvedFragmentReference_WhenElementResolverCanResolveIt_ThenFallsBackToResolver and the sibling bare-# tests in ResolveFunctionTests.cs for the executable contract.
Container scoping
Contained resources are scoped to the nearest container boundary, not to the whole instance. A container boundary is the root itself, or any Bundle.entry.resource / Parameters.parameter[.part].resource — per R4 references.html §2.3.0.8: "resolution stops at the elements Bundle.entry.resource and Parameters.parameter.resource, but not at DomainResource.contained".
Practically:
- A
#fragreference inside a Bundle entry's resource resolves against that entry's owncontainedarray, and cannot see a same-named#fragcontained in a different entry. - Bare
#from inside a contained resource that itself lives inside a Bundle entry resolves to the entry resource, not the Bundle root. - Sibling lookups (
fullUrl,Type/id, and the versioned form) are not affected by container scoping — they remain cross-entry by design, because they are Bundle/Parameters-level lookups, not containment.
// Two Bundle entries each contain an Organization with id "org1". Container scoping means each
// Patient's "#org1" resolves within its own entry, never the other entry's same-named contained
// resource.
IFhirSchemaProvider schemaProvider = FhirVersion.R4.GetSchemaProvider();
var bundleJson = """
{
"resourceType": "Bundle",
"type": "collection",
"entry": [
{
"resource": {
"resourceType": "Patient", "id": "patA",
"managingOrganization": { "reference": "#org1" },
"contained": [ { "resourceType": "Organization", "id": "org1", "name": "OrgA" } ]
}
},
{
"resource": {
"resourceType": "Patient", "id": "patB",
"managingOrganization": { "reference": "#org1" },
"contained": [ { "resourceType": "Organization", "id": "org1", "name": "OrgB" } ]
}
}
]
}
""";
var bundle = ResourceJsonNode.Parse(bundleJson).ToElement(schemaProvider);
var context = new EvaluationContext { Resource = bundle };
var orgAName = bundle
.Select("Bundle.entry.resource.ofType(Patient).where(id = 'patA').managingOrganization.resolve().name", context)
.Single();
var orgBName = bundle
.Select("Bundle.entry.resource.ofType(Patient).where(id = 'patB').managingOrganization.resolve().name", context)
.Single();
// orgAName.Value == "OrgA", orgBName.Value == "OrgB" - each entry sees only its own contained pool.
External resolver (fallback)
Configure an ElementResolver on FhirEvaluationContext to resolve references that are not part of the instance being evaluated - a reference to a resource that lives on another server, in a database, or behind a cache.
using Ignixa.FhirPath.Evaluation;
using Ignixa.Serialization;
using Ignixa.Specification;
// Obtain a schema provider for your FHIR version
// Example: var schemaProvider = new R4CoreSchemaProvider();
IFhirSchemaProvider schemaProvider = GetSchemaProvider();
// Create a FHIR evaluation context with an ElementResolver that resolves references
var context = new FhirEvaluationContext().WithElementResolver(reference =>
{
// reference will be a string like "Patient/123" or "Practitioner/456"
// Fetch from your data store (database, API, cache, etc.)
// This method should return the resource JSON or null if not found
string? resourceJson = GetResourceByReference(reference);
if (resourceJson == null)
return null; // Return null if resource not found
// Parse and return as IElement
var sourceNode = JsonSourceNodeFactory.Parse(resourceJson);
return sourceNode.ToElement(schemaProvider);
});
// Example implementation of GetResourceByReference:
// string? GetResourceByReference(string reference)
// {
// // Parse reference (e.g., "Patient/123" -> type="Patient", id="123")
// var parts = reference.Split('/', 2);
// if (parts.Length != 2) return null;
//
// // Fetch from database, cache, or other data source
// return FetchFromDatabase(parts[0], parts[1]);
// }
// Now resolve() works in FHIRPath expressions
var encounterJson = """
{
"resourceType": "Encounter",
"id": "enc1",
"participant": [
{
"individual": {
"reference": "Practitioner/dr-smith"
}
}
]
}
""";
var encounter = JsonSourceNodeFactory.Parse(encounterJson).ToElement(schemaProvider);
// Use resolve() to follow the reference and check the practitioner type
var practitioners = encounter.Select(
"participant.individual.where(resolve() is Practitioner)",
context);
// Access properties of resolved resources
var practitionerNames = encounter.Select(
"participant.individual.resolve().name.family",
context);
Common use cases:
// Check if a reference resolves to a specific resource type
"subject.resolve() is Patient"
// Access properties through references
"performer.resolve().name.family"
// Filter by resolved resource properties
"participant.individual.where(resolve().active = true)"
// Chain multiple references
"encounter.resolve().serviceProvider.resolve().name"
The resolve() function's error-handling contract:
- The reference is not found in-instance, and either no
ElementResolveris configured or it also misses → returns empty. This follows FHIRPath's propagation semantics - operations on empty collections return empty rather than throwing exceptions, so expressions can keep evaluating even when a reference can't be resolved. - The configured
ElementResolverthrows → treated the same as "not found" (empty), exceptOperationCanceledExceptionandOutOfMemoryException, which propagate rather than being swallowed. The host resolver is caller-supplied code and a trust boundary, so an ordinary failure there is "reference not found" per spec - but cancellation and out-of-memory are not ordinary failures. - A defect while building or querying the in-instance index itself (for example a broken
IElementimplementation) is not treated as "not found" - it propagates. Only the external-resolver boundary is trusted enough to swallow exceptions; a bug in Ignixa's own resolution logic is not.
A contained or intra-Bundle/Parameters reference resolves with no ElementResolver configured at all - see In-instance resolution. ResolveFunctionTests.cs (test/Ignixa.FhirPath.Tests/Evaluation/) is the executable spec behind this section - in-instance resolution, container scoping, the bare-# versus #id asymmetry, and this error-handling contract are all asserted there. If this page and that file ever disagree, trust the tests.
Instance Creator
Instance selectors require an InstanceCreator on the evaluation context. Ignixa.FhirPath references only Ignixa.Abstractions and has no object model of its own, so it delegates construction to the host — the same extension-point shape as ElementResolver for resolve().
Ignixa.Serialization ships the reference implementation, SourceNodeInstanceFactory. Wire its Create method as a method group:
using Ignixa.Abstractions;
using Ignixa.FhirPath.Evaluation;
using Ignixa.Serialization.SourceNodes;
using Ignixa.Specification.Extensions;
IFhirSchemaProvider schemaProvider = FhirVersion.R4.GetSchemaProvider();
var context = new EvaluationContext()
.WithInstanceCreator(new SourceNodeInstanceFactory(schemaProvider).Create);
var coding = element
.Select("Coding { system: 'http://loinc.org', code: '8480-6' }", context)
.Single();
The delegate signature is Func<InstanceCreationRequest, IElement?>. The request types live in Ignixa.Abstractions:
public sealed record InstanceCreationRequest(
string TypeName,
string? NamespacePrefix,
IReadOnlyList<InstanceElement> Elements);
public sealed record InstanceElement(string Name, IReadOnlyList<IElement> Values);
Implement it directly to construct into your own model. Return null to decline a type — the engine then yields an empty collection.
InstanceCreator is declared on the base EvaluationContext, so it can be combined with the FHIR-specific hooks in a single object initializer:
var context = new FhirEvaluationContext
{
ElementResolver = ResolveReference,
InstanceCreator = new SourceNodeInstanceFactory(schemaProvider).Create
};
SourceNodeInstanceFactory builds ISourceNode-backed elements and makes these choices, which the STU spec section leaves open:
- Resources get a
resourceTypeproperty, written last so an element assignment cannot forge it. - Assigning a choice element by its base name (
value) emits the type-suffixed property (valueQuantity) when the assigned value's type matches a declared choice type. Already-suffixed names pass through unchanged. - Repeated assignments to the same element name aggregate into an array rather than overwriting. Exceeding the element's cardinality throws.
- If the target type is a FHIR primitive and the only assignment is
value, the result is a primitive node (HasPrimitiveValue == true), not an object with avaluechild. - Types the schema does not know, and the
Systemnamespace, are declined.
Unlike resolve(), an unconfigured InstanceCreator does not degrade to an empty collection - evaluating an instance selector throws InvalidOperationException naming WithInstanceCreator. A stand-in node would carry no schema metadata and could not be serialized, so the failure is surfaced instead of hidden.
Error Handling
Parse Errors
Invalid FHIRPath expressions throw FormatException when parsed:
try
{
var result = element.Select("invalid[[[path");
}
catch (FormatException ex)
{
// "Tokenization failed: ..." or "Parsing failed: ..."
Console.WriteLine($"Parse error: {ex.Message}");
}
Evaluation Errors
Evaluation errors throw specific exceptions:
try
{
// single() throws when collection has multiple items
var result = element.Select("name.single()");
}
catch (InvalidOperationException ex)
{
// "single() called on collection with multiple items"
Console.WriteLine($"Evaluation error: {ex.Message}");
}
try
{
// Unsupported functions throw NotSupportedException
var result = element.Select("customFunction()");
}
catch (NotSupportedException ex)
{
// "Function 'customFunction' is not yet implemented"
Console.WriteLine($"Unsupported: {ex.Message}");
}
try
{
// Instance selectors throw when no InstanceCreator is configured.
// Select() is lazy, so the throw surfaces on enumeration.
var result = element.Select("Coding { code: 'c1' }").ToList();
}
catch (InvalidOperationException ex)
{
// "Cannot construct 'Coding': no instance creator is configured on the evaluation context..."
Console.WriteLine($"Not configured: {ex.Message}");
}
FHIRPath follows propagation semantics for empty collections - operations on empty values typically return empty rather than throwing exceptions. Only constraint violations (like single() on multiple items) throw. An instance selector is one of these: it also throws InvalidOperationException ("Instance selector requires a single input item or empty collection") when the input collection it is evaluated against holds more than one item.
Architecture
The FHIRPath engine uses a visitor pattern architecture with compile-time optimization:
Expression String → Parser (with optimization) → AST → Visitor-based Evaluator → Results
Visitor Pattern Design
The AST uses the visitor pattern to cleanly separate structure from operations:
// Expression base class
public abstract class Expression {
public abstract TOutput AcceptVisitor<TContext, TOutput>(
IFhirPathExpressionVisitor<TContext, TOutput> visitor,
TContext context);
}
// Evaluator implements visitor interface
public class FhirPathEvaluator : IFhirPathExpressionVisitor<EvaluationContext, IEnumerable<IElement>> {
public IEnumerable<IElement> VisitBinary(BinaryExpression expr, EvaluationContext context) { ... }
public IEnumerable<IElement> VisitFunctionCall(FunctionCallExpression expr, EvaluationContext context) { ... }
// ... 11 more visitor methods
}
Benefits:
- Extensibility: New visitors (optimizer, debugger, SQL translator) can be added without modifying AST
- Type Safety: Compiler enforces handling of all expression types via double dispatch
- Separation of Concerns: AST structure decoupled from evaluation/analysis logic
- Consistency: Matches the visitor pattern used throughout the Ignixa codebase
Components
FhirPathParser: Tokenizes and parses expression strings into an Abstract Syntax Tree (AST) using the Superpower parser combinator library. Includes optional compile-time optimization pass for constant folding, short-circuiting, and algebraic simplification.
FhirPathEvaluator: Visitor-based evaluator that traverses the AST using the visitor pattern. Implements optimizations like ReferenceEquals context checking and constant indexer fast paths for improved performance.
FhirPathAnalyzer: Static analyzer visitor that performs type inference and validation on expressions before execution. Uses the same visitor infrastructure for consistency.
FhirPathDelegateCompiler: Compiles common AST patterns to executable delegates for improved performance. Supports approximately 80% of typical search parameter patterns:
- Simple paths:
name,identifier - Two-level paths:
name.family,identifier.value - Where clauses:
telecom.where(system='phone') - Collection functions:
name.first(),identifier.exists()
Direct API Access
For advanced scenarios, you can access the components directly:
using Ignixa.FhirPath.Parser;
using Ignixa.FhirPath.Evaluation;
using Ignixa.FhirPath.Expressions;
// Parse to AST
var parser = new FhirPathParser();
Expression ast = parser.Parse("name.where(use = 'official').family");
// Create evaluator
var evaluator = new FhirPathEvaluator();
// Optionally compile to delegate
var compiler = new FhirPathDelegateCompiler(evaluator);
var compiled = compiler.TryCompile(ast);
// Execute
var context = new EvaluationContext { Resource = element };
IEnumerable<IElement> results = compiled != null
? compiled(element, context)
: evaluator.Evaluate(ast, element, context);
Most applications should use the Select(), Scalar(), AsString(), IsTrue() extension methods which handle caching automatically. Direct API access is only needed for custom caching strategies or AST inspection.
Performance Tips
- Automatic caching works best with literal expressions - use the same string repeatedly to benefit from cached ASTs and compiled delegates
- Use specific paths instead of wildcards - simpler expressions compile better and benefit from optimizations like constant indexer fast paths
- Cache evaluation results when evaluating same expression on same data multiple times
- Prefer simple patterns - path navigation and basic predicates compile to fast delegates; complex expressions fall back to visitor-based interpreter
- Compile-time optimization is automatic - the
Select()extension methods automatically use optimized parsing with constant folding and short-circuiting - Constant indexes are optimized - expressions like
name[0]use fast paths that avoid creating unnecessary intermediate objects