@effect-app/infra 4.0.0-beta.212 → 4.0.0-beta.213

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 +11 -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 +51 -1
  6. package/dist/Model/query/dsl.d.ts.map +1 -1
  7. package/dist/Model/query/dsl.js +54 -1
  8. package/dist/Model/query/new-kid-interpreter.d.ts +18 -2
  9. package/dist/Model/query/new-kid-interpreter.d.ts.map +1 -1
  10. package/dist/Model/query/new-kid-interpreter.js +58 -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 +41 -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 +33 -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 +25 -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 +106 -0
  32. package/src/Model/query/new-kid-interpreter.ts +78 -4
  33. package/src/Store/Cosmos/query.ts +57 -23
  34. package/src/Store/Cosmos.ts +10 -2
  35. package/src/Store/Memory.ts +53 -4
  36. package/src/Store/SQL/Pg.ts +10 -1
  37. package/src/Store/SQL/query.ts +35 -1
  38. package/src/Store/SQL.ts +19 -2
  39. package/src/Store/service.ts +9 -2
  40. package/test/query.test.ts +66 -1
  41. package/test/rawQuery.test.ts +36 -1
  42. package/test/sql-store.test.ts +61 -0
@@ -3,6 +3,7 @@
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
5
  import { InfraLogger } from "../logger.js"
6
+ import type { FilterResult } from "../Model/filter/filterApi.js"
6
7
  import type { FieldValues } from "../Model/filter/types.js"
7
8
  import { annotateDb } from "../otel.js"
8
9
  import { codeFilter, codeFilter3_ } from "./codeFilter.js"
@@ -14,6 +15,42 @@ export function get(obj: any, path: string): any {
14
15
  return path.split(".").reduce((res: any, key: string) => (res != null ? res[key] : res), obj)
15
16
  }
16
17
 
18
+ const stripRelationFilterPaths = (state: readonly FilterResult[], relationPath: string): readonly FilterResult[] => {
19
+ const prefix = `${relationPath}.-1.`
20
+ return state.map((entry) =>
21
+ "path" in entry
22
+ ? {
23
+ ...entry,
24
+ path: entry.path.startsWith(prefix) ? entry.path.slice(prefix.length) : entry.path
25
+ }
26
+ : {
27
+ ...entry,
28
+ result: stripRelationFilterPaths(entry.result, relationPath)
29
+ }
30
+ )
31
+ }
32
+
33
+ const computeProjectionValue = (
34
+ row: FieldValues,
35
+ computed: {
36
+ readonly _tag: "relation-count" | "relation-any"
37
+ readonly path: string
38
+ readonly filter: readonly FilterResult[]
39
+ }
40
+ ) => {
41
+ const relation = get(row, computed.path)
42
+ if (!Array.isArray(relation)) {
43
+ return computed._tag === "relation-count" ? 0 : false
44
+ }
45
+ const filter = stripRelationFilterPaths(computed.filter, computed.path)
46
+ switch (computed._tag) {
47
+ case "relation-count":
48
+ return relation.reduce<number>((acc, value) => codeFilter3_(filter, value) ? acc + 1 : acc, 0)
49
+ case "relation-any":
50
+ return relation.some((value) => codeFilter3_(filter, value))
51
+ }
52
+ }
53
+
17
54
  export function memFilter<T extends FieldValues, U extends keyof T = never>(f: FilterArgs<T, U>) {
18
55
  type M = U extends undefined ? T : Pick<T, U>
19
56
  return ((c: T[]): M[] => {
@@ -21,16 +58,28 @@ export function memFilter<T extends FieldValues, U extends keyof T = never>(f: F
21
58
  const sel = f.select
22
59
  if (!sel) return r as M[]
23
60
  return r.map((i) => {
24
- const [keys, subKeys] = pipe(
61
+ const [keys, entries] = pipe(
25
62
  sel,
26
- Array.partition((r) =>
27
- typeof r === "string" ? Result.fail(String(r)) : Result.succeed(r as { key: string; subKeys: string[] })
28
- )
63
+ Array.partition((entry) => typeof entry === "string" ? Result.fail(String(entry)) : Result.succeed(entry))
64
+ )
65
+ const subKeys = entries.filter((entry): entry is { key: string; subKeys: readonly string[] } =>
66
+ typeof entry === "object" && entry !== null && "subKeys" in entry
29
67
  )
68
+ const computedKeys = entries.filter((entry): entry is {
69
+ key: string
70
+ computed: {
71
+ readonly _tag: "relation-count" | "relation-any"
72
+ readonly path: string
73
+ readonly filter: readonly FilterResult[]
74
+ }
75
+ } => typeof entry === "object" && entry !== null && "computed" in entry)
30
76
  const n = Struct.pick(i, keys)
31
77
  subKeys.forEach((subKey) => {
32
78
  n[subKey.key] = i[subKey.key]!.map(Struct.pick(subKey.subKeys as never[]))
33
79
  })
80
+ computedKeys.forEach((entry) => {
81
+ ;(n as Record<string, unknown>)[entry.key] = computeProjectionValue(i, entry.computed)
82
+ })
34
83
  return n as M
35
84
  })
36
85
  }
@@ -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,28 @@ 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 where = computed.filter.length > 0
395
+ ? ` WHERE ${print(computed.filter, computed.path, false)}`
396
+ : ""
397
+ switch (computed._tag) {
398
+ case "relation-count":
399
+ return `(SELECT COUNT(1) FROM ${relationFrom}${where}) AS "${key}"`
400
+ case "relation-any": {
401
+ const existsExpr = `EXISTS(SELECT 1 FROM ${relationFrom}${where})`
402
+ if (dialect.jsonColumnType === "JSON") {
403
+ return `CASE WHEN ${existsExpr} THEN 'true' ELSE 'false' END AS "${key}"`
404
+ }
405
+ return `${existsExpr} AS "${key}"`
406
+ }
407
+ default:
408
+ return assertUnreachable(computed)
409
+ }
410
+ }
411
+
381
412
  const getSelectExpr = (): string => {
382
413
  if (!select) return "id, _etag, data"
383
414
  const fields = select.map((s) => {
@@ -386,6 +417,9 @@ export function buildWhereSQLQuery(
386
417
  if (s === "_etag") return `_etag`
387
418
  return `${dialect.jsonExtractJson(s)} AS "${s}"`
388
419
  }
420
+ if ("computed" in s) {
421
+ return computedSelectExpr(s.key, s.computed)
422
+ }
389
423
  return `${dialect.jsonExtractJson(s.key)} AS "${s.key}"`
390
424
  })
391
425
  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,71 @@ 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
+
578
643
  it(
579
644
  "doesn't mess when refining fields",
580
645
  () =>
@@ -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"
@@ -228,6 +228,47 @@ describe("SQL query builder (SQLite dialect)", () => {
228
228
  expect(result.sql).toContain("LIMIT")
229
229
  expect(result.sql).toContain("OFFSET")
230
230
  })
231
+
232
+ it("computed relation count projection", () => {
233
+ const result = buildWhereSQLQuery(
234
+ sqliteDialect,
235
+ "id",
236
+ [],
237
+ "users",
238
+ {},
239
+ [{
240
+ key: "pickedCount",
241
+ computed: {
242
+ _tag: "relation-count",
243
+ path: "items",
244
+ filter: [{ t: "where", path: "items.-1.description", op: "contains", value: "picked" }]
245
+ }
246
+ }]
247
+ )
248
+ expect(result.sql).toContain(`SELECT COUNT(1) FROM json_each(data, '$.items') AS _items`)
249
+ expect(result.sql).toContain(`AS "pickedCount"`)
250
+ expect(result.params).toContain("%picked%")
251
+ })
252
+
253
+ it("computed relation any projection (sqlite bool encoding)", () => {
254
+ const result = buildWhereSQLQuery(
255
+ sqliteDialect,
256
+ "id",
257
+ [],
258
+ "users",
259
+ {},
260
+ [{
261
+ key: "hasPicked",
262
+ computed: {
263
+ _tag: "relation-any",
264
+ path: "items",
265
+ filter: [{ t: "where", path: "items.-1.description", op: "contains", value: "picked" }]
266
+ }
267
+ }]
268
+ )
269
+ expect(result.sql).toContain("CASE WHEN EXISTS(")
270
+ expect(result.sql).toContain(`AS "hasPicked"`)
271
+ })
231
272
  })
232
273
 
233
274
  describe("SQL query builder (PostgreSQL dialect)", () => {
@@ -291,6 +332,26 @@ describe("SQL query builder (PostgreSQL dialect)", () => {
291
332
  )
292
333
  expect(result.sql).toContain("data->'address'->>'city'")
293
334
  })
335
+
336
+ it("computed relation any projection", () => {
337
+ const result = buildWhereSQLQuery(
338
+ pgDialect,
339
+ "id",
340
+ [],
341
+ "users",
342
+ {},
343
+ [{
344
+ key: "hasPicked",
345
+ computed: {
346
+ _tag: "relation-any",
347
+ path: "items",
348
+ filter: [{ t: "where", path: "items.-1.description", op: "contains", value: "picked" }]
349
+ }
350
+ }]
351
+ )
352
+ expect(result.sql).toContain("EXISTS(SELECT 1 FROM jsonb_array_elements(data->'items') AS _items")
353
+ expect(result.sql).toContain(`AS "hasPicked"`)
354
+ })
294
355
  })
295
356
 
296
357
  // --- Integration tests with in-memory SQLite (direct, no Effect SQL client) ---