@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/Emailer/Sendgrid.d.ts +1 -1
  3. package/dist/Model/Repository/internal/internal.d.ts +1 -1
  4. package/dist/Model/Repository/internal/internal.d.ts.map +1 -1
  5. package/dist/Model/Repository/internal/internal.js +22 -12
  6. package/dist/Model/query/dsl.d.ts +71 -2
  7. package/dist/Model/query/dsl.d.ts.map +1 -1
  8. package/dist/Model/query/dsl.js +47 -1
  9. package/dist/Model/query/new-kid-interpreter.d.ts +38 -2
  10. package/dist/Model/query/new-kid-interpreter.d.ts.map +1 -1
  11. package/dist/Model/query/new-kid-interpreter.js +49 -2
  12. package/dist/Store/Cosmos/query.d.ts +8 -2
  13. package/dist/Store/Cosmos/query.d.ts.map +1 -1
  14. package/dist/Store/Cosmos/query.js +67 -13
  15. package/dist/Store/Memory.d.ts +1 -1
  16. package/dist/Store/Memory.d.ts.map +1 -1
  17. package/dist/Store/Memory.js +72 -5
  18. package/dist/Store/SQL/query.d.ts +8 -2
  19. package/dist/Store/SQL/query.d.ts.map +1 -1
  20. package/dist/Store/SQL/query.js +47 -3
  21. package/dist/Store/service.d.ts +8 -2
  22. package/dist/Store/service.d.ts.map +1 -1
  23. package/dist/Store/service.js +1 -1
  24. package/package.json +2 -2
  25. package/src/Model/Repository/internal/internal.ts +19 -1
  26. package/src/Model/query/dsl.ts +95 -1
  27. package/src/Model/query/new-kid-interpreter.ts +64 -2
  28. package/src/Store/Cosmos/query.ts +78 -17
  29. package/src/Store/Memory.ts +76 -5
  30. package/src/Store/SQL/query.ts +61 -3
  31. package/src/Store/service.ts +7 -1
  32. package/test/cosmos-query.test.ts +64 -0
  33. package/test/query.test.ts +82 -0
  34. package/test/sql-store.test.ts +119 -0
  35. 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.229",
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.229"
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 === "project"
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(
@@ -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 values = [...computedFilters, ...getValues(filter)]
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
- ${order ? `ORDER BY ${order.map((_) => `${dottedToAccess(`f.${_.key}`)} ${_.direction}`).join(", ")}` : ""}
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) =>
@@ -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 select = (r: T[]): M[] => {
181
- const sel = f.select
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 select(
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 select(r)
314
+ return selectPerRow(r)
244
315
  })
245
316
  }
246
317
 
@@ -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 ${order.map((_) => `${fieldExpr(_.key)} ${_.direction}`).join(", ")}`
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}`.trim()
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
  }
@@ -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