fhirpath-ts 0.1.0 → 0.2.1

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.
Files changed (138) hide show
  1. package/README.md +50 -18
  2. package/dist/analyzer/analyze-dto.d.ts +4 -0
  3. package/dist/analyzer/analyze-dto.d.ts.map +1 -1
  4. package/dist/analyzer/analyze-dto.js +12 -14
  5. package/dist/analyzer/analyze-dto.js.map +1 -1
  6. package/dist/analyzer/analyze.d.ts +22 -6
  7. package/dist/analyzer/analyze.d.ts.map +1 -1
  8. package/dist/analyzer/analyze.js +242 -98
  9. package/dist/analyzer/analyze.js.map +1 -1
  10. package/dist/analyzer/declarations.d.ts +26 -0
  11. package/dist/analyzer/declarations.d.ts.map +1 -0
  12. package/dist/analyzer/declarations.js +68 -0
  13. package/dist/analyzer/declarations.js.map +1 -0
  14. package/dist/analyzer/operator-rules.d.ts +127 -0
  15. package/dist/analyzer/operator-rules.d.ts.map +1 -0
  16. package/dist/analyzer/operator-rules.js +70 -0
  17. package/dist/analyzer/operator-rules.js.map +1 -0
  18. package/dist/analyzer/signatures.d.ts +1097 -10
  19. package/dist/analyzer/signatures.d.ts.map +1 -1
  20. package/dist/analyzer/signatures.js +70 -29
  21. package/dist/analyzer/signatures.js.map +1 -1
  22. package/dist/api/compile.d.ts +32 -8
  23. package/dist/api/compile.d.ts.map +1 -1
  24. package/dist/api/compile.js +2 -0
  25. package/dist/api/compile.js.map +1 -1
  26. package/dist/api/engine.d.ts +42 -16
  27. package/dist/api/engine.d.ts.map +1 -1
  28. package/dist/api/engine.js +2 -1
  29. package/dist/api/engine.js.map +1 -1
  30. package/dist/api/project.d.ts +8 -8
  31. package/dist/api/project.d.ts.map +1 -1
  32. package/dist/api/project.js +31 -19
  33. package/dist/api/project.js.map +1 -1
  34. package/dist/api/strict.d.ts +6 -0
  35. package/dist/api/strict.d.ts.map +1 -0
  36. package/dist/api/strict.js +71 -0
  37. package/dist/api/strict.js.map +1 -0
  38. package/dist/api/tagged.d.ts +4 -4
  39. package/dist/api/tagged.d.ts.map +1 -1
  40. package/dist/api/tagged.js.map +1 -1
  41. package/dist/cli/dto-check.js +7 -3
  42. package/dist/cli/dto-check.js.map +1 -1
  43. package/dist/engine/context.d.ts +3 -1
  44. package/dist/engine/context.d.ts.map +1 -1
  45. package/dist/engine/context.js +5 -1
  46. package/dist/engine/context.js.map +1 -1
  47. package/dist/engine/navigation.d.ts.map +1 -1
  48. package/dist/engine/navigation.js +7 -48
  49. package/dist/engine/navigation.js.map +1 -1
  50. package/dist/engine/type-matching.d.ts.map +1 -1
  51. package/dist/engine/type-matching.js +6 -16
  52. package/dist/engine/type-matching.js.map +1 -1
  53. package/dist/index.d.ts +4 -3
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/parser/parser.d.ts.map +1 -1
  57. package/dist/parser/parser.js +102 -73
  58. package/dist/parser/parser.js.map +1 -1
  59. package/dist/parser/precedence.d.ts +368 -4
  60. package/dist/parser/precedence.d.ts.map +1 -1
  61. package/dist/parser/precedence.js +146 -32
  62. package/dist/parser/precedence.js.map +1 -1
  63. package/dist/r4/generated/resources-data.d.ts +6 -2
  64. package/dist/r4/generated/resources-data.d.ts.map +1 -1
  65. package/dist/r4/generated/resources-data.js +626 -28627
  66. package/dist/r4/generated/resources-data.js.map +1 -1
  67. package/dist/r4/generated/type-maps.d.ts +1367 -0
  68. package/dist/r4/generated/type-maps.d.ts.map +1 -1
  69. package/dist/r4/generated/types-data.d.ts +6 -2
  70. package/dist/r4/generated/types-data.d.ts.map +1 -1
  71. package/dist/r4/generated/types-data.js +84 -2204
  72. package/dist/r4/generated/types-data.js.map +1 -1
  73. package/dist/r4/index.d.ts +3 -1
  74. package/dist/r4/index.d.ts.map +1 -1
  75. package/dist/r4/index.js +10 -4
  76. package/dist/r4/index.js.map +1 -1
  77. package/dist/r4/model-data.d.ts +17 -0
  78. package/dist/r4/model-data.d.ts.map +1 -1
  79. package/dist/r4/model-data.js +118 -1
  80. package/dist/r4/model-data.js.map +1 -1
  81. package/dist/typed/context-maps.d.ts +23 -0
  82. package/dist/typed/context-maps.d.ts.map +1 -0
  83. package/dist/typed/context-maps.js +2 -0
  84. package/dist/typed/context-maps.js.map +1 -0
  85. package/dist/typed/infer.d.ts +99 -229
  86. package/dist/typed/infer.d.ts.map +1 -1
  87. package/dist/typed/infer.js +1 -84
  88. package/dist/typed/infer.js.map +1 -1
  89. package/dist/typed/inference-limits.d.ts +7 -0
  90. package/dist/typed/inference-limits.d.ts.map +1 -0
  91. package/dist/typed/inference-limits.js +6 -0
  92. package/dist/typed/inference-limits.js.map +1 -0
  93. package/dist/typed/metadata-compact.d.ts +189 -0
  94. package/dist/typed/metadata-compact.d.ts.map +1 -0
  95. package/dist/typed/metadata-compact.js +3 -0
  96. package/dist/typed/metadata-compact.js.map +1 -0
  97. package/dist/typed/parser.d.ts +515 -0
  98. package/dist/typed/parser.d.ts.map +1 -0
  99. package/dist/typed/parser.js +2 -0
  100. package/dist/typed/parser.js.map +1 -0
  101. package/dist/values/type-compat.d.ts +8 -0
  102. package/dist/values/type-compat.d.ts.map +1 -1
  103. package/dist/values/type-compat.js +44 -1
  104. package/dist/values/type-compat.js.map +1 -1
  105. package/package.json +20 -1
  106. package/src/analyzer/analyze-dto.ts +21 -17
  107. package/src/analyzer/analyze.ts +335 -109
  108. package/src/analyzer/declarations.ts +120 -0
  109. package/src/analyzer/operator-rules.ts +88 -0
  110. package/src/analyzer/signatures.ts +99 -41
  111. package/src/api/compile.ts +66 -11
  112. package/src/api/engine.ts +97 -33
  113. package/src/api/project.ts +54 -30
  114. package/src/api/strict.ts +112 -0
  115. package/src/api/tagged.ts +5 -5
  116. package/src/cli/dto-check.ts +7 -3
  117. package/src/engine/context.ts +7 -2
  118. package/src/engine/navigation.ts +7 -52
  119. package/src/engine/type-matching.ts +11 -17
  120. package/src/index.ts +25 -2
  121. package/src/parser/parser.ts +106 -74
  122. package/src/parser/precedence.ts +250 -33
  123. package/src/r4/generated/resources-data.ts +624 -28628
  124. package/src/r4/generated/type-maps.ts +1557 -0
  125. package/src/r4/generated/types-data.ts +82 -2205
  126. package/src/r4/index.ts +11 -5
  127. package/src/r4/model-data.ts +127 -0
  128. package/src/typed/context-maps.ts +41 -0
  129. package/src/typed/infer.ts +157 -425
  130. package/src/typed/inference-limits.ts +9 -0
  131. package/src/typed/metadata-compact.ts +307 -0
  132. package/src/typed/parser.ts +2277 -0
  133. package/src/values/type-compat.ts +48 -1
  134. package/dist/typed/perf-fixture.types.d.ts +0 -136
  135. package/dist/typed/perf-fixture.types.d.ts.map +0 -1
  136. package/dist/typed/perf-fixture.types.js +0 -2
  137. package/dist/typed/perf-fixture.types.js.map +0 -1
  138. package/src/typed/perf-fixture.types.ts +0 -143
package/src/api/engine.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import { mergeEnvKeys } from '../engine/context.ts'
2
2
  import type { R4TypeOf } from '../r4/generated/type-maps.ts'
3
- import type { FhirpathInput, FhirpathResult } from '../typed/infer.ts'
3
+ import type {
4
+ EmptyFhirpathTypeContext,
5
+ FhirpathInput,
6
+ FhirpathResultForContext,
7
+ FhirpathRootOf,
8
+ FhirpathTypeContextOf,
9
+ MergeFhirpathTypeContexts,
10
+ } from '../typed/infer.ts'
4
11
  import { criteriaBoolean } from '../values/collection.ts'
5
12
  import type { TypedValue } from '../values/typed-value.ts'
6
13
  import { type BundleLike, isBundle, normalizeInput, toSubjects } from './bundle.ts'
@@ -10,6 +17,7 @@ import {
10
17
  type Compiler,
11
18
  createCachedCompiler,
12
19
  type CustomFunction,
20
+ type Declaring,
13
21
  type EvaluateOptions,
14
22
  type SingleCustomFunction,
15
23
  } from './compile.ts'
@@ -30,6 +38,47 @@ export type EngineInput<Expr extends string = string> = FhirpathInput<Expr> | re
30
38
  /** Per-call options that declare a result type when inference returns `unknown`. Runtime code ignores `type`. */
31
39
  export type TypedEvaluateOptions<T extends keyof R4TypeOf> = EvaluateOptions & { type: T }
32
40
 
41
+ /** Literal text or a compatible compiled expression accepted by engine evaluation methods. */
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- engine methods infer their own input and result
43
+ export type EngineExpression<Expr extends string> = Expr | CompiledExpression<Expr, any, any, any>
44
+
45
+ /**
46
+ * evaluate() reaches this shape through normalizeInput(), while project()
47
+ * reaches it through toSubjects(). A bare Bundle is expression-dependent or
48
+ * expands to heterogeneous entries, so it stays opaque; an array keeps each
49
+ * item raw, including a Bundle deliberately wrapped as `[bundle]`.
50
+ */
51
+ export type EngineInputRoot<Input> = Input extends readonly (infer Item)[]
52
+ ? FhirpathRootOf<Item>
53
+ : Input extends { readonly resourceType: 'Bundle' }
54
+ ? 'opaque'
55
+ : FhirpathRootOf<Input>
56
+
57
+ /** The inferred result returned by an engine or bound expression call. */
58
+ export type EngineResult<Expr extends string, Input, Defaults, Options> = FhirpathResultForContext<
59
+ Expr,
60
+ EngineInputRoot<Input>,
61
+ MergeFhirpathTypeContexts<FhirpathTypeContextOf<Defaults>, FhirpathTypeContextOf<Options>>
62
+ >
63
+
64
+ /** The merged static declarations visible while project() evaluates a row. */
65
+ export type EngineProjectionContext<Defaults, Options> = MergeFhirpathTypeContexts<
66
+ MergeFhirpathTypeContexts<FhirpathTypeContextOf<Defaults>, FhirpathTypeContextOf<Options>>,
67
+ {
68
+ env: {
69
+ rowIndex: { type: 'System.Integer' }
70
+ rowTotal: { type: 'System.Integer' }
71
+ }
72
+ }
73
+ >
74
+
75
+ /** The inferred row returned by project(), including its built-in row variables. */
76
+ export type EngineProjection<Columns extends ProjectionColumns, Input, Defaults, Options> = Projection<
77
+ Columns,
78
+ EngineInputRoot<Input>,
79
+ EngineProjectionContext<Defaults, Options>
80
+ >
81
+
33
82
  /** Engines created during the current recording session. */
34
83
  let session: FhirPathEngine[] | undefined
35
84
 
@@ -81,7 +130,7 @@ export interface EngineOptions extends EvaluateOptions {
81
130
  * replace defaults, while `env`, `vars`, and `functions` merge by name. Keep an
82
131
  * engine for reuse because its parse cache is private to that instance.
83
132
  */
84
- export class FhirPathEngine {
133
+ export class FhirPathEngine<const Defaults extends EngineOptions = EmptyFhirpathTypeContext> {
85
134
  /** The per-call options bound at construction; engine-only settings are not part of them. */
86
135
  readonly defaults: EvaluateOptions
87
136
  /**
@@ -92,24 +141,29 @@ export class FhirPathEngine {
92
141
  readonly dtos: readonly DtoClass[]
93
142
  private readonly compileCached: Compiler
94
143
 
95
- constructor({ cacheSize, resourceDtos, ...defaults }: EngineOptions = {}) {
144
+ constructor(options: Declaring<Defaults, EngineOptions> = {} as Declaring<Defaults, EngineOptions>) {
145
+ const { cacheSize, resourceDtos, ...defaults } = options
96
146
  this.compileCached = createCachedCompiler(cacheSize)
97
147
  this.dtos = resourceDtos ?? []
98
- this.defaults = this.precompiled(withDtos(defaults, this.dtos, this.compileCached))
148
+ this.defaults = this.precompiled(withDtos(defaults as EvaluateOptions, this.dtos, this.compileCached))
99
149
  recordEngine(this)
100
150
  }
101
151
 
102
152
  /** Compile (LRU-cached by expression text) and evaluate in one call; typed like `compile().evaluate()`. */
103
153
  evaluate<const Expr extends string, T extends keyof R4TypeOf>(
104
- expression: Expr | CompiledExpression<Expr>,
154
+ expression: EngineExpression<Expr>,
105
155
  input: EngineInput<Expr> | undefined,
106
156
  options: TypedEvaluateOptions<T>
107
157
  ): R4TypeOf[T][]
108
- evaluate<const Expr extends string>(
109
- expression: Expr | CompiledExpression<Expr>,
110
- input?: EngineInput<Expr>,
111
- options?: EvaluateOptions
112
- ): FhirpathResult<Expr>
158
+ evaluate<
159
+ const Expr extends string,
160
+ const Input extends EngineInput<Expr> | undefined = undefined,
161
+ const Options extends object = EmptyFhirpathTypeContext,
162
+ >(
163
+ expression: EngineExpression<Expr>,
164
+ input?: Input,
165
+ options?: Declaring<Options>
166
+ ): EngineResult<Expr, Input, Defaults, Options>
113
167
  evaluate(expression: AnyExpression, input?: unknown, options?: EvaluateOptions): unknown[] {
114
168
  const compiled = this.compileCached(expression)
115
169
  const merged = this.merged(options)
@@ -124,21 +178,25 @@ export class FhirPathEngine {
124
178
  }
125
179
 
126
180
  /** Parse once for reuse, with this engine's defaults bound. Does not touch the parse cache. */
127
- compile<const Expr extends string>(expression: Expr): BoundExpression<Expr> {
181
+ compile<const Expr extends string>(expression: Expr): BoundExpression<Expr, Defaults> {
128
182
  return new BoundExpression(this, new CompiledExpression(expression))
129
183
  }
130
184
 
131
185
  /** The first result, or undefined when the expression comes up empty. */
132
186
  first<const Expr extends string, T extends keyof R4TypeOf>(
133
- expression: Expr | CompiledExpression<Expr>,
187
+ expression: EngineExpression<Expr>,
134
188
  input: EngineInput<Expr> | undefined,
135
189
  options: TypedEvaluateOptions<T>
136
190
  ): R4TypeOf[T] | undefined
137
- first<const Expr extends string>(
138
- expression: Expr | CompiledExpression<Expr>,
139
- input?: EngineInput<Expr>,
140
- options?: EvaluateOptions
141
- ): FhirpathResult<Expr>[number] | undefined
191
+ first<
192
+ const Expr extends string,
193
+ const Input extends EngineInput<Expr> | undefined = undefined,
194
+ const Options extends object = EmptyFhirpathTypeContext,
195
+ >(
196
+ expression: EngineExpression<Expr>,
197
+ input?: Input,
198
+ options?: Declaring<Options>
199
+ ): EngineResult<Expr, Input, Defaults, Options>[number] | undefined
142
200
  first(expression: AnyExpression, input?: unknown, options?: EvaluateOptions): unknown {
143
201
  return this.evaluate(expression, input, options)[0]
144
202
  }
@@ -180,16 +238,16 @@ export class FhirPathEngine {
180
238
  options?: EvaluateOptions
181
239
  ): InstanceType<C>[]
182
240
  project<C extends DtoClass>(input: unknown, dto: C, options?: EvaluateOptions): InstanceType<C>
183
- project<const Columns extends ProjectionColumns>(
184
- input: readonly unknown[] | BundleLike,
185
- columns: Columns,
186
- options?: EvaluateOptions
187
- ): Projection<Columns>[]
188
- project<const Columns extends ProjectionColumns>(
189
- input: unknown,
190
- columns: Columns,
191
- options?: EvaluateOptions
192
- ): Projection<Columns>
241
+ project<
242
+ const Input extends readonly unknown[] | BundleLike,
243
+ const Columns extends ProjectionColumns,
244
+ const Options extends object = EmptyFhirpathTypeContext,
245
+ >(input: Input, columns: Columns, options?: Declaring<Options>): EngineProjection<Columns, Input, Defaults, Options>[]
246
+ project<
247
+ const Input,
248
+ const Columns extends ProjectionColumns,
249
+ const Options extends object = EmptyFhirpathTypeContext,
250
+ >(input: Input, columns: Columns, options?: Declaring<Options>): EngineProjection<Columns, Input, Defaults, Options>
193
251
  project(input: unknown, columns: ProjectionColumns | DtoClass, options?: EvaluateOptions): unknown {
194
252
  if (typeof columns === 'function') {
195
253
  assertInputMatchesDto(input, columns)
@@ -282,16 +340,16 @@ export class FhirPathEngine {
282
340
  }
283
341
 
284
342
  /** A compiled expression carrying an engine's defaults, so `evaluate(input)` needs nothing else. */
285
- export class BoundExpression<Expr extends string = string> {
343
+ export class BoundExpression<Expr extends string = string, Defaults extends EngineOptions = EmptyFhirpathTypeContext> {
286
344
  readonly expression: CompiledExpression<Expr>
287
- private readonly engine: FhirPathEngine
345
+ private readonly engine: FhirPathEngine<Defaults>
288
346
 
289
- constructor(engine: FhirPathEngine, expression: CompiledExpression<Expr>) {
347
+ constructor(engine: FhirPathEngine<Defaults>, expression: CompiledExpression<Expr>) {
290
348
  this.engine = engine
291
349
  this.expression = expression
292
350
  }
293
351
 
294
- get source(): string {
352
+ get source(): Expr {
295
353
  return this.expression.source
296
354
  }
297
355
 
@@ -299,7 +357,10 @@ export class BoundExpression<Expr extends string = string> {
299
357
  input: EngineInput<Expr> | undefined,
300
358
  options: TypedEvaluateOptions<T>
301
359
  ): R4TypeOf[T][]
302
- evaluate(input?: EngineInput<Expr>, options?: EvaluateOptions): FhirpathResult<Expr>
360
+ evaluate<
361
+ const Input extends EngineInput<Expr> | undefined = undefined,
362
+ const Options extends object = EmptyFhirpathTypeContext,
363
+ >(input?: Input, options?: Declaring<Options>): EngineResult<Expr, Input, Defaults, Options>
303
364
  evaluate(input?: EngineInput<Expr>, options?: EvaluateOptions): unknown {
304
365
  return this.engine.evaluate(this.expression, input, options)
305
366
  }
@@ -312,7 +373,10 @@ export class BoundExpression<Expr extends string = string> {
312
373
  input: EngineInput<Expr> | undefined,
313
374
  options: TypedEvaluateOptions<T>
314
375
  ): R4TypeOf[T] | undefined
315
- first(input?: EngineInput<Expr>, options?: EvaluateOptions): FhirpathResult<Expr>[number] | undefined
376
+ first<
377
+ const Input extends EngineInput<Expr> | undefined = undefined,
378
+ const Options extends object = EmptyFhirpathTypeContext,
379
+ >(input?: Input, options?: Declaring<Options>): EngineResult<Expr, Input, Defaults, Options>[number] | undefined
316
380
  first(input?: EngineInput<Expr>, options?: EvaluateOptions): unknown {
317
381
  return this.engine.first(this.expression, input, options)
318
382
  }
@@ -2,11 +2,12 @@ import { type EvaluationContext, forkVariables } from '../engine/context.ts'
2
2
  import { evaluateNode } from '../engine/evaluator.ts'
3
3
  import { FhirPathRuntimeError } from '../errors.ts'
4
4
  import type { R4TypeOf } from '../r4/generated/type-maps.ts'
5
- import type { FhirpathResultIn } from '../typed/infer.ts'
5
+ import type { EmptyFhirpathTypeContext, FhirpathResultForContext } from '../typed/infer.ts'
6
6
  import { criteriaBoolean } from '../values/collection.ts'
7
7
  import { toCollection, type TypedValue, unwrap } from '../values/typed-value.ts'
8
8
  import { toSubjects } from './bundle.ts'
9
9
  import { type Compiler, contextFactory, type EvaluateOptions } from './compile.ts'
10
+ import { assertStrictExpression } from './strict.ts'
10
11
 
11
12
  /**
12
13
  * One `project()` column. A string or `{ path }` returns one optional value.
@@ -38,7 +39,9 @@ type ColumnPath<Column> = Column extends string
38
39
  : never
39
40
 
40
41
  /** Infers the values after the selected conversion, or from the path when no conversion is set. */
41
- type ColumnValues<Column, Root extends string> = Column extends { as: (value: never) => infer R }
42
+ type ColumnValues<Column, Root extends string, Context extends object> = Column extends {
43
+ as: (value: never) => infer R
44
+ }
42
45
  ? R[]
43
46
  : Column extends { as: 'Date' }
44
47
  ? Date[]
@@ -52,25 +55,30 @@ type ColumnValues<Column, Root extends string> = Column extends { as: (value: ne
52
55
  ? V[]
53
56
  : Column extends { type: infer T extends keyof R4TypeOf }
54
57
  ? R4TypeOf[T][]
55
- : FhirpathResultIn<ColumnPath<Column>, Root>
58
+ : FhirpathResultForContext<ColumnPath<Column>, Root, Context>
56
59
 
57
60
  /** A column's output type. A default replaces `undefined`; a collection returns every value. */
58
61
  export type ColumnResult<
59
62
  Column extends string | { path: string } | { test: string },
60
63
  Root extends string = 'opaque',
64
+ Context extends object = EmptyFhirpathTypeContext,
61
65
  > = Column extends {
62
66
  test: string
63
67
  }
64
68
  ? boolean
65
69
  : Column extends { collection: true }
66
- ? ColumnValues<Column, Root>
70
+ ? ColumnValues<Column, Root, Context>
67
71
  : Column extends { default: infer D }
68
- ? ColumnValues<Column, Root>[number] | D
69
- : ColumnValues<Column, Root>[number] | undefined
72
+ ? ColumnValues<Column, Root, Context>[number] | D
73
+ : ColumnValues<Column, Root, Context>[number] | undefined
70
74
 
71
75
  /** The row shape `project()` produces: each column's type inferred from its expression. */
72
- export type Projection<Columns extends ProjectionColumns, Root extends string = 'opaque'> = {
73
- -readonly [K in keyof Columns]: ColumnResult<Columns[K], Root>
76
+ export type Projection<
77
+ Columns extends ProjectionColumns,
78
+ Root extends string = 'opaque',
79
+ Context extends object = EmptyFhirpathTypeContext,
80
+ > = {
81
+ -readonly [K in keyof Columns]: ColumnResult<Columns[K], Root, Context>
74
82
  }
75
83
 
76
84
  /** The `as: 'Date'` coercion; a value that is not a parseable date string becomes empty. */
@@ -183,6 +191,11 @@ function assertShaperOptions(name: string, spec: Extract<ProjectionColumn, { pat
183
191
  */
184
192
  type ColumnReader = (root: TypedValue[], context: EvaluationContext, at: RowPosition) => unknown
185
193
 
194
+ interface PlannedColumn {
195
+ ast: ReturnType<Compiler>['ast']
196
+ read: ColumnReader
197
+ }
198
+
186
199
  /** Where a row sits in the batch, for the errors that need to point at one of many. */
187
200
  interface RowPosition {
188
201
  index: number
@@ -199,32 +212,38 @@ function inRow({ index, total }: RowPosition): string {
199
212
  }
200
213
 
201
214
  /** Take the column union apart once, at plan time; rows only run the result. */
202
- function planColumn(name: string, column: ProjectionColumn, compile: Compiler): ColumnReader {
215
+ function planColumn(name: string, column: ProjectionColumn, compile: Compiler): PlannedColumn {
203
216
  if (typeof column !== 'string' && 'test' in column) {
204
217
  const criteria = compile(column.test)
205
- return (root, context) => criteriaBoolean(evaluateNode(criteria.ast, forkVariables(context), root))
218
+ return {
219
+ ast: criteria.ast,
220
+ read: (root, context) => criteriaBoolean(evaluateNode(criteria.ast, forkVariables(context), root)),
221
+ }
206
222
  }
207
223
  const spec: Extract<ProjectionColumn, { path: string }> = typeof column === 'string' ? { path: column } : column
208
224
  assertShaperOptions(name, spec)
209
225
  const expression = compile(spec.path)
210
226
  const applyAs = coercion(spec)
211
227
  const empty = 'default' in spec ? spec.default : undefined
212
- return (root, context, at) => {
213
- const values = evaluateNode(expression.ast, forkVariables(context), root).map(unwrap)
214
- if (spec.collection === true) {
215
- return applyAs(values)
216
- }
217
- // The scalar-column rule counts the expression's values, before any `as`
218
- // coercion or `choices` miss drops them. One row of a batch can be the only
219
- // one that breaks it, so the message says which `%rowIndex` numbering,
220
- // the same the columns see.
221
- if (values.length > 1) {
222
- throw new FhirPathRuntimeError(
223
- `project(): column '${name}' yielded ${values.length} values${inRow(at)}; append first() or set collection: true`
224
- )
225
- }
226
- const coerced = applyAs(values)
227
- return coerced.length > 0 ? coerced[0] : empty
228
+ return {
229
+ ast: expression.ast,
230
+ read: (root, context, at) => {
231
+ const values = evaluateNode(expression.ast, forkVariables(context), root).map(unwrap)
232
+ if (spec.collection === true) {
233
+ return applyAs(values)
234
+ }
235
+ // The scalar-column rule counts the expression's values, before any `as`
236
+ // coercion or `choices` miss drops them. One row of a batch can be the only
237
+ // one that breaks it, so the message says which — `%rowIndex` numbering,
238
+ // the same the columns see.
239
+ if (values.length > 1) {
240
+ throw new FhirPathRuntimeError(
241
+ `project(): column '${name}' yielded ${values.length} values${inRow(at)}; append first() or set collection: true`
242
+ )
243
+ }
244
+ const coerced = applyAs(values)
245
+ return coerced.length > 0 ? coerced[0] : empty
246
+ },
228
247
  }
229
248
  }
230
249
 
@@ -239,16 +258,21 @@ export function projectRows(
239
258
  options: EvaluateOptions,
240
259
  compile: Compiler
241
260
  ): Record<string, unknown>[] {
242
- const readers = Object.entries(columns).map(([name, column]) => [name, planColumn(name, column, compile)] as const)
261
+ const planned = Object.entries(columns).map(([name, column]) => [name, planColumn(name, column, compile)] as const)
243
262
  const makeContext = contextFactory(options)
244
263
  const subjects = toSubjects(input)
245
264
  return subjects.map((subject, index) => {
246
265
  const root = toCollection(subject.value)
247
266
  const at: RowPosition = { index, total: subjects.length }
248
- const context = makeContext(root, { rowIndex: index, rowTotal: subjects.length })
267
+ const rowEnv = { rowIndex: index, rowTotal: subjects.length }
268
+ const strictOptions = options.strict === true ? { ...options, env: { ...options.env, ...rowEnv } } : options
269
+ for (const [, column] of planned) {
270
+ assertStrictExpression(column.ast, root, strictOptions)
271
+ }
272
+ const context = makeContext(root, rowEnv)
249
273
  const row: Record<string, unknown> = {}
250
- for (const [name, read] of readers) {
251
- row[name] = read(root, context, at)
274
+ for (const [name, column] of planned) {
275
+ row[name] = column.read(root, context, at)
252
276
  }
253
277
  return row
254
278
  })
@@ -0,0 +1,112 @@
1
+ import {
2
+ type AnalyzerDiagnostic,
3
+ type AnalyzerRoot,
4
+ analyzeRuntimeAstDetailed,
5
+ type RuntimeAnalyzeOptions,
6
+ } from '../analyzer/analyze.ts'
7
+ import {
8
+ analyzerVariable,
9
+ type AnalyzerVariableState,
10
+ runtimeAnalyzerEnvironmentVariables,
11
+ runtimeAnalyzerVariable,
12
+ } from '../analyzer/declarations.ts'
13
+ import { normalizeEnvKeys } from '../engine/context.ts'
14
+ import { FhirPathTypeError } from '../errors.ts'
15
+ import type { ModelProvider } from '../model/provider.ts'
16
+ import type { AstNode } from '../parser/ast.ts'
17
+ import { parse } from '../parser/parser.ts'
18
+ import type { TypedValue } from '../values/typed-value.ts'
19
+ import type { AnyExpression, EvaluateOptions } from './compile.ts'
20
+
21
+ interface StrictFinding {
22
+ diagnostic: AnalyzerDiagnostic
23
+ subject?: string
24
+ }
25
+
26
+ /** Reject every error-severity analyzer finding before strict evaluation begins. */
27
+ export function assertStrictExpression(ast: AstNode, root: TypedValue[], options: EvaluateOptions | undefined): void {
28
+ if (options?.strict !== true) {
29
+ return
30
+ }
31
+
32
+ const model = options.model
33
+ const analyzerRoot = runtimeRoot(root, model)
34
+ const variables: Record<string, AnalyzerVariableState> = runtimeAnalyzerEnvironmentVariables(
35
+ options.env,
36
+ options.envTypes,
37
+ model
38
+ )
39
+ const declarations = normalizeEnvKeys(options.varTypes)
40
+ const findings: StrictFinding[] = []
41
+
42
+ for (const [name, value] of Object.entries(normalizeEnvKeys(options.vars))) {
43
+ const declaration = declarations[name]
44
+ if (isResolvedCollection(value)) {
45
+ variables[name] = runtimeAnalyzerVariable(value, model, declaration)
46
+ continue
47
+ }
48
+
49
+ const details = analyzeRuntimeAstDetailed(astOf(value), analyzerOptions(options, variables), analyzerRoot)
50
+ findings.push(
51
+ ...details.diagnostics
52
+ .filter(diagnostic => diagnostic.severity === 'error')
53
+ .map(diagnostic => ({ diagnostic, subject: `vars.${name}` }))
54
+ )
55
+ variables[name] =
56
+ declaration === undefined
57
+ ? {
58
+ ...(details.result.types !== undefined && { types: details.result.types }),
59
+ ...(details.result.single !== undefined && { single: details.result.single }),
60
+ }
61
+ : analyzerVariable(declaration)
62
+ }
63
+
64
+ for (const [name, declaration] of Object.entries(declarations)) {
65
+ variables[name] ??= analyzerVariable(declaration)
66
+ }
67
+
68
+ const details = analyzeRuntimeAstDetailed(ast, analyzerOptions(options, variables), analyzerRoot)
69
+ findings.push(
70
+ ...details.diagnostics.filter(diagnostic => diagnostic.severity === 'error').map(diagnostic => ({ diagnostic }))
71
+ )
72
+
73
+ if (findings.length > 0) {
74
+ throw new FhirPathTypeError(formatFindings(findings))
75
+ }
76
+ }
77
+
78
+ function analyzerOptions(
79
+ options: EvaluateOptions,
80
+ variables: Record<string, AnalyzerVariableState>
81
+ ): RuntimeAnalyzeOptions {
82
+ return {
83
+ ...(options.model !== undefined && { model: options.model }),
84
+ ...(options.functions !== undefined && { functions: options.functions }),
85
+ variables,
86
+ }
87
+ }
88
+
89
+ function runtimeRoot(root: TypedValue[], model: ModelProvider | undefined): AnalyzerRoot {
90
+ const variable = runtimeAnalyzerVariable(root, model)
91
+ return {
92
+ types: variable.types,
93
+ single: variable.single,
94
+ exactTypes: variable.exactTypes,
95
+ }
96
+ }
97
+
98
+ function isResolvedCollection(value: AnyExpression | readonly TypedValue[]): value is readonly TypedValue[] {
99
+ return Array.isArray(value)
100
+ }
101
+
102
+ function astOf(expression: AnyExpression): AstNode {
103
+ return typeof expression === 'string' ? parse(expression) : expression.ast
104
+ }
105
+
106
+ function formatFindings(findings: StrictFinding[]): string {
107
+ const lines = findings.map(({ diagnostic, subject }) => {
108
+ const prefix = subject === undefined ? '' : `${subject}: `
109
+ return `- [${diagnostic.code}] ${prefix}${diagnostic.message} (line ${diagnostic.span.line}, column ${diagnostic.span.column})`
110
+ })
111
+ return `Strict evaluation failed:\n${lines.join('\n')}`
112
+ }
package/src/api/tagged.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { FhirPathError } from '../errors.ts'
2
2
  import type { R4TypeOf } from '../r4/generated/type-maps.ts'
3
- import type { FhirpathInput, FhirpathResult, FhirpathResultIn, FhirTypeName } from '../typed/infer.ts'
4
- import { CompiledExpression } from './compile.ts'
3
+ import type { FhirpathInput, FhirTypeName } from '../typed/infer.ts'
4
+ import { CompiledExpression, type InferredExpressionResult } from './compile.ts'
5
5
 
6
6
  /**
7
7
  * Compiles a FHIRPath expression. The call form infers supported literal
@@ -13,12 +13,12 @@ import { CompiledExpression } from './compile.ts'
13
13
  export function fhirpath<
14
14
  const Expr extends string,
15
15
  const Root extends FhirTypeName,
16
- TResult extends unknown[] = FhirpathResultIn<Expr, Root>,
17
- >(expression: Expr, inputType: Root): CompiledExpression<Expr, R4TypeOf[Root], TResult>
16
+ TResult extends unknown[] | InferredExpressionResult = InferredExpressionResult,
17
+ >(expression: Expr, inputType: Root): CompiledExpression<Expr, R4TypeOf[Root], TResult, Root>
18
18
  export function fhirpath<
19
19
  const Expr extends string,
20
20
  TInput = FhirpathInput<Expr>,
21
- TResult extends unknown[] = FhirpathResult<Expr>,
21
+ TResult extends unknown[] | InferredExpressionResult = InferredExpressionResult,
22
22
  >(expression: Expr): CompiledExpression<Expr, TInput, TResult>
23
23
  export function fhirpath(strings: TemplateStringsArray, ...substitutions: never[]): CompiledExpression
24
24
  export function fhirpath(input: string | TemplateStringsArray, ...rest: unknown[]): CompiledExpression {
@@ -79,9 +79,10 @@ function analyzeFor(dto: DtoClass, engines: readonly FhirPathEngine[]): DtoDiagn
79
79
  }
80
80
 
81
81
  /**
82
- * Every engine's context as one: the union of their registered functions and env
83
- * names. The first engine's `model` stands for all of them — a project binds one
84
- * FHIR version, and an unregistered DTO names no engine that could pick another.
82
+ * Every engine's context as one: the union of their registered functions,
83
+ * environment declarations, and vars. The first engine's `model` stands for
84
+ * all of them — a project binds one FHIR version, and an unregistered DTO names
85
+ * no engine that could pick another.
85
86
  */
86
87
  function merged(engines: readonly FhirPathEngine[]): AnalyzedContext {
87
88
  return {
@@ -89,6 +90,9 @@ function merged(engines: readonly FhirPathEngine[]): AnalyzedContext {
89
90
  ...engines[0]?.defaults,
90
91
  functions: Object.assign({}, ...engines.map(engine => engine.defaults.functions)),
91
92
  env: Object.assign({}, ...engines.map(engine => engine.defaults.env)),
93
+ envTypes: Object.assign({}, ...engines.map(engine => engine.defaults.envTypes)),
94
+ vars: Object.assign({}, ...engines.map(engine => engine.defaults.vars)),
95
+ varTypes: Object.assign({}, ...engines.map(engine => engine.defaults.varTypes)),
92
96
  },
93
97
  }
94
98
  }
@@ -157,16 +157,21 @@ export const BUILTIN_ENV_VARIABLE_NAMES: ReadonlySet<string> = new Set([
157
157
  'rootResource',
158
158
  ])
159
159
 
160
+ /** The canonical key used for environment and variable maps. */
161
+ export function bareEnvironmentName(name: string): string {
162
+ return name.startsWith('%') ? name.slice(1) : name
163
+ }
164
+
160
165
  /**
161
166
  * Env records accept variable names with or without the leading `%`
162
167
  * (`{ '%loinc': … }` or `{ loinc: … }`). Normalize to bare names — the form the
163
168
  * context binds — so code that merges or overrides env records treats both
164
169
  * spellings as one namespace. Later entries win on the same bare name.
165
170
  */
166
- export function normalizeEnvKeys<T>(env: Record<string, T> | undefined): Record<string, T> {
171
+ export function normalizeEnvKeys<T>(env: Readonly<Record<string, T>> | undefined): Record<string, T> {
167
172
  const normalized: Record<string, T> = {}
168
173
  for (const [name, value] of Object.entries(env ?? {})) {
169
- normalized[name.startsWith('%') ? name.slice(1) : name] = value
174
+ normalized[bareEnvironmentName(name)] = value
170
175
  }
171
176
  return normalized
172
177
  }
@@ -1,6 +1,6 @@
1
- import { FhirPathTypeError } from '../errors.ts'
2
1
  import { readModelProperty } from '../fhir/model-navigation.ts'
3
- import { OBJECT_TYPE, toTypedValue, type TypedValue } from '../values/typed-value.ts'
2
+ import { rootTypeMatches } from '../values/type-compat.ts'
3
+ import { toTypedValue, type TypedValue } from '../values/typed-value.ts'
4
4
  import type { EvaluationContext } from './context.ts'
5
5
 
6
6
  /**
@@ -11,22 +11,15 @@ import type { EvaluationContext } from './context.ts'
11
11
  export function navigateIdentifier(context: EvaluationContext, name: string, input: TypedValue[]): TypedValue[] {
12
12
  const results: TypedValue[] = []
13
13
  for (const item of input) {
14
- if (matchesTypeName(context, item, name)) {
14
+ if (rootTypeMatches(context.model, item.type, name)) {
15
15
  results.push(item)
16
16
  } else if (context.model && item.type.startsWith(`${context.model.namespace}.`)) {
17
17
  const modelRead = readModelProperty(context.model, item, name)
18
18
  if (modelRead === undefined) {
19
- // Choice elements go by stem name; a suffixed key like Observation.valueQuantity
20
- // is the one unknown-element shape the official suites require to error at
21
- // runtime. Everything else is the static analyzer's job (spec §11) plain
22
- // unknown elements navigate to empty, and types the model has never heard of
23
- // (custom resourceTypes) read like raw JSON.
24
- // See: https://hl7.org/fhirpath/en/index.html#type-safety-and-strict-evaluation
25
- if (isChoiceKeyMisuse(context, item.type, name)) {
26
- throw new FhirPathTypeError(
27
- `Element '${name}' is not defined on ${item.type}; choice elements use their stem name`
28
- )
29
- }
19
+ // Unknown model elements navigate to empty. Strict evaluation runs the
20
+ // analyzer before reaching this point, including for choice-key misuse.
21
+ // Types the model has never heard of (custom resourceTypes) still read
22
+ // like raw JSON.
30
23
  if (
31
24
  name === 'resourceType' ||
32
25
  (context.model.listElements !== undefined && context.model.listElements(item.type) === undefined)
@@ -46,44 +39,6 @@ export function navigateIdentifier(context: EvaluationContext, name: string, inp
46
39
  return results
47
40
  }
48
41
 
49
- /** True for `valueQuantity`-style keys whose stem is a choice element of the type. */
50
- function isChoiceKeyMisuse(context: EvaluationContext, type: string, name: string): boolean {
51
- const model = context.model as NonNullable<EvaluationContext['model']>
52
- for (let position = 1; position < name.length; position++) {
53
- if (!/[A-Z]/.test(name[position] as string)) {
54
- continue
55
- }
56
- const stem = name.slice(0, position)
57
- if (model.getElement(type, stem)?.isChoice === true) {
58
- return true
59
- }
60
- }
61
- return false
62
- }
63
-
64
- function matchesTypeName(context: EvaluationContext, item: TypedValue, name: string): boolean {
65
- // Only the namespace strips off: the backbone type FHIR.ValueSet.expansion.contains
66
- // must not answer to the element name 'contains'. Lowercase primitive type names
67
- // (FHIR.code) never self-match either — `children().code` means the element.
68
- if (!/^[A-Z]/.test(name)) {
69
- // Lowercase names are always elements: `children().code` never filters by the
70
- // primitive type FHIR.code.
71
- return false
72
- }
73
- const separator = item.type.indexOf('.')
74
- const local = separator === -1 ? item.type : item.type.slice(separator + 1)
75
- if (local === name && item.type !== OBJECT_TYPE) {
76
- return true
77
- }
78
- if (context.model) {
79
- const canonical = context.model.resolveType(name)
80
- if (canonical !== undefined) {
81
- return context.model.isSubtypeOf(item.type, canonical)
82
- }
83
- }
84
- return false
85
- }
86
-
87
42
  /** Read one child element from a complex value, flattening arrays. Missing → empty. */
88
43
  export function getProperty(item: TypedValue, name: string): TypedValue[] {
89
44
  const value = item.value