@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
@@ -2,8 +2,11 @@
2
2
 
3
3
  import { Array, Context, Effect, flow, type NonEmptyReadonlyArray, Option, Order, pipe, Ref, Result, Semaphore, Struct } from "effect-app"
4
4
  import { NonEmptyString255 } from "effect-app/Schema"
5
+ import { assertUnreachable } from "effect-app/utils"
5
6
  import { InfraLogger } from "../logger.js"
7
+ import type { FilterResult } from "../Model/filter/filterApi.js"
6
8
  import type { FieldValues } from "../Model/filter/types.js"
9
+ import type { ComputedProjectionIrExpression } from "../Model/query.js"
7
10
  import { annotateDb } from "../otel.js"
8
11
  import { codeFilter, codeFilter3_ } from "./codeFilter.js"
9
12
  import { type FilterArgs, type PersistenceModelType, type Store, type StoreConfig, StoreMaker } from "./service.js"
@@ -14,6 +17,87 @@ export function get(obj: any, path: string): any {
14
17
  return path.split(".").reduce((res: any, key: string) => (res != null ? res[key] : res), obj)
15
18
  }
16
19
 
20
+ const stripRelationFilterPaths = (state: readonly FilterResult[], relationPath: string): readonly FilterResult[] => {
21
+ const prefix = `${relationPath}.-1.`
22
+ return state.map((entry) =>
23
+ "path" in entry
24
+ ? {
25
+ ...entry,
26
+ path: entry.path.startsWith(prefix) ? entry.path.slice(prefix.length) : entry.path
27
+ }
28
+ : {
29
+ ...entry,
30
+ result: stripRelationFilterPaths(entry.result, relationPath)
31
+ }
32
+ )
33
+ }
34
+
35
+ const emptyValueFor = (tag: ComputedProjectionIrExpression["_tag"]) => {
36
+ switch (tag) {
37
+ case "relation-count":
38
+ case "relation-distinct-count":
39
+ case "relation-sum":
40
+ return 0
41
+ case "relation-any":
42
+ return false
43
+ case "relation-every":
44
+ return true
45
+ case "relation-collect":
46
+ return [] as unknown[]
47
+ default:
48
+ return assertUnreachable(tag)
49
+ }
50
+ }
51
+
52
+ const computeProjectionValue = (
53
+ row: FieldValues,
54
+ computed: ComputedProjectionIrExpression
55
+ ) => {
56
+ const relation = get(row, computed.path)
57
+ if (!Array.isArray(relation)) {
58
+ return emptyValueFor(computed._tag)
59
+ }
60
+ const filter = stripRelationFilterPaths(computed.filter, computed.path)
61
+ const matches = (value: unknown) => codeFilter3_(filter, value)
62
+ switch (computed._tag) {
63
+ case "relation-count":
64
+ return relation.reduce<number>((acc, value) => matches(value) ? acc + 1 : acc, 0)
65
+ case "relation-any":
66
+ return relation.some(matches)
67
+ case "relation-every":
68
+ return relation.every(matches)
69
+ case "relation-distinct-count": {
70
+ const seen = new Set<unknown>()
71
+ for (const value of relation) {
72
+ if (matches(value)) seen.add(get(value, computed.field))
73
+ }
74
+ return seen.size
75
+ }
76
+ case "relation-sum":
77
+ return relation.reduce<number>((acc, value) => {
78
+ if (!matches(value)) return acc
79
+ const v = get(value, computed.field)
80
+ return acc + (typeof v === "number" ? v : Number(v) || 0)
81
+ }, 0)
82
+ case "relation-collect": {
83
+ const out: unknown[] = []
84
+ const seen = computed.distinct ? new Set<unknown>() : undefined
85
+ for (const value of relation) {
86
+ if (!matches(value)) continue
87
+ const v = get(value, computed.field)
88
+ if (seen) {
89
+ if (seen.has(v)) continue
90
+ seen.add(v)
91
+ }
92
+ out.push(v)
93
+ }
94
+ return out
95
+ }
96
+ default:
97
+ return assertUnreachable(computed)
98
+ }
99
+ }
100
+
17
101
  export function memFilter<T extends FieldValues, U extends keyof T = never>(f: FilterArgs<T, U>) {
18
102
  type M = U extends undefined ? T : Pick<T, U>
19
103
  return ((c: T[]): M[] => {
@@ -21,16 +105,24 @@ export function memFilter<T extends FieldValues, U extends keyof T = never>(f: F
21
105
  const sel = f.select
22
106
  if (!sel) return r as M[]
23
107
  return r.map((i) => {
24
- const [keys, subKeys] = pipe(
108
+ const [keys, entries] = pipe(
25
109
  sel,
26
- Array.partition((r) =>
27
- typeof r === "string" ? Result.fail(String(r)) : Result.succeed(r as { key: string; subKeys: string[] })
28
- )
110
+ Array.partition((entry) => typeof entry === "string" ? Result.fail(String(entry)) : Result.succeed(entry))
29
111
  )
112
+ const subKeys = entries.filter((entry): entry is { key: string; subKeys: readonly string[] } =>
113
+ typeof entry === "object" && entry !== null && "subKeys" in entry
114
+ )
115
+ const computedKeys = entries.filter((entry): entry is {
116
+ key: string
117
+ computed: ComputedProjectionIrExpression
118
+ } => typeof entry === "object" && entry !== null && "computed" in entry)
30
119
  const n = Struct.pick(i, keys)
31
120
  subKeys.forEach((subKey) => {
32
121
  n[subKey.key] = i[subKey.key]!.map(Struct.pick(subKey.subKeys as never[]))
33
122
  })
123
+ computedKeys.forEach((entry) => {
124
+ ;(n as Record<string, unknown>)[entry.key] = computeProjectionValue(i, entry.computed)
125
+ })
34
126
  return n as M
35
127
  })
36
128
  }
@@ -6,6 +6,7 @@ import { SqlClient } from "effect/unstable/sql"
6
6
  import { OptimisticConcurrencyException } from "../../errors.js"
7
7
  import { InfraLogger } from "../../logger.js"
8
8
  import type { FieldValues } from "../../Model/filter/types.js"
9
+ import type { ComputedProjectionIrExpression } from "../../Model/query.js"
9
10
  import { annotateDb } from "../../otel.js"
10
11
  import { storeId } from "../Memory.js"
11
12
  import { type FilterArgs, type PersistenceModelType, type StorageConfig, type Store, type StoreConfig, StoreMaker } from "../service.js"
@@ -217,7 +218,15 @@ const makePgStore = Effect.fnUntraced(function*({ prefix }: StorageConfig) {
217
218
  tableName,
218
219
  defaultValues,
219
220
  f.select as
220
- | NonEmptyReadonlyArray<string | { key: string; subKeys: readonly string[] }>
221
+ | NonEmptyReadonlyArray<
222
+ string | {
223
+ key: string
224
+ subKeys: readonly string[]
225
+ } | {
226
+ key: string
227
+ computed: ComputedProjectionIrExpression
228
+ }
229
+ >
221
230
  | undefined,
222
231
  f.order,
223
232
  f.skip,
@@ -3,6 +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
7
  import { isRelationCheck } from "../codeFilter.js"
7
8
 
8
9
  export interface SQLDialect {
@@ -153,7 +154,15 @@ export function buildWhereSQLQuery(
153
154
  filter: readonly FilterResult[],
154
155
  tableName: string,
155
156
  defaultValues: Record<string, unknown>,
156
- select?: NonEmptyReadonlyArray<string | { key: string; subKeys: readonly string[] }>,
157
+ select?: NonEmptyReadonlyArray<
158
+ string | {
159
+ key: string
160
+ subKeys: readonly string[]
161
+ } | {
162
+ key: string
163
+ computed: ComputedProjectionIrExpression
164
+ }
165
+ >,
157
166
  order?: NonEmptyReadonlyArray<{ key: string; direction: "ASC" | "DESC" }>,
158
167
  skip?: number,
159
168
  limit?: number
@@ -378,6 +387,58 @@ export function buildWhereSQLQuery(
378
387
  return s
379
388
  }
380
389
 
390
+ const computedSelectExpr = (key: string, computed: ComputedProjectionIrExpression): string => {
391
+ const relationPath = dottedToJsonPath(computed.path)
392
+ const relationAlias = `_${computed.path}`
393
+ const relationFrom = dialect.jsonEachFrom(relationPath, relationAlias)
394
+ const whereClause = () =>
395
+ computed.filter.length > 0
396
+ ? ` WHERE ${print(computed.filter, computed.path, false)}`
397
+ : ""
398
+ const boolExpr = (sqlExpr: string) =>
399
+ dialect.jsonColumnType === "JSON"
400
+ ? `CASE WHEN ${sqlExpr} THEN 'true' ELSE 'false' END AS "${key}"`
401
+ : `${sqlExpr} AS "${key}"`
402
+ switch (computed._tag) {
403
+ case "relation-count":
404
+ return `(SELECT COUNT(1) FROM ${relationFrom}${whereClause()}) AS "${key}"`
405
+ case "relation-any":
406
+ return boolExpr(`EXISTS(SELECT 1 FROM ${relationFrom}${whereClause()})`)
407
+ case "relation-every":
408
+ // ∀x.P(x) ≡ ¬∃x.¬P(x). When no filter, no element exists that violates ⊤ → true.
409
+ return boolExpr(
410
+ computed.filter.length === 0
411
+ ? `1=1`
412
+ : `NOT EXISTS(SELECT 1 FROM ${relationFrom} WHERE NOT (${print(computed.filter, computed.path, false)}))`
413
+ )
414
+ case "relation-distinct-count": {
415
+ const fieldExtract = dialect.jsonExtractElement(relationAlias, computed.field)
416
+ return `(SELECT COUNT(DISTINCT ${fieldExtract}) FROM ${relationFrom}${whereClause()}) AS "${key}"`
417
+ }
418
+ case "relation-sum": {
419
+ const fieldExtract = dialect.jsonExtractElement(relationAlias, computed.field)
420
+ const cast = dialect.jsonColumnType === "JSON"
421
+ ? `CAST(${fieldExtract} AS REAL)`
422
+ : `(${fieldExtract})::numeric`
423
+ return `(SELECT COALESCE(SUM(${cast}), 0) FROM ${relationFrom}${whereClause()}) AS "${key}"`
424
+ }
425
+ case "relation-collect": {
426
+ const fieldExtract = dialect.jsonExtractElement(relationAlias, computed.field)
427
+ if (dialect.jsonColumnType === "JSON") {
428
+ // sqlite: json_group_array does not accept DISTINCT; emulate via inner DISTINCT subquery
429
+ if (computed.distinct) {
430
+ return `(SELECT COALESCE(json_group_array(__v), json_array()) FROM (SELECT DISTINCT ${fieldExtract} AS __v FROM ${relationFrom}${whereClause()})) AS "${key}"`
431
+ }
432
+ return `(SELECT COALESCE(json_group_array(${fieldExtract}), json_array()) FROM ${relationFrom}${whereClause()}) AS "${key}"`
433
+ }
434
+ const aggArg = computed.distinct ? `DISTINCT ${fieldExtract}` : fieldExtract
435
+ return `(SELECT COALESCE(jsonb_agg(${aggArg}), '[]'::jsonb) FROM ${relationFrom}${whereClause()}) AS "${key}"`
436
+ }
437
+ default:
438
+ return assertUnreachable(computed)
439
+ }
440
+ }
441
+
381
442
  const getSelectExpr = (): string => {
382
443
  if (!select) return "id, _etag, data"
383
444
  const fields = select.map((s) => {
@@ -386,6 +447,9 @@ export function buildWhereSQLQuery(
386
447
  if (s === "_etag") return `_etag`
387
448
  return `${dialect.jsonExtractJson(s)} AS "${s}"`
388
449
  }
450
+ if ("computed" in s) {
451
+ return computedSelectExpr(s.key, s.computed)
452
+ }
389
453
  return `${dialect.jsonExtractJson(s.key)} AS "${s.key}"`
390
454
  })
391
455
  return fields.join(", ")
package/src/Store/SQL.ts CHANGED
@@ -7,6 +7,7 @@ import { SqlClient } from "effect/unstable/sql"
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 { ComputedProjectionIrExpression } from "../Model/query.js"
10
11
  import { annotateDb, type DbSystem } from "../otel.js"
11
12
  import { storeId } from "./Memory.js"
12
13
  import { type FilterArgs, type PersistenceModelType, type StorageConfig, type Store, type StoreConfig, StoreMaker } from "./service.js"
@@ -236,7 +237,15 @@ function makeSQLStoreInt(system: DbSystem, dialect: SQLDialect, jsonColumnType:
236
237
  defaultValues,
237
238
  f
238
239
  .select as
239
- | NonEmptyReadonlyArray<string | { key: string; subKeys: readonly string[] }>
240
+ | NonEmptyReadonlyArray<
241
+ string | {
242
+ key: string
243
+ subKeys: readonly string[]
244
+ } | {
245
+ key: string
246
+ computed: ComputedProjectionIrExpression
247
+ }
248
+ >
240
249
  | undefined,
241
250
  f
242
251
  .order,
@@ -580,7 +589,15 @@ function makeSQLiteStorePerNs(
580
589
  defaultValues,
581
590
  f
582
591
  .select as
583
- | NonEmptyReadonlyArray<string | { key: string; subKeys: readonly string[] }>
592
+ | NonEmptyReadonlyArray<
593
+ string | {
594
+ key: string
595
+ subKeys: readonly string[]
596
+ } | {
597
+ key: string
598
+ computed: ComputedProjectionIrExpression
599
+ }
600
+ >
584
601
  | undefined,
585
602
  f
586
603
  .order as NonEmptyReadonlyArray<{ key: string; direction: "ASC" | "DESC" }> | undefined,
@@ -6,7 +6,7 @@ import type { OptimisticConcurrencyException } from "../errors.js"
6
6
  import type { FilterResult } from "../Model/filter/filterApi.js"
7
7
  import type { FieldValues } from "../Model/filter/types.js"
8
8
  import type { FieldPath } from "../Model/filter/types/path/index.js"
9
- import { type RawQuery } from "../Model/query.js"
9
+ import type { ComputedProjectionIrExpression, RawQuery } from "../Model/query.js"
10
10
 
11
11
  export interface StoreConfig<E> {
12
12
  partitionValue: (e?: E) => string
@@ -61,7 +61,14 @@ export interface O<TFieldValues extends FieldValues> {
61
61
  export interface FilterArgs<Encoded extends FieldValues, U extends keyof Encoded = never> {
62
62
  t: Encoded
63
63
  filter?: Filter | undefined
64
- select?: NonEmptyReadonlyArray<U | { key: string; subKeys: readonly string[] }> | undefined
64
+ select?:
65
+ | NonEmptyReadonlyArray<
66
+ U | { key: string; subKeys: readonly string[] } | {
67
+ key: string
68
+ computed: ComputedProjectionIrExpression
69
+ }
70
+ >
71
+ | undefined
65
72
  order?: NonEmptyReadonlyArray<O<NoInfer<Encoded>>>
66
73
  limit?: number | undefined
67
74
  skip?: number | undefined
@@ -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, count, make, one, or, order, page, project, type QueryEnd, type QueryProjection, type QueryWhere, toFilter, where } from "../src/Model/query.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"
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"
@@ -575,6 +575,161 @@ it(
575
575
  .pipe(Effect.provide(TestStoreLive), setupRequestContextFromCurrent(), Effect.runPromise)
576
576
  )
577
577
 
578
+ it("projectComputed sets computed IR and forces project mode", () => {
579
+ const baseSchema = S.Struct({
580
+ id: S.String,
581
+ items: S.Array(S.Struct({
582
+ state: S.Struct({
583
+ _tag: S.String
584
+ })
585
+ }))
586
+ })
587
+ const query = make<S.Codec.Encoded<typeof baseSchema>>().pipe(
588
+ projectComputed(
589
+ S.Struct({
590
+ pickedCount: S.NonNegativeInt
591
+ }),
592
+ computed({
593
+ pickedCount: relation<S.Codec.Encoded<typeof baseSchema>>("items").count(where("state._tag", "Picked"))
594
+ })
595
+ )
596
+ )
597
+ const interpreted = toFilter(query, baseSchema)
598
+ expect(interpreted.mode).toBe("project")
599
+ expect(interpreted.select).toEqual([
600
+ {
601
+ key: "pickedCount",
602
+ computed: {
603
+ _tag: "relation-count",
604
+ path: "items",
605
+ filter: [{ t: "where", path: "items.-1.state._tag", op: "eq", value: "Picked" }]
606
+ }
607
+ }
608
+ ])
609
+ expect(interpreted.computed?.["pickedCount"]?._tag).toBe("relation-count")
610
+ expect(interpreted.computed?.["pickedCount"]?.path).toBe("items")
611
+ expect(interpreted.computed?.["pickedCount"]?.filter).toEqual([
612
+ { t: "where", path: "items.-1.state._tag", op: "eq", value: "Picked" }
613
+ ])
614
+ })
615
+
616
+ it("projectComputed validates extra computed keys", () => {
617
+ const baseSchema = S.Struct({
618
+ id: S.String,
619
+ items: S.Array(S.Struct({ value: S.Number }))
620
+ })
621
+ const query = make<S.Codec.Encoded<typeof baseSchema>>().pipe(
622
+ projectComputed(
623
+ S.Struct({ id: S.String }),
624
+ computed({
625
+ pickedCount: relation<S.Codec.Encoded<typeof baseSchema>>("items").count()
626
+ })
627
+ )
628
+ )
629
+ expect(() => toFilter(query, baseSchema)).toThrowError("Computed projection keys must exist in projection schema")
630
+ })
631
+
632
+ it("projection schema with computed fields fails without computed map", () => {
633
+ const baseSchema = S.Struct({
634
+ id: S.String,
635
+ items: S.Array(S.Struct({ value: S.Number }))
636
+ })
637
+ const query = make<S.Codec.Encoded<typeof baseSchema>>().pipe(
638
+ projectComputed(S.Struct({ pickedCount: S.NonNegativeInt }), computed({}))
639
+ )
640
+ expect(() => toFilter(query, baseSchema)).toThrowError("Missing computed projections for schema keys")
641
+ })
642
+
643
+ it("projectComputed.every emits relation-every IR", () => {
644
+ const baseSchema = S.Struct({
645
+ id: S.String,
646
+ items: S.Array(S.Struct({ state: S.Struct({ _tag: S.String }) }))
647
+ })
648
+ const query = make<S.Codec.Encoded<typeof baseSchema>>().pipe(
649
+ projectComputed(
650
+ S.Struct({ allPicked: S.Boolean }),
651
+ computed({
652
+ allPicked: relation<S.Codec.Encoded<typeof baseSchema>>("items").every(where("state._tag", "Picked"))
653
+ })
654
+ )
655
+ )
656
+ const interpreted = toFilter(query, baseSchema)
657
+ expect(interpreted.computed?.["allPicked"]?._tag).toBe("relation-every")
658
+ expect(interpreted.computed?.["allPicked"]?.path).toBe("items")
659
+ expect(interpreted.computed?.["allPicked"]?.filter).toEqual([
660
+ { t: "where", path: "items.-1.state._tag", op: "eq", value: "Picked" }
661
+ ])
662
+ })
663
+
664
+ it("projectComputed.distinctCount emits relation-distinct-count IR with field", () => {
665
+ const baseSchema = S.Struct({
666
+ id: S.String,
667
+ items: S.Array(S.Struct({ rowId: S.String, state: S.Struct({ _tag: S.String }) }))
668
+ })
669
+ const query = make<S.Codec.Encoded<typeof baseSchema>>().pipe(
670
+ projectComputed(
671
+ S.Struct({ positionCount: S.NonNegativeInt }),
672
+ computed({
673
+ positionCount: relation<S.Codec.Encoded<typeof baseSchema>>("items").distinctCount(
674
+ "rowId",
675
+ where("state._tag", "neq", "cancelled")
676
+ )
677
+ })
678
+ )
679
+ )
680
+ const interpreted = toFilter(query, baseSchema)
681
+ const ir = interpreted.computed?.["positionCount"]
682
+ expect(ir?._tag).toBe("relation-distinct-count")
683
+ expect((ir as { field: string } | undefined)?.field).toBe("rowId")
684
+ expect(ir?.filter).toEqual([
685
+ { t: "where", path: "items.-1.state._tag", op: "neq", value: "cancelled" }
686
+ ])
687
+ })
688
+
689
+ it("projectComputed.sum emits relation-sum IR with field", () => {
690
+ const baseSchema = S.Struct({
691
+ id: S.String,
692
+ items: S.Array(S.Struct({ weight: S.Number }))
693
+ })
694
+ const query = make<S.Codec.Encoded<typeof baseSchema>>().pipe(
695
+ projectComputed(
696
+ S.Struct({ totalWeight: S.Number }),
697
+ computed({ totalWeight: relation<S.Codec.Encoded<typeof baseSchema>>("items").sum("weight") })
698
+ )
699
+ )
700
+ const interpreted = toFilter(query, baseSchema)
701
+ const ir = interpreted.computed?.["totalWeight"]
702
+ expect(ir?._tag).toBe("relation-sum")
703
+ expect((ir as { field: string } | undefined)?.field).toBe("weight")
704
+ expect(ir?.filter).toEqual([])
705
+ })
706
+
707
+ it("projectComputed.collect / collectDistinct emit relation-collect IR", () => {
708
+ const baseSchema = S.Struct({
709
+ id: S.String,
710
+ items: S.Array(S.Struct({ articleId: S.String }))
711
+ })
712
+ const query = make<S.Codec.Encoded<typeof baseSchema>>().pipe(
713
+ projectComputed(
714
+ S.Struct({
715
+ all: S.Array(S.String),
716
+ distinct: S.Array(S.String)
717
+ }),
718
+ computed({
719
+ all: relation<S.Codec.Encoded<typeof baseSchema>>("items").collect("articleId"),
720
+ distinct: relation<S.Codec.Encoded<typeof baseSchema>>("items").collectDistinct("articleId")
721
+ })
722
+ )
723
+ )
724
+ const interpreted = toFilter(query, baseSchema)
725
+ const all = interpreted.computed?.["all"]
726
+ const distinct = interpreted.computed?.["distinct"]
727
+ expect(all?._tag).toBe("relation-collect")
728
+ expect((all as { distinct: boolean } | undefined)?.distinct).toBe(false)
729
+ expect(distinct?._tag).toBe("relation-collect")
730
+ expect((distinct as { distinct: boolean } | undefined)?.distinct).toBe(true)
731
+ })
732
+
578
733
  it(
579
734
  "doesn't mess when refining fields",
580
735
  () =>
@@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest"
2
2
  import { Array, Config, Context, Effect, flow, Layer, ManagedRuntime, Redacted, References, Result, S } from "effect-app"
3
3
  import { LogLevels } from "effect-app/utils"
4
4
  import { setupRequestContextFromCurrent } from "../src/api/setupRequest.js"
5
- import { and, or, project, where, whereEvery, whereSome } from "../src/Model/query.js"
5
+ import { and, computed, or, project, projectComputed, relation, where, whereEvery, whereSome } from "../src/Model/query.js"
6
6
  import { makeRepo } from "../src/Model/Repository/makeRepo.js"
7
7
  import { RepositoryRegistryLive } from "../src/Model/Repository/Registry.js"
8
8
  import { CosmosStoreLayer } from "../src/Store/Cosmos.js"
@@ -356,6 +356,41 @@ describe("multi-level", () => {
356
356
  .pipe(Effect.provide(SomethingRepo.Test), rt.runPromise))
357
357
  })
358
358
 
359
+ describe("computed projections", () => {
360
+ const test = Effect
361
+ .gen(function*() {
362
+ const repo = yield* SomethingRepo
363
+ const output = S.Struct({
364
+ id: S.String,
365
+ pickedCount: S.NonNegativeInt,
366
+ hasPicked: S.Boolean
367
+ })
368
+ const pickedFilter = where("value", "gt", 20)
369
+ const items = yield* repo.query(
370
+ projectComputed(
371
+ output,
372
+ computed({
373
+ pickedCount: relation<S.Codec.Encoded<typeof Something>>("items").count(pickedFilter),
374
+ hasPicked: relation<S.Codec.Encoded<typeof Something>>("items").any(pickedFilter)
375
+ })
376
+ )
377
+ )
378
+ expect(items).toStrictEqual([
379
+ { id: "1", pickedCount: 0, hasPicked: false },
380
+ { id: "2", pickedCount: 2, hasPicked: true }
381
+ ])
382
+ })
383
+ .pipe(setupRequestContextFromCurrent())
384
+
385
+ it.skipIf(!process.env["STORAGE_URL"])("works well in CosmosDB", () =>
386
+ test
387
+ .pipe(Effect.provide(SomethingRepo.TestCosmos), rt.runPromise))
388
+
389
+ it("works well in Memory", () =>
390
+ test
391
+ .pipe(Effect.provide(SomethingRepo.Test), rt.runPromise))
392
+ })
393
+
359
394
  // FUTURE: we need something like this instead:
360
395
  /*
361
396
  const subQuery = <T extends FieldValues>() => <TKey extends keyof T>(key: TKey, type: "some" | "every" = "some") => make<T[TKey][number]>() // todo: mark that this is sub query on field "items"