From 8454d8adf679d7684422502bd40747ec7ba7ccd1 Mon Sep 17 00:00:00 2001 From: Andrey Listopadov Date: Tue, 3 Feb 2026 13:39:02 +0300 Subject: [PATCH 1/3] add support for getReferenceKey and getResourceKey functions --- package.json | 4 +- src/operations/getReferenceKey-function.ts | 154 +++++++ src/operations/getResourceKey-function.ts | 54 +++ src/operations/index.ts | 10 +- .../sql-on-fhir/getReferenceKey.json | 205 +++++++++ .../sql-on-fhir/getResourceKey.json | 147 +++++++ .../operations/sql-on-fhir/integration.json | 144 +++++++ test/sql-on-fhir.test.ts | 404 ++++++++++++++++++ 8 files changed, 1117 insertions(+), 5 deletions(-) create mode 100644 src/operations/getReferenceKey-function.ts create mode 100644 src/operations/getResourceKey-function.ts create mode 100644 test-cases/operations/sql-on-fhir/getReferenceKey.json create mode 100644 test-cases/operations/sql-on-fhir/getResourceKey.json create mode 100644 test-cases/operations/sql-on-fhir/integration.json create mode 100644 test/sql-on-fhir.test.ts diff --git a/package.json b/package.json index d03cea9..88e9c27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@atomic-ehr/fhirpath", - "version": "0.0.5", + "version": "0.0.6", "description": "A TypeScript implementation of FHIRPath", "type": "module", "main": "./dist/index.node.js", @@ -76,4 +76,4 @@ "@atomic-ehr/ucum": "^0.2.5", "fast-xml-parser": "^5.2.5" } -} \ No newline at end of file +} diff --git a/src/operations/getReferenceKey-function.ts b/src/operations/getReferenceKey-function.ts new file mode 100644 index 0000000..dde6c92 --- /dev/null +++ b/src/operations/getReferenceKey-function.ts @@ -0,0 +1,154 @@ +import type { FunctionDefinition, FunctionEvaluator, ASTNode } from '../types'; +import { box, unbox, type FHIRPathValue } from '../interpreter/boxing'; +import { Errors } from '../errors'; +import { isIdentifierNode, isFunctionNode } from '../types'; + +/** + * SQL on FHIR: getReferenceKey(resourceType?) + * + * Returns a foreign key from a Reference element that can be used to join + * to another resource. The returned value equals getResourceKey() on the + * referenced resource. + * + * If resourceType is provided, returns empty collection if the reference + * doesn't point to that type. This enables type-safe joins. + * + * @see https://build.fhir.org/ig/FHIR/sql-on-fhir-v2/functional-model.html + */ + +/** + * Parse a FHIR reference string to extract resourceType and id + * Handles formats: + * - "Patient/123" (relative reference) + * - "http://example.org/fhir/Patient/123" (absolute reference) + * - "urn:uuid:..." (URN reference) + */ +function parseReference(reference: string): { resourceType?: string; id?: string } | null { + if (!reference) return null; + + // Handle URN references (urn:uuid:..., urn:oid:...) + if (reference.startsWith('urn:')) { + // For URN references, the whole URN is the identifier + return { id: reference }; + } + + // Handle absolute URLs - extract the last two path segments + // e.g., "http://example.org/fhir/Patient/123" -> Patient/123 + if (reference.includes('://')) { + const url = reference.split('?')[0]; // Remove query string + const segments = url!.split('/').filter(s => s); + if (segments.length >= 2) { + const id = segments[segments.length - 1]; + const resourceType = segments[segments.length - 2]; + return { resourceType, id }; + } + return null; + } + + // Handle relative references - "ResourceType/id" + const parts = reference.split('/'); + if (parts.length === 2) { + return { resourceType: parts[0], id: parts[1] }; + } + + // Handle contained references (#id) + if (reference.startsWith('#')) { + return { id: reference.substring(1) }; + } + + return null; +} + +/** + * Extract type name from AST node (identifier or function call) + */ +function extractTypeName(typeArg: ASTNode): string { + if (isIdentifierNode(typeArg)) { + return typeArg.name; + } else if (isFunctionNode(typeArg) && isIdentifierNode(typeArg.name)) { + return typeArg.name.name; + } + throw Errors.invalidOperation(`getReferenceKey() requires a type name as argument, got ${typeArg.type}`); +} + +export const evaluate: FunctionEvaluator = async (input, context, args, evaluator) => { + // Extract optional resourceType filter + let targetResourceType: string | undefined; + if (args.length > 0) { + targetResourceType = extractTypeName(args[0]!); + } + + const results: FHIRPathValue[] = []; + + for (const boxedItem of input) { + const item = unbox(boxedItem); + + if (!item || typeof item !== 'object') { + continue; + } + + // Extract reference string from Reference element + let referenceString: string | undefined; + + if ('reference' in item && typeof item.reference === 'string') { + // Standard FHIR Reference: { reference: "Patient/123", ... } + referenceString = item.reference; + } else if (typeof item === 'string') { + // Direct string reference (less common but possible) + referenceString = item; + } + + if (!referenceString) { + continue; + } + + const parsed = parseReference(referenceString); + if (!parsed || !parsed.id) { + continue; + } + + // If resourceType filter is specified, check it matches + if (targetResourceType) { + if (!parsed.resourceType || parsed.resourceType !== targetResourceType) { + // Return empty for this item (type doesn't match) + continue; + } + } + + // Return the reference key (the id portion) + results.push(box(parsed.id, { type: 'String', singleton: true })); + } + + return { value: results, context }; +}; + +export const getReferenceKeyFunction: FunctionDefinition & { evaluate: FunctionEvaluator } = { + name: 'getReferenceKey', + category: ['SQL on FHIR', 'navigation'], + description: + 'Returns a foreign key from a Reference element for joining to another resource. ' + + 'The returned value equals getResourceKey() on the referenced resource. ' + + 'If resourceType is provided, returns empty if the reference is not of that type.', + examples: [ + 'subject.getReferenceKey()', + 'subject.getReferenceKey(Patient)', + 'Observation.subject.getReferenceKey(Patient)', + ], + signatures: [ + { + name: 'getReferenceKey', + input: { type: 'Any', singleton: false }, + parameters: [ + { + name: 'resourceType', + type: { type: 'Any', singleton: true }, + optional: true, + expression: true, + typeReference: true, // Expects a type name like Patient, Observation + } + ], + result: { type: 'String', singleton: false }, + } + ], + evaluate, +}; diff --git a/src/operations/getResourceKey-function.ts b/src/operations/getResourceKey-function.ts new file mode 100644 index 0000000..aabf5a3 --- /dev/null +++ b/src/operations/getResourceKey-function.ts @@ -0,0 +1,54 @@ +import type { FunctionDefinition, FunctionEvaluator } from '../types'; +import { box, unbox, type FHIRPathValue } from '../interpreter/boxing'; + +/** + * SQL on FHIR: getResourceKey() + * + * Returns a unique key identifying this resource for use in database joins. + * The returned value is implementation-dependent but must be a FHIR primitive + * type suitable for efficient joins. + * + * This function is designed to work with getReferenceKey() - the value returned + * by getResourceKey() on a resource must equal the value returned by + * getReferenceKey() on references pointing to that resource. + * + * @see https://build.fhir.org/ig/FHIR/sql-on-fhir-v2/functional-model.html + */ +export const evaluate: FunctionEvaluator = async (input, context, args, evaluator) => { + const results: FHIRPathValue[] = []; + + for (const boxedItem of input) { + const item = unbox(boxedItem); + + // Extract resource key - implementation uses resource id + if (item && typeof item === 'object') { + // Check for FHIR resource with id + if ('id' in item && item.id !== undefined) { + results.push(box(String(item.id), { type: 'String', singleton: true })); + } + // Empty collection if no id found + } + } + + return { value: results, context }; +}; + +export const getResourceKeyFunction: FunctionDefinition & { evaluate: FunctionEvaluator } = { + name: 'getResourceKey', + category: ['SQL on FHIR', 'navigation'], + description: + 'Returns a unique key identifying this resource for use in database joins. ' + + 'The returned value can be used with getReferenceKey() to join resources. ' + + 'This is typically the resource id, but the exact format is implementation-dependent.', + examples: [ + 'getResourceKey()', + 'Patient.getResourceKey()', + ], + signatures: [{ + name: 'getResourceKey', + input: { type: 'Any', singleton: false }, + parameters: [], + result: { type: 'String', singleton: false }, + }], + evaluate, +}; diff --git a/src/operations/index.ts b/src/operations/index.ts index 9525e25..c4e29d7 100644 --- a/src/operations/index.ts +++ b/src/operations/index.ts @@ -68,9 +68,9 @@ export { iifFunction } from './iif-function'; export { defineVariableFunction } from './defineVariable-function'; // Temporal functions -export { - nowFunction, - todayFunction, +export { + nowFunction, + todayFunction, timeOfDayFunction, toDateFunction, toDateTimeFunction, @@ -155,3 +155,7 @@ export { powerFunction } from './power-function'; export { logFunction } from './log-function'; export { lnFunction } from './ln-function'; export { expFunction } from './exp-function'; + +// SQL on FHIR functions +export { getResourceKeyFunction } from './getResourceKey-function'; +export { getReferenceKeyFunction } from './getReferenceKey-function'; diff --git a/test-cases/operations/sql-on-fhir/getReferenceKey.json b/test-cases/operations/sql-on-fhir/getReferenceKey.json new file mode 100644 index 0000000..2243907 --- /dev/null +++ b/test-cases/operations/sql-on-fhir/getReferenceKey.json @@ -0,0 +1,205 @@ +{ + "name": "getReferenceKey Function Tests", + "description": "Tests for the SQL on FHIR getReferenceKey() function that extracts foreign keys from Reference elements", + "tests": [ + { + "name": "getReferenceKey - relative reference", + "expression": "subject.getReferenceKey()", + "input": { + "resourceType": "Observation", + "id": "obs-1", + "subject": { + "reference": "Patient/123" + } + }, + "expected": ["123"], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "relative", "function:getReferenceKey"] + }, + { + "name": "getReferenceKey - absolute URL reference", + "expression": "subject.getReferenceKey()", + "input": { + "resourceType": "Observation", + "id": "obs-1", + "subject": { + "reference": "http://example.org/fhir/Patient/456" + } + }, + "expected": ["456"], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "absolute", "function:getReferenceKey"] + }, + { + "name": "getReferenceKey - urn:uuid reference", + "expression": "subject.getReferenceKey()", + "input": { + "resourceType": "Observation", + "id": "obs-1", + "subject": { + "reference": "urn:uuid:550e8400-e29b-41d4-a716-446655440000" + } + }, + "expected": ["urn:uuid:550e8400-e29b-41d4-a716-446655440000"], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "urn", "uuid", "function:getReferenceKey"] + }, + { + "name": "getReferenceKey - urn:oid reference", + "expression": "subject.getReferenceKey()", + "input": { + "resourceType": "Observation", + "id": "obs-1", + "subject": { + "reference": "urn:oid:1.2.3.4.5" + } + }, + "expected": ["urn:oid:1.2.3.4.5"], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "urn", "oid", "function:getReferenceKey"] + }, + { + "name": "getReferenceKey - contained reference", + "expression": "subject.getReferenceKey()", + "input": { + "resourceType": "Observation", + "id": "obs-1", + "subject": { + "reference": "#contained-patient" + } + }, + "expected": ["contained-patient"], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "contained", "function:getReferenceKey"] + }, + { + "name": "getReferenceKey - with display text", + "expression": "subject.getReferenceKey()", + "input": { + "resourceType": "Observation", + "id": "obs-1", + "subject": { + "reference": "Patient/789", + "display": "John Smith" + } + }, + "expected": ["789"], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "display", "function:getReferenceKey"] + }, + { + "name": "getReferenceKey - empty for reference without reference field", + "expression": "subject.getReferenceKey()", + "input": { + "resourceType": "Observation", + "id": "obs-1", + "subject": { + "display": "John Smith" + } + }, + "expected": [], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "display-only", "function:getReferenceKey"] + }, + { + "name": "getReferenceKey - multiple references", + "expression": "performer.getReferenceKey()", + "input": { + "resourceType": "Observation", + "id": "obs-1", + "performer": [ + { "reference": "Practitioner/dr-1" }, + { "reference": "Practitioner/dr-2" }, + { "reference": "Organization/org-1" } + ] + }, + "expected": ["dr-1", "dr-2", "org-1"], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "multiple", "function:getReferenceKey"] + }, + { + "name": "getReferenceKey - with type filter matching", + "expression": "subject.getReferenceKey(Patient)", + "input": { + "resourceType": "Observation", + "id": "obs-1", + "subject": { + "reference": "Patient/123" + } + }, + "expected": ["123"], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "type-filter", "match", "function:getReferenceKey"] + }, + { + "name": "getReferenceKey - with type filter not matching", + "expression": "subject.getReferenceKey(Group)", + "input": { + "resourceType": "Observation", + "id": "obs-1", + "subject": { + "reference": "Patient/123" + } + }, + "expected": [], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "type-filter", "no-match", "function:getReferenceKey"] + }, + { + "name": "getReferenceKey - type filter on multiple references", + "expression": "performer.getReferenceKey(Practitioner)", + "input": { + "resourceType": "Observation", + "id": "obs-1", + "performer": [ + { "reference": "Practitioner/dr-1" }, + { "reference": "Organization/org-1" }, + { "reference": "Practitioner/dr-2" } + ] + }, + "expected": ["dr-1", "dr-2"], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "type-filter", "multiple", "function:getReferenceKey"] + }, + { + "name": "getReferenceKey - URL with query parameters", + "expression": "subject.getReferenceKey()", + "input": { + "resourceType": "Observation", + "id": "obs-1", + "subject": { + "reference": "http://example.org/fhir/Patient/xyz?_format=json" + } + }, + "expected": ["xyz"], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "query-params", "function:getReferenceKey"] + }, + { + "name": "getReferenceKey - empty for empty collection", + "expression": "subject.getReferenceKey()", + "input": { + "resourceType": "Observation", + "id": "obs-1" + }, + "expected": [], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "empty", "function:getReferenceKey"] + }, + { + "name": "getReferenceKey - from Observation example subject", + "expression": "subject.getReferenceKey()", + "inputFile": "../../input/observation-example.json", + "expected": ["example"], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "fhir-example", "function:getReferenceKey"] + }, + { + "name": "getReferenceKey - from Observation example encounter", + "expression": "encounter.getReferenceKey()", + "inputFile": "../../input/observation-example.json", + "expected": ["example"], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "fhir-example", "function:getReferenceKey"] + }, + { + "name": "getReferenceKey - type filter on Observation example", + "expression": "subject.getReferenceKey(Patient)", + "inputFile": "../../input/observation-example.json", + "expected": ["example"], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "type-filter", "fhir-example", "function:getReferenceKey"] + }, + { + "name": "getReferenceKey - type filter mismatch on Observation example", + "expression": "subject.getReferenceKey(Device)", + "inputFile": "../../input/observation-example.json", + "expected": [], + "tags": ["function", "sql-on-fhir", "getReferenceKey", "type-filter", "fhir-example", "function:getReferenceKey"] + } + ] +} diff --git a/test-cases/operations/sql-on-fhir/getResourceKey.json b/test-cases/operations/sql-on-fhir/getResourceKey.json new file mode 100644 index 0000000..ec3a644 --- /dev/null +++ b/test-cases/operations/sql-on-fhir/getResourceKey.json @@ -0,0 +1,147 @@ +{ + "name": "getResourceKey Function Tests", + "description": "Tests for the SQL on FHIR getResourceKey() function that returns a unique key for database joins", + "tests": [ + { + "name": "getResourceKey - returns id from Patient resource", + "expression": "getResourceKey()", + "input": { + "resourceType": "Patient", + "id": "patient-123", + "name": [{ "family": "Smith" }] + }, + "expected": ["patient-123"], + "tags": ["function", "sql-on-fhir", "getResourceKey", "function:getResourceKey"] + }, + { + "name": "getResourceKey - returns id from Observation resource", + "expression": "getResourceKey()", + "input": { + "resourceType": "Observation", + "id": "obs-456", + "status": "final" + }, + "expected": ["obs-456"], + "tags": ["function", "sql-on-fhir", "getResourceKey", "function:getResourceKey"] + }, + { + "name": "getResourceKey - empty for resource without id", + "expression": "getResourceKey()", + "input": { + "resourceType": "Patient", + "name": [{ "family": "Smith" }] + }, + "expected": [], + "tags": ["function", "sql-on-fhir", "getResourceKey", "empty", "function:getResourceKey"] + }, + { + "name": "getResourceKey - with chained expression", + "expression": "Patient.getResourceKey()", + "input": { + "resourceType": "Patient", + "id": "p1" + }, + "expected": ["p1"], + "tags": ["function", "sql-on-fhir", "getResourceKey", "chain", "function:getResourceKey"], + "note": "Patient is an identifier that navigates to the resource itself when at root" + }, + { + "name": "getResourceKey - multiple resources in collection", + "expression": "getResourceKey()", + "input": [ + { "resourceType": "Patient", "id": "p1" }, + { "resourceType": "Patient", "id": "p2" }, + { "resourceType": "Patient", "id": "p3" } + ], + "expected": ["p1", "p2", "p3"], + "tags": ["function", "sql-on-fhir", "getResourceKey", "collection", "function:getResourceKey"] + }, + { + "name": "getResourceKey - mixed resources with and without id", + "expression": "getResourceKey()", + "input": [ + { "resourceType": "Patient", "id": "p1" }, + { "resourceType": "Patient" }, + { "resourceType": "Patient", "id": "p3" } + ], + "expected": ["p1", "p3"], + "tags": ["function", "sql-on-fhir", "getResourceKey", "mixed", "function:getResourceKey"] + }, + { + "name": "getResourceKey - numeric id converted to string", + "expression": "getResourceKey()", + "input": { + "resourceType": "Patient", + "id": 12345 + }, + "expected": ["12345"], + "tags": ["function", "sql-on-fhir", "getResourceKey", "numeric", "function:getResourceKey"] + }, + { + "name": "getResourceKey - UUID id", + "expression": "getResourceKey()", + "input": { + "resourceType": "Patient", + "id": "550e8400-e29b-41d4-a716-446655440000" + }, + "expected": ["550e8400-e29b-41d4-a716-446655440000"], + "tags": ["function", "sql-on-fhir", "getResourceKey", "uuid", "function:getResourceKey"] + }, + { + "name": "getResourceKey - empty for empty collection", + "expression": "getResourceKey()", + "input": [], + "expected": [], + "tags": ["function", "sql-on-fhir", "getResourceKey", "empty", "function:getResourceKey"] + }, + { + "name": "getResourceKey - empty for primitive value", + "expression": "getResourceKey()", + "input": "string", + "expected": [], + "tags": ["function", "sql-on-fhir", "getResourceKey", "primitive", "function:getResourceKey"] + }, + { + "name": "getResourceKey - works with contained resources", + "expression": "contained.getResourceKey()", + "input": { + "resourceType": "Patient", + "id": "main-patient", + "contained": [ + { + "resourceType": "Practitioner", + "id": "practitioner-1" + }, + { + "resourceType": "Organization", + "id": "org-1" + } + ] + }, + "expected": ["practitioner-1", "org-1"], + "tags": ["function", "sql-on-fhir", "getResourceKey", "contained", "function:getResourceKey"] + }, + { + "name": "getResourceKey - with where filter", + "expression": "entry.resource.where(resourceType = 'Patient').getResourceKey()", + "input": { + "resourceType": "Bundle", + "id": "bundle-1", + "entry": [ + { "resource": { "resourceType": "Patient", "id": "p1" } }, + { "resource": { "resourceType": "Observation", "id": "o1" } }, + { "resource": { "resourceType": "Patient", "id": "p2" } } + ] + }, + "expected": ["p1", "p2"], + "tags": ["function", "sql-on-fhir", "getResourceKey", "where", "bundle", "function:getResourceKey"] + }, + { + "name": "getResourceKey - from Observation example", + "expression": "getResourceKey()", + "inputFile": "../../input/observation-example.json", + "expected": ["example"], + "tags": ["function", "sql-on-fhir", "getResourceKey", "fhir-example", "function:getResourceKey"] + } + ] +} diff --git a/test-cases/operations/sql-on-fhir/integration.json b/test-cases/operations/sql-on-fhir/integration.json new file mode 100644 index 0000000..e891a06 --- /dev/null +++ b/test-cases/operations/sql-on-fhir/integration.json @@ -0,0 +1,144 @@ +{ + "name": "SQL on FHIR Integration Tests", + "description": "Tests verifying getResourceKey and getReferenceKey work together for database joins", + "tests": [ + { + "name": "Keys match between resource and reference - simple case", + "description": "Verifies that getResourceKey on a resource returns the same value as getReferenceKey on a reference pointing to it", + "expression": "getResourceKey() = subject.getReferenceKey()", + "input": { + "resourceType": "Patient", + "id": "patient-123", + "subject": { + "reference": "Patient/patient-123" + } + }, + "expected": [true], + "tags": ["function", "sql-on-fhir", "integration", "key-match", "function:getResourceKey", "function:getReferenceKey"] + }, + { + "name": "ViewDefinition pattern - patient id column", + "description": "Simulates ViewDefinition column: { path: 'getResourceKey()', name: 'id' }", + "expression": "getResourceKey()", + "input": { + "resourceType": "Patient", + "id": "p1", + "name": [{ "family": "Smith", "given": ["John"] }] + }, + "expected": ["p1"], + "tags": ["function", "sql-on-fhir", "viewdefinition", "pattern", "function:getResourceKey"] + }, + { + "name": "ViewDefinition pattern - observation with patient foreign key", + "description": "Simulates ViewDefinition columns for Observation view with patient_id foreign key", + "expression": "subject.getReferenceKey(Patient)", + "input": { + "resourceType": "Observation", + "id": "obs-1", + "status": "final", + "subject": { + "reference": "Patient/p1" + } + }, + "expected": ["p1"], + "tags": ["function", "sql-on-fhir", "viewdefinition", "pattern", "foreign-key", "function:getReferenceKey"] + }, + { + "name": "Bundle with multiple resources - extract all patient keys", + "expression": "entry.resource.where(resourceType = 'Patient').getResourceKey()", + "input": { + "resourceType": "Bundle", + "type": "searchset", + "entry": [ + { "resource": { "resourceType": "Patient", "id": "p1", "name": [{ "family": "Smith" }] } }, + { "resource": { "resourceType": "Observation", "id": "o1", "subject": { "reference": "Patient/p1" } } }, + { "resource": { "resourceType": "Patient", "id": "p2", "name": [{ "family": "Jones" }] } }, + { "resource": { "resourceType": "Observation", "id": "o2", "subject": { "reference": "Patient/p2" } } } + ] + }, + "expected": ["p1", "p2"], + "tags": ["function", "sql-on-fhir", "bundle", "extraction", "function:getResourceKey"] + }, + { + "name": "Bundle with multiple resources - extract observation-patient relationships", + "expression": "entry.resource.where(resourceType = 'Observation').subject.getReferenceKey(Patient)", + "input": { + "resourceType": "Bundle", + "type": "searchset", + "entry": [ + { "resource": { "resourceType": "Patient", "id": "p1" } }, + { "resource": { "resourceType": "Observation", "id": "o1", "subject": { "reference": "Patient/p1" } } }, + { "resource": { "resourceType": "Patient", "id": "p2" } }, + { "resource": { "resourceType": "Observation", "id": "o2", "subject": { "reference": "Patient/p2" } } }, + { "resource": { "resourceType": "Observation", "id": "o3", "subject": { "reference": "Group/g1" } } } + ] + }, + "expected": ["p1", "p2"], + "tags": ["function", "sql-on-fhir", "bundle", "extraction", "foreign-key", "function:getReferenceKey"], + "note": "o3's subject is a Group, so it's filtered out by getReferenceKey(Patient)" + }, + { + "name": "Encounter with multiple reference types", + "expression": "participant.individual.getReferenceKey(Practitioner)", + "input": { + "resourceType": "Encounter", + "id": "enc-1", + "participant": [ + { "individual": { "reference": "Practitioner/dr-1" } }, + { "individual": { "reference": "RelatedPerson/rp-1" } }, + { "individual": { "reference": "Practitioner/dr-2" } } + ] + }, + "expected": ["dr-1", "dr-2"], + "tags": ["function", "sql-on-fhir", "multiple-types", "function:getReferenceKey"] + }, + { + "name": "DiagnosticReport with multiple performer types", + "expression": "performer.getReferenceKey()", + "input": { + "resourceType": "DiagnosticReport", + "id": "dr-1", + "performer": [ + { "reference": "Practitioner/p1" }, + { "reference": "Organization/org1" }, + { "reference": "CareTeam/ct1" } + ] + }, + "expected": ["p1", "org1", "ct1"], + "tags": ["function", "sql-on-fhir", "performer", "function:getReferenceKey"] + }, + { + "name": "Contained resource key extraction", + "expression": "contained.getResourceKey()", + "input": { + "resourceType": "MedicationRequest", + "id": "mr-1", + "contained": [ + { "resourceType": "Medication", "id": "med-1" }, + { "resourceType": "Substance", "id": "sub-1" } + ], + "medicationReference": { + "reference": "#med-1" + } + }, + "expected": ["med-1", "sub-1"], + "tags": ["function", "sql-on-fhir", "contained", "function:getResourceKey"] + }, + { + "name": "Reference to contained resource", + "expression": "medicationReference.getReferenceKey()", + "input": { + "resourceType": "MedicationRequest", + "id": "mr-1", + "contained": [ + { "resourceType": "Medication", "id": "med-1" } + ], + "medicationReference": { + "reference": "#med-1" + } + }, + "expected": ["med-1"], + "tags": ["function", "sql-on-fhir", "contained-ref", "function:getReferenceKey"] + } + ] +} diff --git a/test/sql-on-fhir.test.ts b/test/sql-on-fhir.test.ts new file mode 100644 index 0000000..17db4aa --- /dev/null +++ b/test/sql-on-fhir.test.ts @@ -0,0 +1,404 @@ +import { describe, it, expect } from 'bun:test'; +import { registry } from '../src/registry'; +import { getResourceKeyFunction } from '../src/operations/getResourceKey-function'; +import { getReferenceKeyFunction } from '../src/operations/getReferenceKey-function'; +import { box } from '../src/interpreter/boxing'; +import { NodeType } from '../src/types'; +import type { RuntimeContext, IdentifierNode } from '../src/types'; +import { analyze } from '../src/index.node'; +import { provideCompletions, CompletionKind } from '../src/completion-provider'; + +describe('SQL on FHIR Functions', () => { + // Create a minimal runtime context for testing + const createContext = (): RuntimeContext => ({ + input: [], + focus: [], + variables: {}, + }); + + describe('getResourceKey', () => { + describe('Registry', () => { + it('should be registered in the registry', () => { + expect(registry.isFunction('getResourceKey')).toBe(true); + + const func = registry.getFunction('getResourceKey'); + expect(func).toBeDefined(); + expect(func!.name).toBe('getResourceKey'); + expect(func!.category).toContain('SQL on FHIR'); + }); + + it('should have correct signature', () => { + const func = registry.getFunction('getResourceKey'); + expect(func!.signatures).toHaveLength(1); + + const sig = func!.signatures[0]; + expect(sig!.parameters).toHaveLength(0); + expect(sig!.result).toEqual({ type: 'String', singleton: false }); + }); + }); + + describe('Evaluation', () => { + it('should return id from a FHIR resource', async () => { + const patient = { resourceType: 'Patient', id: '123', name: [{ family: 'Smith' }] }; + const input = [box(patient)]; + const context = createContext(); + + const result = await getResourceKeyFunction.evaluate(input, context, [], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(1); + expect(result.value[0]!.value).toBe('123'); + }); + + it('should return empty for resource without id', async () => { + const patient = { resourceType: 'Patient', name: [{ family: 'Smith' }] }; + const input = [box(patient)]; + const context = createContext(); + + const result = await getResourceKeyFunction.evaluate(input, context, [], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(0); + }); + + it('should handle multiple resources', async () => { + const resources = [ + { resourceType: 'Patient', id: 'p1' }, + { resourceType: 'Observation', id: 'o1' }, + { resourceType: 'Patient', id: 'p2' }, + ]; + const input = resources.map(r => box(r)); + const context = createContext(); + + const result = await getResourceKeyFunction.evaluate(input, context, [], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(3); + expect(result.value[0]!.value).toBe('p1'); + expect(result.value[1]!.value).toBe('o1'); + expect(result.value[2]!.value).toBe('p2'); + }); + + it('should convert numeric id to string', async () => { + const resource = { resourceType: 'Patient', id: 123 }; + const input = [box(resource)]; + const context = createContext(); + + const result = await getResourceKeyFunction.evaluate(input, context, [], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(1); + expect(result.value[0]!.value).toBe('123'); + expect(typeof result.value[0]!.value).toBe('string'); + }); + + it('should return empty for non-object input', async () => { + const input = [box('string'), box(123), box(null)]; + const context = createContext(); + + const result = await getResourceKeyFunction.evaluate(input, context, [], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(0); + }); + }); + }); + + describe('getReferenceKey', () => { + describe('Registry', () => { + it('should be registered in the registry', () => { + expect(registry.isFunction('getReferenceKey')).toBe(true); + + const func = registry.getFunction('getReferenceKey'); + expect(func).toBeDefined(); + expect(func!.name).toBe('getReferenceKey'); + expect(func!.category).toContain('SQL on FHIR'); + }); + + it('should have signature with optional resourceType parameter', () => { + const func = registry.getFunction('getReferenceKey'); + expect(func!.signatures).toHaveLength(1); + + const sig = func!.signatures[0]; + expect(sig!.parameters).toHaveLength(1); + expect(sig!.parameters[0]!.name).toBe('resourceType'); + expect(sig!.parameters[0]!.optional).toBe(true); + expect(sig!.parameters[0]!.typeReference).toBe(true); + }); + }); + + describe('Evaluation - Relative References', () => { + it('should extract id from relative reference', async () => { + const reference = { reference: 'Patient/123' }; + const input = [box(reference)]; + const context = createContext(); + + const result = await getReferenceKeyFunction.evaluate(input, context, [], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(1); + expect(result.value[0]!.value).toBe('123'); + }); + + it('should handle reference with display', async () => { + const reference = { reference: 'Patient/456', display: 'John Smith' }; + const input = [box(reference)]; + const context = createContext(); + + const result = await getReferenceKeyFunction.evaluate(input, context, [], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(1); + expect(result.value[0]!.value).toBe('456'); + }); + }); + + describe('Evaluation - Absolute References', () => { + it('should extract id from absolute URL reference', async () => { + const reference = { reference: 'http://example.org/fhir/Patient/789' }; + const input = [box(reference)]; + const context = createContext(); + + const result = await getReferenceKeyFunction.evaluate(input, context, [], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(1); + expect(result.value[0]!.value).toBe('789'); + }); + + it('should handle URL with query parameters', async () => { + const reference = { reference: 'http://example.org/fhir/Observation/obs1?_format=json' }; + const input = [box(reference)]; + const context = createContext(); + + const result = await getReferenceKeyFunction.evaluate(input, context, [], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(1); + expect(result.value[0]!.value).toBe('obs1'); + }); + }); + + describe('Evaluation - URN References', () => { + it('should handle urn:uuid references', async () => { + const reference = { reference: 'urn:uuid:12345678-1234-1234-1234-123456789012' }; + const input = [box(reference)]; + const context = createContext(); + + const result = await getReferenceKeyFunction.evaluate(input, context, [], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(1); + expect(result.value[0]!.value).toBe('urn:uuid:12345678-1234-1234-1234-123456789012'); + }); + + it('should handle urn:oid references', async () => { + const reference = { reference: 'urn:oid:1.2.3.4.5' }; + const input = [box(reference)]; + const context = createContext(); + + const result = await getReferenceKeyFunction.evaluate(input, context, [], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(1); + expect(result.value[0]!.value).toBe('urn:oid:1.2.3.4.5'); + }); + }); + + describe('Evaluation - Contained References', () => { + it('should handle contained references (#id)', async () => { + const reference = { reference: '#contained1' }; + const input = [box(reference)]; + const context = createContext(); + + const result = await getReferenceKeyFunction.evaluate(input, context, [], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(1); + expect(result.value[0]!.value).toBe('contained1'); + }); + }); + + describe('Evaluation - Type Filtering', () => { + it('should return id when resourceType matches', async () => { + const reference = { reference: 'Patient/123' }; + const input = [box(reference)]; + const context = createContext(); + + // Simulate passing Patient as type argument + const typeArg: IdentifierNode = { + type: NodeType.Identifier, + name: 'Patient', + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 7 } } + }; + + const result = await getReferenceKeyFunction.evaluate(input, context, [typeArg], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(1); + expect(result.value[0]!.value).toBe('123'); + }); + + it('should return empty when resourceType does not match', async () => { + const reference = { reference: 'Patient/123' }; + const input = [box(reference)]; + const context = createContext(); + + // Simulate passing Observation as type argument (doesn't match Patient) + const typeArg: IdentifierNode = { + type: NodeType.Identifier, + name: 'Observation', + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 11 } } + }; + + const result = await getReferenceKeyFunction.evaluate(input, context, [typeArg], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(0); + }); + + it('should filter multiple references by type', async () => { + const references = [ + { reference: 'Patient/p1' }, + { reference: 'Observation/o1' }, + { reference: 'Patient/p2' }, + { reference: 'Practitioner/pr1' }, + ]; + const input = references.map(r => box(r)); + const context = createContext(); + + // Filter for Patient only + const typeArg: IdentifierNode = { + type: NodeType.Identifier, + name: 'Patient', + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 7 } } + }; + + const result = await getReferenceKeyFunction.evaluate(input, context, [typeArg], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(2); + expect(result.value[0]!.value).toBe('p1'); + expect(result.value[1]!.value).toBe('p2'); + }); + }); + + describe('Evaluation - Edge Cases', () => { + it('should return empty for reference without reference field', async () => { + const reference = { display: 'Some display text' }; + const input = [box(reference)]; + const context = createContext(); + + const result = await getReferenceKeyFunction.evaluate(input, context, [], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(0); + }); + + it('should return empty for non-object input', async () => { + const input = [box('string'), box(123)]; + const context = createContext(); + + const result = await getReferenceKeyFunction.evaluate(input, context, [], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(0); + }); + + it('should handle empty input collection', async () => { + const input: any[] = []; + const context = createContext(); + + const result = await getReferenceKeyFunction.evaluate(input, context, [], async () => ({ value: [], context })); + + expect(result.value).toHaveLength(0); + }); + }); + }); + + describe('Integration - getResourceKey and getReferenceKey', () => { + it('should return matching keys for resource and reference', async () => { + const patient = { resourceType: 'Patient', id: 'patient-123' }; + const reference = { reference: 'Patient/patient-123' }; + + const context = createContext(); + + // Get resource key + const resourceKeyResult = await getResourceKeyFunction.evaluate( + [box(patient)], + context, + [], + async () => ({ value: [], context }) + ); + + // Get reference key + const referenceKeyResult = await getReferenceKeyFunction.evaluate( + [box(reference)], + context, + [], + async () => ({ value: [], context }) + ); + + // Keys should match + expect(resourceKeyResult.value[0]!.value).toBe(referenceKeyResult.value[0]!.value); + expect(resourceKeyResult.value[0]!.value).toBe('patient-123'); + }); + }); + + describe('Analyzer Integration', () => { + it('should analyze getResourceKey() without errors', async () => { + const result = await analyze('getResourceKey()'); + expect(result.diagnostics).toEqual([]); + }); + + it('should analyze getReferenceKey() without errors', async () => { + const result = await analyze('subject.getReferenceKey()'); + expect(result.diagnostics).toEqual([]); + }); + + it('should analyze getReferenceKey(Patient) without errors', async () => { + const result = await analyze('subject.getReferenceKey(Patient)'); + expect(result.diagnostics).toEqual([]); + }); + + it('should analyze chained expression with getResourceKey', async () => { + const result = await analyze('entry.resource.getResourceKey()'); + expect(result.diagnostics).toEqual([]); + }); + + it('should analyze ViewDefinition-style expressions', async () => { + // Typical SQL on FHIR ViewDefinition patterns + const expressions = [ + 'getResourceKey()', + 'subject.getReferenceKey()', + 'subject.getReferenceKey(Patient)', + 'performer.getReferenceKey(Practitioner)', + ]; + + for (const expr of expressions) { + const result = await analyze(expr); + expect(result.diagnostics).toEqual([]); + } + }); + }); + + describe('Completion Provider Integration', () => { + it('should include getResourceKey in function completions', async () => { + const expression = 'Patient.'; + const cursorPosition = 8; + + const completions = await provideCompletions(expression, cursorPosition); + + const getResourceKeyCompletion = completions.find(c => c.label === 'getResourceKey'); + expect(getResourceKeyCompletion).toBeDefined(); + expect(getResourceKeyCompletion?.kind).toBe(CompletionKind.Function); + }); + + it('should include getReferenceKey in function completions', async () => { + const expression = 'subject.'; + const cursorPosition = 8; + + const completions = await provideCompletions(expression, cursorPosition); + + const getReferenceKeyCompletion = completions.find(c => c.label === 'getReferenceKey'); + expect(getReferenceKeyCompletion).toBeDefined(); + expect(getReferenceKeyCompletion?.kind).toBe(CompletionKind.Function); + }); + + it('should show SQL on FHIR description in completion details', async () => { + const expression = 'resource.'; + const cursorPosition = 9; + + const completions = await provideCompletions(expression, cursorPosition); + + const getResourceKeyCompletion = completions.find(c => c.label === 'getResourceKey'); + expect(getResourceKeyCompletion).toBeDefined(); + // The function should have detail mentioning joins (description is stored in detail) + expect(getResourceKeyCompletion?.detail).toBeDefined(); + expect(getResourceKeyCompletion?.detail?.toLowerCase()).toContain('join'); + }); + }); +}); From 4656f1c263479996ab73498dc85179d2a1c3499b Mon Sep 17 00:00:00 2001 From: Andrey Listopadov Date: Tue, 3 Feb 2026 15:01:38 +0300 Subject: [PATCH 2/3] make getReferenceKey only work on Reference types --- src/analyzer.ts | 145 +++++++++++---------- src/analyzer/type-compat.ts | 3 + src/completion-provider.ts | 112 +++++++++------- src/operations/getReferenceKey-function.ts | 2 +- test/sql-on-fhir.test.ts | 127 +++++++++++++++++- 5 files changed, 272 insertions(+), 117 deletions(-) diff --git a/src/analyzer.ts b/src/analyzer.ts index de8bc61..7f786dc 100644 --- a/src/analyzer.ts +++ b/src/analyzer.ts @@ -1,18 +1,18 @@ -import type { - ASTNode, - BinaryNode, - IdentifierNode, - LiteralNode, +import type { + ASTNode, + BinaryNode, + IdentifierNode, + LiteralNode, TemporalLiteralNode, - FunctionNode, - Diagnostic, - AnalysisResult, - UnaryNode, - IndexNode, - CollectionNode, - MembershipTestNode, - TypeCastNode, - TypeInfo, + FunctionNode, + Diagnostic, + AnalysisResult, + UnaryNode, + IndexNode, + CollectionNode, + MembershipTestNode, + TypeCastNode, + TypeInfo, ModelProvider, VariableNode, TypeName, @@ -115,7 +115,7 @@ export class Analyzer { } let result: InternalAnalysisResult; - + switch (node.type) { case NodeType.Binary: result = await this.analyzeBinary(node as BinaryNode, context); @@ -162,10 +162,10 @@ export class Analyzer { diagnostics: [toDiagnostic(Errors.unknownNodeType(String((node as any)?.type), (node as any)?.range))] }; } - + // Annotate the node with type information node.typeInfo = result.type; - + return result; } @@ -181,14 +181,14 @@ export class Analyzer { if (this.stoppedAtCursor) { return { type: { type: 'Any', singleton: false }, diagnostics: leftResult.diagnostics }; } - + const rightResult = await this.analyzeNode(node.right, context.fork()); - + diagnostics.push(...leftResult.diagnostics, ...rightResult.diagnostics); - + // Preserve left operand type per operator signature (leftType) const type = { ...leftResult.type, singleton: false }; - + return { type, diagnostics, @@ -210,18 +210,18 @@ export class Analyzer { }; return await this.analyzeMembershipTest(correctedNode, context); } - + const leftResult = await this.analyzeNode(node.left, context); if (this.stoppedAtCursor) { return { type: { type: 'Any', singleton: false }, diagnostics: leftResult.diagnostics }; } - + // Right side gets left's output as input, with any context changes const rightContext = (leftResult.context || context).withInputType(leftResult.type); const rightResult = await this.analyzeNode(node.right, rightContext); - + diagnostics.push(...leftResult.diagnostics, ...rightResult.diagnostics); - + return { type: rightResult.type, diagnostics, @@ -234,7 +234,7 @@ export class Analyzer { if (this.stoppedAtCursor) { return { type: { type: 'Any', singleton: false }, diagnostics: leftResult.diagnostics }; } - + // Check if right side is a cursor node - if so, set proper context if (this.cursorMode && isCursorNode(node.right)) { this.stoppedAtCursor = true; @@ -248,10 +248,10 @@ export class Analyzer { diagnostics: leftResult.diagnostics }; } - + // For most operators, right side evaluates with original context (not left's output) const rightResult = await this.analyzeNode(node.right, context); - + diagnostics.push(...leftResult.diagnostics, ...rightResult.diagnostics); // Get operator definition for type checking @@ -284,14 +284,14 @@ export class Analyzer { diagnostics }; } - + // Determine result type from matching signature const resultType = resolveResultType(matchingSignature.result as any, { input: context.inputType, left: leftResult.type, right: rightResult.type, }); - + return { type: resultType, diagnostics @@ -546,7 +546,9 @@ export class Analyzer { actualInput.type === 'Any' || expectedInput.type === actualInput.type || (expectedInput.type === 'Decimal' && actualInput.type === 'Integer'); - inputMatches = singletonMatch && typeMatch; + // Check FHIR type name constraint (e.g., 'Reference') + const nameMatch = !expectedInput.name || !actualInput.name || expectedInput.name === actualInput.name; + inputMatches = singletonMatch && typeMatch && nameMatch; } if (inputMatches) { inputMatchingSignature = sig; @@ -563,7 +565,8 @@ export class Analyzer { }) ); } else { - const actualTypeStr = actualInput.singleton ? actualInput.type : `${actualInput.type}[]`; + const actualTypeName = actualInput.name || actualInput.type; + const actualTypeStr = actualInput.singleton ? actualTypeName : `${actualTypeName}[]`; const hasSingletonSignature = funcDef.signatures.some(sig => sig.input?.singleton && sig.input.type === actualInput.type); const permissive = ['anyFalse', 'anyTrue']; if (hasSingletonSignature && !actualInput.singleton) { @@ -576,7 +579,11 @@ export class Analyzer { ); } else if (!permissive.includes(functionName)) { const expectedTypes = funcDef.signatures - .map(sig => (sig.input ? (sig.input.singleton ? sig.input.type : `${sig.input.type}[]`) : 'Any')) + .map(sig => { + if (!sig.input) return 'Any'; + const name = sig.input.name || sig.input.type; + return sig.input.singleton ? name : `${name}[]`; + }) .filter((v, i, a) => a.indexOf(v) === i) .join(' or '); diagnostics.push( @@ -657,14 +664,14 @@ export class Analyzer { // In the analyzer, we track this as the initial input type return { type: context.inputType, diagnostics, context }; } - + // Special handling for FHIR system variables (code system URLs) - if (varName === '%sct' || varName === '%loinc' || varName === '%ucum' || + if (varName === '%sct' || varName === '%loinc' || varName === '%ucum' || varName === '%vs-administrative-gender' || varName === '%`vs-administrative-gender`') { // These are string constants return { type: { type: 'String', singleton: true }, diagnostics, context }; } - + // Handle environment variable syntax with backticks %`variable` let name: string; if (varName.startsWith('%`') && varName.endsWith('`')) { @@ -675,7 +682,7 @@ export class Analyzer { name = varName.substring(1); } const varType = context.userVariables.get(name); - + if (!varType) { // If we have dynamic variables in scope, we can't be sure this is an error if (context.hasDynamicVariables) { @@ -688,7 +695,7 @@ export class Analyzer { return { type: { type: 'Any', singleton: false }, diagnostics }; } } - + // Attach type info to the node for backward compatibility node.typeInfo = varType; return { type: varType, diagnostics, context }; @@ -713,7 +720,7 @@ export class Analyzer { private async analyzeIdentifier(node: IdentifierNode, context: AnalysisContext): Promise { const name = 'name' in node ? node.name : ''; const diagnostics: Diagnostic[] = []; - + // Try to use model provider for accurate type information if (context.modelProvider) { // First try to navigate from input type (property access) @@ -737,7 +744,7 @@ export class Analyzer { }; } } - + // If property not found and we have a concrete non-union type, report warning // FHIRPath returns empty for unknown properties, not an error const mc: any = context.inputType.modelContext; @@ -752,7 +759,7 @@ export class Analyzer { }; } } - + // Without a model provider, we can't know the type // Return Any type - don't make assumptions return { @@ -767,7 +774,7 @@ export class Analyzer { */ private analyzeLiteral(node: LiteralNode, context: AnalysisContext): InternalAnalysisResult { let type: TypeInfo; - + switch (node.valueType) { case 'string': type = { type: 'String', singleton: true }; @@ -795,13 +802,13 @@ export class Analyzer { default: type = { type: 'Any', singleton: true }; } - + return { type, diagnostics: [] }; } - + private analyzeTemporalLiteral(node: TemporalLiteralNode, context: AnalysisContext): InternalAnalysisResult { let type: TypeInfo; - + switch (node.valueType) { case 'date': type = { type: 'Date', singleton: true }; @@ -815,7 +822,7 @@ export class Analyzer { default: type = { type: 'Any', singleton: true }; } - + return { type, diagnostics: [] }; } @@ -920,17 +927,17 @@ export class Analyzer { // ModelProvider requirement for non-primitive target types const primitiveTypes = ['String', 'Integer', 'Decimal', 'Boolean', 'Date', 'DateTime', 'Time', 'Quantity']; - + // Normalize System.X types to check if they're primitive let targetType = node.targetType; if (targetType.startsWith('System.')) { targetType = targetType.substring(7); // Remove "System." prefix } - + if (!context.modelProvider && !primitiveTypes.includes(targetType)) { diagnostics.push(toDiagnostic(Errors.modelProviderRequired('is', node.range))); } - + // Check if testing against a union type if (isUnionType(exprResult.type)) { const targetType = node.targetType; @@ -944,7 +951,7 @@ export class Analyzer { }); } } - + return { type: { type: 'Boolean', singleton: true }, diagnostics @@ -963,7 +970,7 @@ export class Analyzer { if (!context.modelProvider && !primitiveTypes.includes(node.targetType)) { diagnostics.push(toDiagnostic(Errors.modelProviderRequired('as', node.range))); } - + // Check if casting from a union type if (isUnionType(exprResult.type)) { const targetTypeName = node.targetType; @@ -977,13 +984,13 @@ export class Analyzer { }); } } - + // Type cast changes the type - const targetType: TypeInfo = { - type: node.targetType as TypeName, - singleton: exprResult.type.singleton + const targetType: TypeInfo = { + type: node.targetType as TypeName, + singleton: exprResult.type.singleton }; - + return { type: targetType, diagnostics @@ -1013,7 +1020,7 @@ export class Analyzer { expectedType: undefined, functionCall: undefined }; - + // Set expected type based on cursor context if (node.context === CursorContext.Index) { // Index expects an integer @@ -1038,7 +1045,7 @@ export class Analyzer { } } } - + return { type: { type: 'Any', singleton: false }, diagnostics: [] @@ -1059,7 +1066,7 @@ export class Analyzer { source: 'fhirpath' }; } - + private createWarning(node: ASTNode, message: string, code?: string): Diagnostic { return { range: node.range, @@ -1082,11 +1089,11 @@ export class Analyzer { const elementType = this.inferValueType(value[0]); return { ...elementType, singleton: false }; } - + if (typeof value === 'string') { return { type: 'String', singleton: true }; } else if (typeof value === 'number') { - return Number.isInteger(value) + return Number.isInteger(value) ? { type: 'Integer', singleton: true } : { type: 'Decimal', singleton: true }; } else if (typeof value === 'boolean') { @@ -1099,22 +1106,22 @@ export class Analyzer { } async analyze( - ast: ASTNode, - userVariables?: Record, + ast: ASTNode, + userVariables?: Record, inputType?: TypeInfo, options?: AnalyzerOptions ): Promise { this.cursorMode = options?.cursorMode ?? false; this.stoppedAtCursor = false; this.cursorContext = undefined; - + // Create initial context with system and user variables const systemVars = new Map(); // $this should be the input type (the root context), not Any systemVars.set('$this', inputType || { type: 'Any', singleton: false }); systemVars.set('$index', { type: 'Integer', singleton: true }); systemVars.set('$total', { type: 'Any', singleton: false }); - + const userVars = new Map(); if (userVariables) { Object.keys(userVariables).forEach(name => { @@ -1124,7 +1131,7 @@ export class Analyzer { } }); } - + // Create context with analyzeNode callback and model provider const initialContext = new AnalysisContext( inputType || { type: 'Any', singleton: false }, @@ -1133,12 +1140,12 @@ export class Analyzer { (node, ctx) => this.analyzeNode(node, ctx), this.modelProvider ); - + // Run context-flow analysis const result = await this.analyzeNode(ast, initialContext); - + // Legacy annotateAST/visitor path removed from default analysis to avoid duplication. - + return { diagnostics: result.diagnostics, ast, diff --git a/src/analyzer/type-compat.ts b/src/analyzer/type-compat.ts index 70e9fb8..524fa62 100644 --- a/src/analyzer/type-compat.ts +++ b/src/analyzer/type-compat.ts @@ -114,6 +114,9 @@ export function matchFunctionSignature( function isFunctionTypeCompatible(actual: TypeInfo, expected: TypeInfo): boolean { // Enforce singleton when required if (expected.singleton && !actual.singleton) return false; + // Check FHIR type name constraint (e.g., 'Reference') + // If expected specifies a name and actual has a known name, they must match + if (expected.name && actual.name && expected.name !== actual.name) return false; if (expected.type === 'Any') return true; if (actual.type === expected.type) return true; // Allow Integer to be used where Decimal is expected (promotion) diff --git a/src/completion-provider.ts b/src/completion-provider.ts index 2ca2cbd..eb555de 100644 --- a/src/completion-provider.ts +++ b/src/completion-provider.ts @@ -105,7 +105,7 @@ export async function provideCompletions( options: CompletionOptions = {} ): Promise { const { modelProvider, variables, inputType, maxCompletions = 100 } = options; - + try { // Parse with cursor const parseResult = parse(expression, { @@ -121,7 +121,7 @@ export async function provideCompletions( if (!cursorNode || isErrorNode(cursorNode)) { return []; } - + // Analyze with cursor mode const analyzer = new Analyzer(modelProvider); const analysis = await analyzer.analyze( @@ -130,10 +130,10 @@ export async function provideCompletions( inputType, { cursorMode: true } ); - + // Extract cursor context const { typeBeforeCursor, functionCall } = analysis.cursorContext || {}; - + // Get partial text for filtering // Determine partial text for filtering (prefer AST-provided, else infer from source) let partialText = (cursorNode as any).partialText || ''; @@ -147,15 +147,15 @@ export async function provideCompletions( partialText = m[0]; } } - + // Generate completions based on cursor context let completions: CompletionItem[] = []; - + switch (cursorNode.context) { case CursorContext.Identifier: completions = await getIdentifierCompletions(typeBeforeCursor, modelProvider); break; - + case CursorContext.Operator: // Fallback to provided inputType if analyzer did not provide typeBeforeCursor completions = getOperatorCompletions(typeBeforeCursor || (inputType as any)); @@ -165,31 +165,31 @@ export async function provideCompletions( completions = completions.filter(c => c.label !== 'in'); } break; - + case CursorContext.Type: completions = await getTypeCompletions(cursorNode, modelProvider); break; - + case CursorContext.Argument: completions = await getArgumentCompletions(cursorNode, typeBeforeCursor, modelProvider, variables, functionCall); break; - + case CursorContext.Index: completions = getIndexCompletions(typeBeforeCursor, variables); break; } - + // Filter by partial text if (partialText) { completions = filterCompletions(completions, partialText); } - + // Sort and limit completions = rankCompletions(completions); if (maxCompletions > 0 && completions.length > maxCompletions) { completions = completions.slice(0, maxCompletions); } - + return completions; } catch (error) { // Return empty array on error @@ -202,7 +202,7 @@ export async function provideCompletions( */ function getGeneralCompletions(): CompletionItem[] { const completions: CompletionItem[] = []; - + // Get operators from registry const operatorNames = registry.listOperators(); for (const opName of operatorNames) { @@ -215,9 +215,9 @@ function getGeneralCompletions(): CompletionItem[] { }); } } - + // No hardcoded constants - these should come from context - + return completions; } @@ -227,10 +227,30 @@ function getGeneralCompletions(): CompletionItem[] { function isFunctionApplicable(funcDef: any, typeInfo: TypeInfo): boolean { if (!typeInfo || !typeInfo.type) return true; // Pass type with collection info: append [] if not singleton - const typeForRegistry = typeInfo.singleton === false - ? `${typeInfo.type}[]` + const typeForRegistry = typeInfo.singleton === false + ? `${typeInfo.type}[]` : typeInfo.type; - return registry.isFunctionApplicableToType(funcDef.name, typeForRegistry); + if (!registry.isFunctionApplicableToType(funcDef.name, typeForRegistry)) { + return false; + } + + // Check FHIR type name constraint (e.g., getReferenceKey requires input name 'Reference') + if (funcDef.signatures) { + const hasNameConstraint = funcDef.signatures.some( + (sig: any) => sig.input && sig.input.name + ); + if (hasNameConstraint && typeInfo.name) { + // At least one signature's input.name must match typeInfo.name + const nameMatches = funcDef.signatures.some( + (sig: any) => !sig.input?.name || sig.input.name === typeInfo.name + ); + if (!nameMatches) { + return false; + } + } + } + + return true; } /** @@ -271,7 +291,7 @@ async function getIdentifierCompletions( modelProvider?: ModelProvider ): Promise { const completions: CompletionItem[] = []; - + // Add elements from type if available if (typeBeforeCursor && modelProvider) { // Use the name property which contains the actual FHIR type name @@ -291,7 +311,7 @@ async function getIdentifierCompletions( } } } - + // Add FHIRPath functions from registry const functionNames = registry.listFunctions(); for (const name of functionNames) { @@ -299,12 +319,12 @@ async function getIdentifierCompletions( if (funcDef) { // Check if function is appropriate for the current type context const isApplicable = !typeBeforeCursor || isFunctionApplicable(funcDef, typeBeforeCursor); - + if (isApplicable) { // Determine if any signature takes parameters const hasParams = funcDef.signatures?.some(sig => (sig.parameters?.length ?? 0) > 0) ?? false; const funcDescription = funcDef.description || `FHIRPath ${name} function`; - + completions.push({ label: name, kind: CompletionKind.Function, @@ -314,17 +334,17 @@ async function getIdentifierCompletions( } } } - + // Add type-specific functions from registry if (typeBeforeCursor && typeBeforeCursor.type) { // Pass type with collection info: append [] if not singleton - const typeForRegistry = typeBeforeCursor.singleton === false - ? `${typeBeforeCursor.type}[]` + const typeForRegistry = typeBeforeCursor.singleton === false + ? `${typeBeforeCursor.type}[]` : typeBeforeCursor.type; const typeFunctions = registry.getFunctionsForType(typeForRegistry); for (const func of typeFunctions) { - // Check if function is already added from general functions - if (!completions.some(c => c.label === func.name)) { + // Check if function is already added from general functions and passes name constraint + if (!completions.some(c => c.label === func.name) && isFunctionApplicable(func, typeBeforeCursor)) { const hasParams = func.signatures?.some(sig => (sig.parameters?.length ?? 0) > 0) ?? false; completions.push({ label: func.name, @@ -335,7 +355,7 @@ async function getIdentifierCompletions( } } } - + return completions; } @@ -345,10 +365,10 @@ async function getIdentifierCompletions( function getOperatorCompletions(typeBeforeCursor?: TypeInfo): CompletionItem[] { const completions: CompletionItem[] = []; const addedOperators = new Set(); - + // Get all operators from registry const operatorNames = registry.listOperators(); - + for (const opName of operatorNames) { const opDef = registry.getOperatorDefinition(opName); if (opDef) { @@ -361,7 +381,7 @@ function getOperatorCompletions(typeBeforeCursor?: TypeInfo): CompletionItem[] { } // Check if operator is applicable to the current type const isApplicable = !typeBeforeCursor || isOperatorApplicable(opDef, typeBeforeCursor); - + if (isApplicable && !addedOperators.has(opDef.symbol)) { completions.push({ label: opDef.symbol, @@ -373,7 +393,7 @@ function getOperatorCompletions(typeBeforeCursor?: TypeInfo): CompletionItem[] { } } } - + return completions; } @@ -385,7 +405,7 @@ async function getTypeCompletions( modelProvider?: ModelProvider ): Promise { const completions: CompletionItem[] = []; - + // Primitive types - only if modelProvider is available if (modelProvider) { const primitiveTypes = await modelProvider.getPrimitiveTypes(); @@ -397,7 +417,7 @@ async function getTypeCompletions( sortText: `0_${type}` // Priority 0 for primitives }); } - + // Complex types const complexTypes = await modelProvider.getComplexTypes(); for (const type of complexTypes) { @@ -409,7 +429,7 @@ async function getTypeCompletions( }); } } - + // Always add resource types when we have a model provider // They're valid type names in contexts like ofType() if (modelProvider) { @@ -423,7 +443,7 @@ async function getTypeCompletions( }); } } - + return completions; } @@ -555,7 +575,7 @@ function getIndexCompletions( variables?: Record ): CompletionItem[] { const completions: CompletionItem[] = []; - + // Add user variables if available if (variables) { // Add $this if it's in scope (for consistency with argument context) @@ -575,7 +595,7 @@ function getIndexCompletions( detail: 'Current index' }); } - + // Add other user variables for (const varName of Object.keys(variables)) { if (!varName.startsWith('$')) { @@ -587,7 +607,7 @@ function getIndexCompletions( } } } - + // Get index-related functions from registry const functionNames = registry.listFunctions(); for (const name of functionNames) { @@ -603,7 +623,7 @@ function getIndexCompletions( } } } - + return completions; } @@ -613,7 +633,7 @@ function getIndexCompletions( */ function filterCompletions(completions: CompletionItem[], partialText: string): CompletionItem[] { const lowerPartial = partialText.toLowerCase(); - return completions.filter(item => + return completions.filter(item => item.label.toLowerCase().startsWith(lowerPartial) ); } @@ -629,7 +649,7 @@ function rankCompletions(completions: CompletionItem[]): CompletionItem[] { const bSortText = b.sortText || b.label; return aSortText.localeCompare(bSortText); } - + // Sort by kind priority const kindPriority: Record = { [CompletionKind.Property]: 1, @@ -640,14 +660,14 @@ function rankCompletions(completions: CompletionItem[]): CompletionItem[] { [CompletionKind.Keyword]: 6, [CompletionKind.Constant]: 7 }; - + const aPriority = kindPriority[a.kind] || 10; const bPriority = kindPriority[b.kind] || 10; - + if (aPriority !== bPriority) { return aPriority - bPriority; } - + // Then alphabetically return a.label.localeCompare(b.label); }); diff --git a/src/operations/getReferenceKey-function.ts b/src/operations/getReferenceKey-function.ts index dde6c92..9aabd75 100644 --- a/src/operations/getReferenceKey-function.ts +++ b/src/operations/getReferenceKey-function.ts @@ -137,7 +137,7 @@ export const getReferenceKeyFunction: FunctionDefinition & { evaluate: FunctionE signatures: [ { name: 'getReferenceKey', - input: { type: 'Any', singleton: false }, + input: { type: 'Any', singleton: false, name: 'Reference' }, parameters: [ { name: 'resourceType', diff --git a/test/sql-on-fhir.test.ts b/test/sql-on-fhir.test.ts index 17db4aa..69de18a 100644 --- a/test/sql-on-fhir.test.ts +++ b/test/sql-on-fhir.test.ts @@ -4,10 +4,69 @@ import { getResourceKeyFunction } from '../src/operations/getResourceKey-functio import { getReferenceKeyFunction } from '../src/operations/getReferenceKey-function'; import { box } from '../src/interpreter/boxing'; import { NodeType } from '../src/types'; -import type { RuntimeContext, IdentifierNode } from '../src/types'; +import type { RuntimeContext, IdentifierNode, TypeInfo, ModelProvider } from '../src/types'; import { analyze } from '../src/index.node'; import { provideCompletions, CompletionKind } from '../src/completion-provider'; +/** + * Minimal mock ModelProvider for testing type-aware completions. + * Returns known types for Patient and its elements. + */ +const mockModelProvider: ModelProvider = { + async getType(typeName: string): Promise { + if (typeName === 'Patient') { + return { type: 'Any' as any, singleton: true, name: 'Patient' }; + } + if (typeName === 'Reference') { + return { type: 'Any' as any, singleton: true, name: 'Reference' }; + } + return undefined; + }, + async getElementType(parentType: TypeInfo, propertyName: string): Promise { + if (parentType.name === 'Patient' && propertyName === 'subject') { + return { type: 'Any' as any, singleton: true, name: 'Reference' }; + } + if (parentType.name === 'Patient' && propertyName === 'name') { + return { type: 'Any' as any, singleton: false, name: 'HumanName' }; + } + return undefined; + }, + ofType(type: TypeInfo, typeName: any): TypeInfo | undefined { + return undefined; + }, + getElementNames(parentType: TypeInfo): string[] { + if (parentType.name === 'Patient') return ['subject', 'name']; + return []; + }, + async getChildrenType(parentType: TypeInfo): Promise { + return undefined; + }, + async getElements(typeName: string): Promise> { + if (typeName === 'Patient') { + return [ + { name: 'subject', type: 'Reference' }, + { name: 'name', type: 'HumanName[]' }, + ]; + } + if (typeName === 'Reference') { + return [ + { name: 'reference', type: 'string' }, + { name: 'display', type: 'string' }, + ]; + } + return []; + }, + async getResourceTypes(): Promise { + return ['Patient']; + }, + async getComplexTypes(): Promise { + return ['Reference', 'HumanName']; + }, + async getPrimitiveTypes(): Promise { + return ['string', 'boolean', 'integer']; + }, +}; + describe('SQL on FHIR Functions', () => { // Create a minimal runtime context for testing const createContext = (): RuntimeContext => ({ @@ -363,6 +422,29 @@ describe('SQL on FHIR Functions', () => { expect(result.diagnostics).toEqual([]); } }); + + it('should report error when getReferenceKey() is called on Patient (with model provider)', async () => { + const result = await analyze('Patient.getReferenceKey()', { + modelProvider: mockModelProvider, + }); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('getReferenceKey'); + expect(result.diagnostics[0]!.message).toContain('Patient'); + }); + + it('should NOT report error when getReferenceKey() is called on Reference (with model provider)', async () => { + // Patient.subject resolves to Reference via our mock + const result = await analyze('Patient.subject.getReferenceKey()', { + modelProvider: mockModelProvider, + }); + expect(result.diagnostics).toEqual([]); + }); + + it('should NOT report error when getReferenceKey() is called without type context', async () => { + // Without model provider, types are unknown — should be permissive + const result = await analyze('something.getReferenceKey()'); + expect(result.diagnostics).toEqual([]); + }); }); describe('Completion Provider Integration', () => { @@ -400,5 +482,48 @@ describe('SQL on FHIR Functions', () => { expect(getResourceKeyCompletion?.detail).toBeDefined(); expect(getResourceKeyCompletion?.detail?.toLowerCase()).toContain('join'); }); + + it('should NOT include getReferenceKey on Patient type (with model provider)', async () => { + // Patient. should resolve Patient as a type with name 'Patient' + // getReferenceKey has input.name: 'Reference', so it should be excluded + const expression = 'Patient.'; + const cursorPosition = 8; + + const completions = await provideCompletions(expression, cursorPosition, { + modelProvider: mockModelProvider, + }); + + const getReferenceKeyCompletion = completions.find(c => c.label === 'getReferenceKey'); + expect(getReferenceKeyCompletion).toBeUndefined(); + + // But getResourceKey should still be available (no name constraint) + const getResourceKeyCompletion = completions.find(c => c.label === 'getResourceKey'); + expect(getResourceKeyCompletion).toBeDefined(); + }); + + it('should include getReferenceKey on Reference type (with model provider)', async () => { + // Patient.subject. should resolve subject as Reference type + const expression = 'Patient.subject.'; + const cursorPosition = 16; + + const completions = await provideCompletions(expression, cursorPosition, { + modelProvider: mockModelProvider, + }); + + const getReferenceKeyCompletion = completions.find(c => c.label === 'getReferenceKey'); + expect(getReferenceKeyCompletion).toBeDefined(); + expect(getReferenceKeyCompletion?.kind).toBe(CompletionKind.Function); + }); + + it('should include getReferenceKey when type name is unknown', async () => { + // When we don't know the type name, we should still show getReferenceKey + const expression = 'something.'; + const cursorPosition = 10; + + const completions = await provideCompletions(expression, cursorPosition); + + const getReferenceKeyCompletion = completions.find(c => c.label === 'getReferenceKey'); + expect(getReferenceKeyCompletion).toBeDefined(); + }); }); }); From 4d9dd929c7833b3a88f12b99312cf142701cc22e Mon Sep 17 00:00:00 2001 From: Andrey Listopadov Date: Tue, 3 Feb 2026 18:03:24 +0300 Subject: [PATCH 3/3] make getResourceKey only work on resources --- src/analyzer.ts | 22 ++++++++++++++--- src/analyzer/type-compat.ts | 23 +++++++++++++----- src/completion-provider.ts | 29 ++++++++++++++--------- src/operations/getResourceKey-function.ts | 2 +- 4 files changed, 55 insertions(+), 21 deletions(-) diff --git a/src/analyzer.ts b/src/analyzer.ts index 7f786dc..2a5c962 100644 --- a/src/analyzer.ts +++ b/src/analyzer.ts @@ -50,6 +50,7 @@ export interface AnalysisResultWithCursor extends AnalysisResult { export class Analyzer { private modelProvider?: ModelProvider; + private resourceTypesCache: Set | null = null; private cursorMode: boolean = false; private stoppedAtCursor: boolean = false; private cursorContext?: { @@ -518,7 +519,12 @@ export class Analyzer { let match: FunctionSignature | null = null; if (!hasArityError && funcDef.signatures && funcDef.signatures.length > 0) { - match = matchFunctionSignature(actualInput, argTypes, funcDef) || null; + match = matchFunctionSignature( + actualInput, + argTypes, + funcDef, + this.resourceTypesCache ?? undefined + ) || null; if (!match) { const inputIsEmpty = isEmptyCollection(actualInput); @@ -546,8 +552,13 @@ export class Analyzer { actualInput.type === 'Any' || expectedInput.type === actualInput.type || (expectedInput.type === 'Decimal' && actualInput.type === 'Integer'); - // Check FHIR type name constraint (e.g., 'Reference') - const nameMatch = !expectedInput.name || !actualInput.name || expectedInput.name === actualInput.name; + // Check FHIR type name constraint (e.g., 'Reference', 'Resource') + let nameMatch = !expectedInput.name || !actualInput.name || expectedInput.name === actualInput.name; + if (expectedInput.name === 'Resource' && actualInput.name) { + nameMatch = + actualInput.name === 'Resource' || + (this.resourceTypesCache?.has(actualInput.name) ?? true); + } inputMatches = singletonMatch && typeMatch && nameMatch; } if (inputMatches) { @@ -1115,6 +1126,11 @@ export class Analyzer { this.stoppedAtCursor = false; this.cursorContext = undefined; + if (this.modelProvider && this.resourceTypesCache === null) { + const list = await this.modelProvider.getResourceTypes(); + this.resourceTypesCache = new Set(list); + } + // Create initial context with system and user variables const systemVars = new Map(); // $this should be the input type (the root context), not Any diff --git a/src/analyzer/type-compat.ts b/src/analyzer/type-compat.ts index 524fa62..607d78b 100644 --- a/src/analyzer/type-compat.ts +++ b/src/analyzer/type-compat.ts @@ -55,7 +55,8 @@ function specificity(actual: TypeInfo, required: TypeInfo): number { export function matchFunctionSignature( input: TypeInfo, args: TypeInfo[], - def?: FunctionDefinition + def?: FunctionDefinition, + resourceTypes?: Set ): FunctionSignature | undefined { if (!def || !def.signatures || def.signatures.length === 0) return undefined; @@ -63,7 +64,7 @@ export function matchFunctionSignature( for (const sig of def.signatures) { // Input compatibility (if specified) - if (sig.input && !isFunctionTypeCompatible(input, sig.input)) { + if (sig.input && !isFunctionTypeCompatible(input, sig.input, resourceTypes)) { continue; } @@ -111,12 +112,22 @@ export function matchFunctionSignature( return best?.sig; } -function isFunctionTypeCompatible(actual: TypeInfo, expected: TypeInfo): boolean { +function isFunctionTypeCompatible( + actual: TypeInfo, + expected: TypeInfo, + resourceTypes?: Set +): boolean { // Enforce singleton when required if (expected.singleton && !actual.singleton) return false; - // Check FHIR type name constraint (e.g., 'Reference') - // If expected specifies a name and actual has a known name, they must match - if (expected.name && actual.name && expected.name !== actual.name) return false; + // Check FHIR type name constraint (e.g., 'Reference', 'Resource') + if (expected.name && actual.name) { + if (expected.name === 'Resource') { + if (actual.name === 'Resource') return true; + if (resourceTypes) return resourceTypes.has(actual.name); + return true; // no model: allow + } + if (expected.name !== actual.name) return false; + } if (expected.type === 'Any') return true; if (actual.type === expected.type) return true; // Allow Integer to be used where Decimal is expected (promotion) diff --git a/src/completion-provider.ts b/src/completion-provider.ts index eb555de..7cf99a6 100644 --- a/src/completion-provider.ts +++ b/src/completion-provider.ts @@ -224,9 +224,12 @@ function getGeneralCompletions(): CompletionItem[] { /** * Check if a function is applicable to a given type */ -function isFunctionApplicable(funcDef: any, typeInfo: TypeInfo): boolean { +async function isFunctionApplicable( + funcDef: any, + typeInfo: TypeInfo, + modelProvider?: ModelProvider +): Promise { if (!typeInfo || !typeInfo.type) return true; - // Pass type with collection info: append [] if not singleton const typeForRegistry = typeInfo.singleton === false ? `${typeInfo.type}[]` : typeInfo.type; @@ -234,16 +237,22 @@ function isFunctionApplicable(funcDef: any, typeInfo: TypeInfo): boolean { return false; } - // Check FHIR type name constraint (e.g., getReferenceKey requires input name 'Reference') + // Check FHIR type name constraint (e.g., getReferenceKey requires 'Reference', getResourceKey requires 'Resource') if (funcDef.signatures) { const hasNameConstraint = funcDef.signatures.some( (sig: any) => sig.input && sig.input.name ); if (hasNameConstraint && typeInfo.name) { - // At least one signature's input.name must match typeInfo.name - const nameMatches = funcDef.signatures.some( - (sig: any) => !sig.input?.name || sig.input.name === typeInfo.name - ); + const resourceTypes = modelProvider ? await modelProvider.getResourceTypes() : []; + const nameMatches = funcDef.signatures.some((sig: any) => { + if (!sig.input?.name) return true; + if (sig.input.name === typeInfo.name) return true; + if (sig.input.name === 'Resource') { + const name = typeInfo.name; + return name === 'Resource' || (name !== undefined && resourceTypes.includes(name)); + } + return false; + }); if (!nameMatches) { return false; } @@ -317,8 +326,7 @@ async function getIdentifierCompletions( for (const name of functionNames) { const funcDef = registry.getFunction(name); if (funcDef) { - // Check if function is appropriate for the current type context - const isApplicable = !typeBeforeCursor || isFunctionApplicable(funcDef, typeBeforeCursor); + const isApplicable = !typeBeforeCursor || await isFunctionApplicable(funcDef, typeBeforeCursor, modelProvider); if (isApplicable) { // Determine if any signature takes parameters @@ -343,8 +351,7 @@ async function getIdentifierCompletions( : typeBeforeCursor.type; const typeFunctions = registry.getFunctionsForType(typeForRegistry); for (const func of typeFunctions) { - // Check if function is already added from general functions and passes name constraint - if (!completions.some(c => c.label === func.name) && isFunctionApplicable(func, typeBeforeCursor)) { + if (!completions.some(c => c.label === func.name) && await isFunctionApplicable(func, typeBeforeCursor, modelProvider)) { const hasParams = func.signatures?.some(sig => (sig.parameters?.length ?? 0) > 0) ?? false; completions.push({ label: func.name, diff --git a/src/operations/getResourceKey-function.ts b/src/operations/getResourceKey-function.ts index aabf5a3..ad089be 100644 --- a/src/operations/getResourceKey-function.ts +++ b/src/operations/getResourceKey-function.ts @@ -46,7 +46,7 @@ export const getResourceKeyFunction: FunctionDefinition & { evaluate: FunctionEv ], signatures: [{ name: 'getResourceKey', - input: { type: 'Any', singleton: false }, + input: { type: 'Any', singleton: false, name: 'Resource' }, parameters: [], result: { type: 'String', singleton: false }, }],