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

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 +8 -0
  2. package/dist/Model/Repository/internal/internal.d.ts +1 -1
  3. package/dist/Model/query/dsl.d.ts +41 -1
  4. package/dist/Model/query/dsl.d.ts.map +1 -1
  5. package/dist/Model/query/dsl.js +49 -1
  6. package/dist/Model/query/new-kid-interpreter.d.ts +28 -1
  7. package/dist/Model/query/new-kid-interpreter.d.ts.map +1 -1
  8. package/dist/Model/query/new-kid-interpreter.js +30 -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 +30 -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 +44 -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 +37 -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 +99 -0
  30. package/src/Model/query/new-kid-interpreter.ts +62 -0
  31. package/src/Store/Cosmos/query.ts +33 -1
  32. package/src/Store/Memory.ts +40 -1
  33. package/src/Store/SQL/query.ts +39 -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
@@ -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,7 +37,11 @@ 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":
@@ -63,6 +67,18 @@ const computeProjectionValue = (
63
67
  const matches = filter.length === 0
64
68
  ? (_value: unknown) => true
65
69
  : (value: unknown) => codeFilter3_(filter, value)
70
+ const evalExpr = (value: unknown, expression: ComputedProjectionMathIrExpression): number => {
71
+ switch (expression._tag) {
72
+ case "field": {
73
+ const v = get(value, expression.field)
74
+ return typeof v === "number" ? v : Number(v) || 0
75
+ }
76
+ case "mul":
77
+ return evalExpr(value, expression.left) * evalExpr(value, expression.right)
78
+ default:
79
+ return assertUnreachable(expression)
80
+ }
81
+ }
66
82
  switch (computed._tag) {
67
83
  case "relation-count":
68
84
  return relation.reduce<number>((acc, value) => matches(value) ? acc + 1 : acc, 0)
@@ -83,6 +99,29 @@ const computeProjectionValue = (
83
99
  const v = get(value, computed.field)
84
100
  return acc + (typeof v === "number" ? v : Number(v) || 0)
85
101
  }, 0)
102
+ case "relation-sum-expr":
103
+ return relation.reduce<number>((acc, value) => {
104
+ if (!matches(value)) return acc
105
+ return acc + evalExpr(value, computed.expression)
106
+ }, 0)
107
+ case "relation-sum-expr-by": {
108
+ const totals = new Map<unknown, number>()
109
+ for (const value of relation) {
110
+ if (!matches(value)) continue
111
+ const unit = get(value, computed.unit)
112
+ const current = totals.get(unit) ?? 0
113
+ totals.set(unit, current + evalExpr(value, computed.expression))
114
+ }
115
+ return [...totals.entries()].map(([unit, total]) => ({ unit, total }))
116
+ }
117
+ case "relation-sum-expr-normalized":
118
+ return relation.reduce<number>((acc, value) => {
119
+ if (!matches(value)) return acc
120
+ const unit = get(value, computed.unit)
121
+ const factor = unit === computed.toBase ? 1 : computed.factors[String(unit)]
122
+ if (factor === undefined || !Number.isFinite(factor)) return acc
123
+ return acc + evalExpr(value, computed.expression) * factor
124
+ }, 0)
86
125
  case "relation-collect": {
87
126
  const out: unknown[] = []
88
127
  const seen = computed.distinct ? new Set<unknown>() : undefined
@@ -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)
@@ -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", () => {
@@ -345,6 +345,90 @@ describe("SQL query builder (SQLite dialect)", () => {
345
345
  expect(result.sql).toContain(`AS "totalWeight"`)
346
346
  })
347
347
 
348
+ it("computed relation-sum-expr projection (sqlite)", () => {
349
+ const result = buildWhereSQLQuery(
350
+ sqliteDialect,
351
+ "id",
352
+ [],
353
+ "users",
354
+ {},
355
+ [{
356
+ key: "totalWeighted",
357
+ computed: {
358
+ _tag: "relation-sum-expr",
359
+ path: "items",
360
+ expression: {
361
+ _tag: "mul",
362
+ left: { _tag: "field", field: "weight" },
363
+ right: { _tag: "field", field: "tradeUnit.amount" }
364
+ },
365
+ filter: []
366
+ }
367
+ }]
368
+ )
369
+ expect(result.sql).toContain(
370
+ `COALESCE(SUM((CAST(json_extract(_items.value, '$.weight') AS REAL) * CAST(json_extract(_items.value, '$.tradeUnit.amount') AS REAL))), 0)`
371
+ )
372
+ expect(result.sql).toContain(`AS "totalWeighted"`)
373
+ })
374
+
375
+ it("computed relation-sum-expr-by projection (sqlite)", () => {
376
+ const result = buildWhereSQLQuery(
377
+ sqliteDialect,
378
+ "id",
379
+ [],
380
+ "users",
381
+ {},
382
+ [{
383
+ key: "totalsByUnit",
384
+ computed: {
385
+ _tag: "relation-sum-expr-by",
386
+ path: "items",
387
+ expression: {
388
+ _tag: "mul",
389
+ left: { _tag: "field", field: "weight" },
390
+ right: { _tag: "field", field: "tradeUnit.amount" }
391
+ },
392
+ unit: "tradeUnit.unit",
393
+ filter: []
394
+ }
395
+ }]
396
+ )
397
+ expect(result.sql).toContain(`json_group_array(json_object('unit', __unit, 'total', __total))`)
398
+ expect(result.sql).toContain(`GROUP BY json_extract(_items.value, '$.tradeUnit.unit')`)
399
+ expect(result.sql).toContain(`AS "totalsByUnit"`)
400
+ })
401
+
402
+ it("computed relation-sum-expr-normalized projection (sqlite)", () => {
403
+ const result = buildWhereSQLQuery(
404
+ sqliteDialect,
405
+ "id",
406
+ [],
407
+ "users",
408
+ {},
409
+ [{
410
+ key: "totalKg",
411
+ computed: {
412
+ _tag: "relation-sum-expr-normalized",
413
+ path: "items",
414
+ expression: {
415
+ _tag: "mul",
416
+ left: { _tag: "field", field: "weight" },
417
+ right: { _tag: "field", field: "tradeUnit.amount" }
418
+ },
419
+ unit: "tradeUnit.unit",
420
+ toBase: "kg",
421
+ factors: { g: 0.001 },
422
+ filter: []
423
+ }
424
+ }]
425
+ )
426
+ expect(result.sql).toContain(
427
+ `CASE json_extract(_items.value, '$.tradeUnit.unit') WHEN 'kg' THEN 1 WHEN 'g' THEN 0.001 ELSE NULL END`
428
+ )
429
+ expect(result.sql).toContain(`AS "totalKg"`)
430
+ })
431
+
348
432
  it("computed relation-collect (non-distinct) projection (sqlite)", () => {
349
433
  const result = buildWhereSQLQuery(
350
434
  sqliteDialect,
@@ -515,6 +599,88 @@ describe("SQL query builder (PostgreSQL dialect)", () => {
515
599
  expect(result.sql).toContain(`AS "totalWeight"`)
516
600
  })
517
601
 
602
+ it("computed relation-sum-expr (pg)", () => {
603
+ const result = buildWhereSQLQuery(
604
+ pgDialect,
605
+ "id",
606
+ [],
607
+ "users",
608
+ {},
609
+ [{
610
+ key: "totalWeighted",
611
+ computed: {
612
+ _tag: "relation-sum-expr",
613
+ path: "items",
614
+ expression: {
615
+ _tag: "mul",
616
+ left: { _tag: "field", field: "weight" },
617
+ right: { _tag: "field", field: "tradeUnit.amount" }
618
+ },
619
+ filter: []
620
+ }
621
+ }]
622
+ )
623
+ expect(result.sql).toContain(
624
+ `COALESCE(SUM(((_items->>'weight')::numeric * (_items->'tradeUnit'->>'amount')::numeric)), 0)`
625
+ )
626
+ expect(result.sql).toContain(`AS "totalWeighted"`)
627
+ })
628
+
629
+ it("computed relation-sum-expr-by (pg)", () => {
630
+ const result = buildWhereSQLQuery(
631
+ pgDialect,
632
+ "id",
633
+ [],
634
+ "users",
635
+ {},
636
+ [{
637
+ key: "totalsByUnit",
638
+ computed: {
639
+ _tag: "relation-sum-expr-by",
640
+ path: "items",
641
+ expression: {
642
+ _tag: "mul",
643
+ left: { _tag: "field", field: "weight" },
644
+ right: { _tag: "field", field: "tradeUnit.amount" }
645
+ },
646
+ unit: "tradeUnit.unit",
647
+ filter: []
648
+ }
649
+ }]
650
+ )
651
+ expect(result.sql).toContain(`jsonb_agg(jsonb_build_object('unit', __unit, 'total', __total))`)
652
+ expect(result.sql).toContain(`GROUP BY _items->'tradeUnit'->>'unit'`)
653
+ expect(result.sql).toContain(`AS "totalsByUnit"`)
654
+ })
655
+
656
+ it("computed relation-sum-expr-normalized (pg)", () => {
657
+ const result = buildWhereSQLQuery(
658
+ pgDialect,
659
+ "id",
660
+ [],
661
+ "users",
662
+ {},
663
+ [{
664
+ key: "totalKg",
665
+ computed: {
666
+ _tag: "relation-sum-expr-normalized",
667
+ path: "items",
668
+ expression: {
669
+ _tag: "mul",
670
+ left: { _tag: "field", field: "weight" },
671
+ right: { _tag: "field", field: "tradeUnit.amount" }
672
+ },
673
+ unit: "tradeUnit.unit",
674
+ toBase: "kg",
675
+ factors: { g: 0.001 },
676
+ filter: []
677
+ }
678
+ }]
679
+ )
680
+ expect(result.sql).toContain(`CASE _items->'tradeUnit'->>'unit' WHEN 'kg' THEN 1 WHEN 'g' THEN 0.001 ELSE NULL END`)
681
+ expect(result.sql).toContain(`AS "totalKg"`)
682
+ })
683
+
518
684
  it("computed relation-collect (pg jsonb_agg)", () => {
519
685
  const result = buildWhereSQLQuery(
520
686
  pgDialect,