@atomic-ehr/fhirpath
Version:
A TypeScript implementation of FHIRPath
650 lines (556 loc) • 20.7 kB
text/typescript
import type { ModelProvider, TypeInfo, TypeName } from './types';
import { CanonicalManager as createCanonicalManager, type Config, type CanonicalManager, type Resource } from '@atomic-ehr/fhir-canonical-manager';
import { translate, type FHIRSchema, type StructureDefinition } from '@atomic-ehr/fhirschema';
export interface FHIRModelContext {
// Path in the resource (e.g., "Patient.name.given")
path: string;
// FHIRSchema for the current type and its ancestors
schemaHierarchy: FHIRSchema[];
// For union types (choice types)
isUnion?: boolean;
choices?: Array<{
type: TypeName;
code: string; // FHIR type code
choiceName?: string; // The actual element name (e.g., valueString)
schema?: FHIRSchema;
}>;
// Reference to the source schema
canonicalUrl?: string;
version?: string;
}
export interface FHIRModelProviderConfig {
packages: Array<{ name: string; version: string }>;
cacheDir?: string;
registryUrl?: string;
}
/**
* FHIR ModelProvider implementation
*
* Note: This provider requires async initialization before use.
* Call initialize() before using the synchronous methods.
*
* For best performance, pre-load common types during initialization.
*/
export class FHIRModelProvider implements ModelProvider<FHIRModelContext> {
private canonicalManager: ReturnType<typeof createCanonicalManager>;
private schemaCache: Map<string, FHIRSchema> = new Map();
private hierarchyCache: Map<string, FHIRSchema[]> = new Map();
private initialized = false;
// Caches for discovered types
private complexTypesCache?: string[];
private primitiveTypesCache?: string[];
private resourceTypesCache?: string[];
// FHIR Primitives to FHIRPath types mapping
private readonly typeMapping: Record<string, TypeName> = {
'boolean': 'Boolean',
'integer': 'Integer',
'string': 'String',
'decimal': 'Decimal',
'uri': 'String',
'url': 'String',
'canonical': 'String',
'base64Binary': 'String',
'instant': 'DateTime',
'date': 'Date',
'dateTime': 'DateTime',
'time': 'Time',
'code': 'String',
'oid': 'String',
'id': 'String',
'markdown': 'String',
'unsignedInt': 'Integer',
'positiveInt': 'Integer',
'uuid': 'String',
'xhtml': 'String',
// FHIR Complex types that map to FHIRPath types
'Quantity': 'Quantity',
'SimpleQuantity': 'Quantity',
'Money': 'Quantity',
'Duration': 'Quantity',
'Age': 'Quantity',
'Distance': 'Quantity',
'Count': 'Quantity'
};
// Map FHIR primitive names to FHIRPath type names
private readonly primitiveTypeMapping: Record<string, string> = {
'boolean': 'Boolean',
'string': 'String',
'integer': 'Integer',
'decimal': 'Decimal',
'date': 'Date',
'dateTime': 'DateTime',
'time': 'Time',
'instant': 'Instant',
'base64Binary': 'Base64Binary',
'uri': 'Uri',
'url': 'Url',
'canonical': 'Canonical',
'code': 'Code',
'oid': 'Oid',
'id': 'Id',
'markdown': 'Markdown',
'unsignedInt': 'UnsignedInt',
'positiveInt': 'PositiveInt',
'uuid': 'Uuid',
'xhtml': 'Xhtml'
};
constructor(private config: FHIRModelProviderConfig = {
packages: [{ name: 'hl7.fhir.r4.core', version: '4.0.1' }]
}) {
const canonicalConfig: Config = {
packages: config.packages.map(p => `${p.name}@${p.version}`),
workingDir: config.cacheDir || './tmp/.fhir-cache'
};
if (config.registryUrl) {
canonicalConfig.registry = config.registryUrl;
}
this.canonicalManager = createCanonicalManager(canonicalConfig);
}
async initialize(): Promise<void> {
if (this.initialized) return;
try {
await this.canonicalManager.init();
// Just discover type names for completions - schemas load lazily on demand
await Promise.all([
this.getResourceTypes(),
this.getComplexTypes(),
this.getPrimitiveTypes()
]);
this.initialized = true;
} catch (error) {
console.error('Failed to initialize FHIRModelProvider:', error);
// Mark as initialized even on failure to prevent repeated attempts
// The provider will work in degraded mode (primitives only)
this.initialized = true;
}
}
private buildCanonicalUrl(typeName: string): string {
// For R4 core types
return `http://hl7.org/fhir/StructureDefinition/${typeName}`;
}
// Public method to get schema with automatic caching
async getSchema(typeName: string): Promise<FHIRSchema | undefined> {
// Check cache first
if (this.schemaCache.has(typeName)) {
return this.schemaCache.get(typeName);
}
try {
// Resolve canonical URL for the type
const canonicalUrl = this.buildCanonicalUrl(typeName);
const resource = await this.canonicalManager.resolve(canonicalUrl);
if (!resource || resource.resourceType !== 'StructureDefinition') {
return undefined;
}
const structureDefinition = resource as unknown as StructureDefinition;
// Convert to FHIRSchema
const schema = translate(structureDefinition);
this.schemaCache.set(typeName, schema);
// Pre-cache the hierarchy
await this.getSchemaHierarchyAsync(schema);
return schema;
} catch (error) {
console.warn(`Failed to load schema for ${typeName}:`, error);
return undefined;
}
}
private async getSchemaHierarchyAsync(schema: FHIRSchema): Promise<FHIRSchema[]> {
const cacheKey = schema.name || schema.url;
// Check cache
if (this.hierarchyCache.has(cacheKey)) {
return this.hierarchyCache.get(cacheKey)!;
}
const hierarchy: FHIRSchema[] = [schema];
let current = schema;
// Walk up the inheritance chain
while (current.base && current.base !== 'Resource' && current.base !== 'Element') {
// Extract just the type name from the base URL if it's a full URL
let baseTypeName = current.base;
if (baseTypeName && baseTypeName.startsWith('http://')) {
const parts = baseTypeName.split('/');
baseTypeName = parts[parts.length - 1] || baseTypeName;
}
const baseSchema = await this.getSchema(baseTypeName);
if (!baseSchema) break;
hierarchy.push(baseSchema);
current = baseSchema;
}
this.hierarchyCache.set(cacheKey, hierarchy);
return hierarchy;
}
private extractTypeName(url: string): string {
// Extract type name from FHIR structure definition URL
// e.g., "http://hl7.org/fhir/StructureDefinition/Element" -> "Element"
const parts = url.split('/');
return parts[parts.length - 1] || url;
}
private getSchemaHierarchyCached(schema: FHIRSchema): FHIRSchema[] {
const cacheKey = schema.name || schema.url;
const cached = this.hierarchyCache.get(cacheKey);
if (cached) return cached;
// If not cached, build the hierarchy synchronously from cached schemas
const hierarchy: FHIRSchema[] = [schema];
let current = schema;
while (current.base) {
const baseTypeName = this.extractTypeName(current.base);
const baseSchema = this.schemaCache.get(baseTypeName);
if (!baseSchema) break;
hierarchy.push(baseSchema);
current = baseSchema;
}
// Cache for future use
this.hierarchyCache.set(cacheKey, hierarchy);
return hierarchy;
}
private mapToFHIRPathType(fhirType: string): TypeName {
// If it's a mapped type (primitive or special types), use the mapping
if (this.typeMapping[fhirType]) {
return this.typeMapping[fhirType];
}
// Otherwise, keep the FHIR type name (for complex types like CodeableConcept)
return fhirType as TypeName;
}
private isChoiceType(element: any): boolean {
return element.type && Array.isArray(element.type) && element.type.length > 1;
}
private createUnionContext( element: any, path: string, parentSchema: FHIRSchema): FHIRModelContext {
// Map choice names to their types
const choices = element.choices.map((choiceName: string) => {
// Get the actual element for this choice
const choiceElement = parentSchema.elements?.[choiceName];
const choiceType = choiceElement?.type || 'Any';
return {
type: this.mapToFHIRPathType(choiceType),
code: choiceType,
choiceName: choiceName
};
});
return {
path,
schemaHierarchy: [],
isUnion: true,
choices,
canonicalUrl: parentSchema.url,
version: parentSchema.version
};
}
// Async implementation with lazy loading
async getType(typeName: string): Promise<TypeInfo<FHIRModelContext> | undefined> {
// Check if it's a primitive type - these don't require initialization
if (this.typeMapping[typeName]) {
return {
type: this.typeMapping[typeName],
namespace: 'FHIR',
name: typeName,
singleton: true,
modelContext: {
path: typeName,
schemaHierarchy: []
}
};
}
// Complex types require initialization
if (!this.initialized) {
console.warn('FHIRModelProvider not initialized. Only primitive types available.');
return undefined;
}
// Try to load schema lazily
const schema = await this.getSchema(typeName);
if (!schema) {
// Schema not found - this is expected for non-type identifiers
return undefined;
}
const schemaHierarchy = await this.getSchemaHierarchyAsync(schema);
return {
type: 'Any', // Complex types are 'Any' in FHIRPath
namespace: 'FHIR',
name: typeName,
singleton: true,
modelContext: {
path: typeName,
schemaHierarchy,
canonicalUrl: schema.url,
version: schema.version
}
};
}
async getElementType( parentType: TypeInfo<FHIRModelContext>, propertyName: string): Promise<TypeInfo<FHIRModelContext> | undefined> {
const context = parentType.modelContext;
if (!context) return undefined;
// Search through schema hierarchy for the property
for (const schema of context.schemaHierarchy) {
const element = schema.elements?.[propertyName];
if (!element) continue;
const path = `${context.path}.${propertyName}`;
// Handle choice types - check if element has choices array
if (element.choices && Array.isArray(element.choices)) {
return {
type: 'Any',
namespace: 'FHIR',
name: propertyName,
singleton: !element.array,
modelContext: this.createUnionContext(element, path, schema)
};
}
// Handle regular types
const elementType = Array.isArray(element.type) ? element.type[0] : element.type;
const fhirpathType = this.mapToFHIRPathType(elementType);
// Load schema from cache for complex types
let elementSchemaHierarchy: FHIRSchema[] = [];
// Special handling for BackboneElement - it has inline elements
if (elementType === 'BackboneElement' && element.elements) {
// Create a synthetic schema for the inline BackboneElement
const inlineSchema: FHIRSchema = {
name: `${schema.name}.${propertyName}`,
type: 'BackboneElement',
url: `${schema.url}#${propertyName}`,
version: schema.version,
kind: 'complex-type',
class: 'complex-type',
elements: element.elements,
base: 'BackboneElement'
} as FHIRSchema;
elementSchemaHierarchy = [inlineSchema];
} else if (!this.typeMapping[elementType]) {
// For complex types, we need to load the schema and its hierarchy
const elementSchema = await this.getSchema(elementType);
if (elementSchema) {
elementSchemaHierarchy = await this.getSchemaHierarchyAsync(elementSchema);
}
}
return {
type: fhirpathType,
namespace: 'FHIR',
name: elementType,
singleton: !element.array,
modelContext: {
path,
schemaHierarchy: elementSchemaHierarchy,
canonicalUrl: schema.url,
version: schema.version
}
};
}
return undefined;
}
ofType(
type: TypeInfo<FHIRModelContext>,
typeName: TypeName
): TypeInfo<FHIRModelContext> | undefined {
const context = type.modelContext;
// Handle union types
if (context?.isUnion && context?.choices) {
for (const choice of context.choices) {
if (choice.type === typeName) {
return {
type: choice.type,
namespace: 'FHIR',
name: choice.code,
singleton: type.singleton,
modelContext: {
path: context.path + `[${choice.code}]`,
schemaHierarchy: [],
canonicalUrl: context.canonicalUrl,
version: context.version
}
};
}
}
return undefined;
}
// For non-union types, check if the type matches or is a subtype
// First check direct match on FHIRPath type
if (type.type === typeName) {
return type;
}
// Check if the type name matches
if (type.name === typeName) {
return type;
}
// Check if any of the schemas in the hierarchy match
if (context?.schemaHierarchy) {
for (const schema of context.schemaHierarchy) {
if (schema.type === typeName || schema.name === typeName) {
return type;
}
}
}
return undefined;
}
getElementNames(parentType: TypeInfo<FHIRModelContext>): string[] {
const context = parentType.modelContext;
if (!context) return [];
const names: Set<string> = new Set();
// Collect properties from all schemas in hierarchy
for (const schema of context.schemaHierarchy) {
if (schema.elements) {
// Filter out choice-specific elements (e.g., deceasedBoolean when deceased exists)
Object.keys(schema.elements).forEach(name => {
const element = schema.elements![name];
if (element && !element.choiceOf) {
names.add(name);
}
});
}
}
return Array.from(names);
}
async getChildrenType(parentType: TypeInfo<FHIRModelContext>): Promise<TypeInfo<FHIRModelContext> | undefined> {
const elementNames = this.getElementNames(parentType);
if (elementNames.length === 0) return undefined;
// Collect all unique child types
const childTypes = new Map<string, TypeInfo<FHIRModelContext>>();
for (const elementName of elementNames) {
const elementType = await this.getElementType(parentType, elementName);
if (elementType) {
// Use a combination of namespace and name as key to deduplicate
const key = `${elementType.namespace || ''}.${elementType.name || elementType.type}`;
childTypes.set(key, elementType);
}
}
if (childTypes.size === 0) return undefined;
// Create a union type representing all possible children
return {
type: 'Any',
namespace: parentType.namespace,
name: 'ChildrenUnion',
singleton: false, // children() always returns a collection
modelContext: {
path: `${parentType.modelContext?.path || ''}.children()`,
schemaHierarchy: [],
isUnion: true,
choices: Array.from(childTypes.values()).map(type => ({
type: type.type as TypeName,
code: type.name || type.type,
namespace: type.namespace,
modelContext: type.modelContext
}))
} as FHIRModelContext
};
}
// Async helper methods for loading additional schemas
async loadType(typeName: string): Promise<TypeInfo<FHIRModelContext> | undefined> {
await this.getSchema(typeName);
return this.getType(typeName);
}
// Get detailed information about elements of a type
async getElements(typeName: string): Promise<Array<{ name: string; type: string; documentation?: string }>> {
if (!this.initialized) {
console.warn('FHIRModelProvider not initialized. Cannot get elements.');
return [];
}
// Load schema lazily using the public getSchema method
const schema = await this.getSchema(typeName);
if (!schema || !schema.elements) {
return [];
}
const elements: Array<{ name: string; type: string; documentation?: string }> = [];
// Get all elements from this schema and its hierarchy
const schemaHierarchy = await this.getSchemaHierarchyAsync(schema);
const addedElements = new Set<string>();
for (const currentSchema of schemaHierarchy) {
if (currentSchema.elements) {
for (const [elementName, element] of Object.entries(currentSchema.elements)) {
// Skip choice-specific elements and already added elements
if (element && !element.choiceOf && !addedElements.has(elementName)) {
const elementType = Array.isArray(element.type) ? element.type[0] : element.type;
elements.push({
name: elementName,
type: elementType + (element.array ? '[]' : ''),
documentation: element.short
});
addedElements.add(elementName);
}
}
}
}
return elements;
}
async getResourceTypes(): Promise<string[]> {
if (this.resourceTypesCache) {
return this.resourceTypesCache;
}
const resources = await this.canonicalManager.search({
kind: 'resource'
});
this.resourceTypesCache = resources
.filter(r => r.resourceType === 'StructureDefinition')
.map(r => (r as unknown as StructureDefinition).name)
.filter((name): name is string => !!name)
.sort();
return this.resourceTypesCache || [];
}
async getComplexTypes(): Promise<string[]> {
if (this.complexTypesCache) {
return this.complexTypesCache;
}
const resources = await this.canonicalManager.search({
kind: 'complex-type'
});
this.complexTypesCache = resources
.filter(r => r.resourceType === 'StructureDefinition')
.map(r => r as unknown as StructureDefinition)
.filter(sd => {
// Only include base complex types, not extensions or constraints
return sd.type !== 'Extension' &&
sd.derivation !== 'constraint' &&
!sd.name?.includes('.') && // Skip nested types
(!sd.abstract || sd.name === 'BackboneElement'); // Keep BackboneElement
})
.map(sd => sd.name)
.filter((name): name is string => !!name)
.sort();
return this.complexTypesCache || [];
}
async getPrimitiveTypes(): Promise<string[]> {
if (this.primitiveTypesCache) {
return this.primitiveTypesCache;
}
const resources = await this.canonicalManager.search({
kind: 'primitive-type'
});
// Get FHIR primitive names and map to FHIRPath names
const fhirPrimitives = resources
.filter(r => r.resourceType === 'StructureDefinition')
.map(r => (r as unknown as StructureDefinition).name)
.filter((name): name is string => !!name);
this.primitiveTypesCache = fhirPrimitives
.map(name => this.primitiveTypeMapping[name] || name)
.sort();
return this.primitiveTypesCache || [];
}
// Synchronous method to get type from cache (for analyzer)
getTypeFromCache(typeName: string): TypeInfo<FHIRModelContext> | undefined {
// Check if it's a primitive type - these don't require initialization
if (this.typeMapping[typeName]) {
return {
type: this.typeMapping[typeName],
namespace: 'FHIR',
name: typeName,
singleton: true,
modelContext: {
path: typeName,
schemaHierarchy: []
}
};
}
// For complex types, check if schema is in cache
const schema = this.schemaCache.get(typeName);
if (!schema) {
return undefined;
}
// Get cached hierarchy or at least the current schema
const schemaHierarchy = this.hierarchyCache.get(schema.name || schema.url) || [schema];
return {
type: 'Any', // Complex types are 'Any' in FHIRPath
namespace: 'FHIR',
name: typeName,
singleton: true,
modelContext: {
path: typeName,
schemaHierarchy,
canonicalUrl: schema.url,
version: schema.version
}
};
}
}