@atomic-ehr/fhirpath 0.0.2 → 0.0.3
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/README.md +716 -238
- package/dist/index.d.ts +225 -119
- package/dist/index.js +10911 -5600
- package/dist/index.js.map +1 -1
- package/package.json +9 -4
- package/src/analyzer/augmentor.ts +242 -0
- package/src/analyzer/cursor-services.ts +75 -0
- package/src/analyzer/scope-manager.ts +57 -0
- package/src/analyzer/trivia-indexer.ts +58 -0
- package/src/analyzer/type-compat.ts +157 -0
- package/src/analyzer/utils.ts +132 -0
- package/src/analyzer.ts +921 -1208
- package/src/completion-provider.ts +209 -191
- package/src/{quantity-value.ts → complex-types/quantity-value.ts} +112 -22
- package/src/complex-types/temporal.ts +1737 -0
- package/src/errors.ts +25 -3
- package/src/index.ts +17 -104
- package/src/inspect.ts +4 -4
- package/src/{boxing.ts → interpreter/boxing.ts} +1 -1
- package/src/interpreter/navigator.ts +94 -0
- package/src/interpreter/runtime-context.ts +273 -0
- package/src/interpreter.ts +435 -469
- package/src/lexer.ts +188 -210
- package/src/model-provider.ts +71 -43
- package/src/operations/abs-function.ts +1 -1
- package/src/operations/aggregate-function.ts +84 -5
- package/src/operations/all-function.ts +4 -3
- package/src/operations/allFalse-function.ts +2 -1
- package/src/operations/allTrue-function.ts +2 -1
- package/src/operations/and-operator.ts +2 -1
- package/src/operations/anyFalse-function.ts +2 -1
- package/src/operations/anyTrue-function.ts +2 -1
- package/src/operations/as-function.ts +58 -0
- package/src/operations/as-operator.ts +57 -19
- package/src/operations/ceiling-function.ts +1 -1
- package/src/operations/children-function.ts +14 -5
- package/src/operations/combine-function.ts +6 -3
- package/src/operations/combine-operator.ts +6 -7
- package/src/operations/comparison.ts +692 -0
- package/src/operations/contains-function.ts +1 -1
- package/src/operations/contains-operator.ts +2 -1
- package/src/operations/convertsToBoolean-function.ts +78 -0
- package/src/operations/convertsToDecimal-function.ts +82 -0
- package/src/operations/convertsToInteger-function.ts +71 -0
- package/src/operations/convertsToLong-function.ts +89 -0
- package/src/operations/convertsToQuantity-function.ts +116 -0
- package/src/operations/convertsToString-function.ts +88 -0
- package/src/operations/count-function.ts +2 -1
- package/src/operations/dateOf-function.ts +69 -0
- package/src/operations/dayOf-function.ts +66 -0
- package/src/operations/decimal-boundaries.ts +133 -0
- package/src/operations/defineVariable-function.ts +130 -17
- package/src/operations/distinct-function.ts +1 -1
- package/src/operations/div-operator.ts +1 -1
- package/src/operations/divide-operator.ts +12 -7
- package/src/operations/dot-operator.ts +1 -1
- package/src/operations/empty-function.ts +30 -21
- package/src/operations/endsWith-function.ts +6 -1
- package/src/operations/equal-operator.ts +23 -32
- package/src/operations/equivalent-operator.ts +13 -53
- package/src/operations/exclude-function.ts +2 -1
- package/src/operations/exists-function.ts +4 -3
- package/src/operations/first-function.ts +1 -1
- package/src/operations/floor-function.ts +1 -1
- package/src/operations/greater-operator.ts +20 -3
- package/src/operations/greater-or-equal-operator.ts +20 -3
- package/src/operations/highBoundary-function.ts +120 -0
- package/src/operations/hourOf-function.ts +66 -0
- package/src/operations/iif-function.ts +186 -7
- package/src/operations/implies-operator.ts +1 -1
- package/src/operations/in-operator.ts +2 -1
- package/src/operations/index.ts +41 -0
- package/src/operations/indexOf-function.ts +1 -1
- package/src/operations/intersect-function.ts +1 -1
- package/src/operations/is-function.ts +59 -0
- package/src/operations/is-operator.ts +20 -9
- package/src/operations/isDistinct-function.ts +2 -1
- package/src/operations/join-function.ts +1 -1
- package/src/operations/last-function.ts +1 -1
- package/src/operations/lastIndexOf-function.ts +85 -0
- package/src/operations/length-function.ts +1 -1
- package/src/operations/less-operator.ts +20 -3
- package/src/operations/less-or-equal-operator.ts +20 -3
- package/src/operations/less-than.ts +2 -2
- package/src/operations/lowBoundary-function.ts +120 -0
- package/src/operations/lower-function.ts +1 -1
- package/src/operations/matches-function.ts +86 -0
- package/src/operations/matchesFull-function.ts +96 -0
- package/src/operations/millisecondOf-function.ts +66 -0
- package/src/operations/minus-operator.ts +69 -4
- package/src/operations/minuteOf-function.ts +66 -0
- package/src/operations/mod-operator.ts +1 -1
- package/src/operations/monthOf-function.ts +66 -0
- package/src/operations/multiply-operator.ts +27 -3
- package/src/operations/not-equal-operator.ts +24 -30
- package/src/operations/not-equivalent-operator.ts +13 -53
- package/src/operations/not-function.ts +1 -1
- package/src/operations/ofType-function.ts +8 -12
- package/src/operations/or-operator.ts +2 -1
- package/src/operations/plus-operator.ts +71 -7
- package/src/operations/power-function.ts +35 -10
- package/src/operations/repeat-function.ts +169 -0
- package/src/operations/replace-function.ts +1 -1
- package/src/operations/replaceMatches-function.ts +120 -0
- package/src/operations/round-function.ts +1 -1
- package/src/operations/secondOf-function.ts +66 -0
- package/src/operations/select-function.ts +66 -5
- package/src/operations/single-function.ts +1 -1
- package/src/operations/skip-function.ts +1 -1
- package/src/operations/split-function.ts +1 -1
- package/src/operations/sqrt-function.ts +15 -8
- package/src/operations/startsWith-function.ts +1 -1
- package/src/operations/subsetOf-function.ts +6 -2
- package/src/operations/substring-function.ts +1 -1
- package/src/operations/supersetOf-function.ts +6 -2
- package/src/operations/tail-function.ts +1 -1
- package/src/operations/take-function.ts +1 -1
- package/src/operations/temporal-functions.ts +555 -0
- package/src/operations/timeOf-function.ts +67 -0
- package/src/operations/timezoneOffsetOf-function.ts +69 -0
- package/src/operations/toBoolean-function.ts +27 -8
- package/src/operations/toChars-function.ts +56 -0
- package/src/operations/toDecimal-function.ts +27 -8
- package/src/operations/toInteger-function.ts +15 -3
- package/src/operations/toLong-function.ts +98 -0
- package/src/operations/toQuantity-function.ts +181 -0
- package/src/operations/toString-function.ts +45 -3
- package/src/operations/trace-function.ts +1 -1
- package/src/operations/trim-function.ts +1 -1
- package/src/operations/truncate-function.ts +1 -1
- package/src/operations/unary-minus-operator.ts +2 -2
- package/src/operations/unary-plus-operator.ts +1 -1
- package/src/operations/union-function.ts +1 -1
- package/src/operations/union-operator.ts +16 -26
- package/src/operations/upper-function.ts +1 -1
- package/src/operations/where-function.ts +3 -3
- package/src/operations/xor-operator.ts +1 -1
- package/src/operations/yearOf-function.ts +66 -0
- package/src/{cursor-nodes.ts → parser/cursor-nodes.ts} +10 -7
- package/src/parser.ts +248 -501
- package/src/registry.ts +53 -42
- package/src/types.ts +128 -16
- package/src/utils/pprint.ts +151 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
import type { FHIRPathValue } from '../interpreter/boxing';
|
|
2
|
+
import { unbox } from '../interpreter/boxing';
|
|
3
|
+
import type { QuantityValue } from '../complex-types/quantity-value';
|
|
4
|
+
import { compareQuantities } from '../complex-types/quantity-value';
|
|
5
|
+
import type { TemporalValue } from '../complex-types/temporal';
|
|
6
|
+
import { equals as temporalEquals, compare as temporalCompare, isFHIRDate, isFHIRDateTime, isFHIRTime } from '../complex-types/temporal';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Result of comparing two values
|
|
10
|
+
*/
|
|
11
|
+
export type ComparisonResult =
|
|
12
|
+
| { kind: 'equal' }
|
|
13
|
+
| { kind: 'less' }
|
|
14
|
+
| { kind: 'greater' }
|
|
15
|
+
| { kind: 'incomparable'; reason?: string };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compare two FHIRPath values
|
|
19
|
+
* Returns a ComparisonResult indicating the relationship between the values
|
|
20
|
+
*/
|
|
21
|
+
export function compare(a: unknown, b: unknown): ComparisonResult {
|
|
22
|
+
// Early exit: reference equality
|
|
23
|
+
if (a === b) {
|
|
24
|
+
return { kind: 'equal' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Handle null/undefined
|
|
28
|
+
if (a === null || a === undefined || b === null || b === undefined) {
|
|
29
|
+
return { kind: 'incomparable', reason: 'null or undefined value' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Early exit: type mismatch for primitives
|
|
33
|
+
const typeA = typeof a;
|
|
34
|
+
const typeB = typeof b;
|
|
35
|
+
if (typeA !== typeB && typeA !== 'object' && typeB !== 'object') {
|
|
36
|
+
return { kind: 'incomparable', reason: 'type mismatch' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Check if values are temporal types
|
|
40
|
+
if (isTemporalValue(a) && isTemporalValue(b)) {
|
|
41
|
+
return compareTemporal(a, b);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check if values are quantities
|
|
45
|
+
if (isQuantity(a) && isQuantity(b)) {
|
|
46
|
+
return compareQuantityValues(a, b);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Handle mixed quantity and number comparison
|
|
50
|
+
if (isQuantity(a) && typeof b === 'number') {
|
|
51
|
+
return compareQuantityToNumber(a, b);
|
|
52
|
+
}
|
|
53
|
+
if (typeof a === 'number' && isQuantity(b)) {
|
|
54
|
+
const result = compareQuantityToNumber(b, a);
|
|
55
|
+
// Flip the comparison result
|
|
56
|
+
if (result.kind === 'less') return { kind: 'greater' };
|
|
57
|
+
if (result.kind === 'greater') return { kind: 'less' };
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check if values are complex types (objects/arrays)
|
|
62
|
+
if (isComplex(a) && isComplex(b)) {
|
|
63
|
+
return compareComplex(a, b);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Primitive comparison
|
|
67
|
+
return comparePrimitive(a, b);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Compare collections (arrays of values)
|
|
72
|
+
* Collections are compared element-by-element in order
|
|
73
|
+
*/
|
|
74
|
+
export function compareCollections(left: FHIRPathValue[], right: FHIRPathValue[]): ComparisonResult {
|
|
75
|
+
// Empty collections
|
|
76
|
+
if (left.length === 0 || right.length === 0) {
|
|
77
|
+
return { kind: 'incomparable', reason: 'empty collection' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Single value collections
|
|
81
|
+
if (left.length === 1 && right.length === 1) {
|
|
82
|
+
const leftItem = left[0];
|
|
83
|
+
const rightItem = right[0];
|
|
84
|
+
if (!leftItem || !rightItem) {
|
|
85
|
+
return { kind: 'incomparable', reason: 'undefined element in collection' };
|
|
86
|
+
}
|
|
87
|
+
const leftValue = unbox(leftItem);
|
|
88
|
+
const rightValue = unbox(rightItem);
|
|
89
|
+
return compare(leftValue, rightValue);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Multiple value collections - must be same length for comparison
|
|
93
|
+
if (left.length !== right.length) {
|
|
94
|
+
// For ordering comparison, different lengths are incomparable
|
|
95
|
+
// But the helper functions handle equality specially
|
|
96
|
+
return { kind: 'incomparable', reason: 'different collection lengths' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Compare element by element
|
|
100
|
+
for (let i = 0; i < left.length; i++) {
|
|
101
|
+
const leftItem = left[i];
|
|
102
|
+
const rightItem = right[i];
|
|
103
|
+
if (!leftItem || !rightItem) {
|
|
104
|
+
return { kind: 'incomparable', reason: 'undefined element in collection' };
|
|
105
|
+
}
|
|
106
|
+
const leftValue = unbox(leftItem);
|
|
107
|
+
const rightValue = unbox(rightItem);
|
|
108
|
+
const result = compare(leftValue, rightValue);
|
|
109
|
+
|
|
110
|
+
if (result.kind !== 'equal') {
|
|
111
|
+
// First non-equal element determines the result
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { kind: 'equal' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Check if collections are equal
|
|
121
|
+
*/
|
|
122
|
+
export function collectionsEqual(left: FHIRPathValue[], right: FHIRPathValue[]): boolean | null {
|
|
123
|
+
// Early exit: reference equality
|
|
124
|
+
if (left === right) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Empty collections return null (incomparable)
|
|
129
|
+
if (left.length === 0 || right.length === 0) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Early exit: different lengths are definitively not equal
|
|
134
|
+
if (left.length !== right.length) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Compare elements
|
|
139
|
+
const result = compareCollections(left, right);
|
|
140
|
+
if (result.kind === 'incomparable') {
|
|
141
|
+
// Special case: if the reason is "complex types not equal", we know they're not equal
|
|
142
|
+
if (result.reason === 'complex types not equal') {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return result.kind === 'equal';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Check if collections are not equal
|
|
152
|
+
*/
|
|
153
|
+
export function collectionsNotEqual(left: FHIRPathValue[], right: FHIRPathValue[]): boolean | null {
|
|
154
|
+
// Early exit: reference equality means definitely equal (so not not-equal)
|
|
155
|
+
if (left === right && left.length > 0) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Empty collections return null (incomparable)
|
|
160
|
+
if (left.length === 0 || right.length === 0) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Early exit: different lengths are definitively not equal
|
|
165
|
+
if (left.length !== right.length) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Compare elements
|
|
170
|
+
const result = compareCollections(left, right);
|
|
171
|
+
if (result.kind === 'incomparable') {
|
|
172
|
+
// Special case: if the reason is "complex types not equal", we know they're not equal
|
|
173
|
+
if (result.reason === 'complex types not equal') {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
return result.kind !== 'equal';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Type guards
|
|
182
|
+
function isTemporalValue(value: unknown): value is TemporalValue {
|
|
183
|
+
if (!value || typeof value !== 'object') return false;
|
|
184
|
+
const v = value as any;
|
|
185
|
+
return isFHIRDate(v) || isFHIRDateTime(v) || isFHIRTime(v);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isQuantity(value: unknown): value is QuantityValue {
|
|
189
|
+
if (!value || typeof value !== 'object') return false;
|
|
190
|
+
const v = value as any;
|
|
191
|
+
return 'unit' in v && 'value' in v && typeof v.value === 'number' && typeof v.unit === 'string';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isComplex(value: unknown): value is object {
|
|
195
|
+
return value !== null && typeof value === 'object' && !isTemporalValue(value) && !isQuantity(value);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Type-specific comparison functions
|
|
199
|
+
|
|
200
|
+
function compareTemporal(a: TemporalValue, b: TemporalValue): ComparisonResult {
|
|
201
|
+
// Use existing temporal comparison logic
|
|
202
|
+
const compareResult = temporalCompare(a, b);
|
|
203
|
+
|
|
204
|
+
if (compareResult === null) {
|
|
205
|
+
return { kind: 'incomparable', reason: 'incomparable temporal values' };
|
|
206
|
+
}
|
|
207
|
+
if (compareResult === 0) {
|
|
208
|
+
return { kind: 'equal' };
|
|
209
|
+
}
|
|
210
|
+
if (compareResult < 0) {
|
|
211
|
+
return { kind: 'less' };
|
|
212
|
+
}
|
|
213
|
+
return { kind: 'greater' };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function compareQuantityValues(a: QuantityValue, b: QuantityValue): ComparisonResult {
|
|
217
|
+
const result = compareQuantities(a, b);
|
|
218
|
+
|
|
219
|
+
if (result === null) {
|
|
220
|
+
return { kind: 'incomparable', reason: 'incompatible quantity dimensions' };
|
|
221
|
+
}
|
|
222
|
+
if (result === 0) {
|
|
223
|
+
return { kind: 'equal' };
|
|
224
|
+
}
|
|
225
|
+
if (result < 0) {
|
|
226
|
+
return { kind: 'less' };
|
|
227
|
+
}
|
|
228
|
+
return { kind: 'greater' };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function compareQuantityToNumber(quantity: QuantityValue, number: number): ComparisonResult {
|
|
232
|
+
// Dimensionless quantities can be compared to numbers
|
|
233
|
+
if (quantity.unit === '1' || quantity.unit === '') {
|
|
234
|
+
if (quantity.value === number) {
|
|
235
|
+
return { kind: 'equal' };
|
|
236
|
+
}
|
|
237
|
+
if (quantity.value < number) {
|
|
238
|
+
return { kind: 'less' };
|
|
239
|
+
}
|
|
240
|
+
return { kind: 'greater' };
|
|
241
|
+
}
|
|
242
|
+
// Non-dimensionless quantity cannot be compared to a number
|
|
243
|
+
return { kind: 'incomparable', reason: 'cannot compare dimensioned quantity to number' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function compareComplex(a: any, b: any): ComparisonResult {
|
|
247
|
+
// Use deep equality for complex types
|
|
248
|
+
if (deepEqual(a, b)) {
|
|
249
|
+
return { kind: 'equal' };
|
|
250
|
+
}
|
|
251
|
+
// Complex types are not orderable, so we can't say less or greater
|
|
252
|
+
// But for equality purposes, we know they're not equal
|
|
253
|
+
return { kind: 'incomparable', reason: 'complex types not equal' };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function comparePrimitive(a: unknown, b: unknown): ComparisonResult {
|
|
257
|
+
// String comparison
|
|
258
|
+
if (typeof a === 'string' && typeof b === 'string') {
|
|
259
|
+
if (a === b) return { kind: 'equal' };
|
|
260
|
+
if (a < b) return { kind: 'less' };
|
|
261
|
+
return { kind: 'greater' };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Number comparison
|
|
265
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
266
|
+
if (a === b) return { kind: 'equal' };
|
|
267
|
+
if (a < b) return { kind: 'less' };
|
|
268
|
+
return { kind: 'greater' };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Boolean comparison
|
|
272
|
+
if (typeof a === 'boolean' && typeof b === 'boolean') {
|
|
273
|
+
if (a === b) return { kind: 'equal' };
|
|
274
|
+
// false < true in FHIRPath
|
|
275
|
+
if (!a && b) return { kind: 'less' };
|
|
276
|
+
return { kind: 'greater' };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Type mismatch
|
|
280
|
+
if (typeof a !== typeof b) {
|
|
281
|
+
return { kind: 'incomparable', reason: 'type mismatch' };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Fallback to strict equality
|
|
285
|
+
if (a === b) {
|
|
286
|
+
return { kind: 'equal' };
|
|
287
|
+
}
|
|
288
|
+
return { kind: 'incomparable', reason: 'incomparable values' };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Deep equality comparison for complex types
|
|
293
|
+
* Handles arrays, objects, and nested structures
|
|
294
|
+
*/
|
|
295
|
+
export function deepEqual(a: any, b: any): boolean {
|
|
296
|
+
// Early exit: same reference
|
|
297
|
+
if (a === b) return true;
|
|
298
|
+
|
|
299
|
+
// Early exit: different types
|
|
300
|
+
const typeA = typeof a;
|
|
301
|
+
const typeB = typeof b;
|
|
302
|
+
if (typeA !== typeB) return false;
|
|
303
|
+
|
|
304
|
+
// Early exit: primitives
|
|
305
|
+
if (typeA !== 'object') return false; // We already checked a === b
|
|
306
|
+
|
|
307
|
+
// Null or undefined
|
|
308
|
+
if (a == null || b == null) return a === b;
|
|
309
|
+
|
|
310
|
+
// Arrays
|
|
311
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
312
|
+
if (a.length !== b.length) return false;
|
|
313
|
+
for (let i = 0; i < a.length; i++) {
|
|
314
|
+
if (!deepEqual(a[i], b[i])) return false;
|
|
315
|
+
}
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Objects
|
|
320
|
+
if (typeof a === 'object' && typeof b === 'object') {
|
|
321
|
+
// Special handling for temporal and quantity types
|
|
322
|
+
if (isTemporalValue(a) && isTemporalValue(b)) {
|
|
323
|
+
return temporalEquals(a, b) === true;
|
|
324
|
+
}
|
|
325
|
+
if (isQuantity(a) && isQuantity(b)) {
|
|
326
|
+
return compareQuantities(a, b) === 0;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// General object comparison
|
|
330
|
+
const keysA = Object.keys(a);
|
|
331
|
+
const keysB = Object.keys(b);
|
|
332
|
+
|
|
333
|
+
if (keysA.length !== keysB.length) return false;
|
|
334
|
+
|
|
335
|
+
for (const key of keysA) {
|
|
336
|
+
if (!keysB.includes(key)) return false;
|
|
337
|
+
if (!deepEqual(a[key], b[key])) return false;
|
|
338
|
+
}
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Primitives
|
|
343
|
+
return a === b;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Performance optimization: Caching for repeated comparisons
|
|
347
|
+
const comparisonCache = new WeakMap<any, WeakMap<any, ComparisonResult>>();
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Compare with caching for performance
|
|
351
|
+
* Uses WeakMap to avoid memory leaks and automatically clean up when objects are garbage collected
|
|
352
|
+
*/
|
|
353
|
+
export function compareWithCache(a: unknown, b: unknown): ComparisonResult {
|
|
354
|
+
// Only cache object comparisons (primitives are fast enough)
|
|
355
|
+
if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
|
|
356
|
+
let aCache = comparisonCache.get(a);
|
|
357
|
+
if (aCache) {
|
|
358
|
+
const cached = aCache.get(b);
|
|
359
|
+
if (cached) return cached;
|
|
360
|
+
} else {
|
|
361
|
+
aCache = new WeakMap();
|
|
362
|
+
comparisonCache.set(a, aCache);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const result = compare(a, b);
|
|
366
|
+
aCache.set(b, result);
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return compare(a, b);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ============================================================================
|
|
374
|
+
// Equivalence Implementation
|
|
375
|
+
// ============================================================================
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Check if two values are equivalent according to FHIRPath semantics
|
|
379
|
+
* Equivalence is more permissive than equality:
|
|
380
|
+
* - Strings are compared case-insensitively with normalized whitespace
|
|
381
|
+
* - Decimals ignore trailing zeros (2.0 ~ 2.00)
|
|
382
|
+
* - Quantities use UCUM semantic equivalence
|
|
383
|
+
* - Collections are compared without considering order
|
|
384
|
+
* - null/empty are considered equivalent
|
|
385
|
+
*/
|
|
386
|
+
export function equivalent(a: unknown, b: unknown): boolean | null {
|
|
387
|
+
// Handle null/empty equivalence
|
|
388
|
+
if (isEmpty(a) && isEmpty(b)) return true;
|
|
389
|
+
if (isEmpty(a) || isEmpty(b)) return false;
|
|
390
|
+
|
|
391
|
+
// String equivalence - case insensitive, normalized whitespace
|
|
392
|
+
if (typeof a === 'string' && typeof b === 'string') {
|
|
393
|
+
return stringEquivalent(a, b);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Number/Decimal equivalence - semantic value comparison
|
|
397
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
398
|
+
return decimalEquivalent(a, b);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Quantity equivalence
|
|
402
|
+
if (isQuantity(a) && isQuantity(b)) {
|
|
403
|
+
return quantityEquivalent(a, b);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Temporal types use equality semantics
|
|
407
|
+
// But for equivalence, incomparable (null) means not equivalent (false)
|
|
408
|
+
if (isTemporalValue(a) && isTemporalValue(b)) {
|
|
409
|
+
const result = temporalEquals(a, b);
|
|
410
|
+
// If temporal values are incomparable (different precision), they're not equivalent
|
|
411
|
+
return result === null ? false : result;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Boolean equivalence is same as equality
|
|
415
|
+
if (typeof a === 'boolean' && typeof b === 'boolean') {
|
|
416
|
+
return a === b;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Complex types need deep equivalence
|
|
420
|
+
if (isComplex(a) && isComplex(b)) {
|
|
421
|
+
return deepEquivalent(a, b);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Type mismatch
|
|
425
|
+
if (typeof a !== typeof b) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Default to strict equality
|
|
430
|
+
return a === b;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Check if a value is empty (null, undefined, or empty array/object)
|
|
435
|
+
*/
|
|
436
|
+
function isEmpty(value: unknown): boolean {
|
|
437
|
+
if (value === null || value === undefined) return true;
|
|
438
|
+
if (Array.isArray(value) && value.length === 0) return true;
|
|
439
|
+
if (typeof value === 'object' && Object.keys(value).length === 0) return true;
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* String equivalence with case-insensitive comparison and whitespace normalization
|
|
445
|
+
*/
|
|
446
|
+
function stringEquivalent(a: string, b: string): boolean {
|
|
447
|
+
// Normalize whitespace: collapse multiple spaces, trim ends
|
|
448
|
+
const normalize = (s: string) =>
|
|
449
|
+
s.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
450
|
+
|
|
451
|
+
return normalize(a) === normalize(b);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Decimal equivalence for FHIRPath
|
|
456
|
+
*
|
|
457
|
+
* Per spec: "comparison is done on values rounded to the precision of the
|
|
458
|
+
* least precise operand. Trailing zeroes after the decimal are ignored in
|
|
459
|
+
* determining precision."
|
|
460
|
+
*
|
|
461
|
+
* Since JavaScript loses literal precision (1.0 becomes 1), we deduce precision:
|
|
462
|
+
* - Numbers with no fractional part (1, 2.0 -> 2) have 0 decimal places
|
|
463
|
+
* - Numbers with fractional parts use their actual decimal places
|
|
464
|
+
*/
|
|
465
|
+
function decimalEquivalent(a: number, b: number): boolean {
|
|
466
|
+
// Handle special cases
|
|
467
|
+
if (Number.isNaN(a) && Number.isNaN(b)) return true;
|
|
468
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return false;
|
|
469
|
+
|
|
470
|
+
// Infinite values must match exactly
|
|
471
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) {
|
|
472
|
+
return a === b;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Deduce precision from the numeric values
|
|
476
|
+
const aPrecision = getDecimalPrecision(a);
|
|
477
|
+
const bPrecision = getDecimalPrecision(b);
|
|
478
|
+
|
|
479
|
+
// Round both to the minimum precision
|
|
480
|
+
const minPrecision = Math.min(aPrecision, bPrecision);
|
|
481
|
+
|
|
482
|
+
// Round both numbers to the minimum precision
|
|
483
|
+
const factor = Math.pow(10, minPrecision);
|
|
484
|
+
const aRounded = Math.round(a * factor) / factor;
|
|
485
|
+
const bRounded = Math.round(b * factor) / factor;
|
|
486
|
+
|
|
487
|
+
return aRounded === bRounded;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Get the effective decimal precision of a number.
|
|
492
|
+
* Numbers with no fractional part (like 1.0 which becomes 1) have 0 precision.
|
|
493
|
+
* Numbers with fractional parts have precision based on significant decimal places.
|
|
494
|
+
*/
|
|
495
|
+
function getDecimalPrecision(n: number): number {
|
|
496
|
+
// If it's effectively an integer (no fractional part), precision is 0
|
|
497
|
+
if (Number.isInteger(n)) {
|
|
498
|
+
return 0;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Convert to string to count decimal places
|
|
502
|
+
// Use a reasonable maximum precision to avoid floating point artifacts
|
|
503
|
+
const str = n.toFixed(8).replace(/0+$/, '').replace(/\.$/, '');
|
|
504
|
+
const decimalIndex = str.indexOf('.');
|
|
505
|
+
|
|
506
|
+
if (decimalIndex === -1) {
|
|
507
|
+
return 0;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return str.length - decimalIndex - 1;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Calendar to UCUM duration mappings for equivalence
|
|
514
|
+
const CALENDAR_TO_UCUM_MAP: Record<string, string> = {
|
|
515
|
+
'year': 'a',
|
|
516
|
+
'years': 'a',
|
|
517
|
+
'month': 'mo',
|
|
518
|
+
'months': 'mo',
|
|
519
|
+
'week': 'wk',
|
|
520
|
+
'weeks': 'wk',
|
|
521
|
+
'day': 'd',
|
|
522
|
+
'days': 'd',
|
|
523
|
+
'hour': 'h',
|
|
524
|
+
'hours': 'h',
|
|
525
|
+
'minute': 'min',
|
|
526
|
+
'minutes': 'min',
|
|
527
|
+
'second': 's',
|
|
528
|
+
'seconds': 's',
|
|
529
|
+
'millisecond': 'ms',
|
|
530
|
+
'milliseconds': 'ms'
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Quantity equivalence with UCUM semantic comparison and calendar mappings
|
|
535
|
+
*/
|
|
536
|
+
function quantityEquivalent(a: QuantityValue, b: QuantityValue): boolean | null {
|
|
537
|
+
// Check for calendar to UCUM equivalence
|
|
538
|
+
const aIsCalendar = CALENDAR_TO_UCUM_MAP[a.unit] !== undefined;
|
|
539
|
+
const bIsCalendar = CALENDAR_TO_UCUM_MAP[b.unit] !== undefined;
|
|
540
|
+
|
|
541
|
+
// Calendar to UCUM mapping
|
|
542
|
+
if (aIsCalendar && !bIsCalendar) {
|
|
543
|
+
const ucumUnit = CALENDAR_TO_UCUM_MAP[a.unit];
|
|
544
|
+
return ucumUnit === b.unit && a.value === b.value;
|
|
545
|
+
}
|
|
546
|
+
if (!aIsCalendar && bIsCalendar) {
|
|
547
|
+
const ucumUnit = CALENDAR_TO_UCUM_MAP[b.unit];
|
|
548
|
+
return ucumUnit === a.unit && a.value === b.value;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Both calendar units - must be same unit and value
|
|
552
|
+
if (aIsCalendar && bIsCalendar) {
|
|
553
|
+
// Normalize to singular/plural
|
|
554
|
+
const aNorm = CALENDAR_TO_UCUM_MAP[a.unit];
|
|
555
|
+
const bNorm = CALENDAR_TO_UCUM_MAP[b.unit];
|
|
556
|
+
return aNorm === bNorm && a.value === b.value;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Standard UCUM semantic equivalence (1000 mg ~ 1 g)
|
|
560
|
+
const result = compareQuantities(a, b);
|
|
561
|
+
return result === 0;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Deep equivalence for complex objects
|
|
566
|
+
* Similar to deep equality but uses equivalence rules for nested values
|
|
567
|
+
*/
|
|
568
|
+
function deepEquivalent(a: any, b: any): boolean {
|
|
569
|
+
// Early exit: same reference
|
|
570
|
+
if (a === b) return true;
|
|
571
|
+
|
|
572
|
+
// Arrays - compare elements using equivalence
|
|
573
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
574
|
+
if (a.length !== b.length) return false;
|
|
575
|
+
for (let i = 0; i < a.length; i++) {
|
|
576
|
+
const equiv = equivalent(a[i], b[i]);
|
|
577
|
+
if (equiv !== true) return false;
|
|
578
|
+
}
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Objects - compare properties using equivalence
|
|
583
|
+
if (typeof a === 'object' && typeof b === 'object') {
|
|
584
|
+
const keysA = Object.keys(a);
|
|
585
|
+
const keysB = Object.keys(b);
|
|
586
|
+
|
|
587
|
+
if (keysA.length !== keysB.length) return false;
|
|
588
|
+
|
|
589
|
+
for (const key of keysA) {
|
|
590
|
+
if (!keysB.includes(key)) return false;
|
|
591
|
+
const equiv = equivalent(a[key], b[key]);
|
|
592
|
+
if (equiv !== true) return false;
|
|
593
|
+
}
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Compare collections for equivalence
|
|
602
|
+
* Collections are equivalent if they contain the same elements regardless of order
|
|
603
|
+
*/
|
|
604
|
+
export function collectionsEquivalent(left: FHIRPathValue[], right: FHIRPathValue[]): boolean | null {
|
|
605
|
+
// Empty collections are equivalent
|
|
606
|
+
if (left.length === 0 && right.length === 0) return true;
|
|
607
|
+
|
|
608
|
+
// One empty, one not - not equivalent
|
|
609
|
+
if (left.length === 0 || right.length === 0) return false;
|
|
610
|
+
|
|
611
|
+
// Different lengths = not equivalent
|
|
612
|
+
if (left.length !== right.length) return false;
|
|
613
|
+
|
|
614
|
+
// Single element collections
|
|
615
|
+
if (left.length === 1 && right.length === 1) {
|
|
616
|
+
const leftValue = unbox(left[0]!);
|
|
617
|
+
const rightValue = unbox(right[0]!);
|
|
618
|
+
return equivalent(leftValue, rightValue);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Sort both collections for comparison
|
|
622
|
+
// We need a stable sort that groups equivalent elements together
|
|
623
|
+
const sortedLeft = [...left].sort((a, b) => sortCompareForEquivalence(unbox(a), unbox(b)));
|
|
624
|
+
const sortedRight = [...right].sort((a, b) => sortCompareForEquivalence(unbox(a), unbox(b)));
|
|
625
|
+
|
|
626
|
+
// Compare sorted elements using equivalence
|
|
627
|
+
for (let i = 0; i < sortedLeft.length; i++) {
|
|
628
|
+
const leftValue = unbox(sortedLeft[i]!);
|
|
629
|
+
const rightValue = unbox(sortedRight[i]!);
|
|
630
|
+
const equiv = equivalent(leftValue, rightValue);
|
|
631
|
+
|
|
632
|
+
if (equiv === null) return null;
|
|
633
|
+
if (equiv === false) return false;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Compare collections for non-equivalence
|
|
641
|
+
*/
|
|
642
|
+
export function collectionsNotEquivalent(left: FHIRPathValue[], right: FHIRPathValue[]): boolean | null {
|
|
643
|
+
const result = collectionsEquivalent(left, right);
|
|
644
|
+
if (result === null) return null;
|
|
645
|
+
return !result;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Comparison function for sorting collections for equivalence comparison
|
|
650
|
+
* Groups equivalent items together
|
|
651
|
+
*/
|
|
652
|
+
function sortCompareForEquivalence(a: unknown, b: unknown): number {
|
|
653
|
+
// Handle null/undefined
|
|
654
|
+
if (a === null || a === undefined) return b === null || b === undefined ? 0 : -1;
|
|
655
|
+
if (b === null || b === undefined) return 1;
|
|
656
|
+
|
|
657
|
+
// Type-based ordering
|
|
658
|
+
const typeOrder = ['boolean', 'number', 'string', 'object'];
|
|
659
|
+
const typeA = typeof a;
|
|
660
|
+
const typeB = typeof b;
|
|
661
|
+
const orderA = typeOrder.indexOf(typeA);
|
|
662
|
+
const orderB = typeOrder.indexOf(typeB);
|
|
663
|
+
|
|
664
|
+
if (orderA !== orderB) {
|
|
665
|
+
return orderA - orderB;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Same type comparison
|
|
669
|
+
switch (typeA) {
|
|
670
|
+
case 'boolean':
|
|
671
|
+
return (a as boolean) === (b as boolean) ? 0 : (a as boolean) ? 1 : -1;
|
|
672
|
+
|
|
673
|
+
case 'number':
|
|
674
|
+
return (a as number) - (b as number);
|
|
675
|
+
|
|
676
|
+
case 'string':
|
|
677
|
+
// Use normalized comparison for sorting
|
|
678
|
+
const aNorm = (a as string).toLowerCase().trim();
|
|
679
|
+
const bNorm = (b as string).toLowerCase().trim();
|
|
680
|
+
return aNorm < bNorm ? -1 : aNorm > bNorm ? 1 : 0;
|
|
681
|
+
|
|
682
|
+
case 'object':
|
|
683
|
+
// For objects, use a stable stringification
|
|
684
|
+
// This is not perfect but works for sorting purposes
|
|
685
|
+
const aStr = JSON.stringify(a);
|
|
686
|
+
const bStr = JSON.stringify(b);
|
|
687
|
+
return aStr < bStr ? -1 : aStr > bStr ? 1 : 0;
|
|
688
|
+
|
|
689
|
+
default:
|
|
690
|
+
return 0;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { FunctionDefinition } from '../types';
|
|
2
2
|
import { Errors } from '../errors';
|
|
3
3
|
import type { FunctionEvaluator } from '../types';
|
|
4
|
-
import { box, unbox } from '../boxing';
|
|
4
|
+
import { box, unbox } from '../interpreter/boxing';
|
|
5
5
|
|
|
6
6
|
export const evaluate: FunctionEvaluator = async (input, context, args, evaluator) => {
|
|
7
7
|
// contains() requires exactly 1 argument
|