@effect-app/infra 4.0.0-beta.216 → 4.0.0-beta.218

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 (37) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/Model/Repository/internal/internal.d.ts +1 -1
  3. package/dist/Model/query/dsl.d.ts +49 -1
  4. package/dist/Model/query/dsl.d.ts.map +1 -1
  5. package/dist/Model/query/dsl.js +77 -1
  6. package/dist/Model/query/new-kid-interpreter.d.ts +34 -1
  7. package/dist/Model/query/new-kid-interpreter.d.ts.map +1 -1
  8. package/dist/Model/query/new-kid-interpreter.js +41 -1
  9. package/dist/QueueMaker/SQLQueue.d.ts +1 -1
  10. package/dist/QueueMaker/memQueue.d.ts +1 -1
  11. package/dist/QueueMaker/sbqueue.d.ts +1 -1
  12. package/dist/Store/Cosmos/query.d.ts +1 -1
  13. package/dist/Store/Cosmos/query.d.ts.map +1 -1
  14. package/dist/Store/Cosmos/query.js +42 -1
  15. package/dist/Store/Cosmos.d.ts +1 -1
  16. package/dist/Store/Memory.d.ts +1 -1
  17. package/dist/Store/Memory.d.ts.map +1 -1
  18. package/dist/Store/Memory.js +64 -1
  19. package/dist/Store/SQL/Pg.d.ts +1 -1
  20. package/dist/Store/SQL/query.d.ts +1 -1
  21. package/dist/Store/SQL/query.d.ts.map +1 -1
  22. package/dist/Store/SQL/query.js +54 -5
  23. package/dist/Store/SQL.d.ts +1 -1
  24. package/dist/adapters/ServiceBus.d.ts +1 -1
  25. package/dist/adapters/logger.d.ts +1 -1
  26. package/dist/api/routing.d.ts +1 -1
  27. package/dist/otel.d.ts +1 -1
  28. package/package.json +2 -2
  29. package/src/Model/query/dsl.ts +139 -0
  30. package/src/Model/query/new-kid-interpreter.ts +80 -0
  31. package/src/Store/Cosmos/query.ts +45 -1
  32. package/src/Store/Memory.ts +58 -1
  33. package/src/Store/SQL/query.ts +56 -5
  34. package/test/dist/rawQuery.test.d.ts.map +1 -1
  35. package/test/query.test.ts +108 -3
  36. package/test/rawQuery.test.ts +5 -9
  37. package/test/sql-store.test.ts +166 -0
@@ -8,6 +8,17 @@ 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 ComputedProjectionMathIrExpression =
12
+ | {
13
+ readonly _tag: "field"
14
+ readonly field: string
15
+ }
16
+ | {
17
+ readonly _tag: "mul"
18
+ readonly left: ComputedProjectionMathIrExpression
19
+ readonly right: ComputedProjectionMathIrExpression
20
+ }
21
+
11
22
  export type ComputedProjectionIrExpression =
12
23
  | {
13
24
  readonly _tag: "relation-count"
@@ -36,6 +47,28 @@ export type ComputedProjectionIrExpression =
36
47
  readonly field: string
37
48
  readonly filter: readonly FilterResult[]
38
49
  }
50
+ | {
51
+ readonly _tag: "relation-sum-expr"
52
+ readonly path: string
53
+ readonly expression: ComputedProjectionMathIrExpression
54
+ readonly filter: readonly FilterResult[]
55
+ }
56
+ | {
57
+ readonly _tag: "relation-sum-expr-by"
58
+ readonly path: string
59
+ readonly expression: ComputedProjectionMathIrExpression
60
+ readonly unit: string
61
+ readonly filter: readonly FilterResult[]
62
+ }
63
+ | {
64
+ readonly _tag: "relation-sum-expr-normalized"
65
+ readonly path: string
66
+ readonly expression: ComputedProjectionMathIrExpression
67
+ readonly unit: string
68
+ readonly toBase: string
69
+ readonly factors: Readonly<Record<string, number>>
70
+ readonly filter: readonly FilterResult[]
71
+ }
39
72
  | {
40
73
  readonly _tag: "relation-collect"
41
74
  readonly path: string
@@ -43,6 +76,13 @@ export type ComputedProjectionIrExpression =
43
76
  readonly distinct: boolean
44
77
  readonly filter: readonly FilterResult[]
45
78
  }
79
+ | {
80
+ readonly _tag: "relation-collect-fields"
81
+ readonly path: string
82
+ readonly fields: readonly string[]
83
+ readonly distinct: boolean
84
+ readonly filter: readonly FilterResult[]
85
+ }
46
86
 
47
87
  type Result<TFieldValues extends FieldValues, A = TFieldValues, R = never> = {
48
88
  filter: FilterResult[]
@@ -197,6 +237,35 @@ const interpret = <
197
237
  key,
198
238
  { _tag: e._tag, path: e.path, field: e.field, filter } as ComputedProjectionIrExpression
199
239
  ]
240
+ case "relation-sum-expr":
241
+ return [
242
+ key,
243
+ { _tag: e._tag, path: e.path, expression: e.expression, filter } as ComputedProjectionIrExpression
244
+ ]
245
+ case "relation-sum-expr-by":
246
+ return [
247
+ key,
248
+ {
249
+ _tag: e._tag,
250
+ path: e.path,
251
+ expression: e.expression,
252
+ unit: e.unit,
253
+ filter
254
+ } as ComputedProjectionIrExpression
255
+ ]
256
+ case "relation-sum-expr-normalized":
257
+ return [
258
+ key,
259
+ {
260
+ _tag: e._tag,
261
+ path: e.path,
262
+ expression: e.expression,
263
+ unit: e.unit,
264
+ toBase: e.toBase,
265
+ factors: e.factors,
266
+ filter
267
+ } as ComputedProjectionIrExpression
268
+ ]
200
269
  case "relation-collect":
201
270
  return [
202
271
  key,
@@ -208,6 +277,17 @@ const interpret = <
208
277
  filter
209
278
  } as ComputedProjectionIrExpression
210
279
  ]
280
+ case "relation-collect-fields":
281
+ return [
282
+ key,
283
+ {
284
+ _tag: e._tag,
285
+ path: e.path,
286
+ fields: e.fields,
287
+ distinct: e.distinct,
288
+ filter
289
+ } as ComputedProjectionIrExpression
290
+ ]
211
291
  }
212
292
  })
213
293
  )
@@ -4,7 +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
+ import type { ComputedProjectionIrExpression, ComputedProjectionMathIrExpression } from "../../Model/query.js"
8
8
  import { isRelationCheck } from "../codeFilter.js"
9
9
  import type { SupportedValues } from "../service.js"
10
10
 
@@ -295,6 +295,23 @@ export function buildWhereCosmosQuery3(
295
295
  const relationPath = computed.path
296
296
  const relationAlias = relationPath
297
297
  const relationSource = dottedToAccess(`f.${relationPath}`)
298
+ const compileExpr = (expression: ComputedProjectionMathIrExpression): string => {
299
+ switch (expression._tag) {
300
+ case "field":
301
+ return dottedToAccess(`${relationAlias}.${expression.field}`)
302
+ case "mul":
303
+ return `(${compileExpr(expression.left)} * ${compileExpr(expression.right)})`
304
+ default:
305
+ return assertUnreachable(expression)
306
+ }
307
+ }
308
+ const factorExpr = (unitExpr: string, toBase: string, factors: Readonly<Record<string, number>>) => {
309
+ const entries = Object.entries(factors).filter(([, factor]) => Number.isFinite(factor))
310
+ return entries.reduceRight<string>(
311
+ (acc, [unit, factor]) => `IIF(${unitExpr} = ${JSON.stringify(unit)}, ${factor}, ${acc})`,
312
+ `IIF(${unitExpr} = ${JSON.stringify(toBase)}, 1, 0)`
313
+ )
314
+ }
298
315
  const where = computed.filter.length > 0
299
316
  ? ` WHERE ${print(computed.filter, relationPath, false)}`
300
317
  : ""
@@ -319,6 +336,21 @@ export function buildWhereCosmosQuery3(
319
336
  const fieldRef = dottedToAccess(`${relationAlias}.${computed.field}`)
320
337
  return `(SELECT VALUE SUM(${fieldRef}) FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
321
338
  }
339
+ case "relation-sum-expr": {
340
+ const expression = compileExpr(computed.expression)
341
+ return `(SELECT VALUE SUM(${expression}) FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
342
+ }
343
+ case "relation-sum-expr-by": {
344
+ const unitRef = dottedToAccess(`${relationAlias}.${computed.unit}`)
345
+ const expression = compileExpr(computed.expression)
346
+ return `ARRAY(SELECT VALUE { "unit": ${unitRef}, "total": SUM(${expression}) } FROM ${relationAlias} IN ${relationSource}${where} GROUP BY ${unitRef}) AS ${key}`
347
+ }
348
+ case "relation-sum-expr-normalized": {
349
+ const unitRef = dottedToAccess(`${relationAlias}.${computed.unit}`)
350
+ const expression = compileExpr(computed.expression)
351
+ const factor = factorExpr(unitRef, computed.toBase, computed.factors)
352
+ return `(SELECT VALUE SUM((${expression}) * (${factor})) FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
353
+ }
322
354
  case "relation-collect": {
323
355
  const fieldRef = dottedToAccess(`${relationAlias}.${computed.field}`)
324
356
  if (computed.distinct) {
@@ -326,6 +358,18 @@ export function buildWhereCosmosQuery3(
326
358
  }
327
359
  return `ARRAY(SELECT VALUE ${fieldRef} FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
328
360
  }
361
+ case "relation-collect-fields": {
362
+ const subqueries = computed.fields.map((field) => {
363
+ const fieldRef = dottedToAccess(`${relationAlias}.${field}`)
364
+ return computed.distinct
365
+ ? `ARRAY(SELECT DISTINCT VALUE ${fieldRef} FROM ${relationAlias} IN ${relationSource}${where})`
366
+ : `ARRAY(SELECT VALUE ${fieldRef} FROM ${relationAlias} IN ${relationSource}${where})`
367
+ })
368
+ const combined = computed.distinct
369
+ ? subqueries.reduce((acc, sq) => `SetUnion(${acc}, ${sq})`)
370
+ : subqueries.reduce((acc, sq) => `ARRAY_CONCAT(${acc}, ${sq})`)
371
+ return `${combined} AS ${key}`
372
+ }
329
373
  }
330
374
  }
331
375
  // with joins, you should use DISTINCT
@@ -6,7 +6,7 @@ import { assertUnreachable } from "effect-app/utils"
6
6
  import { InfraLogger } from "../logger.js"
7
7
  import type { FilterResult } from "../Model/filter/filterApi.js"
8
8
  import type { FieldValues } from "../Model/filter/types.js"
9
- import type { ComputedProjectionIrExpression } from "../Model/query.js"
9
+ import type { ComputedProjectionIrExpression, ComputedProjectionMathIrExpression } from "../Model/query.js"
10
10
  import { annotateDb } from "../otel.js"
11
11
  import { codeFilter, codeFilter3_ } from "./codeFilter.js"
12
12
  import { type FilterArgs, type PersistenceModelType, type Store, type StoreConfig, StoreMaker } from "./service.js"
@@ -37,13 +37,19 @@ const emptyValueFor = (tag: ComputedProjectionIrExpression["_tag"]) => {
37
37
  case "relation-count":
38
38
  case "relation-distinct-count":
39
39
  case "relation-sum":
40
+ case "relation-sum-expr":
41
+ case "relation-sum-expr-normalized":
40
42
  return 0
43
+ case "relation-sum-expr-by":
44
+ return [] as unknown[]
41
45
  case "relation-any":
42
46
  return false
43
47
  case "relation-every":
44
48
  return true
45
49
  case "relation-collect":
46
50
  return [] as unknown[]
51
+ case "relation-collect-fields":
52
+ return [] as unknown[]
47
53
  default:
48
54
  return assertUnreachable(tag)
49
55
  }
@@ -63,6 +69,18 @@ const computeProjectionValue = (
63
69
  const matches = filter.length === 0
64
70
  ? (_value: unknown) => true
65
71
  : (value: unknown) => codeFilter3_(filter, value)
72
+ const evalExpr = (value: unknown, expression: ComputedProjectionMathIrExpression): number => {
73
+ switch (expression._tag) {
74
+ case "field": {
75
+ const v = get(value, expression.field)
76
+ return typeof v === "number" ? v : Number(v) || 0
77
+ }
78
+ case "mul":
79
+ return evalExpr(value, expression.left) * evalExpr(value, expression.right)
80
+ default:
81
+ return assertUnreachable(expression)
82
+ }
83
+ }
66
84
  switch (computed._tag) {
67
85
  case "relation-count":
68
86
  return relation.reduce<number>((acc, value) => matches(value) ? acc + 1 : acc, 0)
@@ -83,6 +101,29 @@ const computeProjectionValue = (
83
101
  const v = get(value, computed.field)
84
102
  return acc + (typeof v === "number" ? v : Number(v) || 0)
85
103
  }, 0)
104
+ case "relation-sum-expr":
105
+ return relation.reduce<number>((acc, value) => {
106
+ if (!matches(value)) return acc
107
+ return acc + evalExpr(value, computed.expression)
108
+ }, 0)
109
+ case "relation-sum-expr-by": {
110
+ const totals = new Map<unknown, number>()
111
+ for (const value of relation) {
112
+ if (!matches(value)) continue
113
+ const unit = get(value, computed.unit)
114
+ const current = totals.get(unit) ?? 0
115
+ totals.set(unit, current + evalExpr(value, computed.expression))
116
+ }
117
+ return [...totals.entries()].map(([unit, total]) => ({ unit, total }))
118
+ }
119
+ case "relation-sum-expr-normalized":
120
+ return relation.reduce<number>((acc, value) => {
121
+ if (!matches(value)) return acc
122
+ const unit = get(value, computed.unit)
123
+ const factor = unit === computed.toBase ? 1 : computed.factors[String(unit)]
124
+ if (factor === undefined || !Number.isFinite(factor)) return acc
125
+ return acc + evalExpr(value, computed.expression) * factor
126
+ }, 0)
86
127
  case "relation-collect": {
87
128
  const out: unknown[] = []
88
129
  const seen = computed.distinct ? new Set<unknown>() : undefined
@@ -97,6 +138,22 @@ const computeProjectionValue = (
97
138
  }
98
139
  return out
99
140
  }
141
+ case "relation-collect-fields": {
142
+ const out: unknown[] = []
143
+ const seen = computed.distinct ? new Set<unknown>() : undefined
144
+ for (const value of relation) {
145
+ if (!matches(value)) continue
146
+ for (const field of computed.fields) {
147
+ const v = get(value, field)
148
+ if (seen) {
149
+ if (seen.has(v)) continue
150
+ seen.add(v)
151
+ }
152
+ out.push(v)
153
+ }
154
+ }
155
+ return out
156
+ }
100
157
  default:
101
158
  return assertUnreachable(computed)
102
159
  }
@@ -3,7 +3,7 @@ import { Effect, type NonEmptyReadonlyArray } from "effect-app"
3
3
  import { assertUnreachable } from "effect-app/utils"
4
4
  import { InfraLogger } from "../../logger.js"
5
5
  import type { FilterR, FilterResult } from "../../Model/filter/filterApi.js"
6
- import type { ComputedProjectionIrExpression } from "../../Model/query.js"
6
+ import type { ComputedProjectionIrExpression, ComputedProjectionMathIrExpression } from "../../Model/query.js"
7
7
  import { isRelationCheck } from "../codeFilter.js"
8
8
 
9
9
  export interface SQLDialect {
@@ -148,6 +148,8 @@ const dottedToJsonPath = (path: string) =>
148
148
  .filter((p) => p !== "-1")
149
149
  .join(".")
150
150
 
151
+ const sqlStringLiteral = (value: string) => `'${value.replaceAll("'", "''")}'`
152
+
151
153
  export function buildWhereSQLQuery(
152
154
  dialect: SQLDialect,
153
155
  idKey: PropertyKey,
@@ -392,6 +394,23 @@ export function buildWhereSQLQuery(
392
394
  const relationPath = dottedToJsonPath(computed.path)
393
395
  const relationAlias = `_${computed.path}`
394
396
  const relationFrom = dialect.jsonEachFrom(relationPath, relationAlias)
397
+ const toNumber = (expr: string) =>
398
+ dialect.jsonColumnType === "JSON" ? `CAST(${expr} AS REAL)` : `(${expr})::numeric`
399
+ const compileExpr = (expression: ComputedProjectionMathIrExpression): string => {
400
+ switch (expression._tag) {
401
+ case "field":
402
+ return toNumber(dialect.jsonExtractElement(relationAlias, expression.field))
403
+ case "mul":
404
+ return `(${compileExpr(expression.left)} * ${compileExpr(expression.right)})`
405
+ default:
406
+ return assertUnreachable(expression)
407
+ }
408
+ }
409
+ const factorCaseExpr = (unitExpr: string, toBase: string, factors: Readonly<Record<string, number>>) => {
410
+ const entries = Object.entries(factors).filter(([, factor]) => Number.isFinite(factor))
411
+ const cases = entries.map(([unit, factor]) => ` WHEN ${sqlStringLiteral(unit)} THEN ${factor}`).join("")
412
+ return `CASE ${unitExpr} WHEN ${sqlStringLiteral(toBase)} THEN 1${cases} ELSE NULL END`
413
+ }
395
414
  const whereClause = () =>
396
415
  computed.filter.length > 0
397
416
  ? ` WHERE ${print(computed.filter, computed.path, false)}`
@@ -418,10 +437,25 @@ export function buildWhereSQLQuery(
418
437
  }
419
438
  case "relation-sum": {
420
439
  const fieldExtract = dialect.jsonExtractElement(relationAlias, computed.field)
421
- const cast = dialect.jsonColumnType === "JSON"
422
- ? `CAST(${fieldExtract} AS REAL)`
423
- : `(${fieldExtract})::numeric`
424
- return `(SELECT COALESCE(SUM(${cast}), 0) FROM ${relationFrom}${whereClause()}) AS "${key}"`
440
+ return `(SELECT COALESCE(SUM(${toNumber(fieldExtract)}), 0) FROM ${relationFrom}${whereClause()}) AS "${key}"`
441
+ }
442
+ case "relation-sum-expr": {
443
+ const expression = compileExpr(computed.expression)
444
+ return `(SELECT COALESCE(SUM(${expression}), 0) FROM ${relationFrom}${whereClause()}) AS "${key}"`
445
+ }
446
+ case "relation-sum-expr-by": {
447
+ const expression = compileExpr(computed.expression)
448
+ const unitExpr = dialect.jsonExtractElement(relationAlias, computed.unit)
449
+ if (dialect.jsonColumnType === "JSON") {
450
+ return `(SELECT COALESCE(json_group_array(json_object('unit', __unit, 'total', __total)), json_array()) FROM (SELECT ${unitExpr} AS __unit, COALESCE(SUM(${expression}), 0) AS __total FROM ${relationFrom}${whereClause()} GROUP BY ${unitExpr})) AS "${key}"`
451
+ }
452
+ return `(SELECT COALESCE(jsonb_agg(jsonb_build_object('unit', __unit, 'total', __total)), '[]'::jsonb) FROM (SELECT ${unitExpr} AS __unit, COALESCE(SUM(${expression}), 0) AS __total FROM ${relationFrom}${whereClause()} GROUP BY ${unitExpr}) __grouped) AS "${key}"`
453
+ }
454
+ case "relation-sum-expr-normalized": {
455
+ const expression = compileExpr(computed.expression)
456
+ const unitExpr = dialect.jsonExtractElement(relationAlias, computed.unit)
457
+ const factorExpr = factorCaseExpr(unitExpr, computed.toBase, computed.factors)
458
+ return `(SELECT COALESCE(SUM((${expression}) * (${factorExpr})), 0) FROM ${relationFrom}${whereClause()}) AS "${key}"`
425
459
  }
426
460
  case "relation-collect": {
427
461
  const fieldExtract = dialect.jsonExtractElement(relationAlias, computed.field)
@@ -435,6 +469,23 @@ export function buildWhereSQLQuery(
435
469
  const aggArg = computed.distinct ? `DISTINCT ${fieldExtract}` : fieldExtract
436
470
  return `(SELECT COALESCE(jsonb_agg(${aggArg}), '[]'::jsonb) FROM ${relationFrom}${whereClause()}) AS "${key}"`
437
471
  }
472
+ case "relation-collect-fields": {
473
+ const branches = computed.fields.map((field) => {
474
+ const fieldExtract = dialect.jsonExtractElement(relationAlias, field)
475
+ return `SELECT ${fieldExtract} AS __v FROM ${relationFrom}${whereClause()}`
476
+ })
477
+ const unionQuery = branches.join(" UNION ALL ")
478
+ if (dialect.jsonColumnType === "JSON") {
479
+ if (computed.distinct) {
480
+ return `(SELECT COALESCE(json_group_array(__v), json_array()) FROM (SELECT DISTINCT __v FROM (${unionQuery}))) AS "${key}"`
481
+ }
482
+ return `(SELECT COALESCE(json_group_array(__v), json_array()) FROM (${unionQuery})) AS "${key}"`
483
+ }
484
+ if (computed.distinct) {
485
+ return `(SELECT COALESCE(jsonb_agg(__v), '[]'::jsonb) FROM (SELECT DISTINCT __v FROM (${unionQuery}) inner_q) outer_q) AS "${key}"`
486
+ }
487
+ return `(SELECT COALESCE(jsonb_agg(__v), '[]'::jsonb) FROM (${unionQuery}) t) AS "${key}"`
488
+ }
438
489
  default:
439
490
  return assertUnreachable(computed)
440
491
  }
@@ -1 +1 @@
1
- {"version":3,"file":"rawQuery.test.d.ts","sourceRoot":"","sources":["../rawQuery.test.ts"],"names":[],"mappings":"AACA,OAAO,EAA+C,cAAc,EAA2C,MAAM,YAAY,CAAA;AAWjI,eAAO,MAAM,EAAE,6CAWb,CAAA"}
1
+ {"version":3,"file":"rawQuery.test.d.ts","sourceRoot":"","sources":["../rawQuery.test.ts"],"names":[],"mappings":"AAEA,OAAO,EAA+C,cAAc,EAA2C,MAAM,YAAY,CAAA;AAUjI,eAAO,MAAM,EAAE,6CAWb,CAAA"}
@@ -6,7 +6,7 @@ import { Context, Effect, flow, Layer, Option, pipe, S, Struct } from "effect-ap
6
6
  import { inspect } from "util"
7
7
  import { expect, expectTypeOf, it } from "vitest"
8
8
  import { setupRequestContextFromCurrent } from "../src/api/setupRequest.js"
9
- import { and, computed, count, make, one, or, order, page, project, projectComputed, type QueryEnd, type QueryProjection, type QueryWhere, relation, toFilter, where } from "../src/Model/query.js"
9
+ import { and, computed, count, expr, make, one, or, order, page, project, projectComputed, type QueryEnd, type QueryProjection, type QueryWhere, relation, toFilter, where } from "../src/Model/query.js"
10
10
  import { makeRepo } from "../src/Model/Repository.js"
11
11
  import { RepositoryRegistryLive } from "../src/Model/Repository/Registry.js"
12
12
  import { memFilter, MemoryStoreLive } from "../src/Store/Memory.js"
@@ -730,6 +730,61 @@ it("projectComputed.collect / collectDistinct emit relation-collect IR", () => {
730
730
  expect((distinct as { distinct: boolean } | undefined)?.distinct).toBe(true)
731
731
  })
732
732
 
733
+ it("projectComputed.sumExpr emits relation-sum-expr IR", () => {
734
+ const baseSchema = S.Struct({
735
+ id: S.String,
736
+ items: S.Array(S.Struct({
737
+ weight: S.Number,
738
+ tradeUnit: S.Struct({ amount: S.Number, unit: S.String })
739
+ }))
740
+ })
741
+ const query = make<S.Codec.Encoded<typeof baseSchema>>().pipe(
742
+ projectComputed(
743
+ S.Struct({ total: S.Number }),
744
+ computed({
745
+ total: relation<S.Codec.Encoded<typeof baseSchema>>("items").sumExpr(
746
+ expr.mul(expr.field("weight"), expr.field("tradeUnit.amount"))
747
+ )
748
+ })
749
+ )
750
+ )
751
+ const interpreted = toFilter(query, baseSchema)
752
+ const ir = interpreted.computed?.["total"]
753
+ expect(ir?._tag).toBe("relation-sum-expr")
754
+ expect((ir as { expression: unknown } | undefined)?.expression).toEqual({
755
+ _tag: "mul",
756
+ left: { _tag: "field", field: "weight" },
757
+ right: { _tag: "field", field: "tradeUnit.amount" }
758
+ })
759
+ })
760
+
761
+ it("projectComputed.sumExprBy emits relation-sum-expr-by IR", () => {
762
+ const baseSchema = S.Struct({
763
+ id: S.String,
764
+ items: S.Array(S.Struct({
765
+ weight: S.Number,
766
+ tradeUnit: S.Struct({ amount: S.Number, unit: S.String })
767
+ }))
768
+ })
769
+ const query = make<S.Codec.Encoded<typeof baseSchema>>().pipe(
770
+ projectComputed(
771
+ S.Struct({
772
+ totals: S.Array(S.Struct({ unit: S.String, total: S.Number }))
773
+ }),
774
+ computed({
775
+ totals: relation<S.Codec.Encoded<typeof baseSchema>>("items").sumExprBy(
776
+ expr.mul(expr.field("weight"), expr.field("tradeUnit.amount")),
777
+ { unit: "tradeUnit.unit" }
778
+ )
779
+ })
780
+ )
781
+ )
782
+ const interpreted = toFilter(query, baseSchema)
783
+ const ir = interpreted.computed?.["totals"]
784
+ expect(ir?._tag).toBe("relation-sum-expr-by")
785
+ expect((ir as { unit: string } | undefined)?.unit).toBe("tradeUnit.unit")
786
+ })
787
+
733
788
  it(
734
789
  "doesn't mess when refining fields",
735
790
  () =>
@@ -1584,6 +1639,57 @@ it("memFilter: computed projection with multi-statement relation filter", () =>
1584
1639
  ])
1585
1640
  })
1586
1641
 
1642
+ it("memFilter: relation-sum-expr / sum-expr-by / sum-expr-normalized", () => {
1643
+ const schema = S.Struct({
1644
+ id: S.String,
1645
+ items: S.Array(S.Struct({
1646
+ weight: S.Finite,
1647
+ tradeUnit: S.Struct({ amount: S.Finite, unit: S.String })
1648
+ }))
1649
+ })
1650
+ type Row = S.Codec.Encoded<typeof schema>
1651
+ const rows: Row[] = [
1652
+ {
1653
+ id: "r1",
1654
+ items: [
1655
+ { weight: 2, tradeUnit: { amount: 5, unit: "kg" } },
1656
+ { weight: 4, tradeUnit: { amount: 1000, unit: "g" } },
1657
+ { weight: 3, tradeUnit: { amount: 1, unit: "kg" } }
1658
+ ]
1659
+ },
1660
+ { id: "r2", items: [] }
1661
+ ]
1662
+ const weighted = expr.mul(expr.field("weight"), expr.field("tradeUnit.amount"))
1663
+ const q = make<Row>().pipe(
1664
+ projectComputed(
1665
+ S.Struct({
1666
+ id: S.String,
1667
+ totalRaw: S.Finite,
1668
+ totalsByUnit: S.Array(S.Struct({ unit: S.String, total: S.Finite })),
1669
+ totalKg: S.Finite
1670
+ }),
1671
+ computed({
1672
+ totalRaw: relation<Row>("items").sumExpr(weighted, where("weight", "gte", 0)),
1673
+ totalsByUnit: relation<Row>("items").sumExprBy(weighted, { unit: "tradeUnit.unit" }, where("weight", "gte", 0)),
1674
+ totalKg: relation<Row>("items").sumExprNormalized(weighted, {
1675
+ unit: "tradeUnit.unit",
1676
+ toBase: "kg",
1677
+ factors: { g: 0.001 }
1678
+ }, where("weight", "gte", 0))
1679
+ })
1680
+ )
1681
+ )
1682
+ expect(memFilter(toFilter(q, schema))(rows)).toEqual([
1683
+ {
1684
+ id: "r1",
1685
+ totalRaw: 4013,
1686
+ totalsByUnit: [{ unit: "kg", total: 13 }, { unit: "g", total: 4000 }],
1687
+ totalKg: 17
1688
+ },
1689
+ { id: "r2", totalRaw: 0, totalsByUnit: [], totalKg: 0 }
1690
+ ])
1691
+ })
1692
+
1587
1693
  it("memFilter: computed projection combined with root where filter", () => {
1588
1694
  const q = make<ComputedBase>().pipe(
1589
1695
  where("id", "neq", "r3"),
@@ -1680,8 +1786,7 @@ const cfRows: CFRow[] = [
1680
1786
  { id: "4", tag: "y", qty: 30, desc: "World cup", tags: [], nested: { kind: "k2", v: 0 } }
1681
1787
  ]
1682
1788
 
1683
- const runCF = (q: any) =>
1684
- (memFilter(toFilter(q))(cfRows) as unknown as readonly CFRow[]).map((_) => _.id)
1789
+ const runCF = (q: any) => (memFilter(toFilter(q))(cfRows) as unknown as readonly CFRow[]).map((_) => _.id)
1685
1790
 
1686
1791
  it("codeFilter: where + and chain", () => {
1687
1792
  const q = make<CFRow>().pipe(
@@ -1,3 +1,4 @@
1
+ import { SqliteClient } from "@effect/sql-sqlite-node"
1
2
  import { describe, expect, it } from "@effect/vitest"
2
3
  import { Array, Config, Context, Effect, flow, Layer, ManagedRuntime, Redacted, References, Result, S, Struct } from "effect-app"
3
4
  import { LogLevels } from "effect-app/utils"
@@ -5,7 +6,6 @@ import { setupRequestContextFromCurrent } from "../src/api/setupRequest.js"
5
6
  import { and, computed, or, project, projectComputed, relation, where, whereEvery, whereSome } from "../src/Model/query.js"
6
7
  import { makeRepo } from "../src/Model/Repository/makeRepo.js"
7
8
  import { RepositoryRegistryLive } from "../src/Model/Repository/Registry.js"
8
- import { SqliteClient } from "@effect/sql-sqlite-node"
9
9
  import { CosmosStoreLayer } from "../src/Store/Cosmos.js"
10
10
  import { MemoryStoreLive } from "../src/Store/Memory.js"
11
11
  import { SQLiteStoreLayer } from "../src/Store/SQL.js"
@@ -596,11 +596,9 @@ describe("scanner-style AllPickList computed projections", () => {
596
596
  })
597
597
  .pipe(setupRequestContextFromCurrent())
598
598
 
599
- it("works well in Memory", () =>
600
- test.pipe(Effect.provide(OrderRepo.Test), rt.runPromise))
599
+ it("works well in Memory", () => test.pipe(Effect.provide(OrderRepo.Test), rt.runPromise))
601
600
 
602
- it("works well in SQLite", () =>
603
- test.pipe(Effect.provide(OrderRepo.TestSqlite), rt.runPromise))
601
+ it("works well in SQLite", () => test.pipe(Effect.provide(OrderRepo.TestSqlite), rt.runPromise))
604
602
  })
605
603
 
606
604
  // Same but mimics the FULL controller projection: includes `items` array
@@ -647,11 +645,9 @@ describe("scanner-style AllPickList — items + computed combined", () => {
647
645
  })
648
646
  .pipe(setupRequestContextFromCurrent())
649
647
 
650
- it("works well in Memory", () =>
651
- test.pipe(Effect.provide(OrderRepo.Test), rt.runPromise))
648
+ it("works well in Memory", () => test.pipe(Effect.provide(OrderRepo.Test), rt.runPromise))
652
649
 
653
- it("works well in SQLite", () =>
654
- test.pipe(Effect.provide(OrderRepo.TestSqlite), rt.runPromise))
650
+ it("works well in SQLite", () => test.pipe(Effect.provide(OrderRepo.TestSqlite), rt.runPromise))
655
651
  })
656
652
 
657
653
  describe("removeByIds", () => {