@atomic-ehr/fhirpath 0.0.3 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.browser.d.ts +22 -0
- package/dist/index.browser.js +15758 -0
- package/dist/index.browser.js.map +1 -0
- package/dist/index.node.d.ts +24 -0
- package/dist/{index.js → index.node.js} +5450 -3809
- package/dist/index.node.js.map +1 -0
- package/dist/{index.d.ts → model-provider.common-oir-zg7r.d.ts} +81 -74
- package/package.json +10 -5
- package/src/analyzer.ts +46 -9
- package/src/complex-types/quantity-value.ts +131 -9
- package/src/complex-types/temporal.ts +45 -6
- package/src/errors.ts +4 -0
- package/src/index.browser.ts +4 -0
- package/src/{index.ts → index.common.ts} +18 -14
- package/src/index.node.ts +4 -0
- package/src/interpreter/navigator.ts +12 -0
- package/src/interpreter/runtime-context.ts +60 -25
- package/src/interpreter.ts +118 -33
- package/src/lexer.ts +4 -1
- package/src/model-provider.browser.ts +35 -0
- package/src/{model-provider.ts → model-provider.common.ts} +29 -26
- package/src/model-provider.node.ts +41 -0
- package/src/operations/allTrue-function.ts +6 -10
- package/src/operations/and-operator.ts +2 -2
- package/src/operations/as-function.ts +41 -0
- package/src/operations/combine-operator.ts +17 -4
- package/src/operations/comparison.ts +73 -21
- package/src/operations/convertsToQuantity-function.ts +56 -7
- package/src/operations/decode-function.ts +114 -0
- package/src/operations/divide-operator.ts +3 -3
- package/src/operations/encode-function.ts +110 -0
- package/src/operations/escape-function.ts +114 -0
- package/src/operations/exp-function.ts +65 -0
- package/src/operations/extension-function.ts +88 -0
- package/src/operations/greater-operator.ts +5 -24
- package/src/operations/greater-or-equal-operator.ts +5 -24
- package/src/operations/hasValue-function.ts +84 -0
- package/src/operations/iif-function.ts +7 -1
- package/src/operations/implies-operator.ts +1 -0
- package/src/operations/index.ts +11 -0
- package/src/operations/is-function.ts +11 -0
- package/src/operations/is-operator.ts +187 -5
- package/src/operations/less-operator.ts +6 -24
- package/src/operations/less-or-equal-operator.ts +5 -24
- package/src/operations/less-than.ts +7 -12
- package/src/operations/ln-function.ts +62 -0
- package/src/operations/log-function.ts +113 -0
- package/src/operations/lowBoundary-function.ts +14 -0
- package/src/operations/minus-operator.ts +8 -1
- package/src/operations/mod-operator.ts +7 -1
- package/src/operations/not-function.ts +9 -2
- package/src/operations/ofType-function.ts +35 -0
- package/src/operations/plus-operator.ts +46 -3
- package/src/operations/precision-function.ts +146 -0
- package/src/operations/replace-function.ts +19 -19
- package/src/operations/replaceMatches-function.ts +5 -0
- package/src/operations/sort-function.ts +209 -0
- package/src/operations/take-function.ts +1 -1
- package/src/operations/toQuantity-function.ts +0 -1
- package/src/operations/toString-function.ts +76 -12
- package/src/operations/trace-function.ts +20 -3
- package/src/operations/unescape-function.ts +119 -0
- package/src/operations/where-function.ts +3 -1
- package/src/parser.ts +14 -2
- package/src/types.ts +7 -5
- package/src/utils/decimal.ts +76 -0
- package/dist/index.js.map +0 -1
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import type { ModelProvider, TypeInfo, TypeName } from './types';
|
|
2
|
-
import { CanonicalManager as createCanonicalManager, type Config, type CanonicalManager, type Resource } from '@atomic-ehr/fhir-canonical-manager';
|
|
3
2
|
import { translate, type FHIRSchema, type StructureDefinition } from '@atomic-ehr/fhirschema';
|
|
4
3
|
|
|
4
|
+
export type Resource = {
|
|
5
|
+
url?: string;
|
|
6
|
+
version?: string;
|
|
7
|
+
id: string;
|
|
8
|
+
resourceType: string;
|
|
9
|
+
[key: string]: any;
|
|
10
|
+
};
|
|
11
|
+
|
|
5
12
|
export interface FHIRModelContext {
|
|
6
13
|
// Path in the resource (e.g., "Patient.name.given")
|
|
7
14
|
path: string;
|
|
@@ -23,12 +30,6 @@ export interface FHIRModelContext {
|
|
|
23
30
|
version?: string;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
|
-
export interface FHIRModelProviderConfig {
|
|
27
|
-
packages: Array<{ name: string; version: string }>;
|
|
28
|
-
cacheDir?: string;
|
|
29
|
-
registryUrl?: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
33
|
/**
|
|
33
34
|
* FHIR ModelProvider implementation
|
|
34
35
|
*
|
|
@@ -37,8 +38,7 @@ export interface FHIRModelProviderConfig {
|
|
|
37
38
|
*
|
|
38
39
|
* For best performance, pre-load common types during initialization.
|
|
39
40
|
*/
|
|
40
|
-
export class
|
|
41
|
-
private canonicalManager: ReturnType<typeof createCanonicalManager>;
|
|
41
|
+
export class FHIRModelProviderBase implements ModelProvider<FHIRModelContext> {
|
|
42
42
|
private schemaCache: Map<string, FHIRSchema> = new Map();
|
|
43
43
|
private hierarchyCache: Map<string, FHIRSchema[]> = new Map();
|
|
44
44
|
private initialized = false;
|
|
@@ -105,26 +105,21 @@ export class FHIRModelProvider implements ModelProvider<FHIRModelContext> {
|
|
|
105
105
|
'xhtml': 'Xhtml'
|
|
106
106
|
};
|
|
107
107
|
|
|
108
|
-
constructor(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const canonicalConfig: Config = {
|
|
112
|
-
packages: config.packages.map(p => `${p.name}@${p.version}`),
|
|
113
|
-
workingDir: config.cacheDir || './tmp/.fhir-cache'
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
if (config.registryUrl) {
|
|
117
|
-
canonicalConfig.registry = config.registryUrl;
|
|
108
|
+
constructor() {
|
|
109
|
+
if (this.constructor === FHIRModelProviderBase) {
|
|
110
|
+
throw new Error("FHIRModelProviderBase can't be instantiated directly.");
|
|
118
111
|
}
|
|
119
|
-
|
|
120
|
-
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async prepare(): Promise<void> {
|
|
115
|
+
// Override this in your code
|
|
121
116
|
}
|
|
122
117
|
|
|
123
118
|
async initialize(): Promise<void> {
|
|
124
119
|
if (this.initialized) return;
|
|
125
120
|
|
|
126
121
|
try {
|
|
127
|
-
await this.
|
|
122
|
+
await this.prepare();
|
|
128
123
|
|
|
129
124
|
// Just discover type names for completions - schemas load lazily on demand
|
|
130
125
|
await Promise.all([
|
|
@@ -142,6 +137,14 @@ export class FHIRModelProvider implements ModelProvider<FHIRModelContext> {
|
|
|
142
137
|
}
|
|
143
138
|
}
|
|
144
139
|
|
|
140
|
+
async resolve(_canonicalUrl: string): Promise<Resource | null> {
|
|
141
|
+
throw new Error("Resolve not implemented.")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async search(_params: {kind: "primitive-type" | "complex-type" | "resource"}): Promise<Resource[]> {
|
|
145
|
+
throw new Error("Search not implemented.")
|
|
146
|
+
}
|
|
147
|
+
|
|
145
148
|
private buildCanonicalUrl(typeName: string): string {
|
|
146
149
|
// For R4 core types
|
|
147
150
|
return `http://hl7.org/fhir/StructureDefinition/${typeName}`;
|
|
@@ -157,7 +160,7 @@ export class FHIRModelProvider implements ModelProvider<FHIRModelContext> {
|
|
|
157
160
|
try {
|
|
158
161
|
// Resolve canonical URL for the type
|
|
159
162
|
const canonicalUrl = this.buildCanonicalUrl(typeName);
|
|
160
|
-
const resource = await this.
|
|
163
|
+
const resource = await this.resolve(canonicalUrl);
|
|
161
164
|
if (!resource || resource.resourceType !== 'StructureDefinition') {
|
|
162
165
|
return undefined;
|
|
163
166
|
}
|
|
@@ -614,7 +617,7 @@ export class FHIRModelProvider implements ModelProvider<FHIRModelContext> {
|
|
|
614
617
|
return this.resourceTypesCache;
|
|
615
618
|
}
|
|
616
619
|
|
|
617
|
-
const resources = await this.
|
|
620
|
+
const resources = await this.search({
|
|
618
621
|
kind: 'resource'
|
|
619
622
|
});
|
|
620
623
|
|
|
@@ -632,7 +635,7 @@ export class FHIRModelProvider implements ModelProvider<FHIRModelContext> {
|
|
|
632
635
|
return this.complexTypesCache;
|
|
633
636
|
}
|
|
634
637
|
|
|
635
|
-
const resources = await this.
|
|
638
|
+
const resources = await this.search({
|
|
636
639
|
kind: 'complex-type'
|
|
637
640
|
});
|
|
638
641
|
|
|
@@ -658,7 +661,7 @@ export class FHIRModelProvider implements ModelProvider<FHIRModelContext> {
|
|
|
658
661
|
return this.primitiveTypesCache;
|
|
659
662
|
}
|
|
660
663
|
|
|
661
|
-
const resources = await this.
|
|
664
|
+
const resources = await this.search({
|
|
662
665
|
kind: 'primitive-type'
|
|
663
666
|
});
|
|
664
667
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export * from './model-provider.common'
|
|
2
|
+
import { CanonicalManager as createCanonicalManager, type Config, } from '@atomic-ehr/fhir-canonical-manager';
|
|
3
|
+
import { FHIRModelProviderBase, type Resource } from './model-provider.common';
|
|
4
|
+
|
|
5
|
+
export interface FHIRModelProviderConfig {
|
|
6
|
+
packages: Array<{ name: string; version: string }>;
|
|
7
|
+
cacheDir?: string;
|
|
8
|
+
registryUrl?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class FHIRModelProvider extends FHIRModelProviderBase {
|
|
12
|
+
private canonicalManager: ReturnType<typeof createCanonicalManager>;
|
|
13
|
+
|
|
14
|
+
override async prepare(): Promise<void> {
|
|
15
|
+
await this.canonicalManager.init();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
override async resolve(canonicalUrl: string): Promise<Resource> {
|
|
19
|
+
return await this.canonicalManager.resolve(canonicalUrl);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
override async search(params: { kind: 'primitive-type' | 'complex-type' | 'resource' }): Promise<Resource[]> {
|
|
23
|
+
return await this.canonicalManager.search(params);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
constructor(private config: FHIRModelProviderConfig = {
|
|
27
|
+
packages: [{ name: 'hl7.fhir.r4.core', version: '4.0.1' }]
|
|
28
|
+
}) {
|
|
29
|
+
super();
|
|
30
|
+
const canonicalConfig: Config = {
|
|
31
|
+
packages: config.packages.map(p => `${p.name}@${p.version}`),
|
|
32
|
+
workingDir: config.cacheDir || './tmp/.fhir-cache'
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
if (config.registryUrl) {
|
|
36
|
+
canonicalConfig.registry = config.registryUrl;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.canonicalManager = createCanonicalManager(canonicalConfig);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -8,16 +8,12 @@ export const evaluate: FunctionEvaluator = async (input, context, args, evaluato
|
|
|
8
8
|
return { value: [box(true, { type: 'Boolean', singleton: true })], context };
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// Return true if all items are true, false if any item is false
|
|
20
|
-
const result = input.every(item => unbox(item) === true);
|
|
11
|
+
// Check all items: return false if any item is non-boolean or false
|
|
12
|
+
// Return true only if ALL items are boolean true
|
|
13
|
+
const result = input.every(item => {
|
|
14
|
+
const unboxedValue = unbox(item);
|
|
15
|
+
return unboxedValue === true; // This will return false for non-booleans or false values
|
|
16
|
+
});
|
|
21
17
|
|
|
22
18
|
return { value: [box(result, { type: 'Boolean', singleton: true })], context };
|
|
23
19
|
};
|
|
@@ -39,8 +39,8 @@ export const andOperator: OperatorDefinition & { evaluate: OperationEvaluator }
|
|
|
39
39
|
signatures: [
|
|
40
40
|
{
|
|
41
41
|
name: 'and',
|
|
42
|
-
left: { type: '
|
|
43
|
-
right: { type: '
|
|
42
|
+
left: { type: 'Any', singleton: true },
|
|
43
|
+
right: { type: 'Any', singleton: true },
|
|
44
44
|
result: { type: 'Boolean', singleton: true }
|
|
45
45
|
}
|
|
46
46
|
],
|
|
@@ -19,6 +19,12 @@ const asEvaluator: FunctionEvaluator = async (
|
|
|
19
19
|
return { value: [], context };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// as() function only works on singleton values (single items), not collections
|
|
23
|
+
// According to FHIRPath spec section 6.6
|
|
24
|
+
if (input.length > 1) {
|
|
25
|
+
throw new Error('as() can only be used on single values, not on collections');
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
// Extract type name from the argument AST node
|
|
23
29
|
let typeName: string;
|
|
24
30
|
|
|
@@ -29,6 +35,41 @@ const asEvaluator: FunctionEvaluator = async (
|
|
|
29
35
|
throw new Error(`as() requires a type name as argument, got ${typeArg.type}`);
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
// Validate the type name using ModelProvider if available
|
|
39
|
+
if (context.modelProvider) {
|
|
40
|
+
// Try to get the type from the model provider
|
|
41
|
+
const typeInfo = await context.modelProvider.getType(typeName);
|
|
42
|
+
if (!typeInfo) {
|
|
43
|
+
// Not a valid FHIR type, check if it's a System type
|
|
44
|
+
const systemTypes = ['Boolean', 'String', 'Integer', 'Decimal', 'Date', 'DateTime', 'Time', 'Quantity'];
|
|
45
|
+
const fhirPrimitiveTypes = ['boolean', 'integer', 'string', 'decimal', 'uri', 'url', 'canonical',
|
|
46
|
+
'base64Binary', 'instant', 'date', 'dateTime', 'time', 'code', 'oid',
|
|
47
|
+
'id', 'markdown', 'unsignedInt', 'positiveInt', 'uuid', 'xhtml'];
|
|
48
|
+
|
|
49
|
+
if (!systemTypes.includes(typeName) && !fhirPrimitiveTypes.includes(typeName)) {
|
|
50
|
+
throw new Error(`Unknown type: ${typeName}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
// Without ModelProvider, only allow basic System types and reject obvious invalid names
|
|
55
|
+
const systemTypes = ['Boolean', 'String', 'Integer', 'Decimal', 'Date', 'DateTime', 'Time'];
|
|
56
|
+
const fhirPrimitiveTypes = ['boolean', 'integer', 'string', 'decimal', 'uri', 'url', 'canonical',
|
|
57
|
+
'base64Binary', 'instant', 'date', 'dateTime', 'time', 'code', 'oid',
|
|
58
|
+
'id', 'markdown', 'unsignedInt', 'positiveInt', 'uuid'];
|
|
59
|
+
|
|
60
|
+
// If it's not a known primitive type and looks invalid, reject it
|
|
61
|
+
if (!systemTypes.includes(typeName) && !fhirPrimitiveTypes.includes(typeName)) {
|
|
62
|
+
// Check if it looks like a valid type name (starts with letter, contains only valid chars)
|
|
63
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(typeName) && !/^[a-z][a-z0-9]*$/i.test(typeName)) {
|
|
64
|
+
throw new Error(`Invalid type name: ${typeName}`);
|
|
65
|
+
}
|
|
66
|
+
// If it contains numbers but isn't a known type, it's likely invalid
|
|
67
|
+
if (/\d/.test(typeName) && typeName !== 'base64Binary') {
|
|
68
|
+
throw new Error(`Unknown type: ${typeName}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
32
73
|
// Use the as operator implementation with the type name
|
|
33
74
|
return asOperatorEvaluate(input, context, input, [typeName]);
|
|
34
75
|
};
|
|
@@ -4,10 +4,23 @@ import type { OperationEvaluator } from '../types';
|
|
|
4
4
|
import { box, unbox } from '../interpreter/boxing';
|
|
5
5
|
|
|
6
6
|
export const evaluate: OperationEvaluator = async (input, context, left, right) => {
|
|
7
|
-
//
|
|
7
|
+
// & operator requires singleton operands (or empty)
|
|
8
8
|
// Empty collections are treated as empty string
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
|
|
10
|
+
// Check for multiple items in left operand
|
|
11
|
+
if (left.length > 1) {
|
|
12
|
+
const { Errors } = await import('../errors');
|
|
13
|
+
throw Errors.invalidOperation('& operator requires singleton operands, left operand contains multiple items');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Check for multiple items in right operand
|
|
17
|
+
if (right.length > 1) {
|
|
18
|
+
const { Errors } = await import('../errors');
|
|
19
|
+
throw Errors.invalidOperation('& operator requires singleton operands, right operand contains multiple items');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const leftStr = left.length === 0 ? '' : String(unbox(left[0]));
|
|
23
|
+
const rightStr = right.length === 0 ? '' : String(unbox(right[0]));
|
|
11
24
|
|
|
12
25
|
// Always return a string, even if both are empty
|
|
13
26
|
return { value: [box(leftStr + rightStr, { type: 'String', singleton: true })], context };
|
|
@@ -20,7 +33,7 @@ export const combineOperator: OperatorDefinition & { evaluate: OperationEvaluato
|
|
|
20
33
|
category: ['string'],
|
|
21
34
|
precedence: PRECEDENCE.ADDITIVE,
|
|
22
35
|
associativity: 'left',
|
|
23
|
-
description: 'String concatenation operator',
|
|
36
|
+
description: 'String concatenation operator that requires singleton operands. Empty collections are treated as empty strings.',
|
|
24
37
|
examples: ['first & " " & last'],
|
|
25
38
|
signatures: [
|
|
26
39
|
{
|
|
@@ -142,6 +142,12 @@ export function collectionsEqual(left: FHIRPathValue[], right: FHIRPathValue[]):
|
|
|
142
142
|
if (result.reason === 'complex types not equal') {
|
|
143
143
|
return false;
|
|
144
144
|
}
|
|
145
|
+
// Special case: Date vs Time are definitively not equal
|
|
146
|
+
// Per XML tests: Date != Time returns true, Date = Time returns false
|
|
147
|
+
if (result.reason === 'date vs time') {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
// Other incomparable cases (e.g., Date vs DateTime with same date) remain incomparable
|
|
145
151
|
return null;
|
|
146
152
|
}
|
|
147
153
|
return result.kind === 'equal';
|
|
@@ -173,6 +179,12 @@ export function collectionsNotEqual(left: FHIRPathValue[], right: FHIRPathValue[
|
|
|
173
179
|
if (result.reason === 'complex types not equal') {
|
|
174
180
|
return true;
|
|
175
181
|
}
|
|
182
|
+
// Special case: Date vs Time are definitively not equal
|
|
183
|
+
// Per XML tests: Date != Time returns true, Date = Time returns false
|
|
184
|
+
if (result.reason === 'date vs time') {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
// Other incomparable cases (e.g., Date vs DateTime with same date) remain incomparable
|
|
176
188
|
return null;
|
|
177
189
|
}
|
|
178
190
|
return result.kind !== 'equal';
|
|
@@ -188,7 +200,15 @@ function isTemporalValue(value: unknown): value is TemporalValue {
|
|
|
188
200
|
function isQuantity(value: unknown): value is QuantityValue {
|
|
189
201
|
if (!value || typeof value !== 'object') return false;
|
|
190
202
|
const v = value as any;
|
|
191
|
-
|
|
203
|
+
// Check for FHIRPath quantity (has unit and value)
|
|
204
|
+
if ('unit' in v && 'value' in v && typeof v.value === 'number' && typeof v.unit === 'string') {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
// Check for FHIR Quantity (has code or unit, and value)
|
|
208
|
+
if ('value' in v && typeof v.value === 'number' && ('code' in v || 'unit' in v)) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
192
212
|
}
|
|
193
213
|
|
|
194
214
|
function isComplex(value: unknown): value is object {
|
|
@@ -198,6 +218,12 @@ function isComplex(value: unknown): value is object {
|
|
|
198
218
|
// Type-specific comparison functions
|
|
199
219
|
|
|
200
220
|
function compareTemporal(a: TemporalValue, b: TemporalValue): ComparisonResult {
|
|
221
|
+
// Check for Date vs Time - these are definitively not equal
|
|
222
|
+
if ((isFHIRDate(a) && isFHIRTime(b)) || (isFHIRTime(a) && isFHIRDate(b))) {
|
|
223
|
+
// Date and Time are completely different types - not equal, not incomparable
|
|
224
|
+
return { kind: 'incomparable', reason: 'date vs time' };
|
|
225
|
+
}
|
|
226
|
+
|
|
201
227
|
// Use existing temporal comparison logic
|
|
202
228
|
const compareResult = temporalCompare(a, b);
|
|
203
229
|
|
|
@@ -213,8 +239,20 @@ function compareTemporal(a: TemporalValue, b: TemporalValue): ComparisonResult {
|
|
|
213
239
|
return { kind: 'greater' };
|
|
214
240
|
}
|
|
215
241
|
|
|
242
|
+
function normalizeQuantity(q: any): QuantityValue {
|
|
243
|
+
// For FHIR Quantity, use code field if present, otherwise unit
|
|
244
|
+
if ('code' in q && typeof q.code === 'string') {
|
|
245
|
+
return { value: q.value, unit: q.code };
|
|
246
|
+
}
|
|
247
|
+
return { value: q.value, unit: q.unit };
|
|
248
|
+
}
|
|
249
|
+
|
|
216
250
|
function compareQuantityValues(a: QuantityValue, b: QuantityValue): ComparisonResult {
|
|
217
|
-
|
|
251
|
+
// Normalize both quantities to handle FHIR vs FHIRPath quantities
|
|
252
|
+
const normA = normalizeQuantity(a);
|
|
253
|
+
const normB = normalizeQuantity(b);
|
|
254
|
+
|
|
255
|
+
const result = compareQuantities(normA, normB);
|
|
218
256
|
|
|
219
257
|
if (result === null) {
|
|
220
258
|
return { kind: 'incomparable', reason: 'incompatible quantity dimensions' };
|
|
@@ -534,31 +572,45 @@ const CALENDAR_TO_UCUM_MAP: Record<string, string> = {
|
|
|
534
572
|
* Quantity equivalence with UCUM semantic comparison and calendar mappings
|
|
535
573
|
*/
|
|
536
574
|
function quantityEquivalent(a: QuantityValue, b: QuantityValue): boolean | null {
|
|
537
|
-
//
|
|
538
|
-
const
|
|
539
|
-
const
|
|
575
|
+
// Normalize both quantities to handle FHIR vs FHIRPath quantities
|
|
576
|
+
const normA = normalizeQuantity(a);
|
|
577
|
+
const normB = normalizeQuantity(b);
|
|
578
|
+
|
|
579
|
+
// Use compareQuantities which now handles calendar unit conversions
|
|
580
|
+
const result = compareQuantities(normA, normB);
|
|
540
581
|
|
|
541
|
-
//
|
|
542
|
-
if (
|
|
543
|
-
|
|
544
|
-
return ucumUnit === b.unit && a.value === b.value;
|
|
582
|
+
// Null means incomparable
|
|
583
|
+
if (result === null) {
|
|
584
|
+
return null;
|
|
545
585
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
586
|
+
|
|
587
|
+
// For equivalence, we check if they're equal when converted
|
|
588
|
+
// compareQuantities returns 0 for equal values
|
|
589
|
+
if (result === 0) {
|
|
590
|
+
return true;
|
|
549
591
|
}
|
|
550
592
|
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
593
|
+
// For approximate equivalence (~), check if values are within tolerance
|
|
594
|
+
// This is primarily for handling precision differences like 4 g ~ 4.040 g
|
|
595
|
+
// We need special handling for common unit conversions with tolerance
|
|
596
|
+
|
|
597
|
+
// Check if one is g and other is mg
|
|
598
|
+
if ((a.unit === 'g' && b.unit === 'mg') || (a.unit === 'mg' && b.unit === 'g')) {
|
|
599
|
+
// Convert both to mg for comparison
|
|
600
|
+
const aInMg = a.unit === 'g' ? a.value * 1000 : a.value;
|
|
601
|
+
const bInMg = b.unit === 'g' ? b.value * 1000 : b.value;
|
|
602
|
+
|
|
603
|
+
// Check if within 1% tolerance for approximate equivalence
|
|
604
|
+
const diff = Math.abs(aInMg - bInMg);
|
|
605
|
+
const avg = (aInMg + bInMg) / 2;
|
|
606
|
+
const tolerance = avg * 0.01; // 1% tolerance
|
|
607
|
+
|
|
608
|
+
return diff <= tolerance;
|
|
557
609
|
}
|
|
558
610
|
|
|
559
|
-
//
|
|
560
|
-
|
|
561
|
-
return
|
|
611
|
+
// For other units, compareQuantities handles the conversion
|
|
612
|
+
// If it returned non-zero, they're not equivalent
|
|
613
|
+
return false;
|
|
562
614
|
}
|
|
563
615
|
|
|
564
616
|
/**
|
|
@@ -50,13 +50,25 @@ export const evaluate: FunctionEvaluator = async (input, context, args, evaluato
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// String - check if it can be parsed as a quantity
|
|
53
|
+
// String - check if it can be parsed as a quantity or plain number
|
|
54
54
|
if (typeof inputValue === 'string') {
|
|
55
|
+
const trimmed = inputValue.trim();
|
|
56
|
+
|
|
57
|
+
// First check if it's just a plain number (integer or decimal)
|
|
58
|
+
const numberRegex = /^(\+|-)?\d+(\.\d+)?$/;
|
|
59
|
+
if (numberRegex.test(trimmed)) {
|
|
60
|
+
const value = parseFloat(trimmed);
|
|
61
|
+
if (!isNaN(value)) {
|
|
62
|
+
// Plain number strings can be converted to quantity with unit '1'
|
|
63
|
+
return { value: [box(true, { type: 'Boolean', singleton: true })], context };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
// Try to parse as quantity: number followed by space(s) and unit
|
|
56
|
-
// This matches the pattern: <number> <unit>
|
|
68
|
+
// This matches the pattern: <number> <unit> or <number> '<unit>'
|
|
57
69
|
const quantityRegex = /^(\+|-)?\d+(\.\d+)?\s+.+$/;
|
|
58
70
|
|
|
59
|
-
if (!quantityRegex.test(
|
|
71
|
+
if (!quantityRegex.test(trimmed)) {
|
|
60
72
|
return { value: [box(false, { type: 'Boolean', singleton: true })], context };
|
|
61
73
|
}
|
|
62
74
|
|
|
@@ -67,7 +79,40 @@ export const evaluate: FunctionEvaluator = async (input, context, args, evaluato
|
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
const valueStr = parts[0];
|
|
70
|
-
|
|
82
|
+
let unit = parts.slice(1).join(' ');
|
|
83
|
+
|
|
84
|
+
// Check if unit is quoted
|
|
85
|
+
const isQuoted = unit.startsWith("'") && unit.endsWith("'") && unit.length > 2;
|
|
86
|
+
|
|
87
|
+
if (isQuoted) {
|
|
88
|
+
// Remove quotes for validation
|
|
89
|
+
unit = unit.slice(1, -1);
|
|
90
|
+
} else {
|
|
91
|
+
// For unquoted units, check special cases
|
|
92
|
+
// Calendar duration words are always valid
|
|
93
|
+
const CALENDAR_DURATION_WORDS = [
|
|
94
|
+
'year', 'years',
|
|
95
|
+
'month', 'months',
|
|
96
|
+
'week', 'weeks',
|
|
97
|
+
'day', 'days',
|
|
98
|
+
'hour', 'hours',
|
|
99
|
+
'minute', 'minutes',
|
|
100
|
+
'second', 'seconds',
|
|
101
|
+
'millisecond', 'milliseconds'
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
// Time unit abbreviations that should NOT be accepted without quotes
|
|
105
|
+
// (they have calendar duration equivalents)
|
|
106
|
+
const TIME_UNIT_ABBREVS = ['wk', 'd', 'h', 'min', 's', 'ms'];
|
|
107
|
+
|
|
108
|
+
if (TIME_UNIT_ABBREVS.includes(unit)) {
|
|
109
|
+
// These time abbreviations require quotes
|
|
110
|
+
return { value: [box(false, { type: 'Boolean', singleton: true })], context };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Calendar duration words are valid
|
|
114
|
+
// Other units (like mg, km, etc.) are also valid
|
|
115
|
+
}
|
|
71
116
|
|
|
72
117
|
const value = parseFloat(valueStr!);
|
|
73
118
|
if (isNaN(value)) {
|
|
@@ -84,10 +129,14 @@ export const evaluate: FunctionEvaluator = async (input, context, args, evaluato
|
|
|
84
129
|
}
|
|
85
130
|
}
|
|
86
131
|
|
|
87
|
-
// Integer or Decimal
|
|
88
|
-
// (quantities must have units)
|
|
132
|
+
// Integer or Decimal - can be converted to quantity with unit '1'
|
|
89
133
|
if (typeof inputValue === 'number') {
|
|
90
|
-
return { value: [box(
|
|
134
|
+
return { value: [box(true, { type: 'Boolean', singleton: true })], context };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Boolean - can be converted to quantity (1 or 0 with unit '1')
|
|
138
|
+
if (typeof inputValue === 'boolean') {
|
|
139
|
+
return { value: [box(true, { type: 'Boolean', singleton: true })], context };
|
|
91
140
|
}
|
|
92
141
|
|
|
93
142
|
// For all other types, return false
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { FunctionDefinition, FunctionEvaluator } from '../types';
|
|
2
|
+
import { Errors } from '../errors';
|
|
3
|
+
import { box, unbox } from '../interpreter/boxing';
|
|
4
|
+
|
|
5
|
+
export const evaluate: FunctionEvaluator = async (input, context, args, evaluator) => {
|
|
6
|
+
// Handle empty input collection
|
|
7
|
+
if (input.length === 0) {
|
|
8
|
+
return { value: [], context };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// If input contains multiple items, signal an error
|
|
12
|
+
if (input.length > 1) {
|
|
13
|
+
throw Errors.invalidOperation('decode can only be applied to a singleton string');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const boxedInputValue = input[0];
|
|
17
|
+
if (!boxedInputValue) {
|
|
18
|
+
return { value: [], context };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const inputValue = unbox(boxedInputValue);
|
|
22
|
+
|
|
23
|
+
// Type check the input - must be a string
|
|
24
|
+
if (typeof inputValue !== 'string') {
|
|
25
|
+
throw Errors.stringOperationOnNonString('decode');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// decode() requires exactly one argument (format)
|
|
29
|
+
if (!args || args.length !== 1) {
|
|
30
|
+
throw Errors.invalidOperation('decode requires exactly one argument (format)');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Evaluate the format parameter
|
|
34
|
+
const formatArg = args[0];
|
|
35
|
+
if (!formatArg) {
|
|
36
|
+
return { value: [], context };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const formatResult = await evaluator(formatArg, input, context);
|
|
40
|
+
if (formatResult.value.length === 0) {
|
|
41
|
+
// If no format is specified, the result is empty
|
|
42
|
+
return { value: [], context };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (formatResult.value.length > 1) {
|
|
46
|
+
throw Errors.invalidOperation('decode format must be a singleton string');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const boxedFormat = formatResult.value[0];
|
|
50
|
+
if (!boxedFormat) {
|
|
51
|
+
return { value: [], context };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const format = unbox(boxedFormat);
|
|
55
|
+
if (typeof format !== 'string') {
|
|
56
|
+
throw Errors.invalidOperation('decode format must be a string');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let result: string;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
switch (format.toLowerCase()) {
|
|
63
|
+
case 'hex': {
|
|
64
|
+
// Decode from hexadecimal
|
|
65
|
+
const bytes = Buffer.from(inputValue, 'hex');
|
|
66
|
+
result = bytes.toString('utf-8');
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case 'base64': {
|
|
70
|
+
// Standard base64 decoding
|
|
71
|
+
const bytes = Buffer.from(inputValue, 'base64');
|
|
72
|
+
result = bytes.toString('utf-8');
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case 'urlbase64': {
|
|
76
|
+
// URL-safe base64 decoding
|
|
77
|
+
// First, convert urlbase64 to standard base64 format if needed
|
|
78
|
+
// Replace URL-safe characters with standard base64 characters
|
|
79
|
+
const standardBase64 = inputValue.replace(/-/g, '+').replace(/_/g, '/');
|
|
80
|
+
const bytes = Buffer.from(standardBase64, 'base64');
|
|
81
|
+
result = bytes.toString('utf-8');
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
// Unknown format, return empty
|
|
86
|
+
return { value: [], context };
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
// If decoding fails (invalid encoding), throw an error
|
|
90
|
+
throw Errors.invalidOperation(`Failed to decode string with format '${format}': invalid encoding`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { value: [box(result, { type: 'String', singleton: true })], context };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const decodeFunction: FunctionDefinition & { evaluate: FunctionEvaluator } = {
|
|
97
|
+
name: 'decode',
|
|
98
|
+
category: ['string'],
|
|
99
|
+
description: 'Returns the result of decoding the input string according to the given format. Available formats are hex, base64, and urlbase64.',
|
|
100
|
+
examples: [
|
|
101
|
+
"'dGVzdA=='.decode('base64') // returns 'test'",
|
|
102
|
+
"'74657374'.decode('hex') // returns 'test'",
|
|
103
|
+
"'c3ViamVjdHM_X2Q='.decode('urlbase64') // returns 'subjects?_d'"
|
|
104
|
+
],
|
|
105
|
+
signatures: [{
|
|
106
|
+
name: 'decode',
|
|
107
|
+
input: { type: 'String', singleton: true },
|
|
108
|
+
parameters: [
|
|
109
|
+
{ name: 'format', type: { type: 'String', singleton: true } }
|
|
110
|
+
],
|
|
111
|
+
result: { type: 'String', singleton: true }
|
|
112
|
+
}],
|
|
113
|
+
evaluate
|
|
114
|
+
};
|
|
@@ -23,7 +23,7 @@ export const evaluate: OperationEvaluator = async (input, context, left, right)
|
|
|
23
23
|
r && typeof r === 'object' && 'unit' in r) {
|
|
24
24
|
const rightQuantity = r as QuantityValue;
|
|
25
25
|
if (rightQuantity.value === 0) {
|
|
26
|
-
|
|
26
|
+
return { value: [], context }; // Return empty for division by zero
|
|
27
27
|
}
|
|
28
28
|
const result = divideQuantities(l as QuantityValue, rightQuantity);
|
|
29
29
|
return { value: result ? [box(result, { type: 'Quantity', singleton: true })] : [], context };
|
|
@@ -32,7 +32,7 @@ export const evaluate: OperationEvaluator = async (input, context, left, right)
|
|
|
32
32
|
// Handle quantity / number
|
|
33
33
|
if (l && typeof l === 'object' && 'unit' in l && typeof r === 'number') {
|
|
34
34
|
if (r === 0) {
|
|
35
|
-
|
|
35
|
+
return { value: [], context }; // Return empty for division by zero
|
|
36
36
|
}
|
|
37
37
|
const q = l as QuantityValue;
|
|
38
38
|
return { value: [box({ value: q.value / r, unit: q.unit }, { type: 'Quantity', singleton: true })], context };
|
|
@@ -41,7 +41,7 @@ export const evaluate: OperationEvaluator = async (input, context, left, right)
|
|
|
41
41
|
// Handle numeric division
|
|
42
42
|
if (typeof l === 'number' && typeof r === 'number') {
|
|
43
43
|
if (r === 0) {
|
|
44
|
-
|
|
44
|
+
return { value: [], context }; // Return empty for division by zero
|
|
45
45
|
}
|
|
46
46
|
return { value: [box(l / r, { type: 'Any', singleton: true })], context };
|
|
47
47
|
}
|