@effect-app/infra 4.0.0-beta.229 → 4.0.0-beta.230
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 +16 -0
- package/dist/Emailer/Sendgrid.d.ts +1 -1
- package/dist/Model/Repository/internal/internal.d.ts +1 -1
- package/dist/Model/Repository/internal/internal.d.ts.map +1 -1
- package/dist/Model/Repository/internal/internal.js +22 -12
- package/dist/Model/query/dsl.d.ts +71 -2
- package/dist/Model/query/dsl.d.ts.map +1 -1
- package/dist/Model/query/dsl.js +47 -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 +49 -2
- package/dist/Store/Cosmos/query.d.ts +8 -2
- package/dist/Store/Cosmos/query.d.ts.map +1 -1
- package/dist/Store/Cosmos/query.js +67 -13
- package/dist/Store/Memory.d.ts +1 -1
- package/dist/Store/Memory.d.ts.map +1 -1
- package/dist/Store/Memory.js +72 -5
- package/dist/Store/SQL/query.d.ts +8 -2
- package/dist/Store/SQL/query.d.ts.map +1 -1
- package/dist/Store/SQL/query.js +47 -3
- package/dist/Store/service.d.ts +8 -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 +19 -1
- package/src/Model/query/dsl.ts +95 -1
- package/src/Model/query/new-kid-interpreter.ts +64 -2
- package/src/Store/Cosmos/query.ts +78 -17
- package/src/Store/Memory.ts +76 -5
- package/src/Store/SQL/query.ts +61 -3
- package/src/Store/service.ts +7 -1
- package/test/cosmos-query.test.ts +64 -0
- package/test/query.test.ts +82 -0
- package/test/sql-store.test.ts +119 -0
- package/test/dist/date-query.test.d.ts.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-app/infra",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.230",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"proper-lockfile": "^4.1.2",
|
|
14
14
|
"pure-rand": "8.4.0",
|
|
15
15
|
"query-string": "^9.3.1",
|
|
16
|
-
"effect-app": "4.0.0-beta.
|
|
16
|
+
"effect-app": "4.0.0-beta.230"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@azure/cosmos": "^4.9.3",
|
|
@@ -333,10 +333,28 @@ export function makeRepoInternal<
|
|
|
333
333
|
} = (<A, R, EncodedRefined extends Encoded = Encoded>(q: Q.QAll<Encoded, EncodedRefined, A, R>) => {
|
|
334
334
|
const a = Q.toFilter(q, schema)
|
|
335
335
|
// Mode dispatch — see `Q.project` JSDoc for the contract:
|
|
336
|
+
// aggregate: GROUP BY + aggregate functions at DB level; decode raw rows with schema; SchemaError surfaces.
|
|
336
337
|
// project : decode raw encoded rows with schema; no PM reverse-mapping; SchemaError surfaces.
|
|
337
338
|
// collect : same as project, but schema yields Option and None rows are dropped.
|
|
338
339
|
// transform: PM reverse-map (re-inject _etag/PM state from cms cache) then decode; orDie.
|
|
339
|
-
const eff = a.mode === "
|
|
340
|
+
const eff = a.mode === "aggregate"
|
|
341
|
+
? store
|
|
342
|
+
// `a.select` contains `{ key, aggregate }` items not expressible in FilterFunc<Encoded, U>'s
|
|
343
|
+
// `U extends keyof Encoded` generic. Cast is unavoidable until FilterFunc supports aggregate mode.
|
|
344
|
+
.filter(a as any)
|
|
345
|
+
// Decode raw aggregate rows directly — no PM reverse-mapping, no id/_etag needed.
|
|
346
|
+
.pipe(
|
|
347
|
+
Effect.andThen(
|
|
348
|
+
flow(
|
|
349
|
+
S.decodeEffectConcurrently(S.Array(a.schema ?? schema)),
|
|
350
|
+
provideRctx,
|
|
351
|
+
Effect.withSpan("parseMany", {
|
|
352
|
+
attributes: { "app.entity": name, "app.query.mode": "aggregate" }
|
|
353
|
+
})
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
: a.mode === "project"
|
|
340
358
|
? filter(a)
|
|
341
359
|
// TODO: mapFrom but need to support per field and dependencies
|
|
342
360
|
.pipe(
|
package/src/Model/query/dsl.ts
CHANGED
|
@@ -184,6 +184,37 @@ export type ComputedProjectionExpression =
|
|
|
184
184
|
readonly path: string
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
/**
|
|
188
|
+
* An expression that aggregates values across documents (for use with {@link aggregate}).
|
|
189
|
+
* `agg-field` references a document field to group by; other tags are aggregate functions.
|
|
190
|
+
*/
|
|
191
|
+
export type AggregateExpression =
|
|
192
|
+
| {
|
|
193
|
+
readonly _tag: "agg-field"
|
|
194
|
+
readonly path: string
|
|
195
|
+
}
|
|
196
|
+
| {
|
|
197
|
+
readonly _tag: "agg-count"
|
|
198
|
+
}
|
|
199
|
+
| {
|
|
200
|
+
readonly _tag: "agg-count-when"
|
|
201
|
+
readonly operation: ComputedProjectionOperation
|
|
202
|
+
}
|
|
203
|
+
| {
|
|
204
|
+
readonly _tag: "agg-sum"
|
|
205
|
+
readonly field: string
|
|
206
|
+
}
|
|
207
|
+
| {
|
|
208
|
+
readonly _tag: "agg-min"
|
|
209
|
+
readonly field: string
|
|
210
|
+
}
|
|
211
|
+
| {
|
|
212
|
+
readonly _tag: "agg-max"
|
|
213
|
+
readonly field: string
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export type AggregateMap = Readonly<Record<string, AggregateExpression>>
|
|
217
|
+
|
|
187
218
|
export type ComputedProjectionMap = Readonly<Record<string, ComputedProjectionExpression>>
|
|
188
219
|
export type Q<TFieldValues extends FieldValues> =
|
|
189
220
|
| Initial<TFieldValues>
|
|
@@ -298,8 +329,9 @@ export class Project<A, TFieldValues extends FieldValues, R, TType extends "one"
|
|
|
298
329
|
extends Data.TaggedClass("project")<{
|
|
299
330
|
current: Query<TFieldValues> | QueryWhere<any, TFieldValues> | QueryEnd<TFieldValues, TType>
|
|
300
331
|
schema: S.Codec<A, TFieldValues, R>
|
|
301
|
-
mode: "collect" | "project" | "transform"
|
|
332
|
+
mode: "collect" | "project" | "transform" | "aggregate"
|
|
302
333
|
computed?: ComputedProjectionMap
|
|
334
|
+
aggregateMap?: AggregateMap
|
|
303
335
|
}>
|
|
304
336
|
implements QueryProjection<TFieldValues, A, R>
|
|
305
337
|
{
|
|
@@ -698,6 +730,68 @@ export const projectComputed: {
|
|
|
698
730
|
} = (schema: any, computedProjection: ComputedProjectionMap, mode = "project") => (current: any) =>
|
|
699
731
|
new Project({ current, schema, mode, computed: computedProjection } as any)
|
|
700
732
|
|
|
733
|
+
/**
|
|
734
|
+
* DSL helpers for building aggregate expressions used with {@link aggregate}.
|
|
735
|
+
*
|
|
736
|
+
* - `agg.field(path)` — references a document field for GROUP BY (with optional output alias)
|
|
737
|
+
* - `agg.count()` — COUNT(*) across all rows in the group
|
|
738
|
+
* - `agg.countWhen(op)` — COUNT of rows matching the filter operation
|
|
739
|
+
* - `agg.sum(field)` — SUM of a numeric document field
|
|
740
|
+
* - `agg.min(field)` / `agg.max(field)` — MIN/MAX of a document field
|
|
741
|
+
*/
|
|
742
|
+
export const agg = {
|
|
743
|
+
field: (path: string): AggregateExpression => ({ _tag: "agg-field", path }),
|
|
744
|
+
count: (): AggregateExpression => ({ _tag: "agg-count" }),
|
|
745
|
+
countWhen: (operation: ComputedProjectionOperation): AggregateExpression => ({
|
|
746
|
+
_tag: "agg-count-when",
|
|
747
|
+
operation
|
|
748
|
+
}),
|
|
749
|
+
sum: (field: string): AggregateExpression => ({ _tag: "agg-sum", field }),
|
|
750
|
+
min: (field: string): AggregateExpression => ({ _tag: "agg-min", field }),
|
|
751
|
+
max: (field: string): AggregateExpression => ({ _tag: "agg-max", field })
|
|
752
|
+
} as const
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Attach an aggregate projection to a query, performing GROUP BY + aggregate functions at the
|
|
756
|
+
* database level instead of fetching all rows and grouping in memory.
|
|
757
|
+
*
|
|
758
|
+
* The `aggregateMap` maps each output field name to either:
|
|
759
|
+
* - `agg.field(path)` — a group-by field (document path → output alias)
|
|
760
|
+
* - `agg.count()` / `agg.countWhen(op)` / `agg.sum(f)` / `agg.min(f)` / `agg.max(f)` — an aggregate
|
|
761
|
+
*
|
|
762
|
+
* The output is decoded directly with `schema` (no PM reverse-mapping, no etag tracking).
|
|
763
|
+
* Decode failures surface as `S.SchemaError`.
|
|
764
|
+
*
|
|
765
|
+
* @example
|
|
766
|
+
* ```ts
|
|
767
|
+
* repo.query(
|
|
768
|
+
* where("status", "active"),
|
|
769
|
+
* aggregate(
|
|
770
|
+
* S.Struct({ city: S.String, count: S.Number }),
|
|
771
|
+
* {
|
|
772
|
+
* city: agg.field("address.city"),
|
|
773
|
+
* count: agg.countWhen((q) => q.pipe(where("active", true)))
|
|
774
|
+
* }
|
|
775
|
+
* )
|
|
776
|
+
* )
|
|
777
|
+
* ```
|
|
778
|
+
*/
|
|
779
|
+
export const aggregate: {
|
|
780
|
+
<
|
|
781
|
+
Q extends Query<any> | QueryWhere<any, any, any> | QueryEnd<any, "one" | "many", any>,
|
|
782
|
+
I extends Record<string, unknown>,
|
|
783
|
+
A = ExtractFieldValuesRefined<Q>,
|
|
784
|
+
R = never,
|
|
785
|
+
E extends boolean = ExtractExclusiveness<Q>
|
|
786
|
+
>(
|
|
787
|
+
schema: S.Codec<A, I, R>,
|
|
788
|
+
aggregateMap: AggregateMap
|
|
789
|
+
): (
|
|
790
|
+
current: Q
|
|
791
|
+
) => QueryProjection<ExtractFieldValuesRefined<Q>, A, R, ExtractTType<Q>, E>
|
|
792
|
+
} = (schema: any, aggregateMap: AggregateMap) => (current: any) =>
|
|
793
|
+
new Project({ current, schema, mode: "aggregate", aggregateMap } as any)
|
|
794
|
+
|
|
701
795
|
type GetArV<T> = T extends readonly (infer R)[] ? R : never
|
|
702
796
|
|
|
703
797
|
export type FilterContinuations<IsCurrentInitial extends boolean = false> = {
|
|
@@ -13,6 +13,17 @@ import type { FieldValues } from "../filter/types.js"
|
|
|
13
13
|
import type { FieldPath } from "../filter/types/path/eager.js"
|
|
14
14
|
import { make, type Q, type QAll } from "../query/dsl.js"
|
|
15
15
|
|
|
16
|
+
export type AggregateIrExpression =
|
|
17
|
+
| { readonly _tag: "agg-count" }
|
|
18
|
+
| { readonly _tag: "agg-count-when"; readonly filter: readonly FilterResult[] }
|
|
19
|
+
| { readonly _tag: "agg-sum"; readonly field: string }
|
|
20
|
+
| { readonly _tag: "agg-min"; readonly field: string }
|
|
21
|
+
| { readonly _tag: "agg-max"; readonly field: string }
|
|
22
|
+
|
|
23
|
+
export type AggregateIrItem =
|
|
24
|
+
| AggregateIrExpression
|
|
25
|
+
| { readonly _tag: "agg-field"; readonly path: string }
|
|
26
|
+
|
|
16
27
|
export type ComputedProjectionMathIrExpression =
|
|
17
28
|
| {
|
|
18
29
|
readonly _tag: "field"
|
|
@@ -100,8 +111,9 @@ type Result<TFieldValues extends FieldValues, A = TFieldValues, R = never> = {
|
|
|
100
111
|
skip: number | undefined
|
|
101
112
|
order: { key: FieldPath<TFieldValues>; direction: "ASC" | "DESC" }[]
|
|
102
113
|
ttype: "one" | "many" | "count" | undefined
|
|
103
|
-
mode: "collect" | "project" | "transform" | undefined
|
|
114
|
+
mode: "collect" | "project" | "transform" | "aggregate" | undefined
|
|
104
115
|
computed: Record<string, ComputedProjectionIrExpression> | undefined
|
|
116
|
+
aggregateMap: Record<string, AggregateIrItem> | undefined
|
|
105
117
|
}
|
|
106
118
|
|
|
107
119
|
const interpret = <
|
|
@@ -120,7 +132,8 @@ const interpret = <
|
|
|
120
132
|
order: [],
|
|
121
133
|
ttype: undefined,
|
|
122
134
|
mode: undefined,
|
|
123
|
-
computed: undefined
|
|
135
|
+
computed: undefined,
|
|
136
|
+
aggregateMap: undefined
|
|
124
137
|
}
|
|
125
138
|
|
|
126
139
|
const upd = (
|
|
@@ -134,6 +147,7 @@ const interpret = <
|
|
|
134
147
|
if (v.schema !== undefined) data.schema = v.schema
|
|
135
148
|
if (v.mode !== undefined) data.mode = v.mode
|
|
136
149
|
if (v.computed !== undefined) data.computed = v.computed
|
|
150
|
+
if (v.aggregateMap !== undefined) data.aggregateMap = v.aggregateMap
|
|
137
151
|
}
|
|
138
152
|
|
|
139
153
|
const applyPath = (path: string) => (_: FilterResult): FilterResult =>
|
|
@@ -223,6 +237,31 @@ const interpret = <
|
|
|
223
237
|
},
|
|
224
238
|
project: (v) => {
|
|
225
239
|
upd(interpret(v.current))
|
|
240
|
+
if (v.mode === "aggregate" && v.aggregateMap) {
|
|
241
|
+
data.schema = v.schema
|
|
242
|
+
data.mode = "aggregate"
|
|
243
|
+
data.aggregateMap = Object.fromEntries(
|
|
244
|
+
Object.entries(v.aggregateMap).map(([key, expression]) => {
|
|
245
|
+
switch (expression._tag) {
|
|
246
|
+
case "agg-field":
|
|
247
|
+
return [key, { _tag: "agg-field" as const, path: expression.path }]
|
|
248
|
+
case "agg-count":
|
|
249
|
+
return [key, { _tag: "agg-count" as const }]
|
|
250
|
+
case "agg-count-when": {
|
|
251
|
+
const filter = interpret(expression.operation(make())).filter
|
|
252
|
+
return [key, { _tag: "agg-count-when" as const, filter }]
|
|
253
|
+
}
|
|
254
|
+
case "agg-sum":
|
|
255
|
+
return [key, { _tag: "agg-sum" as const, field: expression.field }]
|
|
256
|
+
case "agg-min":
|
|
257
|
+
return [key, { _tag: "agg-min" as const, field: expression.field }]
|
|
258
|
+
case "agg-max":
|
|
259
|
+
return [key, { _tag: "agg-max" as const, field: expression.field }]
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
226
265
|
if (v.computed && v.mode === "transform") {
|
|
227
266
|
throw new Error("Computed projections require mode 'project' or 'collect', not 'transform'")
|
|
228
267
|
}
|
|
@@ -329,6 +368,29 @@ export const toFilter = <
|
|
|
329
368
|
) => {
|
|
330
369
|
// TODO: Native interpreter for each db adapter, instead of the intermediate "new-kid" format
|
|
331
370
|
const a = interpret(q)
|
|
371
|
+
|
|
372
|
+
// Aggregate mode: build select entirely from aggregateMap (no schema-driven field list)
|
|
373
|
+
if (a.mode === "aggregate" && a.aggregateMap) {
|
|
374
|
+
const aggSelect = Object.entries(a.aggregateMap).map(([key, item]) => {
|
|
375
|
+
if (item._tag === "agg-field") {
|
|
376
|
+
return { key, path: item.path }
|
|
377
|
+
}
|
|
378
|
+
return { key, aggregate: item }
|
|
379
|
+
})
|
|
380
|
+
return dropUndefinedT({
|
|
381
|
+
t: null as unknown as TFieldValues,
|
|
382
|
+
limit: a.limit,
|
|
383
|
+
skip: a.skip,
|
|
384
|
+
select: Option.getOrUndefined(toNonEmptyArray(aggSelect)) as any,
|
|
385
|
+
schema: a.schema,
|
|
386
|
+
computed: undefined,
|
|
387
|
+
order: Option.getOrUndefined(toNonEmptyArray(a.order)),
|
|
388
|
+
ttype: a.ttype,
|
|
389
|
+
mode: "aggregate" as const,
|
|
390
|
+
filter: a.filter.length ? a.filter : undefined
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
|
|
332
394
|
const schema = a.schema
|
|
333
395
|
let select: (keyof TFieldValues | { key: string; subKeys: string[] } | {
|
|
334
396
|
key: string
|
|
@@ -6,7 +6,7 @@ import * as Effect from "effect-app/Effect"
|
|
|
6
6
|
import { assertUnreachable } from "effect-app/utils"
|
|
7
7
|
import { InfraLogger } from "../../logger.js"
|
|
8
8
|
import type { FilterR, FilterResult, Ops } from "../../Model/filter/filterApi.js"
|
|
9
|
-
import type { ComputedProjectionIrExpression, ComputedProjectionMathIrExpression } from "../../Model/query.js"
|
|
9
|
+
import type { AggregateIrExpression, ComputedProjectionIrExpression, ComputedProjectionMathIrExpression } from "../../Model/query.js"
|
|
10
10
|
import { isRelationCheck } from "../codeFilter.js"
|
|
11
11
|
import type { SupportedValues } from "../service.js"
|
|
12
12
|
|
|
@@ -50,6 +50,12 @@ export function buildWhereCosmosQuery3(
|
|
|
50
50
|
} | {
|
|
51
51
|
key: string
|
|
52
52
|
computed: ComputedProjectionIrExpression
|
|
53
|
+
} | {
|
|
54
|
+
key: string
|
|
55
|
+
path: string
|
|
56
|
+
} | {
|
|
57
|
+
key: string
|
|
58
|
+
aggregate: AggregateIrExpression
|
|
53
59
|
}
|
|
54
60
|
>,
|
|
55
61
|
order?: NonEmptyReadonlyArray<{ key: string; direction: "ASC" | "DESC" }>,
|
|
@@ -293,7 +299,43 @@ export function buildWhereCosmosQuery3(
|
|
|
293
299
|
typeof _ === "object" && "computed" in _ && "filter" in _.computed ? getValues(_.computed.filter) : []
|
|
294
300
|
)
|
|
295
301
|
: []
|
|
296
|
-
const
|
|
302
|
+
const aggregateFilters = select
|
|
303
|
+
? select.flatMap((_) =>
|
|
304
|
+
typeof _ === "object" && "aggregate" in _ && "filter" in _.aggregate ? getValues(_.aggregate.filter) : []
|
|
305
|
+
)
|
|
306
|
+
: []
|
|
307
|
+
const values = [...computedFilters, ...aggregateFilters, ...getValues(filter)]
|
|
308
|
+
|
|
309
|
+
const hasAggregates = select
|
|
310
|
+
? select.some((s) => typeof s === "object" && s !== null && "aggregate" in s)
|
|
311
|
+
: false
|
|
312
|
+
|
|
313
|
+
const aggregateSelectExpr = (key: string, agg: AggregateIrExpression): string => {
|
|
314
|
+
switch (agg._tag) {
|
|
315
|
+
case "agg-count":
|
|
316
|
+
return `COUNT(1) AS ${key}`
|
|
317
|
+
case "agg-count-when": {
|
|
318
|
+
if (agg.filter.length === 0) return `COUNT(1) AS ${key}`
|
|
319
|
+
const cond = print(agg.filter, null, false)
|
|
320
|
+
// Cosmos supports SUM(IIF(cond, 1, 0)) as a conditional count
|
|
321
|
+
return `SUM(IIF(${cond}, 1, 0)) AS ${key}`
|
|
322
|
+
}
|
|
323
|
+
case "agg-sum": {
|
|
324
|
+
const fieldRef = dottedToAccess(`f.${agg.field}`)
|
|
325
|
+
return `SUM(${fieldRef}) AS ${key}`
|
|
326
|
+
}
|
|
327
|
+
case "agg-min": {
|
|
328
|
+
const fieldRef = dottedToAccess(`f.${agg.field}`)
|
|
329
|
+
return `MIN(${fieldRef}) AS ${key}`
|
|
330
|
+
}
|
|
331
|
+
case "agg-max": {
|
|
332
|
+
const fieldRef = dottedToAccess(`f.${agg.field}`)
|
|
333
|
+
return `MAX(${fieldRef}) AS ${key}`
|
|
334
|
+
}
|
|
335
|
+
default:
|
|
336
|
+
return assertUnreachable(agg)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
297
339
|
|
|
298
340
|
const computedSelectExpr = (key: string, computed: ComputedProjectionIrExpression) => {
|
|
299
341
|
const relationPath = computed.path
|
|
@@ -378,28 +420,47 @@ export function buildWhereCosmosQuery3(
|
|
|
378
420
|
}
|
|
379
421
|
}
|
|
380
422
|
}
|
|
423
|
+
|
|
424
|
+
const buildSelectList = (): string => {
|
|
425
|
+
if (!select) return "f"
|
|
426
|
+
return select
|
|
427
|
+
.map((s) => {
|
|
428
|
+
if (typeof s === "string") {
|
|
429
|
+
return dottedToAccess(s === idKey ? "f.id" : `f.${s}`)
|
|
430
|
+
}
|
|
431
|
+
if ("computed" in s) return computedSelectExpr(s.key, s.computed)
|
|
432
|
+
if ("aggregate" in s) return aggregateSelectExpr(s.key, s.aggregate)
|
|
433
|
+
if ("path" in s) return `${dottedToAccess(`f.${s.path}`)} AS ${s.key}`
|
|
434
|
+
// subKeys
|
|
435
|
+
return `ARRAY (SELECT ${s.subKeys.map((_) => dottedToAccess(`t.${_}`)).join(",")}
|
|
436
|
+
FROM t in ${dottedToAccess(`f.${s.key}`)}) AS ${s.key}`
|
|
437
|
+
})
|
|
438
|
+
.join(", ")
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const groupByClause = hasAggregates && select
|
|
442
|
+
? (() => {
|
|
443
|
+
const groupByExprs = select
|
|
444
|
+
.filter((s): s is { key: string; path: string } =>
|
|
445
|
+
typeof s === "object" && s !== null && "path" in s && !("aggregate" in s)
|
|
446
|
+
)
|
|
447
|
+
.map((s) => dottedToAccess(`f.${s.path}`))
|
|
448
|
+
return groupByExprs.length > 0 ? `GROUP BY ${groupByExprs.join(", ")}` : ""
|
|
449
|
+
})()
|
|
450
|
+
: ""
|
|
451
|
+
|
|
452
|
+
const orderExpr = (key: string) => hasAggregates ? key : dottedToAccess(`f.${key}`)
|
|
453
|
+
|
|
381
454
|
// with joins, you should use DISTINCT
|
|
382
455
|
// or you can end up with duplicates
|
|
383
456
|
return {
|
|
384
457
|
query: `
|
|
385
|
-
SELECT ${
|
|
386
|
-
select
|
|
387
|
-
? select
|
|
388
|
-
.map((s) =>
|
|
389
|
-
typeof s === "string"
|
|
390
|
-
? dottedToAccess(s === idKey ? "f.id" : `f.${s}`) // x["y"} vs x.y, helps with reserved keywords like "value"
|
|
391
|
-
: "computed" in s
|
|
392
|
-
? computedSelectExpr(s.key, s.computed)
|
|
393
|
-
: `ARRAY (SELECT ${s.subKeys.map((_) => dottedToAccess(`t.${_}`)).join(",")}
|
|
394
|
-
FROM t in ${dottedToAccess(`f.${s.key}`)}) AS ${s.key}`
|
|
395
|
-
)
|
|
396
|
-
.join(", ")
|
|
397
|
-
: "f"
|
|
398
|
-
}
|
|
458
|
+
SELECT ${buildSelectList()}
|
|
399
459
|
FROM ${name} f
|
|
400
460
|
|
|
401
461
|
${filter.length ? `WHERE (${print(filter, null, false)})` : ""}
|
|
402
|
-
${
|
|
462
|
+
${groupByClause}
|
|
463
|
+
${order ? `ORDER BY ${order.map((_) => `${orderExpr(_.key)} ${_.direction}`).join(", ")}` : ""}
|
|
403
464
|
${skip !== undefined || limit !== undefined ? `OFFSET ${skip ?? 0} LIMIT ${limit ?? 999999}` : ""}`,
|
|
404
465
|
parameters: values
|
|
405
466
|
.flatMap((x, i) =>
|
package/src/Store/Memory.ts
CHANGED
|
@@ -16,7 +16,7 @@ import * as Struct from "effect/Struct"
|
|
|
16
16
|
import { InfraLogger } from "../logger.js"
|
|
17
17
|
import type { FilterResult } from "../Model/filter/filterApi.js"
|
|
18
18
|
import type { FieldValues } from "../Model/filter/types.js"
|
|
19
|
-
import type { ComputedProjectionIrExpression, ComputedProjectionMathIrExpression } from "../Model/query.js"
|
|
19
|
+
import type { AggregateIrExpression, ComputedProjectionIrExpression, ComputedProjectionMathIrExpression } from "../Model/query.js"
|
|
20
20
|
import { annotateDb } from "../otel.js"
|
|
21
21
|
import { codeFilter, codeFilter3_ } from "./codeFilter.js"
|
|
22
22
|
import { type FilterArgs, type PersistenceModelType, type Store, type StoreConfig, StoreMaker } from "./service.js"
|
|
@@ -174,12 +174,76 @@ const computeProjectionValue = (
|
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
const computeAggregateValue = <T extends FieldValues>(rows: readonly T[], agg: AggregateIrExpression): unknown => {
|
|
178
|
+
switch (agg._tag) {
|
|
179
|
+
case "agg-count":
|
|
180
|
+
return rows.length
|
|
181
|
+
case "agg-count-when": {
|
|
182
|
+
const filter = agg.filter
|
|
183
|
+
const matches = filter.length === 0 ? () => true : (row: unknown) => codeFilter3_(filter, row)
|
|
184
|
+
return rows.filter((row) => matches(row)).length
|
|
185
|
+
}
|
|
186
|
+
case "agg-sum":
|
|
187
|
+
return rows.reduce<number>((acc, row) => {
|
|
188
|
+
const v = get(row, agg.field)
|
|
189
|
+
return acc + (typeof v === "number" ? v : Number(v) || 0)
|
|
190
|
+
}, 0)
|
|
191
|
+
case "agg-min": {
|
|
192
|
+
let min: unknown = undefined
|
|
193
|
+
for (const row of rows) {
|
|
194
|
+
const v = get(row, agg.field)
|
|
195
|
+
if (v == null) continue
|
|
196
|
+
if (min === undefined || v < (min as any)) min = v
|
|
197
|
+
}
|
|
198
|
+
return min ?? null
|
|
199
|
+
}
|
|
200
|
+
case "agg-max": {
|
|
201
|
+
let max: unknown = undefined
|
|
202
|
+
for (const row of rows) {
|
|
203
|
+
const v = get(row, agg.field)
|
|
204
|
+
if (v == null) continue
|
|
205
|
+
if (max === undefined || v > (max as any)) max = v
|
|
206
|
+
}
|
|
207
|
+
return max ?? null
|
|
208
|
+
}
|
|
209
|
+
default:
|
|
210
|
+
return assertUnreachable(agg)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
177
214
|
export function memFilter<T extends FieldValues, U extends keyof T = never>(f: FilterArgs<T, U>) {
|
|
178
215
|
type M = U extends undefined ? T : Pick<T, U>
|
|
179
216
|
return ((c: T[]): M[] => {
|
|
180
|
-
const
|
|
181
|
-
|
|
217
|
+
const sel = f.select
|
|
218
|
+
|
|
219
|
+
const selectPerRow = (r: T[]): M[] => {
|
|
182
220
|
if (!sel) return r as M[]
|
|
221
|
+
|
|
222
|
+
// Detect aggregate mode: any select item has `aggregate` key
|
|
223
|
+
const hasAggregates = sel.some((s) => typeof s === "object" && s !== null && "aggregate" in s)
|
|
224
|
+
if (hasAggregates) {
|
|
225
|
+
// GROUP BY + aggregate
|
|
226
|
+
const fieldItems = sel.filter((s): s is { key: string; path: string } =>
|
|
227
|
+
typeof s === "object" && s !== null && "path" in s && !("aggregate" in s)
|
|
228
|
+
)
|
|
229
|
+
const aggregateItems = sel.filter((s): s is { key: string; aggregate: AggregateIrExpression } =>
|
|
230
|
+
typeof s === "object" && s !== null && "aggregate" in s
|
|
231
|
+
)
|
|
232
|
+
const groups = new Map<string, T[]>()
|
|
233
|
+
for (const row of r) {
|
|
234
|
+
const key = fieldItems.map((fi) => JSON.stringify(get(row, fi.path))).join("\0")
|
|
235
|
+
const existing = groups.get(key) ?? []
|
|
236
|
+
existing.push(row)
|
|
237
|
+
groups.set(key, existing)
|
|
238
|
+
}
|
|
239
|
+
return [...groups.values()].map((rows) => {
|
|
240
|
+
const result: Record<string, unknown> = {}
|
|
241
|
+
for (const fi of fieldItems) result[fi.key] = get(rows[0]!, fi.path)
|
|
242
|
+
for (const ai of aggregateItems) result[ai.key] = computeAggregateValue(rows, ai.aggregate)
|
|
243
|
+
return result as M
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
183
247
|
return r.map((i) => {
|
|
184
248
|
const [keys, entries] = pipe(
|
|
185
249
|
sel,
|
|
@@ -192,6 +256,9 @@ export function memFilter<T extends FieldValues, U extends keyof T = never>(f: F
|
|
|
192
256
|
key: string
|
|
193
257
|
computed: ComputedProjectionIrExpression
|
|
194
258
|
} => typeof entry === "object" && entry !== null && "computed" in entry)
|
|
259
|
+
const pathKeys = entries.filter((entry): entry is { key: string; path: string } =>
|
|
260
|
+
typeof entry === "object" && entry !== null && "path" in entry && !("aggregate" in entry)
|
|
261
|
+
)
|
|
195
262
|
const n = Struct.pick(i, keys)
|
|
196
263
|
subKeys.forEach((subKey) => {
|
|
197
264
|
n[subKey.key] = i[subKey.key]!.map(Struct.pick(subKey.subKeys as never[]))
|
|
@@ -199,9 +266,13 @@ export function memFilter<T extends FieldValues, U extends keyof T = never>(f: F
|
|
|
199
266
|
computedKeys.forEach((entry) => {
|
|
200
267
|
;(n as Record<string, unknown>)[entry.key] = computeProjectionValue(i, entry.computed)
|
|
201
268
|
})
|
|
269
|
+
pathKeys.forEach((entry) => {
|
|
270
|
+
;(n as Record<string, unknown>)[entry.key] = get(i, entry.path)
|
|
271
|
+
})
|
|
202
272
|
return n as M
|
|
203
273
|
})
|
|
204
274
|
}
|
|
275
|
+
|
|
205
276
|
const skip = f?.skip
|
|
206
277
|
const limit = f?.limit
|
|
207
278
|
const ords = Option.map(Option.fromNullishOr(f.order), (_) =>
|
|
@@ -223,7 +294,7 @@ export function memFilter<T extends FieldValues, U extends keyof T = never>(f: F
|
|
|
223
294
|
c = Array.sortBy(...ords.value)(c)
|
|
224
295
|
}
|
|
225
296
|
if (!skip && limit === 1) {
|
|
226
|
-
return
|
|
297
|
+
return selectPerRow(
|
|
227
298
|
Array.findFirst(c, f.filter ? codeFilter(f.filter) : (_) => Option.some(_)).pipe(
|
|
228
299
|
Option.map(Array.make),
|
|
229
300
|
Option.getOrElse(
|
|
@@ -240,7 +311,7 @@ export function memFilter<T extends FieldValues, U extends keyof T = never>(f: F
|
|
|
240
311
|
r = Array.take(r, limit)
|
|
241
312
|
}
|
|
242
313
|
|
|
243
|
-
return
|
|
314
|
+
return selectPerRow(r)
|
|
244
315
|
})
|
|
245
316
|
}
|
|
246
317
|
|
package/src/Store/SQL/query.ts
CHANGED
|
@@ -4,7 +4,7 @@ import * as Effect from "effect-app/Effect"
|
|
|
4
4
|
import { assertUnreachable } from "effect-app/utils"
|
|
5
5
|
import { InfraLogger } from "../../logger.js"
|
|
6
6
|
import type { FilterR, FilterResult } from "../../Model/filter/filterApi.js"
|
|
7
|
-
import type { ComputedProjectionIrExpression, ComputedProjectionMathIrExpression } from "../../Model/query.js"
|
|
7
|
+
import type { AggregateIrExpression, ComputedProjectionIrExpression, ComputedProjectionMathIrExpression } from "../../Model/query.js"
|
|
8
8
|
import { isRelationCheck } from "../codeFilter.js"
|
|
9
9
|
|
|
10
10
|
export interface SQLDialect {
|
|
@@ -164,6 +164,12 @@ export function buildWhereSQLQuery(
|
|
|
164
164
|
} | {
|
|
165
165
|
key: string
|
|
166
166
|
computed: ComputedProjectionIrExpression
|
|
167
|
+
} | {
|
|
168
|
+
key: string
|
|
169
|
+
path: string
|
|
170
|
+
} | {
|
|
171
|
+
key: string
|
|
172
|
+
aggregate: AggregateIrExpression
|
|
167
173
|
}
|
|
168
174
|
>,
|
|
169
175
|
order?: NonEmptyReadonlyArray<{ key: string; direction: "ASC" | "DESC" }>,
|
|
@@ -497,6 +503,30 @@ export function buildWhereSQLQuery(
|
|
|
497
503
|
}
|
|
498
504
|
}
|
|
499
505
|
|
|
506
|
+
const aggregateSelectExpr = (key: string, agg: AggregateIrExpression): string => {
|
|
507
|
+
switch (agg._tag) {
|
|
508
|
+
case "agg-count":
|
|
509
|
+
return `COUNT(1) AS "${key}"`
|
|
510
|
+
case "agg-count-when": {
|
|
511
|
+
if (agg.filter.length === 0) return `COUNT(1) AS "${key}"`
|
|
512
|
+
const cond = print([{ t: "where-scope", result: agg.filter, relation: "some" }], null, false)
|
|
513
|
+
return `COUNT(CASE WHEN ${cond} THEN 1 END) AS "${key}"`
|
|
514
|
+
}
|
|
515
|
+
case "agg-sum":
|
|
516
|
+
return `COALESCE(SUM(${fieldExpr(agg.field)}), 0) AS "${key}"`
|
|
517
|
+
case "agg-min":
|
|
518
|
+
return `MIN(${fieldExpr(agg.field)}) AS "${key}"`
|
|
519
|
+
case "agg-max":
|
|
520
|
+
return `MAX(${fieldExpr(agg.field)}) AS "${key}"`
|
|
521
|
+
default:
|
|
522
|
+
return assertUnreachable(agg)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const hasAggregates = select
|
|
527
|
+
? select.some((s) => typeof s === "object" && s !== null && "aggregate" in s)
|
|
528
|
+
: false
|
|
529
|
+
|
|
500
530
|
const getSelectExpr = (): string => {
|
|
501
531
|
if (!select) return "id, _etag, data"
|
|
502
532
|
const fields = select.map((s) => {
|
|
@@ -508,6 +538,13 @@ export function buildWhereSQLQuery(
|
|
|
508
538
|
if ("computed" in s) {
|
|
509
539
|
return computedSelectExpr(s.key, s.computed)
|
|
510
540
|
}
|
|
541
|
+
if ("aggregate" in s) {
|
|
542
|
+
return aggregateSelectExpr(s.key, s.aggregate)
|
|
543
|
+
}
|
|
544
|
+
if ("path" in s) {
|
|
545
|
+
// Group-by fields: extract as scalar (not JSON-encoded) so grouping works and values compare as plain strings/numbers
|
|
546
|
+
return `${fieldExpr(dottedToJsonPath(s.path))} AS "${s.key}"`
|
|
547
|
+
}
|
|
511
548
|
return `${dialect.jsonExtractJson(s.key)} AS "${s.key}"`
|
|
512
549
|
})
|
|
513
550
|
return fields.join(", ")
|
|
@@ -531,15 +568,36 @@ export function buildWhereSQLQuery(
|
|
|
531
568
|
? `WHERE ${userWhere}`
|
|
532
569
|
: ""
|
|
533
570
|
|
|
571
|
+
const groupByClause = hasAggregates && select
|
|
572
|
+
? (() => {
|
|
573
|
+
const groupByExprs = select
|
|
574
|
+
.filter((s): s is string | { key: string; path: string } =>
|
|
575
|
+
typeof s === "string" || (typeof s === "object" && s !== null && "path" in s)
|
|
576
|
+
)
|
|
577
|
+
.map((s) => typeof s === "string" ? fieldExpr(s) : fieldExpr(dottedToJsonPath(s.path)))
|
|
578
|
+
return groupByExprs.length > 0 ? `GROUP BY ${groupByExprs.join(", ")}` : ""
|
|
579
|
+
})()
|
|
580
|
+
: ""
|
|
581
|
+
|
|
534
582
|
const orderClause = order
|
|
535
|
-
? `ORDER BY ${
|
|
583
|
+
? `ORDER BY ${
|
|
584
|
+
order
|
|
585
|
+
.map((_) =>
|
|
586
|
+
hasAggregates
|
|
587
|
+
? `"${_.key}" ${_.direction}`
|
|
588
|
+
: `${fieldExpr(_.key)} ${_.direction}`
|
|
589
|
+
)
|
|
590
|
+
.join(", ")
|
|
591
|
+
}`
|
|
536
592
|
: ""
|
|
537
593
|
|
|
538
594
|
const limitClause = limit !== undefined || skip !== undefined
|
|
539
595
|
? `LIMIT ${addParam(limit ?? 999999)} OFFSET ${addParam(skip ?? 0)}`
|
|
540
596
|
: ""
|
|
541
597
|
|
|
542
|
-
const sql = `SELECT ${selectExpr} FROM "${tableName}" ${whereClause} ${orderClause} ${limitClause}
|
|
598
|
+
const sql = `SELECT ${selectExpr} FROM "${tableName}" ${whereClause} ${groupByClause} ${orderClause} ${limitClause}`
|
|
599
|
+
.replace(/\s+/g, " ")
|
|
600
|
+
.trim()
|
|
543
601
|
|
|
544
602
|
return { sql, params }
|
|
545
603
|
}
|
package/src/Store/service.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type { OptimisticConcurrencyException } from "../errors.js"
|
|
|
10
10
|
import type { FilterResult } from "../Model/filter/filterApi.js"
|
|
11
11
|
import type { FieldValues } from "../Model/filter/types.js"
|
|
12
12
|
import type { FieldPath } from "../Model/filter/types/path/index.js"
|
|
13
|
-
import type { ComputedProjectionIrExpression, RawQuery } from "../Model/query.js"
|
|
13
|
+
import type { AggregateIrExpression, ComputedProjectionIrExpression, RawQuery } from "../Model/query.js"
|
|
14
14
|
|
|
15
15
|
export interface StoreConfig<E> {
|
|
16
16
|
partitionValue: (e?: E) => string
|
|
@@ -70,6 +70,12 @@ export interface FilterArgs<Encoded extends FieldValues, U extends keyof Encoded
|
|
|
70
70
|
U | { key: string; subKeys: readonly string[] } | {
|
|
71
71
|
key: string
|
|
72
72
|
computed: ComputedProjectionIrExpression
|
|
73
|
+
} | {
|
|
74
|
+
key: string
|
|
75
|
+
path: string
|
|
76
|
+
} | {
|
|
77
|
+
key: string
|
|
78
|
+
aggregate: AggregateIrExpression
|
|
73
79
|
}
|
|
74
80
|
>
|
|
75
81
|
| undefined
|