@effect-app/infra 4.0.0-beta.224 → 4.0.0-beta.226
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/dist/Emailer/Sendgrid.d.ts.map +1 -1
- package/dist/Emailer/Sendgrid.js +2 -2
- package/dist/Emailer/fake.js +2 -2
- package/dist/Model/Repository/internal/internal.d.ts.map +1 -1
- package/dist/Model/Repository/internal/internal.js +13 -5
- package/dist/Model/query/dsl.d.ts +6 -2
- package/dist/Model/query/dsl.d.ts.map +1 -1
- package/dist/Model/query/dsl.js +6 -2
- package/dist/Model/query/new-kid-interpreter.d.ts +4 -1
- package/dist/Model/query/new-kid-interpreter.d.ts.map +1 -1
- package/dist/Model/query/new-kid-interpreter.js +5 -2
- package/dist/Store/Cosmos/query.d.ts.map +1 -1
- package/dist/Store/Cosmos/query.js +7 -4
- package/dist/Store/Memory.d.ts.map +1 -1
- package/dist/Store/Memory.js +6 -1
- package/dist/Store/SQL/query.d.ts.map +1 -1
- package/dist/Store/SQL/query.js +10 -5
- package/package.json +2 -2
- package/src/Emailer/Sendgrid.ts +45 -43
- package/src/Emailer/fake.ts +1 -1
- package/src/Model/Repository/internal/internal.ts +19 -4
- package/src/Model/query/dsl.ts +9 -1
- package/src/Model/query/new-kid-interpreter.ts +8 -1
- package/src/Store/Cosmos/query.ts +8 -3
- package/src/Store/Memory.ts +5 -0
- package/src/Store/SQL/query.ts +9 -4
- package/test/cosmos-query.test.ts +41 -0
- package/test/dist/cosmos-query.test.d.ts.map +1 -0
- package/test/dist/date-query.test.d.ts.map +1 -0
- package/test/query.test.ts +4 -4
- package/test/rawQuery.test.ts +26 -0
|
@@ -277,7 +277,9 @@ export function makeRepoInternal<
|
|
|
277
277
|
}
|
|
278
278
|
)
|
|
279
279
|
|
|
280
|
-
const parseMany = Effect.fn("parseMany", {
|
|
280
|
+
const parseMany = Effect.fn("parseMany", {
|
|
281
|
+
attributes: { "app.entity": name, "app.query.mode": "transform" }
|
|
282
|
+
})(
|
|
281
283
|
function*(items: readonly PM[]) {
|
|
282
284
|
const cm = yield* cms
|
|
283
285
|
return yield* decodeMany(items.map((_) => mapReverse(_, cm.set))).pipe(Effect.orDie)
|
|
@@ -295,7 +297,9 @@ export function makeRepoInternal<
|
|
|
295
297
|
}
|
|
296
298
|
return dec
|
|
297
299
|
}
|
|
298
|
-
const parseMany2 = Effect.fn("
|
|
300
|
+
const parseMany2 = Effect.fn("parseMany", {
|
|
301
|
+
attributes: { "app.entity": name, "app.query.mode": "transform" }
|
|
302
|
+
})(
|
|
299
303
|
function*<A, R>(items: readonly PM[], schema: S.Codec<A, Encoded, R>) {
|
|
300
304
|
const cm = yield* cms
|
|
301
305
|
return yield* getDecodeMany(schema)(items.map((_) => mapReverse(_, cm.set))).pipe(Effect.orDie)
|
|
@@ -336,7 +340,15 @@ export function makeRepoInternal<
|
|
|
336
340
|
? filter(a)
|
|
337
341
|
// TODO: mapFrom but need to support per field and dependencies
|
|
338
342
|
.pipe(
|
|
339
|
-
Effect.andThen(
|
|
343
|
+
Effect.andThen(
|
|
344
|
+
flow(
|
|
345
|
+
S.decodeEffectConcurrently(S.Array(a.schema ?? schema)),
|
|
346
|
+
provideRctx,
|
|
347
|
+
Effect.withSpan("parseMany", {
|
|
348
|
+
attributes: { "app.entity": name, "app.query.mode": "project" }
|
|
349
|
+
})
|
|
350
|
+
)
|
|
351
|
+
)
|
|
340
352
|
)
|
|
341
353
|
: a.mode === "collect"
|
|
342
354
|
? filter(a)
|
|
@@ -345,7 +357,10 @@ export function makeRepoInternal<
|
|
|
345
357
|
Effect.flatMap(flow(
|
|
346
358
|
S.decodeEffectConcurrently(S.Array(a.schema)),
|
|
347
359
|
Effect.map(Array.getSomes),
|
|
348
|
-
provideRctx
|
|
360
|
+
provideRctx,
|
|
361
|
+
Effect.withSpan("parseMany", {
|
|
362
|
+
attributes: { "app.entity": name, "app.query.mode": "collect" }
|
|
363
|
+
})
|
|
349
364
|
))
|
|
350
365
|
)
|
|
351
366
|
: Effect.flatMap(
|
package/src/Model/query/dsl.ts
CHANGED
|
@@ -179,6 +179,10 @@ export type ComputedProjectionExpression =
|
|
|
179
179
|
readonly distinct: boolean
|
|
180
180
|
readonly operation?: ComputedProjectionOperation
|
|
181
181
|
}
|
|
182
|
+
| {
|
|
183
|
+
readonly _tag: "relation-length"
|
|
184
|
+
readonly path: string
|
|
185
|
+
}
|
|
182
186
|
|
|
183
187
|
export type ComputedProjectionMap = Readonly<Record<string, ComputedProjectionExpression>>
|
|
184
188
|
export type Q<TFieldValues extends FieldValues> =
|
|
@@ -397,7 +401,7 @@ export const count: {
|
|
|
397
401
|
* persistence-model (PM) reverse-mapping is needed:
|
|
398
402
|
*
|
|
399
403
|
* - `"transform"` (default when `mode` omitted): goes through the repo's
|
|
400
|
-
* `parseMany
|
|
404
|
+
* `parseMany` pipeline. The raw row is reverse-mapped via the
|
|
401
405
|
* etag/PM cache (re-injecting `_etag` and any PM-shape state) before
|
|
402
406
|
* decoding. Decode failures `orDie` (error channel = `never`). Use when
|
|
403
407
|
* the schema operates on the full PM shape (e.g. full-entity reads that
|
|
@@ -473,6 +477,10 @@ export const project: {
|
|
|
473
477
|
export const relation = <TFieldValues extends FieldValues>(
|
|
474
478
|
path: FieldPath<TFieldValues>
|
|
475
479
|
) => ({
|
|
480
|
+
length: (): ComputedProjectionExpression => ({
|
|
481
|
+
_tag: "relation-length",
|
|
482
|
+
path: path as string
|
|
483
|
+
}),
|
|
476
484
|
count: (operation?: ComputedProjectionOperation): ComputedProjectionExpression =>
|
|
477
485
|
operation
|
|
478
486
|
? {
|
|
@@ -88,6 +88,10 @@ export type ComputedProjectionIrExpression =
|
|
|
88
88
|
readonly distinct: boolean
|
|
89
89
|
readonly filter: readonly FilterResult[]
|
|
90
90
|
}
|
|
91
|
+
| {
|
|
92
|
+
readonly _tag: "relation-length"
|
|
93
|
+
readonly path: string
|
|
94
|
+
}
|
|
91
95
|
|
|
92
96
|
type Result<TFieldValues extends FieldValues, A = TFieldValues, R = never> = {
|
|
93
97
|
filter: FilterResult[]
|
|
@@ -230,7 +234,8 @@ const interpret = <
|
|
|
230
234
|
? Object.fromEntries(
|
|
231
235
|
Object.entries(v.computed).map(([key, expression]) => {
|
|
232
236
|
const e = expression
|
|
233
|
-
const
|
|
237
|
+
const op = "operation" in e ? e.operation : undefined
|
|
238
|
+
const filter = op ? interpret(op(make())).filter.map(applyPath(e.path)) : []
|
|
234
239
|
switch (e._tag) {
|
|
235
240
|
case "relation-count":
|
|
236
241
|
case "relation-any":
|
|
@@ -293,6 +298,8 @@ const interpret = <
|
|
|
293
298
|
filter
|
|
294
299
|
} as ComputedProjectionIrExpression
|
|
295
300
|
]
|
|
301
|
+
case "relation-length":
|
|
302
|
+
return [key, { _tag: e._tag, path: e.path } as ComputedProjectionIrExpression]
|
|
296
303
|
}
|
|
297
304
|
})
|
|
298
305
|
)
|
|
@@ -289,7 +289,9 @@ export function buildWhereCosmosQuery3(
|
|
|
289
289
|
: [_]
|
|
290
290
|
)
|
|
291
291
|
const computedFilters = select
|
|
292
|
-
? select.flatMap((_) =>
|
|
292
|
+
? select.flatMap((_) =>
|
|
293
|
+
typeof _ === "object" && "computed" in _ && "filter" in _.computed ? getValues(_.computed.filter) : []
|
|
294
|
+
)
|
|
293
295
|
: []
|
|
294
296
|
const values = [...computedFilters, ...getValues(filter)]
|
|
295
297
|
|
|
@@ -314,8 +316,9 @@ export function buildWhereCosmosQuery3(
|
|
|
314
316
|
`IIF(${unitExpr} = ${JSON.stringify(toBase)}, 1, 0)`
|
|
315
317
|
)
|
|
316
318
|
}
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
+
const filter = "filter" in computed ? computed.filter : []
|
|
320
|
+
const where = filter.length > 0
|
|
321
|
+
? ` WHERE ${print(filter, relationPath, false)}`
|
|
319
322
|
: ""
|
|
320
323
|
switch (computed._tag) {
|
|
321
324
|
case "relation-count":
|
|
@@ -360,6 +363,8 @@ export function buildWhereCosmosQuery3(
|
|
|
360
363
|
}
|
|
361
364
|
return `ARRAY(SELECT VALUE ${fieldRef} FROM ${relationAlias} IN ${relationSource}${where}) AS ${key}`
|
|
362
365
|
}
|
|
366
|
+
case "relation-length":
|
|
367
|
+
return `ARRAY_LENGTH(${relationSource}) AS ${key}`
|
|
363
368
|
case "relation-collect-fields": {
|
|
364
369
|
const subqueries = computed.fields.map((field) => {
|
|
365
370
|
const fieldRef = dottedToAccess(`${relationAlias}.${field}`)
|
package/src/Store/Memory.ts
CHANGED
|
@@ -60,6 +60,8 @@ const emptyValueFor = (tag: ComputedProjectionIrExpression["_tag"]) => {
|
|
|
60
60
|
return [] as unknown[]
|
|
61
61
|
case "relation-collect-fields":
|
|
62
62
|
return [] as unknown[]
|
|
63
|
+
case "relation-length":
|
|
64
|
+
return 0
|
|
63
65
|
default:
|
|
64
66
|
return assertUnreachable(tag)
|
|
65
67
|
}
|
|
@@ -73,6 +75,9 @@ const computeProjectionValue = (
|
|
|
73
75
|
if (!Array.isArray(relation)) {
|
|
74
76
|
return emptyValueFor(computed._tag)
|
|
75
77
|
}
|
|
78
|
+
if (computed._tag === "relation-length") {
|
|
79
|
+
return relation.length
|
|
80
|
+
}
|
|
76
81
|
const filter = stripRelationFilterPaths(computed.filter, computed.path)
|
|
77
82
|
// empty filter = unconditional match (codeFilter3_ uses eval on a built
|
|
78
83
|
// string and chokes on `( )`, so short-circuit before invoking it).
|
package/src/Store/SQL/query.ts
CHANGED
|
@@ -412,9 +412,10 @@ export function buildWhereSQLQuery(
|
|
|
412
412
|
const cases = entries.map(([unit, factor]) => ` WHEN ${sqlStringLiteral(unit)} THEN ${factor}`).join("")
|
|
413
413
|
return `CASE ${unitExpr} WHEN ${sqlStringLiteral(toBase)} THEN 1${cases} ELSE NULL END`
|
|
414
414
|
}
|
|
415
|
+
const filter = "filter" in computed ? computed.filter : []
|
|
415
416
|
const whereClause = () =>
|
|
416
|
-
|
|
417
|
-
? ` WHERE ${print(
|
|
417
|
+
filter.length > 0
|
|
418
|
+
? ` WHERE ${print(filter, computed.path, false)}`
|
|
418
419
|
: ""
|
|
419
420
|
const boolExpr = (sqlExpr: string) =>
|
|
420
421
|
dialect.jsonColumnType === "JSON"
|
|
@@ -428,9 +429,9 @@ export function buildWhereSQLQuery(
|
|
|
428
429
|
case "relation-every":
|
|
429
430
|
// ∀x.P(x) ≡ ¬∃x.¬P(x). When no filter, no element exists that violates ⊤ → true.
|
|
430
431
|
return boolExpr(
|
|
431
|
-
|
|
432
|
+
filter.length === 0
|
|
432
433
|
? `1=1`
|
|
433
|
-
: `NOT EXISTS(SELECT 1 FROM ${relationFrom} WHERE NOT (${print(
|
|
434
|
+
: `NOT EXISTS(SELECT 1 FROM ${relationFrom} WHERE NOT (${print(filter, computed.path, false)}))`
|
|
434
435
|
)
|
|
435
436
|
case "relation-distinct-count": {
|
|
436
437
|
const fieldExtract = dialect.jsonExtractElement(relationAlias, computed.field)
|
|
@@ -470,6 +471,10 @@ export function buildWhereSQLQuery(
|
|
|
470
471
|
const aggArg = computed.distinct ? `DISTINCT ${fieldExtract}` : fieldExtract
|
|
471
472
|
return `(SELECT COALESCE(jsonb_agg(${aggArg}), '[]'::jsonb) FROM ${relationFrom}${whereClause()}) AS "${key}"`
|
|
472
473
|
}
|
|
474
|
+
case "relation-length": {
|
|
475
|
+
const arrPath = dottedToJsonPath(computed.path)
|
|
476
|
+
return `${dialect.arrayLength(arrPath)} AS "${key}"`
|
|
477
|
+
}
|
|
473
478
|
case "relation-collect-fields": {
|
|
474
479
|
const branches = computed.fields.map((field) => {
|
|
475
480
|
const fieldExtract = dialect.jsonExtractElement(relationAlias, field)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import * as S from "effect-app/Schema"
|
|
3
|
+
import { describe, expect, it } from "vitest"
|
|
4
|
+
import { computed, make, projectComputed, relation, toFilter } from "../src/Model/query.js"
|
|
5
|
+
import { buildWhereCosmosQuery3 } from "../src/Store/Cosmos/query.js"
|
|
6
|
+
|
|
7
|
+
class Order extends S.Class<Order>("Order")({
|
|
8
|
+
id: S.String,
|
|
9
|
+
packages: S.Array(S.Struct({ id: S.String, weight: S.Finite }))
|
|
10
|
+
}) {}
|
|
11
|
+
|
|
12
|
+
type OrderEnc = S.Codec.Encoded<typeof Order>
|
|
13
|
+
|
|
14
|
+
// Length projection via `relation(...).length()` should emit a scalar
|
|
15
|
+
// ARRAY_LENGTH expression rather than pulling (or reshaping) the array.
|
|
16
|
+
describe("cosmos query projection: array length", () => {
|
|
17
|
+
it("projects packages length via ARRAY_LENGTH", () => {
|
|
18
|
+
const q = make<OrderEnc>().pipe(
|
|
19
|
+
projectComputed(
|
|
20
|
+
S.Struct({ id: S.String, packageCount: S.NonNegativeInt }),
|
|
21
|
+
computed({ packageCount: relation<OrderEnc>("packages").length() })
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const ir = toFilter(q as any, Order as any)
|
|
26
|
+
|
|
27
|
+
const result = buildWhereCosmosQuery3(
|
|
28
|
+
"id",
|
|
29
|
+
ir.filter ?? [],
|
|
30
|
+
"Orders",
|
|
31
|
+
{},
|
|
32
|
+
ir.select as any
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
expect(result.query).toMatch(/ARRAY_LENGTH\(f(?:\.packages|\["packages"\])\)/)
|
|
36
|
+
expect(result.query).toContain("AS packageCount")
|
|
37
|
+
// Must not pull the full array nor reshape via subquery
|
|
38
|
+
expect(result.query).not.toMatch(/ARRAY\s*\(\s*SELECT[^)]*FROM\s+t\s+in\s+f[\.\["]/i)
|
|
39
|
+
expect(result.query).not.toMatch(/SELECT VALUE COUNT/)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cosmos-query.test.d.ts","sourceRoot":"","sources":["../cosmos-query.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"date-query.test.d.ts","sourceRoot":"","sources":["../date-query.test.ts"],"names":[],"mappings":""}
|
package/test/query.test.ts
CHANGED
|
@@ -614,7 +614,7 @@ it("projectComputed sets computed IR and forces project mode", () => {
|
|
|
614
614
|
])
|
|
615
615
|
expect(interpreted.computed?.["pickedCount"]?._tag).toBe("relation-count")
|
|
616
616
|
expect(interpreted.computed?.["pickedCount"]?.path).toBe("items")
|
|
617
|
-
expect(interpreted.computed?.["pickedCount"]?.filter).toEqual([
|
|
617
|
+
expect((interpreted.computed?.["pickedCount"] as { filter: unknown } | undefined)?.filter).toEqual([
|
|
618
618
|
{ t: "where", path: "items.-1.state._tag", op: "eq", value: "Picked" }
|
|
619
619
|
])
|
|
620
620
|
})
|
|
@@ -662,7 +662,7 @@ it("projectComputed.every emits relation-every IR", () => {
|
|
|
662
662
|
const interpreted = toFilter(query, baseSchema)
|
|
663
663
|
expect(interpreted.computed?.["allPicked"]?._tag).toBe("relation-every")
|
|
664
664
|
expect(interpreted.computed?.["allPicked"]?.path).toBe("items")
|
|
665
|
-
expect(interpreted.computed?.["allPicked"]?.filter).toEqual([
|
|
665
|
+
expect((interpreted.computed?.["allPicked"] as { filter: unknown } | undefined)?.filter).toEqual([
|
|
666
666
|
{ t: "where", path: "items.-1.state._tag", op: "eq", value: "Picked" }
|
|
667
667
|
])
|
|
668
668
|
})
|
|
@@ -687,7 +687,7 @@ it("projectComputed.distinctCount emits relation-distinct-count IR with field",
|
|
|
687
687
|
const ir = interpreted.computed?.["positionCount"]
|
|
688
688
|
expect(ir?._tag).toBe("relation-distinct-count")
|
|
689
689
|
expect((ir as { field: string } | undefined)?.field).toBe("rowId")
|
|
690
|
-
expect(ir?.filter).toEqual([
|
|
690
|
+
expect((ir as { filter: unknown } | undefined)?.filter).toEqual([
|
|
691
691
|
{ t: "where", path: "items.-1.state._tag", op: "neq", value: "cancelled" }
|
|
692
692
|
])
|
|
693
693
|
})
|
|
@@ -707,7 +707,7 @@ it("projectComputed.sum emits relation-sum IR with field", () => {
|
|
|
707
707
|
const ir = interpreted.computed?.["totalWeight"]
|
|
708
708
|
expect(ir?._tag).toBe("relation-sum")
|
|
709
709
|
expect((ir as { field: string } | undefined)?.field).toBe("weight")
|
|
710
|
-
expect(ir?.filter).toEqual([])
|
|
710
|
+
expect((ir as { filter: unknown } | undefined)?.filter).toEqual([])
|
|
711
711
|
})
|
|
712
712
|
|
|
713
713
|
it("projectComputed.collect / collectDistinct emit relation-collect IR", () => {
|
package/test/rawQuery.test.ts
CHANGED
|
@@ -369,6 +369,32 @@ describe("multi-level", () => {
|
|
|
369
369
|
.pipe(Effect.provide(SomethingRepo.Test), rt.runPromise))
|
|
370
370
|
})
|
|
371
371
|
|
|
372
|
+
describe("array length projection", () => {
|
|
373
|
+
const test = Effect
|
|
374
|
+
.gen(function*() {
|
|
375
|
+
const repo = yield* SomethingRepo
|
|
376
|
+
const result = yield* repo.query(
|
|
377
|
+
projectComputed(
|
|
378
|
+
S.Struct({ id: S.String, itemCount: S.NonNegativeInt }),
|
|
379
|
+
computed({ itemCount: relation<S.Codec.Encoded<typeof Something>>("items").length() })
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
expect(result).toStrictEqual([
|
|
383
|
+
{ id: "1", itemCount: 2 },
|
|
384
|
+
{ id: "2", itemCount: 2 }
|
|
385
|
+
])
|
|
386
|
+
})
|
|
387
|
+
.pipe(setupRequestContextFromCurrent())
|
|
388
|
+
|
|
389
|
+
it.skipIf(!process.env["STORAGE_URL"])("works well in CosmosDB", () =>
|
|
390
|
+
test
|
|
391
|
+
.pipe(Effect.provide(SomethingRepo.TestCosmos), rt.runPromise))
|
|
392
|
+
|
|
393
|
+
it("works well in Memory", () =>
|
|
394
|
+
test
|
|
395
|
+
.pipe(Effect.provide(SomethingRepo.Test), rt.runPromise))
|
|
396
|
+
})
|
|
397
|
+
|
|
372
398
|
describe("computed projections", () => {
|
|
373
399
|
const test = Effect
|
|
374
400
|
.gen(function*() {
|