@effect-app/infra 4.0.0-beta.224 → 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.
@@ -277,7 +277,9 @@ export function makeRepoInternal<
277
277
  }
278
278
  )
279
279
 
280
- const parseMany = Effect.fn("parseMany", { attributes: { "app.entity": name } })(
280
+ const parseMany = Effect.fn("parseMany", {
281
+ attributes: { "app.entity": name, "app.query.mode": "transform" }
282
+ })(
281
283
  function*(items: readonly PM[]) {
282
284
  const cm = yield* cms
283
285
  return yield* decodeMany(items.map((_) => mapReverse(_, cm.set))).pipe(Effect.orDie)
@@ -295,7 +297,9 @@ export function makeRepoInternal<
295
297
  }
296
298
  return dec
297
299
  }
298
- const parseMany2 = Effect.fn("parseMany2", { attributes: { "app.entity": name } })(
300
+ const parseMany2 = Effect.fn("parseMany", {
301
+ attributes: { "app.entity": name, "app.query.mode": "transform" }
302
+ })(
299
303
  function*<A, R>(items: readonly PM[], schema: S.Codec<A, Encoded, R>) {
300
304
  const cm = yield* cms
301
305
  return yield* getDecodeMany(schema)(items.map((_) => mapReverse(_, cm.set))).pipe(Effect.orDie)
@@ -336,7 +340,15 @@ export function makeRepoInternal<
336
340
  ? filter(a)
337
341
  // TODO: mapFrom but need to support per field and dependencies
338
342
  .pipe(
339
- Effect.andThen(flow(S.decodeEffectConcurrently(S.Array(a.schema ?? schema)), provideRctx))
343
+ Effect.andThen(
344
+ flow(
345
+ S.decodeEffectConcurrently(S.Array(a.schema ?? schema)),
346
+ provideRctx,
347
+ Effect.withSpan("parseMany", {
348
+ attributes: { "app.entity": name, "app.query.mode": "project" }
349
+ })
350
+ )
351
+ )
340
352
  )
341
353
  : a.mode === "collect"
342
354
  ? filter(a)
@@ -345,7 +357,10 @@ export function makeRepoInternal<
345
357
  Effect.flatMap(flow(
346
358
  S.decodeEffectConcurrently(S.Array(a.schema)),
347
359
  Effect.map(Array.getSomes),
348
- provideRctx
360
+ provideRctx,
361
+ Effect.withSpan("parseMany", {
362
+ attributes: { "app.entity": name, "app.query.mode": "collect" }
363
+ })
349
364
  ))
350
365
  )
351
366
  : Effect.flatMap(
@@ -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> =
@@ -397,7 +401,7 @@ export const count: {
397
401
  * persistence-model (PM) reverse-mapping is needed:
398
402
  *
399
403
  * - `"transform"` (default when `mode` omitted): goes through the repo's
400
- * `parseMany`/`parseMany2` pipeline. The raw row is reverse-mapped via the
404
+ * `parseMany` pipeline. The raw row is reverse-mapped via the
401
405
  * etag/PM cache (re-injecting `_etag` and any PM-shape state) before
402
406
  * decoding. Decode failures `orDie` (error channel = `never`). Use when
403
407
  * the schema operates on the full PM shape (e.g. full-entity reads that
@@ -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,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":""}
@@ -0,0 +1 @@
1
+ {"version":3,"file":"date-query.test.d.ts","sourceRoot":"","sources":["../date-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*() {