@effect-app/infra 4.0.0-beta.213 → 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 +17 -0
- package/dist/Model/query/dsl.d.ts +26 -1
- package/dist/Model/query/dsl.d.ts.map +1 -1
- package/dist/Model/query/dsl.js +58 -1
- package/dist/Model/query/new-kid-interpreter.d.ts +21 -1
- package/dist/Model/query/new-kid-interpreter.d.ts.map +1 -1
- package/dist/Model/query/new-kid-interpreter.js +24 -2
- package/dist/Store/Cosmos/query.d.ts.map +1 -1
- package/dist/Store/Cosmos/query.js +23 -1
- package/dist/Store/Memory.d.ts.map +1 -1
- package/dist/Store/Memory.js +57 -4
- package/dist/Store/SQL/query.d.ts.map +1 -1
- package/dist/Store/SQL/query.js +33 -7
- package/package.json +2 -2
- package/src/Model/query/dsl.ts +85 -0
- package/src/Model/query/new-kid-interpreter.ts +47 -1
- package/src/Store/Cosmos/query.ts +23 -0
- package/src/Store/Memory.ts +56 -13
- package/src/Store/SQL/query.ts +38 -8
- package/test/query.test.ts +90 -0
- package/test/sql-store.test.ts +301 -0
|
@@ -19,6 +19,30 @@ export type ComputedProjectionIrExpression =
|
|
|
19
19
|
readonly path: string
|
|
20
20
|
readonly filter: readonly FilterResult[]
|
|
21
21
|
}
|
|
22
|
+
| {
|
|
23
|
+
readonly _tag: "relation-every"
|
|
24
|
+
readonly path: string
|
|
25
|
+
readonly filter: readonly FilterResult[]
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
readonly _tag: "relation-distinct-count"
|
|
29
|
+
readonly path: string
|
|
30
|
+
readonly field: string
|
|
31
|
+
readonly filter: readonly FilterResult[]
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
readonly _tag: "relation-sum"
|
|
35
|
+
readonly path: string
|
|
36
|
+
readonly field: string
|
|
37
|
+
readonly filter: readonly FilterResult[]
|
|
38
|
+
}
|
|
39
|
+
| {
|
|
40
|
+
readonly _tag: "relation-collect"
|
|
41
|
+
readonly path: string
|
|
42
|
+
readonly field: string
|
|
43
|
+
readonly distinct: boolean
|
|
44
|
+
readonly filter: readonly FilterResult[]
|
|
45
|
+
}
|
|
22
46
|
|
|
23
47
|
type Result<TFieldValues extends FieldValues, A = TFieldValues, R = never> = {
|
|
24
48
|
filter: FilterResult[]
|
|
@@ -162,7 +186,29 @@ const interpret = <
|
|
|
162
186
|
Object.entries(v.computed).map(([key, expression]) => {
|
|
163
187
|
const e = expression
|
|
164
188
|
const filter = e.operation ? interpret(e.operation(make())).filter.map(applyPath(e.path)) : []
|
|
165
|
-
|
|
189
|
+
switch (e._tag) {
|
|
190
|
+
case "relation-count":
|
|
191
|
+
case "relation-any":
|
|
192
|
+
case "relation-every":
|
|
193
|
+
return [key, { _tag: e._tag, path: e.path, filter } as ComputedProjectionIrExpression]
|
|
194
|
+
case "relation-distinct-count":
|
|
195
|
+
case "relation-sum":
|
|
196
|
+
return [
|
|
197
|
+
key,
|
|
198
|
+
{ _tag: e._tag, path: e.path, field: e.field, filter } as ComputedProjectionIrExpression
|
|
199
|
+
]
|
|
200
|
+
case "relation-collect":
|
|
201
|
+
return [
|
|
202
|
+
key,
|
|
203
|
+
{
|
|
204
|
+
_tag: e._tag,
|
|
205
|
+
path: e.path,
|
|
206
|
+
field: e.field,
|
|
207
|
+
distinct: e.distinct,
|
|
208
|
+
filter
|
|
209
|
+
} as ComputedProjectionIrExpression
|
|
210
|
+
]
|
|
211
|
+
}
|
|
166
212
|
})
|
|
167
213
|
)
|
|
168
214
|
: undefined
|
|
@@ -303,6 +303,29 @@ export function buildWhereCosmosQuery3(
|
|
|
303
303
|
return `(SELECT VALUE COUNT(1) FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
|
|
304
304
|
case "relation-any":
|
|
305
305
|
return `EXISTS(SELECT VALUE ${relationAlias} FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
|
|
306
|
+
case "relation-every": {
|
|
307
|
+
// ∀x.P(x) ≡ ¬∃x.¬P(x). Cosmos has no NOT(...) on EXISTS subqueries directly,
|
|
308
|
+
// but we can flip via NOT EXISTS(... WHERE NOT (filter)).
|
|
309
|
+
if (computed.filter.length === 0) return `true AS ${key}`
|
|
310
|
+
return `NOT EXISTS(SELECT VALUE ${relationAlias} FROM ${relationAlias} IN ${relationSource} WHERE NOT (${
|
|
311
|
+
print(computed.filter, relationPath, false)
|
|
312
|
+
})) AS ${key}`
|
|
313
|
+
}
|
|
314
|
+
case "relation-distinct-count": {
|
|
315
|
+
const fieldRef = dottedToAccess(`${relationAlias}.${computed.field}`)
|
|
316
|
+
return `(SELECT VALUE COUNT(1) FROM (SELECT DISTINCT VALUE ${fieldRef} FROM ${relationAlias} IN ${relationSource}${where})) AS ${key}`
|
|
317
|
+
}
|
|
318
|
+
case "relation-sum": {
|
|
319
|
+
const fieldRef = dottedToAccess(`${relationAlias}.${computed.field}`)
|
|
320
|
+
return `(SELECT VALUE SUM(${fieldRef}) FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
|
|
321
|
+
}
|
|
322
|
+
case "relation-collect": {
|
|
323
|
+
const fieldRef = dottedToAccess(`${relationAlias}.${computed.field}`)
|
|
324
|
+
if (computed.distinct) {
|
|
325
|
+
return `ARRAY(SELECT DISTINCT VALUE ${fieldRef} FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
|
|
326
|
+
}
|
|
327
|
+
return `ARRAY(SELECT VALUE ${fieldRef} FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
|
|
328
|
+
}
|
|
306
329
|
}
|
|
307
330
|
}
|
|
308
331
|
// with joins, you should use DISTINCT
|
package/src/Store/Memory.ts
CHANGED
|
@@ -2,9 +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"
|
|
6
7
|
import type { FilterResult } from "../Model/filter/filterApi.js"
|
|
7
8
|
import type { FieldValues } from "../Model/filter/types.js"
|
|
9
|
+
import type { ComputedProjectionIrExpression } from "../Model/query.js"
|
|
8
10
|
import { annotateDb } from "../otel.js"
|
|
9
11
|
import { codeFilter, codeFilter3_ } from "./codeFilter.js"
|
|
10
12
|
import { type FilterArgs, type PersistenceModelType, type Store, type StoreConfig, StoreMaker } from "./service.js"
|
|
@@ -30,24 +32,69 @@ const stripRelationFilterPaths = (state: readonly FilterResult[], relationPath:
|
|
|
30
32
|
)
|
|
31
33
|
}
|
|
32
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
|
+
|
|
33
52
|
const computeProjectionValue = (
|
|
34
53
|
row: FieldValues,
|
|
35
|
-
computed:
|
|
36
|
-
readonly _tag: "relation-count" | "relation-any"
|
|
37
|
-
readonly path: string
|
|
38
|
-
readonly filter: readonly FilterResult[]
|
|
39
|
-
}
|
|
54
|
+
computed: ComputedProjectionIrExpression
|
|
40
55
|
) => {
|
|
41
56
|
const relation = get(row, computed.path)
|
|
42
57
|
if (!Array.isArray(relation)) {
|
|
43
|
-
return computed._tag
|
|
58
|
+
return emptyValueFor(computed._tag)
|
|
44
59
|
}
|
|
45
60
|
const filter = stripRelationFilterPaths(computed.filter, computed.path)
|
|
61
|
+
const matches = (value: unknown) => codeFilter3_(filter, value)
|
|
46
62
|
switch (computed._tag) {
|
|
47
63
|
case "relation-count":
|
|
48
|
-
return relation.reduce<number>((acc, value) =>
|
|
64
|
+
return relation.reduce<number>((acc, value) => matches(value) ? acc + 1 : acc, 0)
|
|
49
65
|
case "relation-any":
|
|
50
|
-
return relation.some(
|
|
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)
|
|
51
98
|
}
|
|
52
99
|
}
|
|
53
100
|
|
|
@@ -67,11 +114,7 @@ export function memFilter<T extends FieldValues, U extends keyof T = never>(f: F
|
|
|
67
114
|
)
|
|
68
115
|
const computedKeys = entries.filter((entry): entry is {
|
|
69
116
|
key: string
|
|
70
|
-
computed:
|
|
71
|
-
readonly _tag: "relation-count" | "relation-any"
|
|
72
|
-
readonly path: string
|
|
73
|
-
readonly filter: readonly FilterResult[]
|
|
74
|
-
}
|
|
117
|
+
computed: ComputedProjectionIrExpression
|
|
75
118
|
} => typeof entry === "object" && entry !== null && "computed" in entry)
|
|
76
119
|
const n = Struct.pick(i, keys)
|
|
77
120
|
subKeys.forEach((subKey) => {
|
package/src/Store/SQL/query.ts
CHANGED
|
@@ -391,18 +391,48 @@ export function buildWhereSQLQuery(
|
|
|
391
391
|
const relationPath = dottedToJsonPath(computed.path)
|
|
392
392
|
const relationAlias = `_${computed.path}`
|
|
393
393
|
const relationFrom = dialect.jsonEachFrom(relationPath, relationAlias)
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
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}"`
|
|
397
402
|
switch (computed._tag) {
|
|
398
403
|
case "relation-count":
|
|
399
|
-
return `(SELECT COUNT(1) FROM ${relationFrom}${
|
|
400
|
-
case "relation-any":
|
|
401
|
-
|
|
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)
|
|
402
427
|
if (dialect.jsonColumnType === "JSON") {
|
|
403
|
-
|
|
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}"`
|
|
404
433
|
}
|
|
405
|
-
|
|
434
|
+
const aggArg = computed.distinct ? `DISTINCT ${fieldExtract}` : fieldExtract
|
|
435
|
+
return `(SELECT COALESCE(jsonb_agg(${aggArg}), '[]'::jsonb) FROM ${relationFrom}${whereClause()}) AS "${key}"`
|
|
406
436
|
}
|
|
407
437
|
default:
|
|
408
438
|
return assertUnreachable(computed)
|
package/test/query.test.ts
CHANGED
|
@@ -640,6 +640,96 @@ it("projection schema with computed fields fails without computed map", () => {
|
|
|
640
640
|
expect(() => toFilter(query, baseSchema)).toThrowError("Missing computed projections for schema keys")
|
|
641
641
|
})
|
|
642
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
|
+
|
|
643
733
|
it(
|
|
644
734
|
"doesn't mess when refining fields",
|
|
645
735
|
() =>
|
package/test/sql-store.test.ts
CHANGED
|
@@ -269,6 +269,116 @@ describe("SQL query builder (SQLite dialect)", () => {
|
|
|
269
269
|
expect(result.sql).toContain("CASE WHEN EXISTS(")
|
|
270
270
|
expect(result.sql).toContain(`AS "hasPicked"`)
|
|
271
271
|
})
|
|
272
|
+
|
|
273
|
+
it("computed relation every projection (sqlite emits NOT EXISTS NOT)", () => {
|
|
274
|
+
const result = buildWhereSQLQuery(
|
|
275
|
+
sqliteDialect,
|
|
276
|
+
"id",
|
|
277
|
+
[],
|
|
278
|
+
"users",
|
|
279
|
+
{},
|
|
280
|
+
[{
|
|
281
|
+
key: "allPicked",
|
|
282
|
+
computed: {
|
|
283
|
+
_tag: "relation-every",
|
|
284
|
+
path: "items",
|
|
285
|
+
filter: [{ t: "where", path: "items.-1.state._tag", op: "eq", value: "picked" }]
|
|
286
|
+
}
|
|
287
|
+
}]
|
|
288
|
+
)
|
|
289
|
+
expect(result.sql).toContain(`NOT EXISTS(SELECT 1 FROM json_each(data, '$.items') AS _items WHERE NOT (`)
|
|
290
|
+
expect(result.sql).toContain(`AS "allPicked"`)
|
|
291
|
+
expect(result.params).toContain("picked")
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it("computed relation-every with no filter degenerates to true", () => {
|
|
295
|
+
const result = buildWhereSQLQuery(
|
|
296
|
+
sqliteDialect,
|
|
297
|
+
"id",
|
|
298
|
+
[],
|
|
299
|
+
"users",
|
|
300
|
+
{},
|
|
301
|
+
[{
|
|
302
|
+
key: "allPicked",
|
|
303
|
+
computed: { _tag: "relation-every", path: "items", filter: [] }
|
|
304
|
+
}]
|
|
305
|
+
)
|
|
306
|
+
expect(result.sql).toContain("CASE WHEN 1=1")
|
|
307
|
+
expect(result.sql).toContain(`AS "allPicked"`)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it("computed relation-distinct-count projection (sqlite)", () => {
|
|
311
|
+
const result = buildWhereSQLQuery(
|
|
312
|
+
sqliteDialect,
|
|
313
|
+
"id",
|
|
314
|
+
[],
|
|
315
|
+
"users",
|
|
316
|
+
{},
|
|
317
|
+
[{
|
|
318
|
+
key: "positionCount",
|
|
319
|
+
computed: {
|
|
320
|
+
_tag: "relation-distinct-count",
|
|
321
|
+
path: "items",
|
|
322
|
+
field: "rowId",
|
|
323
|
+
filter: [{ t: "where", path: "items.-1.state._tag", op: "neq", value: "cancelled" }]
|
|
324
|
+
}
|
|
325
|
+
}]
|
|
326
|
+
)
|
|
327
|
+
expect(result.sql).toContain(`SELECT COUNT(DISTINCT json_extract(_items.value, '$.rowId'))`)
|
|
328
|
+
expect(result.sql).toContain(`AS "positionCount"`)
|
|
329
|
+
expect(result.params).toContain("cancelled")
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it("computed relation-sum projection (sqlite casts to REAL)", () => {
|
|
333
|
+
const result = buildWhereSQLQuery(
|
|
334
|
+
sqliteDialect,
|
|
335
|
+
"id",
|
|
336
|
+
[],
|
|
337
|
+
"users",
|
|
338
|
+
{},
|
|
339
|
+
[{
|
|
340
|
+
key: "totalWeight",
|
|
341
|
+
computed: { _tag: "relation-sum", path: "items", field: "weight", filter: [] }
|
|
342
|
+
}]
|
|
343
|
+
)
|
|
344
|
+
expect(result.sql).toContain(`SELECT COALESCE(SUM(CAST(json_extract(_items.value, '$.weight') AS REAL)), 0)`)
|
|
345
|
+
expect(result.sql).toContain(`AS "totalWeight"`)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it("computed relation-collect (non-distinct) projection (sqlite)", () => {
|
|
349
|
+
const result = buildWhereSQLQuery(
|
|
350
|
+
sqliteDialect,
|
|
351
|
+
"id",
|
|
352
|
+
[],
|
|
353
|
+
"users",
|
|
354
|
+
{},
|
|
355
|
+
[{
|
|
356
|
+
key: "tags",
|
|
357
|
+
computed: { _tag: "relation-collect", path: "items", field: "articleId", distinct: false, filter: [] }
|
|
358
|
+
}]
|
|
359
|
+
)
|
|
360
|
+
expect(result.sql).toContain(
|
|
361
|
+
`SELECT COALESCE(json_group_array(json_extract(_items.value, '$.articleId')), json_array())`
|
|
362
|
+
)
|
|
363
|
+
expect(result.sql).toContain(`AS "tags"`)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it("computed relation-collect (distinct) emits inner DISTINCT subquery (sqlite)", () => {
|
|
367
|
+
const result = buildWhereSQLQuery(
|
|
368
|
+
sqliteDialect,
|
|
369
|
+
"id",
|
|
370
|
+
[],
|
|
371
|
+
"users",
|
|
372
|
+
{},
|
|
373
|
+
[{
|
|
374
|
+
key: "tags",
|
|
375
|
+
computed: { _tag: "relation-collect", path: "items", field: "articleId", distinct: true, filter: [] }
|
|
376
|
+
}]
|
|
377
|
+
)
|
|
378
|
+
expect(result.sql).toContain(`json_group_array(__v)`)
|
|
379
|
+
expect(result.sql).toContain(`SELECT DISTINCT json_extract(_items.value, '$.articleId') AS __v`)
|
|
380
|
+
expect(result.sql).toContain(`AS "tags"`)
|
|
381
|
+
})
|
|
272
382
|
})
|
|
273
383
|
|
|
274
384
|
describe("SQL query builder (PostgreSQL dialect)", () => {
|
|
@@ -352,6 +462,89 @@ describe("SQL query builder (PostgreSQL dialect)", () => {
|
|
|
352
462
|
expect(result.sql).toContain("EXISTS(SELECT 1 FROM jsonb_array_elements(data->'items') AS _items")
|
|
353
463
|
expect(result.sql).toContain(`AS "hasPicked"`)
|
|
354
464
|
})
|
|
465
|
+
|
|
466
|
+
it("computed relation-every (pg)", () => {
|
|
467
|
+
const result = buildWhereSQLQuery(
|
|
468
|
+
pgDialect,
|
|
469
|
+
"id",
|
|
470
|
+
[],
|
|
471
|
+
"users",
|
|
472
|
+
{},
|
|
473
|
+
[{
|
|
474
|
+
key: "allPicked",
|
|
475
|
+
computed: {
|
|
476
|
+
_tag: "relation-every",
|
|
477
|
+
path: "items",
|
|
478
|
+
filter: [{ t: "where", path: "items.-1.state._tag", op: "eq", value: "picked" }]
|
|
479
|
+
}
|
|
480
|
+
}]
|
|
481
|
+
)
|
|
482
|
+
expect(result.sql).toContain(`NOT EXISTS(SELECT 1 FROM jsonb_array_elements(data->'items') AS _items WHERE NOT (`)
|
|
483
|
+
expect(result.sql).toContain(`AS "allPicked"`)
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it("computed relation-distinct-count (pg)", () => {
|
|
487
|
+
const result = buildWhereSQLQuery(
|
|
488
|
+
pgDialect,
|
|
489
|
+
"id",
|
|
490
|
+
[],
|
|
491
|
+
"users",
|
|
492
|
+
{},
|
|
493
|
+
[{
|
|
494
|
+
key: "positions",
|
|
495
|
+
computed: { _tag: "relation-distinct-count", path: "items", field: "rowId", filter: [] }
|
|
496
|
+
}]
|
|
497
|
+
)
|
|
498
|
+
expect(result.sql).toContain(`COUNT(DISTINCT _items->>'rowId')`)
|
|
499
|
+
expect(result.sql).toContain(`AS "positions"`)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it("computed relation-sum (pg casts via ::numeric)", () => {
|
|
503
|
+
const result = buildWhereSQLQuery(
|
|
504
|
+
pgDialect,
|
|
505
|
+
"id",
|
|
506
|
+
[],
|
|
507
|
+
"users",
|
|
508
|
+
{},
|
|
509
|
+
[{
|
|
510
|
+
key: "totalWeight",
|
|
511
|
+
computed: { _tag: "relation-sum", path: "items", field: "weight", filter: [] }
|
|
512
|
+
}]
|
|
513
|
+
)
|
|
514
|
+
expect(result.sql).toContain(`COALESCE(SUM((_items->>'weight')::numeric), 0)`)
|
|
515
|
+
expect(result.sql).toContain(`AS "totalWeight"`)
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it("computed relation-collect (pg jsonb_agg)", () => {
|
|
519
|
+
const result = buildWhereSQLQuery(
|
|
520
|
+
pgDialect,
|
|
521
|
+
"id",
|
|
522
|
+
[],
|
|
523
|
+
"users",
|
|
524
|
+
{},
|
|
525
|
+
[{
|
|
526
|
+
key: "ids",
|
|
527
|
+
computed: { _tag: "relation-collect", path: "items", field: "articleId", distinct: false, filter: [] }
|
|
528
|
+
}]
|
|
529
|
+
)
|
|
530
|
+
expect(result.sql).toContain(`COALESCE(jsonb_agg(_items->>'articleId'), '[]'::jsonb)`)
|
|
531
|
+
expect(result.sql).toContain(`AS "ids"`)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it("computed relation-collect distinct (pg jsonb_agg DISTINCT)", () => {
|
|
535
|
+
const result = buildWhereSQLQuery(
|
|
536
|
+
pgDialect,
|
|
537
|
+
"id",
|
|
538
|
+
[],
|
|
539
|
+
"users",
|
|
540
|
+
{},
|
|
541
|
+
[{
|
|
542
|
+
key: "ids",
|
|
543
|
+
computed: { _tag: "relation-collect", path: "items", field: "articleId", distinct: true, filter: [] }
|
|
544
|
+
}]
|
|
545
|
+
)
|
|
546
|
+
expect(result.sql).toContain(`COALESCE(jsonb_agg(DISTINCT _items->>'articleId'), '[]'::jsonb)`)
|
|
547
|
+
})
|
|
355
548
|
})
|
|
356
549
|
|
|
357
550
|
// --- Integration tests with in-memory SQLite (direct, no Effect SQL client) ---
|
|
@@ -656,6 +849,114 @@ describe("SQL Store (SQLite integration)", () => {
|
|
|
656
849
|
expect((JSON.parse((r10[0] as any).data) as any).name).toBe("Charlie") // oldest first
|
|
657
850
|
}))
|
|
658
851
|
|
|
852
|
+
it("computed relation-every / distinct-count / sum / collect run on SQLite", () =>
|
|
853
|
+
withDb((db) => {
|
|
854
|
+
db.exec(`CREATE TABLE "test_orders" (id TEXT PRIMARY KEY, _etag TEXT, data JSON NOT NULL)`)
|
|
855
|
+
const orders = [
|
|
856
|
+
{
|
|
857
|
+
id: "o1",
|
|
858
|
+
items: [
|
|
859
|
+
{ rowId: "r1", articleId: "A", weight: 1.5, state: { _tag: "picked" } },
|
|
860
|
+
{ rowId: "r2", articleId: "A", weight: 2.5, state: { _tag: "picked" } },
|
|
861
|
+
{ rowId: "r2", articleId: "B", weight: 0.25, state: { _tag: "picking" } }
|
|
862
|
+
]
|
|
863
|
+
},
|
|
864
|
+
{
|
|
865
|
+
id: "o2",
|
|
866
|
+
items: [
|
|
867
|
+
{ rowId: "r9", articleId: "Z", weight: 10, state: { _tag: "packed" } }
|
|
868
|
+
]
|
|
869
|
+
}
|
|
870
|
+
]
|
|
871
|
+
const insert = db.prepare(`INSERT INTO "test_orders" (id, _etag, data) VALUES (?, ?, ?)`)
|
|
872
|
+
for (const o of orders) {
|
|
873
|
+
const { id, ...data } = o
|
|
874
|
+
insert.run(id, `etag_${id}`, JSON.stringify(data))
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const q = buildWhereSQLQuery(
|
|
878
|
+
sqliteDialect,
|
|
879
|
+
"id",
|
|
880
|
+
[],
|
|
881
|
+
"test_orders",
|
|
882
|
+
{},
|
|
883
|
+
[
|
|
884
|
+
{
|
|
885
|
+
key: "allPicked",
|
|
886
|
+
computed: {
|
|
887
|
+
_tag: "relation-every",
|
|
888
|
+
path: "items",
|
|
889
|
+
filter: [{ t: "where", path: "items.-1.state._tag", op: "eq", value: "picked" }]
|
|
890
|
+
}
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
key: "positionCount",
|
|
894
|
+
computed: { _tag: "relation-distinct-count", path: "items", field: "rowId", filter: [] }
|
|
895
|
+
},
|
|
896
|
+
{
|
|
897
|
+
key: "totalWeight",
|
|
898
|
+
computed: { _tag: "relation-sum", path: "items", field: "weight", filter: [] }
|
|
899
|
+
},
|
|
900
|
+
{
|
|
901
|
+
key: "articleIds",
|
|
902
|
+
computed: {
|
|
903
|
+
_tag: "relation-collect",
|
|
904
|
+
path: "items",
|
|
905
|
+
field: "articleId",
|
|
906
|
+
distinct: true,
|
|
907
|
+
filter: []
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
] as any,
|
|
911
|
+
[{ key: "id", direction: "ASC" }] as any
|
|
912
|
+
)
|
|
913
|
+
const rows = query(db, q.sql, q.params) as Array<Record<string, unknown>>
|
|
914
|
+
expect(rows.length).toBe(2)
|
|
915
|
+
// o1: not all picked (one is "picking")
|
|
916
|
+
expect(JSON.parse(rows[0]!["allPicked"] as string)).toBe(false)
|
|
917
|
+
expect(rows[0]!["positionCount"]).toBe(2)
|
|
918
|
+
expect(rows[0]!["totalWeight"]).toBeCloseTo(4.25)
|
|
919
|
+
expect((JSON.parse(rows[0]!["articleIds"] as string) as string[]).sort()).toEqual(["A", "B"])
|
|
920
|
+
// o2: all packed (so not "picked"), allPicked = !exists(NOT picked) = false
|
|
921
|
+
expect(JSON.parse(rows[1]!["allPicked"] as string)).toBe(false)
|
|
922
|
+
expect(rows[1]!["positionCount"]).toBe(1)
|
|
923
|
+
expect(rows[1]!["totalWeight"]).toBeCloseTo(10)
|
|
924
|
+
expect(JSON.parse(rows[1]!["articleIds"] as string)).toEqual(["Z"])
|
|
925
|
+
}))
|
|
926
|
+
|
|
927
|
+
it("computed relation-every is true when all items match filter", () =>
|
|
928
|
+
withDb((db) => {
|
|
929
|
+
db.exec(`CREATE TABLE "test_every" (id TEXT PRIMARY KEY, _etag TEXT, data JSON NOT NULL)`)
|
|
930
|
+
db.prepare(`INSERT INTO "test_every" (id, _etag, data) VALUES (?, ?, ?)`).run(
|
|
931
|
+
"1",
|
|
932
|
+
"e",
|
|
933
|
+
JSON.stringify({
|
|
934
|
+
items: [
|
|
935
|
+
{ state: { _tag: "picked" } },
|
|
936
|
+
{ state: { _tag: "picked" } }
|
|
937
|
+
]
|
|
938
|
+
})
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
const q = buildWhereSQLQuery(
|
|
942
|
+
sqliteDialect,
|
|
943
|
+
"id",
|
|
944
|
+
[],
|
|
945
|
+
"test_every",
|
|
946
|
+
{},
|
|
947
|
+
[{
|
|
948
|
+
key: "allPicked",
|
|
949
|
+
computed: {
|
|
950
|
+
_tag: "relation-every",
|
|
951
|
+
path: "items",
|
|
952
|
+
filter: [{ t: "where", path: "items.-1.state._tag", op: "eq", value: "picked" }]
|
|
953
|
+
}
|
|
954
|
+
}]
|
|
955
|
+
)
|
|
956
|
+
const rows = query(db, q.sql, q.params) as Array<Record<string, unknown>>
|
|
957
|
+
expect(JSON.parse(rows[0]!["allPicked"] as string)).toBe(true)
|
|
958
|
+
}))
|
|
959
|
+
|
|
659
960
|
it("namespace param is in correct position for SQLite positional placeholders", () =>
|
|
660
961
|
withDb((db) => {
|
|
661
962
|
db.exec(
|