@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.
- package/CHANGELOG.md +28 -0
- package/dist/Model/Repository/internal/internal.d.ts.map +1 -1
- package/dist/Model/Repository/internal/internal.js +6 -2
- package/dist/Model/Repository/validation.d.ts +8 -8
- package/dist/Model/query/dsl.d.ts +76 -1
- package/dist/Model/query/dsl.d.ts.map +1 -1
- package/dist/Model/query/dsl.js +111 -1
- package/dist/Model/query/new-kid-interpreter.d.ts +38 -2
- package/dist/Model/query/new-kid-interpreter.d.ts.map +1 -1
- package/dist/Model/query/new-kid-interpreter.js +80 -4
- package/dist/RequestContext.d.ts +12 -12
- package/dist/Store/Cosmos/query.d.ts +5 -1
- package/dist/Store/Cosmos/query.d.ts.map +1 -1
- package/dist/Store/Cosmos/query.js +63 -23
- package/dist/Store/Cosmos.d.ts.map +1 -1
- package/dist/Store/Cosmos.js +1 -1
- package/dist/Store/Memory.d.ts.map +1 -1
- package/dist/Store/Memory.js +86 -2
- package/dist/Store/SQL/Pg.d.ts.map +1 -1
- package/dist/Store/SQL/Pg.js +1 -1
- package/dist/Store/SQL/query.d.ts +5 -1
- package/dist/Store/SQL/query.d.ts.map +1 -1
- package/dist/Store/SQL/query.js +51 -1
- package/dist/Store/SQL.d.ts.map +1 -1
- package/dist/Store/SQL.js +1 -1
- package/dist/Store/service.d.ts +5 -2
- package/dist/Store/service.d.ts.map +1 -1
- package/dist/Store/service.js +1 -1
- package/package.json +2 -2
- package/src/Model/Repository/internal/internal.ts +5 -1
- package/src/Model/query/dsl.ts +191 -0
- package/src/Model/query/new-kid-interpreter.ts +124 -4
- package/src/Store/Cosmos/query.ts +80 -23
- package/src/Store/Cosmos.ts +10 -2
- package/src/Store/Memory.ts +96 -4
- package/src/Store/SQL/Pg.ts +10 -1
- package/src/Store/SQL/query.ts +65 -1
- package/src/Store/SQL.ts +19 -2
- package/src/Store/service.ts +9 -2
- package/test/query.test.ts +156 -1
- package/test/rawQuery.test.ts +36 -1
- package/test/sql-store.test.ts +362 -0
package/src/Store/Memory.ts
CHANGED
|
@@ -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,
|
|
108
|
+
const [keys, entries] = pipe(
|
|
25
109
|
sel,
|
|
26
|
-
Array.partition((
|
|
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
|
}
|
package/src/Store/SQL/Pg.ts
CHANGED
|
@@ -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<
|
|
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,
|
package/src/Store/SQL/query.ts
CHANGED
|
@@ -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<
|
|
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<
|
|
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<
|
|
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,
|
package/src/Store/service.ts
CHANGED
|
@@ -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 {
|
|
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?:
|
|
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
|
package/test/query.test.ts
CHANGED
|
@@ -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
|
() =>
|
package/test/rawQuery.test.ts
CHANGED
|
@@ -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"
|