@effect-app/infra 4.0.0-beta.212 → 4.0.0-beta.214

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 (42) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/Model/Repository/internal/internal.d.ts.map +1 -1
  3. package/dist/Model/Repository/internal/internal.js +6 -2
  4. package/dist/Model/Repository/validation.d.ts +8 -8
  5. package/dist/Model/query/dsl.d.ts +76 -1
  6. package/dist/Model/query/dsl.d.ts.map +1 -1
  7. package/dist/Model/query/dsl.js +111 -1
  8. package/dist/Model/query/new-kid-interpreter.d.ts +38 -2
  9. package/dist/Model/query/new-kid-interpreter.d.ts.map +1 -1
  10. package/dist/Model/query/new-kid-interpreter.js +80 -4
  11. package/dist/RequestContext.d.ts +12 -12
  12. package/dist/Store/Cosmos/query.d.ts +5 -1
  13. package/dist/Store/Cosmos/query.d.ts.map +1 -1
  14. package/dist/Store/Cosmos/query.js +63 -23
  15. package/dist/Store/Cosmos.d.ts.map +1 -1
  16. package/dist/Store/Cosmos.js +1 -1
  17. package/dist/Store/Memory.d.ts.map +1 -1
  18. package/dist/Store/Memory.js +86 -2
  19. package/dist/Store/SQL/Pg.d.ts.map +1 -1
  20. package/dist/Store/SQL/Pg.js +1 -1
  21. package/dist/Store/SQL/query.d.ts +5 -1
  22. package/dist/Store/SQL/query.d.ts.map +1 -1
  23. package/dist/Store/SQL/query.js +51 -1
  24. package/dist/Store/SQL.d.ts.map +1 -1
  25. package/dist/Store/SQL.js +1 -1
  26. package/dist/Store/service.d.ts +5 -2
  27. package/dist/Store/service.d.ts.map +1 -1
  28. package/dist/Store/service.js +1 -1
  29. package/package.json +2 -2
  30. package/src/Model/Repository/internal/internal.ts +5 -1
  31. package/src/Model/query/dsl.ts +191 -0
  32. package/src/Model/query/new-kid-interpreter.ts +124 -4
  33. package/src/Store/Cosmos/query.ts +80 -23
  34. package/src/Store/Cosmos.ts +10 -2
  35. package/src/Store/Memory.ts +96 -4
  36. package/src/Store/SQL/Pg.ts +10 -1
  37. package/src/Store/SQL/query.ts +65 -1
  38. package/src/Store/SQL.ts +19 -2
  39. package/src/Store/service.ts +9 -2
  40. package/test/query.test.ts +156 -1
  41. package/test/rawQuery.test.ts +36 -1
  42. package/test/sql-store.test.ts +362 -0
@@ -97,6 +97,46 @@ export type QueryProjection<
97
97
  R,
98
98
  TType
99
99
  >
100
+
101
+ export type ComputedProjectionOperation = (q: Query<any>) => QueryWhere<any, any, any>
102
+
103
+ export type ComputedProjectionExpression =
104
+ | {
105
+ readonly _tag: "relation-count"
106
+ readonly path: string
107
+ readonly operation?: ComputedProjectionOperation
108
+ }
109
+ | {
110
+ readonly _tag: "relation-any"
111
+ readonly path: string
112
+ readonly operation?: ComputedProjectionOperation
113
+ }
114
+ | {
115
+ readonly _tag: "relation-every"
116
+ readonly path: string
117
+ readonly operation: ComputedProjectionOperation
118
+ }
119
+ | {
120
+ readonly _tag: "relation-distinct-count"
121
+ readonly path: string
122
+ readonly field: string
123
+ readonly operation?: ComputedProjectionOperation
124
+ }
125
+ | {
126
+ readonly _tag: "relation-sum"
127
+ readonly path: string
128
+ readonly field: string
129
+ readonly operation?: ComputedProjectionOperation
130
+ }
131
+ | {
132
+ readonly _tag: "relation-collect"
133
+ readonly path: string
134
+ readonly field: string
135
+ readonly distinct: boolean
136
+ readonly operation?: ComputedProjectionOperation
137
+ }
138
+
139
+ export type ComputedProjectionMap = Readonly<Record<string, ComputedProjectionExpression>>
100
140
  export type Q<TFieldValues extends FieldValues> =
101
141
  | Initial<TFieldValues>
102
142
  | Where<TFieldValues>
@@ -211,6 +251,7 @@ export class Project<A, TFieldValues extends FieldValues, R, TType extends "one"
211
251
  current: Query<TFieldValues> | QueryWhere<any, TFieldValues> | QueryEnd<TFieldValues, TType>
212
252
  schema: S.Codec<A, TFieldValues, R>
213
253
  mode: "collect" | "project" | "transform"
254
+ computed?: ComputedProjectionMap
214
255
  }>
215
256
  implements QueryProjection<TFieldValues, A, R>
216
257
  {
@@ -299,6 +340,35 @@ export const count: {
299
340
  ): QueryProjection<ExtractFieldValuesRefined<Q>, NonNegativeInt, never, "count", ExtractExclusiveness<Q>>
300
341
  } = (current) => new Count({ current })
301
342
 
343
+ /**
344
+ * Attach a projection schema to a query.
345
+ *
346
+ * The `select` clause sent to the store is derived from the schema's encoded
347
+ * AST property names (top-level + per-array sub-keys), so a projection always
348
+ * narrows what is read from the store. The repository augments that select
349
+ * with `id` and `_etag` for change tracking. See {@link toFilter} and the
350
+ * dispatch in `Repository/internal/internal.ts`.
351
+ *
352
+ * Modes — pick based on shape of the decoded value and on whether the
353
+ * persistence-model (PM) reverse-mapping is needed:
354
+ *
355
+ * - `"transform"` (default when `mode` omitted): goes through the repo's
356
+ * `parseMany`/`parseMany2` pipeline. The raw row is reverse-mapped via the
357
+ * etag/PM cache (re-injecting `_etag` and any PM-shape state) before
358
+ * decoding. Decode failures `orDie` (error channel = `never`). Use when
359
+ * the schema operates on the full PM shape (e.g. full-entity reads that
360
+ * must preserve etag tracking).
361
+ *
362
+ * - `"project"`: decodes the raw encoded row directly with the supplied
363
+ * schema. No PM reverse-mapping, no etag cache merge. Decode failures
364
+ * surface as `S.SchemaError`. Use for slim DTOs / aggregations that do not
365
+ * need etag tracking and whose schema input is a plain subset of `Encoded`.
366
+ *
367
+ * - `"collect"`: like `"project"`, but the schema yields `Option<A>` and
368
+ * `None` values are dropped post-decode (`Array.getSomes`). Use to filter
369
+ * rows during decode (e.g. discriminated-union narrowing where some rows
370
+ * should not appear in the result).
371
+ */
302
372
  export const project: {
303
373
  <
304
374
  Q extends Query<any> | QueryWhere<any, any, any> | QueryEnd<any, "one" | "many", any>,
@@ -356,6 +426,127 @@ export const project: {
356
426
  ) => QueryProjection<ExtractFieldValuesRefined<Q>, A, R, ExtractTType<Q>, E>
357
427
  } = (schema: any, mode = "transform") => (current: any) => new Project({ current, schema, mode } as any)
358
428
 
429
+ export const relation = <TFieldValues extends FieldValues>(
430
+ path: FieldPath<TFieldValues>
431
+ ) => ({
432
+ count: (operation?: ComputedProjectionOperation): ComputedProjectionExpression =>
433
+ operation
434
+ ? {
435
+ _tag: "relation-count",
436
+ path: path as string,
437
+ operation
438
+ }
439
+ : {
440
+ _tag: "relation-count",
441
+ path: path as string
442
+ },
443
+ any: (operation?: ComputedProjectionOperation): ComputedProjectionExpression =>
444
+ operation
445
+ ? {
446
+ _tag: "relation-any",
447
+ path: path as string,
448
+ operation
449
+ }
450
+ : {
451
+ _tag: "relation-any",
452
+ path: path as string
453
+ },
454
+ every: (operation: ComputedProjectionOperation): ComputedProjectionExpression => ({
455
+ _tag: "relation-every",
456
+ path: path as string,
457
+ operation
458
+ }),
459
+ distinctCount: (field: string, operation?: ComputedProjectionOperation): ComputedProjectionExpression =>
460
+ operation
461
+ ? {
462
+ _tag: "relation-distinct-count",
463
+ path: path as string,
464
+ field,
465
+ operation
466
+ }
467
+ : {
468
+ _tag: "relation-distinct-count",
469
+ path: path as string,
470
+ field
471
+ },
472
+ sum: (field: string, operation?: ComputedProjectionOperation): ComputedProjectionExpression =>
473
+ operation
474
+ ? {
475
+ _tag: "relation-sum",
476
+ path: path as string,
477
+ field,
478
+ operation
479
+ }
480
+ : {
481
+ _tag: "relation-sum",
482
+ path: path as string,
483
+ field
484
+ },
485
+ collect: (field: string, operation?: ComputedProjectionOperation): ComputedProjectionExpression =>
486
+ operation
487
+ ? {
488
+ _tag: "relation-collect",
489
+ path: path as string,
490
+ field,
491
+ distinct: false,
492
+ operation
493
+ }
494
+ : {
495
+ _tag: "relation-collect",
496
+ path: path as string,
497
+ field,
498
+ distinct: false
499
+ },
500
+ collectDistinct: (field: string, operation?: ComputedProjectionOperation): ComputedProjectionExpression =>
501
+ operation
502
+ ? {
503
+ _tag: "relation-collect",
504
+ path: path as string,
505
+ field,
506
+ distinct: true,
507
+ operation
508
+ }
509
+ : {
510
+ _tag: "relation-collect",
511
+ path: path as string,
512
+ field,
513
+ distinct: true
514
+ }
515
+ })
516
+
517
+ export const computed = <T extends ComputedProjectionMap>(value: T): T => value
518
+
519
+ export const projectComputed: {
520
+ <
521
+ Q extends Query<any> | QueryWhere<any, any, any> | QueryEnd<any, "one" | "many", any>,
522
+ I extends Record<string, unknown>,
523
+ A = ExtractFieldValuesRefined<Q>,
524
+ R = never,
525
+ E extends boolean = ExtractExclusiveness<Q>
526
+ >(
527
+ schema: S.Codec<Option.Option<A>, I, R>,
528
+ computedProjection: ComputedProjectionMap,
529
+ mode: "collect"
530
+ ): (
531
+ current: Q
532
+ ) => QueryProjection<ExtractFieldValuesRefined<Q>, A, R, ExtractTType<Q>, E>
533
+
534
+ <
535
+ Q extends Query<any> | QueryWhere<any, any, any> | QueryEnd<any, "one" | "many", any>,
536
+ I extends Record<string, unknown>,
537
+ A = ExtractFieldValuesRefined<Q>,
538
+ R = never,
539
+ E extends boolean = ExtractExclusiveness<Q>
540
+ >(
541
+ schema: S.Codec<A, I, R>,
542
+ computedProjection: ComputedProjectionMap,
543
+ mode?: "project"
544
+ ): (
545
+ current: Q
546
+ ) => QueryProjection<ExtractFieldValuesRefined<Q>, A, R, ExtractTType<Q>, E>
547
+ } = (schema: any, computedProjection: ComputedProjectionMap, mode = "project") => (current: any) =>
548
+ new Project({ current, schema, mode, computed: computedProjection } as any)
549
+
359
550
  type GetArV<T> = T extends readonly (infer R)[] ? R : never
360
551
 
361
552
  export type FilterContinuations<IsCurrentInitial extends boolean = false> = {
@@ -8,6 +8,42 @@ import type { FieldValues } from "../filter/types.js"
8
8
  import type { FieldPath } from "../filter/types/path/eager.js"
9
9
  import { make, type Q, type QAll } from "../query/dsl.js"
10
10
 
11
+ export type ComputedProjectionIrExpression =
12
+ | {
13
+ readonly _tag: "relation-count"
14
+ readonly path: string
15
+ readonly filter: readonly FilterResult[]
16
+ }
17
+ | {
18
+ readonly _tag: "relation-any"
19
+ readonly path: string
20
+ readonly filter: readonly FilterResult[]
21
+ }
22
+ | {
23
+ readonly _tag: "relation-every"
24
+ readonly path: string
25
+ readonly filter: readonly FilterResult[]
26
+ }
27
+ | {
28
+ readonly _tag: "relation-distinct-count"
29
+ readonly path: string
30
+ readonly field: string
31
+ readonly filter: readonly FilterResult[]
32
+ }
33
+ | {
34
+ readonly _tag: "relation-sum"
35
+ readonly path: string
36
+ readonly field: string
37
+ readonly filter: readonly FilterResult[]
38
+ }
39
+ | {
40
+ readonly _tag: "relation-collect"
41
+ readonly path: string
42
+ readonly field: string
43
+ readonly distinct: boolean
44
+ readonly filter: readonly FilterResult[]
45
+ }
46
+
11
47
  type Result<TFieldValues extends FieldValues, A = TFieldValues, R = never> = {
12
48
  filter: FilterResult[]
13
49
  schema: S.Codec<A, TFieldValues, R> | undefined
@@ -16,6 +52,7 @@ type Result<TFieldValues extends FieldValues, A = TFieldValues, R = never> = {
16
52
  order: { key: FieldPath<TFieldValues>; direction: "ASC" | "DESC" }[]
17
53
  ttype: "one" | "many" | "count" | undefined
18
54
  mode: "collect" | "project" | "transform" | undefined
55
+ computed: Record<string, ComputedProjectionIrExpression> | undefined
19
56
  }
20
57
 
21
58
  const interpret = <
@@ -33,7 +70,8 @@ const interpret = <
33
70
  skip: undefined,
34
71
  order: [],
35
72
  ttype: undefined,
36
- mode: undefined
73
+ mode: undefined,
74
+ computed: undefined
37
75
  }
38
76
 
39
77
  const upd = (
@@ -46,6 +84,7 @@ const interpret = <
46
84
  if (v.ttype !== undefined) data.ttype = v.ttype
47
85
  if (v.schema !== undefined) data.schema = v.schema
48
86
  if (v.mode !== undefined) data.mode = v.mode
87
+ if (v.computed !== undefined) data.computed = v.computed
49
88
  }
50
89
 
51
90
  const applyPath = (path: string) => (_: FilterResult): FilterResult =>
@@ -135,8 +174,44 @@ const interpret = <
135
174
  },
136
175
  project: (v) => {
137
176
  upd(interpret(v.current))
177
+ if (v.computed && v.mode === "transform") {
178
+ throw new Error("Computed projections require mode 'project' or 'collect', not 'transform'")
179
+ }
138
180
  data.schema = v.schema
139
- data.mode = v.mode
181
+ data.mode = v.computed
182
+ ? v.mode === "collect" ? "collect" : "project"
183
+ : v.mode
184
+ data.computed = v.computed
185
+ ? Object.fromEntries(
186
+ Object.entries(v.computed).map(([key, expression]) => {
187
+ const e = expression
188
+ const filter = e.operation ? interpret(e.operation(make())).filter.map(applyPath(e.path)) : []
189
+ switch (e._tag) {
190
+ case "relation-count":
191
+ case "relation-any":
192
+ case "relation-every":
193
+ return [key, { _tag: e._tag, path: e.path, filter } as ComputedProjectionIrExpression]
194
+ case "relation-distinct-count":
195
+ case "relation-sum":
196
+ return [
197
+ key,
198
+ { _tag: e._tag, path: e.path, field: e.field, filter } as ComputedProjectionIrExpression
199
+ ]
200
+ case "relation-collect":
201
+ return [
202
+ key,
203
+ {
204
+ _tag: e._tag,
205
+ path: e.path,
206
+ field: e.field,
207
+ distinct: e.distinct,
208
+ filter
209
+ } as ComputedProjectionIrExpression
210
+ ]
211
+ }
212
+ })
213
+ )
214
+ : undefined
140
215
  }
141
216
  })
142
217
  )
@@ -157,12 +232,16 @@ export const toFilter = <
157
232
  R,
158
233
  TFieldValuesRefined extends TFieldValues = TFieldValues
159
234
  >(
160
- q: QAll<TFieldValues, TFieldValuesRefined, A, R>
235
+ q: QAll<TFieldValues, TFieldValuesRefined, A, R>,
236
+ baseSchema?: S.Schema<unknown>
161
237
  ) => {
162
238
  // TODO: Native interpreter for each db adapter, instead of the intermediate "new-kid" format
163
239
  const a = interpret(q)
164
240
  const schema = a.schema
165
- let select: (keyof TFieldValues | { key: string; subKeys: string[] })[] = []
241
+ let select: (keyof TFieldValues | { key: string; subKeys: string[] } | {
242
+ key: string
243
+ computed: ComputedProjectionIrExpression
244
+ })[] = []
166
245
  // TODO: support more complex (nested) schemas?
167
246
  if (schema) {
168
247
  const t = walkTransformation(SchemaAST.toEncoded(schema.ast))
@@ -194,12 +273,53 @@ export const toFilter = <
194
273
  }
195
274
  }
196
275
  }
276
+ const computed = a.computed
277
+ const getSelectKey = (_: (typeof select)[number]) => {
278
+ if (typeof _ === "string") {
279
+ return _
280
+ }
281
+ if (typeof _ === "object" && _ !== null && "key" in _) {
282
+ return _.key
283
+ }
284
+ return String(_)
285
+ }
286
+ const schemaKeys = select.map(getSelectKey)
287
+ const nonEncodedSchemaKeys = (() => {
288
+ if (!baseSchema) {
289
+ return [] as string[]
290
+ }
291
+ const encoded = walkTransformation(SchemaAST.toEncoded(baseSchema.ast))
292
+ if (!S.AST.isObjects(encoded)) {
293
+ return [] as string[]
294
+ }
295
+ const encodedKeys = encoded.propertySignatures.map((_) => _.name as string)
296
+ return schemaKeys.filter((key) => !encodedKeys.includes(key))
297
+ })()
298
+ const missingComputedKeys = nonEncodedSchemaKeys.filter((key) => !(computed && key in computed))
299
+
300
+ if (Array.isArrayNonEmpty(missingComputedKeys)) {
301
+ throw new Error(`Missing computed projections for schema keys: ${missingComputedKeys.join(", ")}`)
302
+ }
303
+
304
+ if (computed) {
305
+ const computedKeys = Object.keys(computed)
306
+ const extraComputedKeys = computedKeys.filter((key) => !schemaKeys.includes(key))
307
+ if (Array.isArrayNonEmpty(extraComputedKeys)) {
308
+ throw new Error(`Computed projection keys must exist in projection schema: ${extraComputedKeys.join(", ")}`)
309
+ }
310
+ select = select.filter((_) => {
311
+ const key = getSelectKey(_)
312
+ return !(key in computed)
313
+ })
314
+ select.push(...Object.entries(computed).map(([key, expression]) => ({ key, computed: expression })))
315
+ }
197
316
  return dropUndefinedT({
198
317
  t: null as unknown as TFieldValues,
199
318
  limit: a.limit,
200
319
  skip: a.skip,
201
320
  select: Option.getOrUndefined(toNonEmptyArray(select)),
202
321
  schema,
322
+ computed,
203
323
  order: Option.getOrUndefined(toNonEmptyArray(a.order)),
204
324
  ttype: a.ttype,
205
325
  mode: a.mode ?? "transform",
@@ -4,6 +4,7 @@ import { Array, Effect, type NonEmptyReadonlyArray } from "effect-app"
4
4
  import { assertUnreachable } from "effect-app/utils"
5
5
  import { InfraLogger } from "../../logger.js"
6
6
  import type { FilterR, FilterResult, Ops } from "../../Model/filter/filterApi.js"
7
+ import type { ComputedProjectionIrExpression } from "../../Model/query.js"
7
8
  import { isRelationCheck } from "../codeFilter.js"
8
9
  import type { SupportedValues } from "../service.js"
9
10
 
@@ -40,12 +41,20 @@ export function buildWhereCosmosQuery3(
40
41
  filter: readonly FilterResult[],
41
42
  name: string,
42
43
  defaultValues: Record<string, unknown>,
43
- select?: NonEmptyReadonlyArray<string | { key: string; subKeys: readonly string[] }>,
44
+ select?: NonEmptyReadonlyArray<
45
+ string | {
46
+ key: string
47
+ subKeys: readonly string[]
48
+ } | {
49
+ key: string
50
+ computed: ComputedProjectionIrExpression
51
+ }
52
+ >,
44
53
  order?: NonEmptyReadonlyArray<{ key: string; direction: "ASC" | "DESC" }>,
45
54
  skip?: number,
46
55
  limit?: number
47
56
  ) {
48
- const statement = (x: FilterR, i: number, values: any[]) => {
57
+ const statement = (x: FilterR, i: number) => {
49
58
  if (x.path === idKey) {
50
59
  x = { ...x, path: "id" }
51
60
  }
@@ -60,8 +69,6 @@ export function buildWhereCosmosQuery3(
60
69
 
61
70
  const v = "@v" + i
62
71
 
63
- const realValue = values[i]
64
-
65
72
  switch (x.op) {
66
73
  case "in":
67
74
  return `ARRAY_CONTAINS(${v}, ${k})`
@@ -74,14 +81,22 @@ export function buildWhereCosmosQuery3(
74
81
  return `(NOT ARRAY_CONTAINS(${k}, ${v}))`
75
82
 
76
83
  case "includes-any":
77
- return `ARRAY_CONTAINS_ANY(${k}, ${(realValue as any[]).map((_, i) => `${v}__${i}`).join(", ")})`
84
+ return `ARRAY_CONTAINS_ANY(${k}, ${
85
+ (x.value as unknown as readonly unknown[]).map((_, i) => `${v}__${i}`).join(", ")
86
+ })`
78
87
  case "notIncludes-any":
79
- return `(NOT ARRAY_CONTAINS_ANY(${k}, ${(realValue as any[]).map((_, i) => `${v}__${i}`).join(", ")}))`
88
+ return `(NOT ARRAY_CONTAINS_ANY(${k}, ${
89
+ (x.value as unknown as readonly unknown[]).map((_, i) => `${v}__${i}`).join(", ")
90
+ }))`
80
91
 
81
92
  case "includes-all":
82
- return `ARRAY_CONTAINS_ALL(${k}, ${(realValue as any[]).map((_, i) => `${v}__${i}`).join(", ")})`
93
+ return `ARRAY_CONTAINS_ALL(${k}, ${
94
+ (x.value as unknown as readonly unknown[]).map((_, i) => `${v}__${i}`).join(", ")
95
+ })`
83
96
  case "notIncludes-all":
84
- return `(NOT ARRAY_CONTAINS_ALL(${k}, ${(realValue as any[]).map((_, i) => `${v}__${i}`).join(", ")}))`
97
+ return `(NOT ARRAY_CONTAINS_ALL(${k}, ${
98
+ (x.value as unknown as readonly unknown[]).map((_, i) => `${v}__${i}`).join(", ")
99
+ }))`
85
100
 
86
101
  case "contains":
87
102
  return `CONTAINS(${k}, ${v}, true)`
@@ -165,7 +180,7 @@ export function buildWhereCosmosQuery3(
165
180
  : _
166
181
  : _
167
182
 
168
- const print = (state: readonly FilterResult[], values: any[], isRelation: string | null, every: boolean) => {
183
+ const print = (state: readonly FilterResult[], isRelation: string | null, every: boolean) => {
169
184
  let s = ""
170
185
  let l = 0
171
186
  const printN = (n: number) => {
@@ -174,13 +189,13 @@ export function buildWhereCosmosQuery3(
174
189
  for (const e of state) {
175
190
  switch (e.t) {
176
191
  case "where":
177
- s += statement(e, i++, values)
192
+ s += statement(e, i++)
178
193
  break
179
194
  case "or":
180
- s += ` OR ${statement(e, i++, values)}`
195
+ s += ` OR ${statement(e, i++)}`
181
196
  break
182
197
  case "and":
183
- s += ` AND ${statement(e, i++, values)}`
198
+ s += ` AND ${statement(e, i++)}`
184
199
  break
185
200
  case "or-scope": {
186
201
  ++l
@@ -189,7 +204,7 @@ export function buildWhereCosmosQuery3(
189
204
  if (rel) {
190
205
  const rel = (e.result[0]! as { path: string }).path.split(".-1.")[0]
191
206
  s += isRelation
192
- ? ` OR (\n${printN(l + 1)}${print(e.result, values, rel, every)}\n${printN(l)})`
207
+ ? ` OR (\n${printN(l + 1)}${print(e.result, rel, every)}\n${printN(l)})`
193
208
  : ` OR (\n${printN(l + 1)}${
194
209
  every ? "NOT " : ""
195
210
  }EXISTS(SELECT VALUE ${rel} FROM ${rel} IN f.${rel} WHERE ${
@@ -197,13 +212,12 @@ export function buildWhereCosmosQuery3(
197
212
  e
198
213
  .result
199
214
  .map(flip(every)),
200
- values,
201
215
  rel,
202
216
  every
203
217
  )
204
218
  }))`
205
219
  } else {
206
- s += ` OR (\n${printN(l + 1)}${print(e.result, values, null, every)}\n${printN(l)})`
220
+ s += ` OR (\n${printN(l + 1)}${print(e.result, null, every)}\n${printN(l)})`
207
221
  }
208
222
  --l
209
223
  break
@@ -215,14 +229,14 @@ export function buildWhereCosmosQuery3(
215
229
  if (rel) {
216
230
  const rel = (e.result[0]! as { path: string }).path.split(".-1.")[0]
217
231
  s += isRelation
218
- ? ` AND (\n${printN(l + 1)}${print(e.result, values, rel, every)}\n${printN(l)})`
232
+ ? ` AND (\n${printN(l + 1)}${print(e.result, rel, every)}\n${printN(l)})`
219
233
  : ` AND (\n${printN(l + 1)}${
220
234
  every ? "NOT " : ""
221
235
  }EXISTS(SELECT VALUE ${rel} FROM ${rel} IN f.${rel} WHERE ${
222
- print(e.result.map(flip(every)), values, rel, every)
236
+ print(e.result.map(flip(every)), rel, every)
223
237
  }))`
224
238
  } else {
225
- s += ` AND (\n${printN(l + 1)}${print(e.result, values, null, every)}\n${printN(l)})`
239
+ s += ` AND (\n${printN(l + 1)}${print(e.result, null, every)}\n${printN(l)})`
226
240
  }
227
241
  --l
228
242
  break
@@ -234,12 +248,12 @@ export function buildWhereCosmosQuery3(
234
248
  if (rel) {
235
249
  const rel = (e.result[0]! as { path: string }).path.split(".-1.")[0]
236
250
  s += isRelation
237
- ? `(\n${printN(l + 1)}${print(e.result, values, rel, every)}\n${printN(l)})`
251
+ ? `(\n${printN(l + 1)}${print(e.result, rel, every)}\n${printN(l)})`
238
252
  : `(\n${printN(l + 1)}${every ? "NOT " : ""}EXISTS(SELECT VALUE ${rel} FROM ${rel} IN f.${rel} WHERE ${
239
- print(e.result.map(flip(every)), values, rel, every)
253
+ print(e.result.map(flip(every)), rel, every)
240
254
  }))`
241
255
  } else {
242
- s += `(\n${printN(l + 1)}${print(e.result, values, null, every)}\n${printN(l)})`
256
+ s += `(\n${printN(l + 1)}${print(e.result, null, every)}\n${printN(l)})`
243
257
  }
244
258
  // ;--l
245
259
  break
@@ -272,7 +286,48 @@ export function buildWhereCosmosQuery3(
272
286
  ? getValues(_.result)
273
287
  : [_]
274
288
  )
275
- const values = getValues(filter)
289
+ const computedFilters = select
290
+ ? select.flatMap((_) => typeof _ === "object" && "computed" in _ ? getValues(_.computed.filter) : [])
291
+ : []
292
+ const values = [...computedFilters, ...getValues(filter)]
293
+
294
+ const computedSelectExpr = (key: string, computed: ComputedProjectionIrExpression) => {
295
+ const relationPath = computed.path
296
+ const relationAlias = relationPath
297
+ const relationSource = dottedToAccess(`f.${relationPath}`)
298
+ const where = computed.filter.length > 0
299
+ ? ` WHERE ${print(computed.filter, relationPath, false)}`
300
+ : ""
301
+ switch (computed._tag) {
302
+ case "relation-count":
303
+ return `(SELECT VALUE COUNT(1) FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
304
+ case "relation-any":
305
+ return `EXISTS(SELECT VALUE ${relationAlias} FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
306
+ case "relation-every": {
307
+ // ∀x.P(x) ≡ ¬∃x.¬P(x). Cosmos has no NOT(...) on EXISTS subqueries directly,
308
+ // but we can flip via NOT EXISTS(... WHERE NOT (filter)).
309
+ if (computed.filter.length === 0) return `true AS ${key}`
310
+ return `NOT EXISTS(SELECT VALUE ${relationAlias} FROM ${relationAlias} IN ${relationSource} WHERE NOT (${
311
+ print(computed.filter, relationPath, false)
312
+ })) AS ${key}`
313
+ }
314
+ case "relation-distinct-count": {
315
+ const fieldRef = dottedToAccess(`${relationAlias}.${computed.field}`)
316
+ return `(SELECT VALUE COUNT(1) FROM (SELECT DISTINCT VALUE ${fieldRef} FROM ${relationAlias} IN ${relationSource}${where})) AS ${key}`
317
+ }
318
+ case "relation-sum": {
319
+ const fieldRef = dottedToAccess(`${relationAlias}.${computed.field}`)
320
+ return `(SELECT VALUE SUM(${fieldRef}) FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
321
+ }
322
+ case "relation-collect": {
323
+ const fieldRef = dottedToAccess(`${relationAlias}.${computed.field}`)
324
+ if (computed.distinct) {
325
+ return `ARRAY(SELECT DISTINCT VALUE ${fieldRef} FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
326
+ }
327
+ return `ARRAY(SELECT VALUE ${fieldRef} FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
328
+ }
329
+ }
330
+ }
276
331
  // with joins, you should use DISTINCT
277
332
  // or you can end up with duplicates
278
333
  return {
@@ -283,6 +338,8 @@ export function buildWhereCosmosQuery3(
283
338
  .map((s) =>
284
339
  typeof s === "string"
285
340
  ? dottedToAccess(s === idKey ? "f.id" : `f.${s}`) // x["y"} vs x.y, helps with reserved keywords like "value"
341
+ : "computed" in s
342
+ ? computedSelectExpr(s.key, s.computed)
286
343
  : `ARRAY (SELECT ${s.subKeys.map((_) => dottedToAccess(`t.${_}`)).join(",")}
287
344
  FROM t in ${dottedToAccess(`f.${s.key}`)}) AS ${s.key}`
288
345
  )
@@ -291,7 +348,7 @@ export function buildWhereCosmosQuery3(
291
348
  }
292
349
  FROM ${name} f
293
350
 
294
- ${filter.length ? `WHERE (${print(filter, values.map((_) => _.value), null, false)})` : ""}
351
+ ${filter.length ? `WHERE (${print(filter, null, false)})` : ""}
295
352
  ${order ? `ORDER BY ${order.map((_) => `${dottedToAccess(`f.${_.key}`)} ${_.direction}`).join(", ")}` : ""}
296
353
  ${skip !== undefined || limit !== undefined ? `OFFSET ${skip ?? 0} LIMIT ${limit ?? 999999}` : ""}`,
297
354
  parameters: values
@@ -7,7 +7,7 @@ import { CosmosClient, CosmosClientLayer } from "../adapters/cosmos-client.js"
7
7
  import { OptimisticConcurrencyException } from "../errors.js"
8
8
  import { InfraLogger } from "../logger.js"
9
9
  import type { FieldValues } from "../Model/filter/types.js"
10
- import { type RawQuery } from "../Model/query.js"
10
+ import { type ComputedProjectionIrExpression, type RawQuery } from "../Model/query.js"
11
11
  import { annotateCosmosResponse, annotateDb } from "../otel.js"
12
12
  import { buildWhereCosmosQuery3, logQuery } from "./Cosmos/query.js"
13
13
  import { storeId } from "./Memory.js"
@@ -451,7 +451,15 @@ const makeCosmosStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
451
451
  name,
452
452
  defaultValues,
453
453
  f.select as
454
- | NonEmptyReadonlyArray<string | { key: string; subKeys: readonly string[] }>
454
+ | NonEmptyReadonlyArray<
455
+ string | {
456
+ key: string
457
+ subKeys: readonly string[]
458
+ } | {
459
+ key: string
460
+ computed: ComputedProjectionIrExpression
461
+ }
462
+ >
455
463
  | undefined,
456
464
  f.order as NonEmptyReadonlyArray<{ key: string; direction: "ASC" | "DESC" }> | undefined,
457
465
  skip,