@effect-app/infra 4.0.0-beta.225 → 4.0.0-beta.227

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.
@@ -179,6 +179,10 @@ export type ComputedProjectionExpression =
179
179
  readonly distinct: boolean
180
180
  readonly operation?: ComputedProjectionOperation
181
181
  }
182
+ | {
183
+ readonly _tag: "relation-length"
184
+ readonly path: string
185
+ }
182
186
 
183
187
  export type ComputedProjectionMap = Readonly<Record<string, ComputedProjectionExpression>>
184
188
  export type Q<TFieldValues extends FieldValues> =
@@ -473,6 +477,10 @@ export const project: {
473
477
  export const relation = <TFieldValues extends FieldValues>(
474
478
  path: FieldPath<TFieldValues>
475
479
  ) => ({
480
+ length: (): ComputedProjectionExpression => ({
481
+ _tag: "relation-length",
482
+ path: path as string
483
+ }),
476
484
  count: (operation?: ComputedProjectionOperation): ComputedProjectionExpression =>
477
485
  operation
478
486
  ? {
@@ -88,6 +88,10 @@ export type ComputedProjectionIrExpression =
88
88
  readonly distinct: boolean
89
89
  readonly filter: readonly FilterResult[]
90
90
  }
91
+ | {
92
+ readonly _tag: "relation-length"
93
+ readonly path: string
94
+ }
91
95
 
92
96
  type Result<TFieldValues extends FieldValues, A = TFieldValues, R = never> = {
93
97
  filter: FilterResult[]
@@ -230,7 +234,8 @@ const interpret = <
230
234
  ? Object.fromEntries(
231
235
  Object.entries(v.computed).map(([key, expression]) => {
232
236
  const e = expression
233
- const filter = e.operation ? interpret(e.operation(make())).filter.map(applyPath(e.path)) : []
237
+ const op = "operation" in e ? e.operation : undefined
238
+ const filter = op ? interpret(op(make())).filter.map(applyPath(e.path)) : []
234
239
  switch (e._tag) {
235
240
  case "relation-count":
236
241
  case "relation-any":
@@ -293,6 +298,8 @@ const interpret = <
293
298
  filter
294
299
  } as ComputedProjectionIrExpression
295
300
  ]
301
+ case "relation-length":
302
+ return [key, { _tag: e._tag, path: e.path } as ComputedProjectionIrExpression]
296
303
  }
297
304
  })
298
305
  )
@@ -289,7 +289,9 @@ export function buildWhereCosmosQuery3(
289
289
  : [_]
290
290
  )
291
291
  const computedFilters = select
292
- ? select.flatMap((_) => typeof _ === "object" && "computed" in _ ? getValues(_.computed.filter) : [])
292
+ ? select.flatMap((_) =>
293
+ typeof _ === "object" && "computed" in _ && "filter" in _.computed ? getValues(_.computed.filter) : []
294
+ )
293
295
  : []
294
296
  const values = [...computedFilters, ...getValues(filter)]
295
297
 
@@ -314,9 +316,11 @@ export function buildWhereCosmosQuery3(
314
316
  `IIF(${unitExpr} = ${JSON.stringify(toBase)}, 1, 0)`
315
317
  )
316
318
  }
317
- const where = computed.filter.length > 0
318
- ? ` WHERE ${print(computed.filter, relationPath, false)}`
319
- : ""
319
+ const filter = "filter" in computed ? computed.filter : []
320
+ // Print filter once — `print` mutates the outer `i` parameter counter, so
321
+ // re-walking the same filter would double-bump it and desync @v indices.
322
+ const filterSql = filter.length > 0 ? print(filter, relationPath, false) : ""
323
+ const where = filterSql ? ` WHERE ${filterSql}` : ""
320
324
  switch (computed._tag) {
321
325
  case "relation-count":
322
326
  return `(SELECT VALUE COUNT(1) FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
@@ -325,10 +329,8 @@ export function buildWhereCosmosQuery3(
325
329
  case "relation-every": {
326
330
  // ∀x.P(x) ≡ ¬∃x.¬P(x). Cosmos has no NOT(...) on EXISTS subqueries directly,
327
331
  // but we can flip via NOT EXISTS(... WHERE NOT (filter)).
328
- if (computed.filter.length === 0) return `true AS ${key}`
329
- return `NOT EXISTS(SELECT VALUE ${relationAlias} FROM ${relationAlias} IN ${relationSource} WHERE NOT (${
330
- print(computed.filter, relationPath, false)
331
- })) AS ${key}`
332
+ if (filter.length === 0) return `true AS ${key}`
333
+ return `NOT EXISTS(SELECT VALUE ${relationAlias} FROM ${relationAlias} IN ${relationSource} WHERE NOT (${filterSql})) AS ${key}`
332
334
  }
333
335
  case "relation-distinct-count": {
334
336
  const fieldRef = dottedToAccess(`${relationAlias}.${computed.field}`)
@@ -360,6 +362,8 @@ export function buildWhereCosmosQuery3(
360
362
  }
361
363
  return `ARRAY(SELECT VALUE ${fieldRef} FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
362
364
  }
365
+ case "relation-length":
366
+ return `ARRAY_LENGTH(${relationSource}) AS ${key}`
363
367
  case "relation-collect-fields": {
364
368
  const subqueries = computed.fields.map((field) => {
365
369
  const fieldRef = dottedToAccess(`${relationAlias}.${field}`)
@@ -60,6 +60,8 @@ const emptyValueFor = (tag: ComputedProjectionIrExpression["_tag"]) => {
60
60
  return [] as unknown[]
61
61
  case "relation-collect-fields":
62
62
  return [] as unknown[]
63
+ case "relation-length":
64
+ return 0
63
65
  default:
64
66
  return assertUnreachable(tag)
65
67
  }
@@ -73,6 +75,9 @@ const computeProjectionValue = (
73
75
  if (!Array.isArray(relation)) {
74
76
  return emptyValueFor(computed._tag)
75
77
  }
78
+ if (computed._tag === "relation-length") {
79
+ return relation.length
80
+ }
76
81
  const filter = stripRelationFilterPaths(computed.filter, computed.path)
77
82
  // empty filter = unconditional match (codeFilter3_ uses eval on a built
78
83
  // string and chokes on `( )`, so short-circuit before invoking it).
@@ -412,9 +412,10 @@ export function buildWhereSQLQuery(
412
412
  const cases = entries.map(([unit, factor]) => ` WHEN ${sqlStringLiteral(unit)} THEN ${factor}`).join("")
413
413
  return `CASE ${unitExpr} WHEN ${sqlStringLiteral(toBase)} THEN 1${cases} ELSE NULL END`
414
414
  }
415
+ const filter = "filter" in computed ? computed.filter : []
415
416
  const whereClause = () =>
416
- computed.filter.length > 0
417
- ? ` WHERE ${print(computed.filter, computed.path, false)}`
417
+ filter.length > 0
418
+ ? ` WHERE ${print(filter, computed.path, false)}`
418
419
  : ""
419
420
  const boolExpr = (sqlExpr: string) =>
420
421
  dialect.jsonColumnType === "JSON"
@@ -428,9 +429,9 @@ export function buildWhereSQLQuery(
428
429
  case "relation-every":
429
430
  // ∀x.P(x) ≡ ¬∃x.¬P(x). When no filter, no element exists that violates ⊤ → true.
430
431
  return boolExpr(
431
- computed.filter.length === 0
432
+ filter.length === 0
432
433
  ? `1=1`
433
- : `NOT EXISTS(SELECT 1 FROM ${relationFrom} WHERE NOT (${print(computed.filter, computed.path, false)}))`
434
+ : `NOT EXISTS(SELECT 1 FROM ${relationFrom} WHERE NOT (${print(filter, computed.path, false)}))`
434
435
  )
435
436
  case "relation-distinct-count": {
436
437
  const fieldExtract = dialect.jsonExtractElement(relationAlias, computed.field)
@@ -470,6 +471,10 @@ export function buildWhereSQLQuery(
470
471
  const aggArg = computed.distinct ? `DISTINCT ${fieldExtract}` : fieldExtract
471
472
  return `(SELECT COALESCE(jsonb_agg(${aggArg}), '[]'::jsonb) FROM ${relationFrom}${whereClause()}) AS "${key}"`
472
473
  }
474
+ case "relation-length": {
475
+ const arrPath = dottedToJsonPath(computed.path)
476
+ return `${dialect.arrayLength(arrPath)} AS "${key}"`
477
+ }
473
478
  case "relation-collect-fields": {
474
479
  const branches = computed.fields.map((field) => {
475
480
  const fieldExtract = dialect.jsonExtractElement(relationAlias, field)
@@ -0,0 +1,95 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import * as S from "effect-app/Schema"
3
+ import { describe, expect, it } from "vitest"
4
+ import { and, computed, make, projectComputed, relation, toFilter, where } from "../src/Model/query.js"
5
+ import { buildWhereCosmosQuery3 } from "../src/Store/Cosmos/query.js"
6
+
7
+ class Order extends S.Class<Order>("Order")({
8
+ id: S.String,
9
+ packages: S.Array(S.Struct({ id: S.String, weight: S.Finite }))
10
+ }) {}
11
+
12
+ type OrderEnc = S.Codec.Encoded<typeof Order>
13
+
14
+ // Length projection via `relation(...).length()` should emit a scalar
15
+ // ARRAY_LENGTH expression rather than pulling (or reshaping) the array.
16
+ describe("cosmos query projection: array length", () => {
17
+ it("projects packages length via ARRAY_LENGTH", () => {
18
+ const q = make<OrderEnc>().pipe(
19
+ projectComputed(
20
+ S.Struct({ id: S.String, packageCount: S.NonNegativeInt }),
21
+ computed({ packageCount: relation<OrderEnc>("packages").length() })
22
+ )
23
+ )
24
+
25
+ const ir = toFilter(q as any, Order as any)
26
+
27
+ const result = buildWhereCosmosQuery3(
28
+ "id",
29
+ ir.filter ?? [],
30
+ "Orders",
31
+ {},
32
+ ir.select as any
33
+ )
34
+
35
+ expect(result.query).toMatch(/ARRAY_LENGTH\(f(?:\.packages|\["packages"\])\)/)
36
+ expect(result.query).toContain("AS packageCount")
37
+ // Must not pull the full array nor reshape via subquery
38
+ expect(result.query).not.toMatch(/ARRAY\s*\(\s*SELECT[^)]*FROM\s+t\s+in\s+f[\.\["]/i)
39
+ expect(result.query).not.toMatch(/SELECT VALUE COUNT/)
40
+ })
41
+ })
42
+
43
+ // Regression: `relation-every` previously walked its filter twice (once for the
44
+ // shared `where` variable, once for the NOT-EXISTS branch), double-bumping the
45
+ // shared `i` counter and shifting every subsequent @v index against the bound
46
+ // params array. Verify each filter element binds exactly one parameter and that
47
+ // @v indices in the emitted SQL line up with the bindings.
48
+ describe("cosmos query projection: relation-every parameter binding", () => {
49
+ class Item extends S.Class<Item>("Item")({
50
+ state: S.Struct({ _tag: S.Literals(["initial", "picking", "picked", "packed"]) })
51
+ }) {}
52
+ class DN extends S.Class<DN>("DN")({
53
+ id: S.String,
54
+ state: S.Struct({
55
+ _tag: S.Literals(["initial", "valid", "packed", "closed"]),
56
+ at: S.String
57
+ }),
58
+ items: S.Array(Item)
59
+ }) {}
60
+ type DNEnc = S.Codec.Encoded<typeof DN>
61
+
62
+ it("binds @v indices contiguously across every + main filter", () => {
63
+ const q = make<DNEnc>().pipe(
64
+ where("state.at", "gte", "2026-05-01T00:00:00.000Z"),
65
+ and("state._tag", "neq", "closed"),
66
+ projectComputed(
67
+ S.Struct({
68
+ id: S.String,
69
+ allItemsPicked: S.Boolean,
70
+ allItemsPacked: S.Boolean
71
+ }),
72
+ computed({
73
+ allItemsPicked: relation<DNEnc>("items").every(where("state._tag", "picked")),
74
+ allItemsPacked: relation<DNEnc>("items").every(where("state._tag", "packed"))
75
+ })
76
+ )
77
+ )
78
+
79
+ const ir = toFilter(q as any, DN as any)
80
+ const result = buildWhereCosmosQuery3("id", ir.filter ?? [], "DN", {}, ir.select as any)
81
+
82
+ // Each filter element binds exactly one parameter: 2 every filters + 2 main filter = 4.
83
+ expect(result.parameters).toHaveLength(4)
84
+ expect(result.parameters.map((_) => _.value)).toEqual([
85
+ "picked",
86
+ "packed",
87
+ "2026-05-01T00:00:00.000Z",
88
+ "closed"
89
+ ])
90
+
91
+ // SQL must reference exactly @v0..@v3 in order, no gaps, no overruns.
92
+ const refs = [...result.query.matchAll(/@v\d+/g)].map((m) => m[0])
93
+ expect(refs).toEqual(["@v0", "@v1", "@v2", "@v3"])
94
+ })
95
+ })
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cosmos-query.test.d.ts","sourceRoot":"","sources":["../cosmos-query.test.ts"],"names":[],"mappings":""}
@@ -614,7 +614,7 @@ it("projectComputed sets computed IR and forces project mode", () => {
614
614
  ])
615
615
  expect(interpreted.computed?.["pickedCount"]?._tag).toBe("relation-count")
616
616
  expect(interpreted.computed?.["pickedCount"]?.path).toBe("items")
617
- expect(interpreted.computed?.["pickedCount"]?.filter).toEqual([
617
+ expect((interpreted.computed?.["pickedCount"] as { filter: unknown } | undefined)?.filter).toEqual([
618
618
  { t: "where", path: "items.-1.state._tag", op: "eq", value: "Picked" }
619
619
  ])
620
620
  })
@@ -662,7 +662,7 @@ it("projectComputed.every emits relation-every IR", () => {
662
662
  const interpreted = toFilter(query, baseSchema)
663
663
  expect(interpreted.computed?.["allPicked"]?._tag).toBe("relation-every")
664
664
  expect(interpreted.computed?.["allPicked"]?.path).toBe("items")
665
- expect(interpreted.computed?.["allPicked"]?.filter).toEqual([
665
+ expect((interpreted.computed?.["allPicked"] as { filter: unknown } | undefined)?.filter).toEqual([
666
666
  { t: "where", path: "items.-1.state._tag", op: "eq", value: "Picked" }
667
667
  ])
668
668
  })
@@ -687,7 +687,7 @@ it("projectComputed.distinctCount emits relation-distinct-count IR with field",
687
687
  const ir = interpreted.computed?.["positionCount"]
688
688
  expect(ir?._tag).toBe("relation-distinct-count")
689
689
  expect((ir as { field: string } | undefined)?.field).toBe("rowId")
690
- expect(ir?.filter).toEqual([
690
+ expect((ir as { filter: unknown } | undefined)?.filter).toEqual([
691
691
  { t: "where", path: "items.-1.state._tag", op: "neq", value: "cancelled" }
692
692
  ])
693
693
  })
@@ -707,7 +707,7 @@ it("projectComputed.sum emits relation-sum IR with field", () => {
707
707
  const ir = interpreted.computed?.["totalWeight"]
708
708
  expect(ir?._tag).toBe("relation-sum")
709
709
  expect((ir as { field: string } | undefined)?.field).toBe("weight")
710
- expect(ir?.filter).toEqual([])
710
+ expect((ir as { filter: unknown } | undefined)?.filter).toEqual([])
711
711
  })
712
712
 
713
713
  it("projectComputed.collect / collectDistinct emit relation-collect IR", () => {
@@ -369,6 +369,32 @@ describe("multi-level", () => {
369
369
  .pipe(Effect.provide(SomethingRepo.Test), rt.runPromise))
370
370
  })
371
371
 
372
+ describe("array length projection", () => {
373
+ const test = Effect
374
+ .gen(function*() {
375
+ const repo = yield* SomethingRepo
376
+ const result = yield* repo.query(
377
+ projectComputed(
378
+ S.Struct({ id: S.String, itemCount: S.NonNegativeInt }),
379
+ computed({ itemCount: relation<S.Codec.Encoded<typeof Something>>("items").length() })
380
+ )
381
+ )
382
+ expect(result).toStrictEqual([
383
+ { id: "1", itemCount: 2 },
384
+ { id: "2", itemCount: 2 }
385
+ ])
386
+ })
387
+ .pipe(setupRequestContextFromCurrent())
388
+
389
+ it.skipIf(!process.env["STORAGE_URL"])("works well in CosmosDB", () =>
390
+ test
391
+ .pipe(Effect.provide(SomethingRepo.TestCosmos), rt.runPromise))
392
+
393
+ it("works well in Memory", () =>
394
+ test
395
+ .pipe(Effect.provide(SomethingRepo.Test), rt.runPromise))
396
+ })
397
+
372
398
  describe("computed projections", () => {
373
399
  const test = Effect
374
400
  .gen(function*() {