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

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.
@@ -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,8 +316,9 @@ 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
+ const filter = "filter" in computed ? computed.filter : []
320
+ const where = filter.length > 0
321
+ ? ` WHERE ${print(filter, relationPath, false)}`
319
322
  : ""
320
323
  switch (computed._tag) {
321
324
  case "relation-count":
@@ -360,6 +363,8 @@ export function buildWhereCosmosQuery3(
360
363
  }
361
364
  return `ARRAY(SELECT VALUE ${fieldRef} FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
362
365
  }
366
+ case "relation-length":
367
+ return `ARRAY_LENGTH(${relationSource}) AS ${key}`
363
368
  case "relation-collect-fields": {
364
369
  const subqueries = computed.fields.map((field) => {
365
370
  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,41 @@
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 { computed, make, projectComputed, relation, toFilter } 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
+ })
@@ -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*() {