@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/test/sql-store.test.ts
CHANGED
|
@@ -228,6 +228,157 @@ 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
|
+
})
|
|
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
|
+
})
|
|
231
382
|
})
|
|
232
383
|
|
|
233
384
|
describe("SQL query builder (PostgreSQL dialect)", () => {
|
|
@@ -291,6 +442,109 @@ describe("SQL query builder (PostgreSQL dialect)", () => {
|
|
|
291
442
|
)
|
|
292
443
|
expect(result.sql).toContain("data->'address'->>'city'")
|
|
293
444
|
})
|
|
445
|
+
|
|
446
|
+
it("computed relation any projection", () => {
|
|
447
|
+
const result = buildWhereSQLQuery(
|
|
448
|
+
pgDialect,
|
|
449
|
+
"id",
|
|
450
|
+
[],
|
|
451
|
+
"users",
|
|
452
|
+
{},
|
|
453
|
+
[{
|
|
454
|
+
key: "hasPicked",
|
|
455
|
+
computed: {
|
|
456
|
+
_tag: "relation-any",
|
|
457
|
+
path: "items",
|
|
458
|
+
filter: [{ t: "where", path: "items.-1.description", op: "contains", value: "picked" }]
|
|
459
|
+
}
|
|
460
|
+
}]
|
|
461
|
+
)
|
|
462
|
+
expect(result.sql).toContain("EXISTS(SELECT 1 FROM jsonb_array_elements(data->'items') AS _items")
|
|
463
|
+
expect(result.sql).toContain(`AS "hasPicked"`)
|
|
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
|
+
})
|
|
294
548
|
})
|
|
295
549
|
|
|
296
550
|
// --- Integration tests with in-memory SQLite (direct, no Effect SQL client) ---
|
|
@@ -595,6 +849,114 @@ describe("SQL Store (SQLite integration)", () => {
|
|
|
595
849
|
expect((JSON.parse((r10[0] as any).data) as any).name).toBe("Charlie") // oldest first
|
|
596
850
|
}))
|
|
597
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
|
+
|
|
598
960
|
it("namespace param is in correct position for SQLite positional placeholders", () =>
|
|
599
961
|
withDb((db) => {
|
|
600
962
|
db.exec(
|