UNPKG

@atomic-ehr/fhirpath

Version:

A TypeScript implementation of FHIRPath

773 lines (666 loc) 31.7 kB
import type { ASTNode, LiteralNode, IdentifierNode, BinaryNode, UnaryNode, FunctionNode, VariableNode, CollectionNode, IndexNode, TypeOrIdentifierNode, MembershipTestNode, TypeCastNode, QuantityNode } from './types'; import { NodeType } from './types'; import { Registry } from './registry'; import * as operations from './operations'; import type { EvaluationResult, FunctionEvaluator, NodeEvaluator, OperationEvaluator, RuntimeContext } from './types'; import { createQuantity } from './quantity-value'; import { box, unbox, ensureBoxed, type FHIRPathValue } from './boxing'; import { Errors } from './errors'; /** * Runtime context manager that provides efficient prototype-based context operations * for both interpreter and compiler. */ export class RuntimeContextManager { /** * Create a new runtime context */ static create(input: any[], initialVariables?: Record<string, any>): RuntimeContext { const context = Object.create(null) as RuntimeContext; context.input = input; context.focus = input; // Create variables object with null prototype to avoid pollution context.variables = Object.create(null); // Set root context variables with % prefix context.variables['%context'] = input; context.variables['%resource'] = input; context.variables['%rootResource'] = input; // Add any initial variables (with % prefix for user-defined) if (initialVariables) { for (const [key, value] of Object.entries(initialVariables)) { // Add % prefix if not already present and not a special variable const varKey = key.startsWith('$') || key.startsWith('%') ? key : `%${key}`; context.variables[varKey] = value; } } return context; } /** * Create a child context using prototype inheritance * O(1) operation - no copying needed */ static copy(context: RuntimeContext): RuntimeContext { // Create child context with parent as prototype const newContext = Object.create(context) as RuntimeContext; // Create child variables that inherit from parent's variables newContext.variables = Object.create(context.variables); // input and focus are inherited through prototype chain // Only set them if they need to change return newContext; } /** * Create a new context with updated input/focus */ static withInput(context: RuntimeContext, input: any[], focus?: any[]): RuntimeContext { const newContext = this.copy(context); newContext.input = input; newContext.focus = focus ?? input; return newContext; } /** * Set iterator context ($this, $index) */ static withIterator( context: RuntimeContext, item: any, index: number ): RuntimeContext { let newContext = this.setVariable(context, '$this', [item], true); newContext = this.setVariable(newContext, '$index', index, true); return newContext; } /** * Set a variable in the context (handles both special $ and user % variables) */ static setVariable(context: RuntimeContext, name: string, value: any, allowRedefinition: boolean = false): RuntimeContext { // Ensure value is array for consistency (except for special variables like $index) const arrayValue = (name === '$index' || name === '$total') ? value : Array.isArray(value) ? value : [value]; // Determine variable key based on prefix let varKey = name; if (!name.startsWith('$') && !name.startsWith('%')) { // No prefix - assume user-defined variable, add % prefix varKey = `%${name}`; } // Check for system variables (with or without % prefix) const systemVariables = ['context', 'resource', 'rootResource', 'ucum', 'sct', 'loinc']; const baseVarName = varKey.startsWith('%') ? varKey.substring(1) : varKey; if (systemVariables.includes(baseVarName)) { // Silently return original context for system variable redefinition return context; } // Check if variable already exists (unless redefinition is allowed) if (!allowRedefinition && context.variables && Object.prototype.hasOwnProperty.call(context.variables, varKey)) { // Silently return original context for variable redefinition return context; } // Create new context and set variable const newContext = this.copy(context); newContext.variables[varKey] = arrayValue; // Special handling for $this if (varKey === '$this' && Array.isArray(arrayValue) && arrayValue.length === 1) { newContext.input = arrayValue; newContext.focus = arrayValue; } return newContext; } /** * Get a variable from context */ static getVariable(context: RuntimeContext, name: string): any | undefined { // Handle special cases if (name === '$this' || name === '$index' || name === '$total') { return context.variables[name]; } // Handle environment variables (with or without % prefix) if (name === 'context' || name === '%context') { return context.variables['%context']; } if (name === 'resource' || name === '%resource') { return context.variables['%resource']; } if (name === 'rootResource' || name === '%rootResource') { return context.variables['%rootResource']; } // Handle user-defined variables (add % prefix if not present) const varKey = name.startsWith('%') ? name : `%${name}`; return context.variables[varKey]; } } export class Interpreter { private registry: Registry; private nodeEvaluators: Record<NodeType, NodeEvaluator>; private operationEvaluators: Map<string, OperationEvaluator>; private functionEvaluators: Map<string, FunctionEvaluator>; private modelProvider?: import('./types').ModelProvider<any>; constructor(registry?: Registry, modelProvider?: import('./types').ModelProvider<any>) { this.registry = registry || new Registry(); this.modelProvider = modelProvider; this.operationEvaluators = new Map(); this.functionEvaluators = new Map(); // Initialize node evaluators using object dispatch pattern this.nodeEvaluators = { [NodeType.Literal]: this.evaluateLiteral.bind(this), [NodeType.Identifier]: this.evaluateIdentifier.bind(this), [NodeType.TypeOrIdentifier]: this.evaluateTypeOrIdentifier.bind(this), [NodeType.Binary]: this.evaluateBinary.bind(this), [NodeType.Unary]: this.evaluateUnary.bind(this), [NodeType.Function]: this.evaluateFunction.bind(this), [NodeType.Variable]: this.evaluateVariable.bind(this), [NodeType.Collection]: this.evaluateCollection.bind(this), [NodeType.Index]: this.evaluateIndex.bind(this), [NodeType.MembershipTest]: this.evaluateMembershipTest.bind(this), [NodeType.TypeCast]: this.evaluateTypeCast.bind(this), [NodeType.Quantity]: this.evaluateQuantity.bind(this), [NodeType.EOF]: async () => ({ value: [], context: {} as RuntimeContext }), [NodeType.TypeReference]: async () => ({ value: [], context: {} as RuntimeContext }) }; // Register operation evaluators this.registerOperationEvaluators(); } private registerOperationEvaluators(): void { // Register evaluators from operations modules for (const [name, operation] of Object.entries(operations)) { if (typeof operation === 'object' && 'evaluate' in operation) { if ('symbol' in operation) { // It's an operator // Skip unary operators here - they're handled differently if (name === 'unaryMinusOperator' || name === 'unaryPlusOperator') { continue; } this.operationEvaluators.set(operation.symbol, operation.evaluate); } else if ('signatures' in operation && !('symbol' in operation)) { // It's a function this.functionEvaluators.set(operation.name, operation.evaluate); } } } } // Main evaluate method async evaluate(node: ASTNode, input: any[] = [], context?: RuntimeContext): Promise<EvaluationResult> { // Initialize context if not provided if (!context) { context = this.createInitialContext(input); } // Ensure input is always an array if (!Array.isArray(input)) { input = input === null || input === undefined ? [] : [input]; } // Box the initial input values const boxedInput = input.map(value => ensureBoxed(value)); // Set current node in context const contextWithNode = RuntimeContextManager.copy(context); contextWithNode.currentNode = node; // Dispatch to appropriate evaluator const evaluator = this.nodeEvaluators[node.type]; if (!evaluator) { throw Errors.unknownNodeType(node.type); } return await evaluator(node, boxedInput, contextWithNode); } private createInitialContext(input: any[]): RuntimeContext { const context = RuntimeContextManager.create(input); // Set $this to initial input context.variables['$this'] = input; // Add model provider if available if (this.modelProvider) { context.modelProvider = this.modelProvider; } return context; } // Literal node evaluator private async evaluateLiteral(node: ASTNode, input: FHIRPathValue[], context: RuntimeContext): Promise<EvaluationResult> { const literal = node as LiteralNode; // Box the literal value with appropriate type info let typeInfo: import('./types').TypeInfo | undefined; const value = literal.value; if (typeof value === 'string') { typeInfo = { type: 'String', singleton: true }; } else if (typeof value === 'number') { typeInfo = Number.isInteger(value) ? { type: 'Integer', singleton: true } : { type: 'Decimal', singleton: true }; } else if (typeof value === 'boolean') { typeInfo = { type: 'Boolean', singleton: true }; } return { value: [box(literal.value, typeInfo)], context }; } // Identifier node evaluator private async evaluateIdentifier(node: ASTNode, input: FHIRPathValue[], context: RuntimeContext): Promise<EvaluationResult> { const identifier = node as IdentifierNode; const name = identifier.name; // Navigate property on each boxed item in input const results: FHIRPathValue[] = []; // Get the type info from the node (set by analyzer) const nodeTypeInfo = node.typeInfo; for (const boxedItem of input) { const item = unbox(boxedItem); // Special handling for primitive extension navigation if (name === 'extension' && boxedItem.primitiveElement?.extension) { // Navigation from a primitive value to its extensions for (const ext of boxedItem.primitiveElement.extension) { results.push(box(ext, nodeTypeInfo || { type: 'Any', singleton: false })); } continue; } if (item && typeof item === 'object') { // First, check if this might be a FHIR choice type by looking for choice properties // FHIR choice types use the pattern: base name + Type suffix (e.g., valueQuantity, valueCodeableConcept) let foundChoiceValue = false; // Check for FHIR choice type pattern if (context.modelProvider) { // Try common FHIR choice patterns const possibleChoiceProperties = Object.keys(item).filter(key => key.startsWith(name) && key !== name && key.length > name.length ); if (possibleChoiceProperties.length > 0) { // This looks like a choice type - return all matching values for (const choiceProp of possibleChoiceProperties) { const value = item[choiceProp]; const primitiveElementName = `_${choiceProp}`; const primitiveElement = (primitiveElementName in item) ? item[primitiveElementName] : undefined; // Try to determine the type from the property name suffix let choiceType = 'Any'; const suffix = choiceProp.substring(name.length); if (suffix) { // Remove leading uppercase letter and make it the type choiceType = suffix; } if (Array.isArray(value)) { for (const v of value) { // For FHIR resources, use their resourceType if (v && typeof v === 'object' && 'resourceType' in v) { const typeInfo = await context.modelProvider!.getType(v.resourceType); results.push(box(v, typeInfo || { type: v.resourceType as any, singleton: true }, primitiveElement)); } else { results.push(box(v, { type: choiceType as any, singleton: true }, primitiveElement)); } } } else if (value !== null && value !== undefined) { // For FHIR resources, use their resourceType if (value && typeof value === 'object' && 'resourceType' in value) { const typeInfo = await context.modelProvider!.getType(value.resourceType); results.push(box(value, typeInfo || { type: value.resourceType as any, singleton: true }, primitiveElement)); } else { results.push(box(value, { type: choiceType as any, singleton: !Array.isArray(value) }, primitiveElement)); } } foundChoiceValue = true; } } } // Check if this is a choice type navigation from analyzer if (!foundChoiceValue && nodeTypeInfo?.modelContext && typeof nodeTypeInfo.modelContext === 'object' && 'isUnion' in nodeTypeInfo.modelContext && nodeTypeInfo.modelContext.isUnion && 'choices' in nodeTypeInfo.modelContext && Array.isArray(nodeTypeInfo.modelContext.choices)) { // For choice types, look for any of the choice properties for (const choice of nodeTypeInfo.modelContext.choices) { const choiceName = choice.choiceName; if (choiceName && choiceName in item) { const value = item[choiceName]; const primitiveElementName = `_${choiceName}`; const primitiveElement = (primitiveElementName in item) ? item[primitiveElementName] : undefined; // Box with the specific choice type const choiceTypeInfo = { type: choice.type, singleton: !Array.isArray(value), modelContext: choice }; if (Array.isArray(value)) { for (const v of value) { results.push(box(v, { ...choiceTypeInfo, singleton: true }, primitiveElement)); } } else if (value !== null && value !== undefined) { results.push(box(value, choiceTypeInfo, primitiveElement)); } foundChoiceValue = true; } } } if (!foundChoiceValue && name in item) { // Regular property navigation const value = item[name]; const primitiveElementName = `_${name}`; const primitiveElement = (primitiveElementName in item) ? item[primitiveElementName] : undefined; if (Array.isArray(value)) { // Box each array element with type info // For arrays, make the type singleton since each element is a single value const elementTypeInfo = nodeTypeInfo ? { ...nodeTypeInfo, singleton: true } : undefined; for (const v of value) { // Special handling for FHIR resources - use their resourceType // Do this when the property could be polymorphic (type is 'Any' or 'Resource') if (v && typeof v === 'object' && 'resourceType' in v && typeof v.resourceType === 'string' && (!elementTypeInfo || elementTypeInfo.type === 'Any' || (elementTypeInfo as any).type === 'Resource')) { // Get full type info from model provider if available let resourceTypeInfo; if (context.modelProvider) { resourceTypeInfo = await context.modelProvider.getType(v.resourceType); if (resourceTypeInfo) { // Make it singleton since it's a single element in the array resourceTypeInfo = { ...resourceTypeInfo, singleton: true }; } } if (!resourceTypeInfo) { // Fallback to basic type info resourceTypeInfo = { type: v.resourceType as import('./types').TypeName, singleton: true }; } results.push(box(v, resourceTypeInfo, primitiveElement)); } else { results.push(box(v, elementTypeInfo, primitiveElement)); } } } else if (value !== null && value !== undefined) { // Special handling for FHIR resources - use their resourceType // Do this when the property could be polymorphic (type is 'Any' or 'Resource') if (value && typeof value === 'object' && 'resourceType' in value && typeof value.resourceType === 'string' && (!nodeTypeInfo || nodeTypeInfo.type === 'Any' || (nodeTypeInfo as any).type === 'Resource')) { // Get full type info from model provider if available let resourceTypeInfo; if (context.modelProvider) { resourceTypeInfo = await context.modelProvider.getType(value.resourceType); if (resourceTypeInfo) { // Preserve singleton status resourceTypeInfo = { ...resourceTypeInfo, singleton: !Array.isArray(value) }; } } if (!resourceTypeInfo) { // Fallback to basic type info resourceTypeInfo = { type: value.resourceType as import('./types').TypeName, singleton: !Array.isArray(value) }; } results.push(box(value, resourceTypeInfo, primitiveElement)); } else { // Box single value with primitive element if available results.push(box(value, nodeTypeInfo, primitiveElement)); } } } } } return { value: results, context }; } // TypeOrIdentifier node evaluator (handles Patient, Observation, etc.) private async evaluateTypeOrIdentifier(node: ASTNode, input: FHIRPathValue[], context: RuntimeContext): Promise<EvaluationResult> { const typeOrId = node as TypeOrIdentifierNode; const name = typeOrId.name; // First try as type filter const filtered = input.filter(boxedItem => { const item = unbox(boxedItem); return item && typeof item === 'object' && item.resourceType === name; }); if (filtered.length > 0) { return { value: filtered, context }; } // Otherwise treat as identifier return await this.evaluateIdentifier(node, input, context); } // Binary operator evaluator private async evaluateBinary(node: ASTNode, input: FHIRPathValue[], context: RuntimeContext): Promise<EvaluationResult> { const binary = node as BinaryNode; const operator = binary.operator; // Special handling for dot operator (sequential pipeline) if (operator === '.') { // Evaluate left with current input/context const leftResult = await this.evaluate(binary.left, input, context); // Use left's output as right's input, and left's context flows to right return await this.evaluate(binary.right, leftResult.value, leftResult.context); } // Special handling for union operator (each side gets fresh context from original) if (operator === '|') { // Each side of union should have its own variable scope // Variables defined on left side should not be visible on right side const leftResult = await this.evaluate(binary.left, input, context); const rightResult = await this.evaluate(binary.right, input, context); // Use original context, not leftResult.context // Merge the results const unionEvaluator = this.operationEvaluators.get('union'); if (unionEvaluator) { return await unionEvaluator(input, context, leftResult.value, rightResult.value); } // Fallback if union evaluator not found return { value: [...leftResult.value, ...rightResult.value], context // Original context preserved }; } // Get operation evaluator const evaluator = this.operationEvaluators.get(operator); if (evaluator) { // Most operators evaluate arguments in parallel with same input/context const leftResult = await this.evaluate(binary.left, input, context); const rightResult = await this.evaluate(binary.right, input, context); return await evaluator(input, context, leftResult.value, rightResult.value); } // If no evaluator found, throw error throw Errors.noEvaluatorFound('binary operator', operator); } // Unary operator evaluator private async evaluateUnary(node: ASTNode, input: FHIRPathValue[], context: RuntimeContext): Promise<EvaluationResult> { const unary = node as UnaryNode; const operator = unary.operator; const operandResult = await this.evaluate(unary.operand, input, context); // Check for unary operation evaluators let evaluator: OperationEvaluator | undefined; if (operator === '-' && operations.unaryMinusOperator?.evaluate) { evaluator = operations.unaryMinusOperator.evaluate; } else if (operator === '+' && operations.unaryPlusOperator?.evaluate) { evaluator = operations.unaryPlusOperator.evaluate; } if (evaluator) { return await evaluator(input, context, operandResult.value); } // If no evaluator found, throw error throw Errors.noEvaluatorFound('unary operator', operator); } // Variable evaluator private async evaluateVariable(node: ASTNode, input: FHIRPathValue[], context: RuntimeContext): Promise<EvaluationResult> { const variable = node as VariableNode; const name = variable.name; const value = RuntimeContextManager.getVariable(context, name); if (value !== undefined) { // Ensure value is always an array const arrayValue = Array.isArray(value) ? value : [value]; // Box each value in the array const boxedValues = arrayValue.map(v => ensureBoxed(v)); return { value: boxedValues, context }; } // According to FHIRPath spec: attempting to access an undefined environment variable will result in an error throw Errors.variableNotDefined(name); } // Collection evaluator private async evaluateCollection(node: ASTNode, input: FHIRPathValue[], context: RuntimeContext): Promise<EvaluationResult> { const collection = node as CollectionNode; const results: FHIRPathValue[] = []; for (const element of collection.elements) { const result = await this.evaluate(element, input, context); results.push(...result.value); } return { value: results, context }; } // Function evaluator private async evaluateFunction(node: ASTNode, input: any[], context: RuntimeContext): Promise<EvaluationResult> { const func = node as FunctionNode; const funcName = (func.name as IdentifierNode).name; // Check if function is registered with an evaluator const functionEvaluator = this.functionEvaluators.get(funcName); if (functionEvaluator) { return await functionEvaluator(input, context, func.arguments, this.evaluate.bind(this)); } // No function found in registry throw Errors.unknownFunction(funcName); } // Index evaluator private async evaluateIndex(node: ASTNode, input: FHIRPathValue[], context: RuntimeContext): Promise<EvaluationResult> { const indexNode = node as IndexNode; const exprResult = await this.evaluate(indexNode.expression, input, context); const indexResult = await this.evaluate(indexNode.index, input, context); if (indexResult.value.length === 0 || exprResult.value.length === 0) { return { value: [], context }; } const boxedIndex = indexResult.value[0]; if (boxedIndex) { const index = unbox(boxedIndex); if (typeof index === 'number' && index >= 0 && index < exprResult.value.length) { const result = exprResult.value[index]; return { value: result ? [result] : [], context }; } } return { value: [], context }; } // Type membership test (is operator) private async evaluateMembershipTest(node: ASTNode, input: FHIRPathValue[], context: RuntimeContext): Promise<EvaluationResult> { const test = node as MembershipTestNode; const exprResult = await this.evaluate(test.expression, input, context); // If expression evaluates to empty, return empty if (exprResult.value.length === 0) { return { value: [], context }; } // If we have type information from analyzer (with ModelProvider), use it if (context.currentNode?.typeInfo?.modelContext) { const modelContext = context.currentNode.typeInfo.modelContext as any; // For union types, check if the target type is valid if (modelContext.isUnion && modelContext.choices) { const hasValidChoice = modelContext.choices.some((c: any) => c.type === test.targetType || c.elementType === test.targetType ); if (!hasValidChoice) { // Type system knows this will always be false return { value: exprResult.value.map(() => box(false, { type: 'Boolean', singleton: true })), context }; } } } // Type checking with subtype support via ModelProvider const results = await Promise.all(exprResult.value.map(async boxedItem => { const item = unbox(boxedItem); // If we have a ModelProvider and typeInfo, use it for accurate subtype checking if (context.modelProvider && boxedItem.typeInfo) { const matchingType = context.modelProvider.ofType(boxedItem.typeInfo, test.targetType as import('./types').TypeName); return box(matchingType !== undefined, { type: 'Boolean', singleton: true }); } // For FHIR resources without typeInfo, try to get it from modelProvider if (context.modelProvider && item && typeof item === 'object' && 'resourceType' in item && typeof item.resourceType === 'string') { const typeInfo = await context.modelProvider.getType(item.resourceType); if (typeInfo) { const matchingType = context.modelProvider.ofType(typeInfo, test.targetType as import('./types').TypeName); return box(matchingType !== undefined, { type: 'Boolean', singleton: true }); } // Fall back to exact match return box(item.resourceType === test.targetType, { type: 'Boolean', singleton: true }); } // Check for FHIR resource types (no ModelProvider available) if (item && typeof item === 'object' && 'resourceType' in item) { return box(item.resourceType === test.targetType, { type: 'Boolean', singleton: true }); } // Check primitive types const isMatch = (() => { switch (test.targetType) { case 'String': return typeof item === 'string'; case 'Boolean': return typeof item === 'boolean'; case 'Integer': return Number.isInteger(item); case 'Decimal': return typeof item === 'number'; case 'Date': case 'DateTime': case 'Time': // Simple check for date-like strings return typeof item === 'string' && !isNaN(Date.parse(item)); default: return false; } })(); return box(isMatch, { type: 'Boolean', singleton: true }); })); return { value: results, context }; } // Type cast (as operator) private async evaluateTypeCast(node: ASTNode, input: FHIRPathValue[], context: RuntimeContext): Promise<EvaluationResult> { const cast = node as TypeCastNode; const exprResult = await this.evaluate(cast.expression, input, context); // If we have type information from analyzer (with ModelProvider), use it if (context.currentNode?.typeInfo?.modelContext) { const modelContext = context.currentNode.typeInfo.modelContext as any; // For union types, check if the cast is valid if (modelContext.isUnion && modelContext.choices) { const validChoice = modelContext.choices.find((c: any) => c.type === cast.targetType || c.elementType === cast.targetType ); if (!validChoice) { // Invalid cast - return empty return { value: [], context }; } } } // Filter values that match the target type with subtype support const filtered = await Promise.all(exprResult.value.map(async boxedItem => { const item = unbox(boxedItem); // If we have a ModelProvider and typeInfo, use it for accurate subtype checking if (context.modelProvider && boxedItem.typeInfo) { const matchingType = context.modelProvider.ofType(boxedItem.typeInfo, cast.targetType as import('./types').TypeName); return matchingType !== undefined; } // For FHIR resources without typeInfo, try to get it from modelProvider if (context.modelProvider && item && typeof item === 'object' && 'resourceType' in item && typeof item.resourceType === 'string') { const typeInfo = await context.modelProvider.getType(item.resourceType); if (typeInfo) { const matchingType = context.modelProvider.ofType(typeInfo, cast.targetType as import('./types').TypeName); return matchingType !== undefined; } // Fall back to exact match return item.resourceType === cast.targetType; } // Check for FHIR resource types (no ModelProvider available) if (item && typeof item === 'object' && 'resourceType' in item) { return item.resourceType === cast.targetType; } // Check primitive types switch (cast.targetType) { case 'String': return typeof item === 'string'; case 'Boolean': return typeof item === 'boolean'; case 'Integer': return Number.isInteger(item); case 'Decimal': return typeof item === 'number'; case 'Date': case 'DateTime': case 'Time': // Simple check for date-like strings return typeof item === 'string' && !isNaN(Date.parse(item)); default: return false; } })); // Filter out the false results (filter returns boolean for each item) const actualFiltered = exprResult.value.filter((_, index) => filtered[index]); return { value: actualFiltered, context }; } private async evaluateQuantity(node: ASTNode, input: FHIRPathValue[], context: RuntimeContext): Promise<EvaluationResult> { const quantity = node as QuantityNode; const quantityValue = createQuantity(quantity.value, quantity.unit, quantity.isCalendarUnit); return { value: [box(quantityValue, { type: 'Quantity', singleton: true })], context }; } }