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, short-circuiting, and algebraic simplification
- 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
Installation
dotnet add package Ignixa.FhirPath
Quick Start
using Ignixa.FhirPath.Evaluation;
// Parse FHIR JSON
var sourceNode = JsonSourceNavigator.Parse(patientJson);
var element = sourceNode.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
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)
// Algebraic simplification
var expr5 = parser.Parse("X + 0", options); // Optimized to: X
var expr6 = parser.Parse("X * 1", options); // Optimized to: X
var expr7 = parser.Parse("X and true", options); // Optimized to: X
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()");
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
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 context.Resource)
%rootResource // Root resource (set via context.RootResource)
Custom Variables
Custom environment variables can be added to the evaluation context:
var context = new EvaluationContext();
context.Resource = patientElement; // Sets %resource variable
// Add custom variables
context.Environment["today"] = new[] { todayElement };
var result = element.Select("birthDate < %today", context);
Element Resolver
The FhirEvaluationContext supports configuring an ElementResolver to enable the resolve() function in FHIRPath expressions. This allows following references from one resource to another.
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
var context = new FhirEvaluationContext();
// Configure the ElementResolver to resolve references
context.ElementResolver = (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 returns an empty collection if:
- No
ElementResolveris configured - The reference cannot be resolved
- An error occurs during resolution
This follows FHIRPath's propagation semantics - operations on empty collections return empty rather than throwing exceptions. This allows FHIRPath expressions to continue evaluating even when references can't be resolved.
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}");
}
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.
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(), 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