@tanstack/db 0.1.12 → 0.2.0

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 (74) hide show
  1. package/dist/cjs/errors.cjs +18 -6
  2. package/dist/cjs/errors.cjs.map +1 -1
  3. package/dist/cjs/errors.d.cts +9 -3
  4. package/dist/cjs/index.cjs +3 -1
  5. package/dist/cjs/index.cjs.map +1 -1
  6. package/dist/cjs/query/builder/functions.cjs +4 -1
  7. package/dist/cjs/query/builder/functions.cjs.map +1 -1
  8. package/dist/cjs/query/builder/functions.d.cts +38 -21
  9. package/dist/cjs/query/builder/index.cjs +25 -16
  10. package/dist/cjs/query/builder/index.cjs.map +1 -1
  11. package/dist/cjs/query/builder/index.d.cts +8 -8
  12. package/dist/cjs/query/builder/ref-proxy.cjs +12 -8
  13. package/dist/cjs/query/builder/ref-proxy.cjs.map +1 -1
  14. package/dist/cjs/query/builder/ref-proxy.d.cts +2 -1
  15. package/dist/cjs/query/builder/types.d.cts +493 -28
  16. package/dist/cjs/query/compiler/evaluators.cjs +29 -0
  17. package/dist/cjs/query/compiler/evaluators.cjs.map +1 -1
  18. package/dist/cjs/query/compiler/group-by.cjs +4 -2
  19. package/dist/cjs/query/compiler/group-by.cjs.map +1 -1
  20. package/dist/cjs/query/compiler/index.cjs +13 -4
  21. package/dist/cjs/query/compiler/index.cjs.map +1 -1
  22. package/dist/cjs/query/compiler/joins.cjs +70 -60
  23. package/dist/cjs/query/compiler/joins.cjs.map +1 -1
  24. package/dist/cjs/query/compiler/select.cjs +131 -42
  25. package/dist/cjs/query/compiler/select.cjs.map +1 -1
  26. package/dist/cjs/query/compiler/select.d.cts +1 -5
  27. package/dist/cjs/query/ir.cjs +4 -0
  28. package/dist/cjs/query/ir.cjs.map +1 -1
  29. package/dist/cjs/query/ir.d.cts +6 -1
  30. package/dist/cjs/query/optimizer.cjs +61 -20
  31. package/dist/cjs/query/optimizer.cjs.map +1 -1
  32. package/dist/esm/errors.d.ts +9 -3
  33. package/dist/esm/errors.js +18 -6
  34. package/dist/esm/errors.js.map +1 -1
  35. package/dist/esm/index.js +4 -2
  36. package/dist/esm/query/builder/functions.d.ts +38 -21
  37. package/dist/esm/query/builder/functions.js +4 -1
  38. package/dist/esm/query/builder/functions.js.map +1 -1
  39. package/dist/esm/query/builder/index.d.ts +8 -8
  40. package/dist/esm/query/builder/index.js +27 -18
  41. package/dist/esm/query/builder/index.js.map +1 -1
  42. package/dist/esm/query/builder/ref-proxy.d.ts +2 -1
  43. package/dist/esm/query/builder/ref-proxy.js +12 -8
  44. package/dist/esm/query/builder/ref-proxy.js.map +1 -1
  45. package/dist/esm/query/builder/types.d.ts +493 -28
  46. package/dist/esm/query/compiler/evaluators.js +29 -0
  47. package/dist/esm/query/compiler/evaluators.js.map +1 -1
  48. package/dist/esm/query/compiler/group-by.js +4 -2
  49. package/dist/esm/query/compiler/group-by.js.map +1 -1
  50. package/dist/esm/query/compiler/index.js +15 -6
  51. package/dist/esm/query/compiler/index.js.map +1 -1
  52. package/dist/esm/query/compiler/joins.js +71 -61
  53. package/dist/esm/query/compiler/joins.js.map +1 -1
  54. package/dist/esm/query/compiler/select.d.ts +1 -5
  55. package/dist/esm/query/compiler/select.js +131 -42
  56. package/dist/esm/query/compiler/select.js.map +1 -1
  57. package/dist/esm/query/ir.d.ts +6 -1
  58. package/dist/esm/query/ir.js +4 -0
  59. package/dist/esm/query/ir.js.map +1 -1
  60. package/dist/esm/query/optimizer.js +62 -21
  61. package/dist/esm/query/optimizer.js.map +1 -1
  62. package/package.json +2 -2
  63. package/src/errors.ts +17 -10
  64. package/src/query/builder/functions.ts +176 -108
  65. package/src/query/builder/index.ts +68 -48
  66. package/src/query/builder/ref-proxy.ts +14 -20
  67. package/src/query/builder/types.ts +622 -110
  68. package/src/query/compiler/evaluators.ts +30 -0
  69. package/src/query/compiler/group-by.ts +6 -1
  70. package/src/query/compiler/index.ts +23 -6
  71. package/src/query/compiler/joins.ts +132 -101
  72. package/src/query/compiler/select.ts +206 -113
  73. package/src/query/ir.ts +14 -1
  74. package/src/query/optimizer.ts +131 -59
@@ -1,4 +1,5 @@
1
1
  import { map } from "@tanstack/db-ivm"
2
+ import { PropRef, Value as ValClass, isExpressionLike } from "../ir.js"
2
3
  import { compileExpression } from "./evaluators.js"
3
4
  import type { Aggregate, BasicExpression, Select } from "../ir.js"
4
5
  import type {
@@ -8,139 +9,135 @@ import type {
8
9
  } from "../../types.js"
9
10
 
10
11
  /**
11
- * Processes the SELECT clause and places results in __select_results
12
- * while preserving the original namespaced row for ORDER BY access
12
+ * Type for operations array used in select processing
13
13
  */
14
- export function processSelectToResults(
15
- pipeline: NamespacedAndKeyedStream,
16
- select: Select,
17
- _allInputs: Record<string, KeyedStream>
18
- ): NamespacedAndKeyedStream {
19
- // Pre-compile all select expressions
20
- const compiledSelect: Array<{
21
- alias: string
22
- compiledExpression: (row: NamespacedRow) => any
23
- }> = []
24
- const spreadAliases: Array<string> = []
25
-
26
- for (const [alias, expression] of Object.entries(select)) {
27
- if (alias.startsWith(`__SPREAD_SENTINEL__`)) {
28
- // Extract the table alias from the sentinel key
29
- const tableAlias = alias.replace(`__SPREAD_SENTINEL__`, ``)
30
- spreadAliases.push(tableAlias)
31
- } else {
32
- if (isAggregateExpression(expression)) {
33
- // For aggregates, we'll store the expression info for GROUP BY processing
34
- // but still compile a placeholder that will be replaced later
35
- compiledSelect.push({
36
- alias,
37
- compiledExpression: () => null, // Placeholder - will be handled by GROUP BY
38
- })
39
- } else {
40
- compiledSelect.push({
41
- alias,
42
- compiledExpression: compileExpression(expression as BasicExpression),
43
- })
44
- }
14
+ type SelectOp =
15
+ | {
16
+ kind: `merge`
17
+ targetPath: Array<string>
18
+ source: (row: NamespacedRow) => any
45
19
  }
46
- }
20
+ | { kind: `field`; alias: string; compiled: (row: NamespacedRow) => any }
47
21
 
48
- return pipeline.pipe(
49
- map(([key, namespacedRow]) => {
50
- const selectResults: Record<string, any> = {}
51
-
52
- // First pass: spread table data for any spread sentinels
53
- for (const tableAlias of spreadAliases) {
54
- const tableData = namespacedRow[tableAlias]
55
- if (tableData && typeof tableData === `object`) {
56
- // Spread the table data into the result, but don't overwrite explicit fields
57
- for (const [fieldName, fieldValue] of Object.entries(tableData)) {
58
- if (!(fieldName in selectResults)) {
59
- selectResults[fieldName] = fieldValue
22
+ /**
23
+ * Unwraps any Value expressions
24
+ */
25
+ function unwrapVal(input: any): any {
26
+ if (input instanceof ValClass) return input.value
27
+ return input
28
+ }
29
+
30
+ /**
31
+ * Processes a merge operation by merging source values into the target path
32
+ */
33
+ function processMerge(
34
+ op: Extract<SelectOp, { kind: `merge` }>,
35
+ namespacedRow: NamespacedRow,
36
+ selectResults: Record<string, any>
37
+ ): void {
38
+ const value = op.source(namespacedRow)
39
+ if (value && typeof value === `object`) {
40
+ // Ensure target object exists
41
+ let cursor: any = selectResults
42
+ const path = op.targetPath
43
+ if (path.length === 0) {
44
+ // Top-level merge
45
+ for (const [k, v] of Object.entries(value)) {
46
+ selectResults[k] = unwrapVal(v)
47
+ }
48
+ } else {
49
+ for (let i = 0; i < path.length; i++) {
50
+ const seg = path[i]!
51
+ if (i === path.length - 1) {
52
+ const dest = (cursor[seg] ??= {})
53
+ if (typeof dest === `object`) {
54
+ for (const [k, v] of Object.entries(value)) {
55
+ dest[k] = unwrapVal(v)
60
56
  }
61
57
  }
58
+ } else {
59
+ const next = cursor[seg]
60
+ if (next == null || typeof next !== `object`) {
61
+ cursor[seg] = {}
62
+ }
63
+ cursor = cursor[seg]
62
64
  }
63
65
  }
66
+ }
67
+ }
68
+ }
64
69
 
65
- // Second pass: evaluate all compiled select expressions (non-aggregates)
66
- for (const { alias, compiledExpression } of compiledSelect) {
67
- selectResults[alias] = compiledExpression(namespacedRow)
70
+ /**
71
+ * Processes a non-merge operation by setting the field value at the specified alias path
72
+ */
73
+ function processNonMergeOp(
74
+ op: Extract<SelectOp, { kind: `field` }>,
75
+ namespacedRow: NamespacedRow,
76
+ selectResults: Record<string, any>
77
+ ): void {
78
+ // Support nested alias paths like "meta.author.name"
79
+ const path = op.alias.split(`.`)
80
+ if (path.length === 1) {
81
+ selectResults[op.alias] = op.compiled(namespacedRow)
82
+ } else {
83
+ let cursor: any = selectResults
84
+ for (let i = 0; i < path.length - 1; i++) {
85
+ const seg = path[i]!
86
+ const next = cursor[seg]
87
+ if (next == null || typeof next !== `object`) {
88
+ cursor[seg] = {}
68
89
  }
90
+ cursor = cursor[seg]
91
+ }
92
+ cursor[path[path.length - 1]!] = unwrapVal(op.compiled(namespacedRow))
93
+ }
94
+ }
69
95
 
70
- // Return the namespaced row with __select_results added
71
- return [
72
- key,
73
- {
74
- ...namespacedRow,
75
- __select_results: selectResults,
76
- },
77
- ] as [
78
- string,
79
- typeof namespacedRow & { __select_results: typeof selectResults },
80
- ]
81
- })
82
- )
96
+ /**
97
+ * Processes a single row to generate select results
98
+ */
99
+ function processRow(
100
+ [key, namespacedRow]: [unknown, NamespacedRow],
101
+ ops: Array<SelectOp>
102
+ ): [unknown, typeof namespacedRow & { __select_results: any }] {
103
+ const selectResults: Record<string, any> = {}
104
+
105
+ for (const op of ops) {
106
+ if (op.kind === `merge`) {
107
+ processMerge(op, namespacedRow, selectResults)
108
+ } else {
109
+ processNonMergeOp(op, namespacedRow, selectResults)
110
+ }
111
+ }
112
+
113
+ // Return the namespaced row with __select_results added
114
+ return [
115
+ key,
116
+ {
117
+ ...namespacedRow,
118
+ __select_results: selectResults,
119
+ },
120
+ ] as [
121
+ unknown,
122
+ typeof namespacedRow & { __select_results: typeof selectResults },
123
+ ]
83
124
  }
84
125
 
85
126
  /**
86
- * Processes the SELECT clause (legacy function - kept for compatibility)
127
+ * Processes the SELECT clause and places results in __select_results
128
+ * while preserving the original namespaced row for ORDER BY access
87
129
  */
88
130
  export function processSelect(
89
131
  pipeline: NamespacedAndKeyedStream,
90
132
  select: Select,
91
133
  _allInputs: Record<string, KeyedStream>
92
- ): KeyedStream {
93
- // Pre-compile all select expressions
94
- const compiledSelect: Array<{
95
- alias: string
96
- compiledExpression: (row: NamespacedRow) => any
97
- }> = []
98
- const spreadAliases: Array<string> = []
99
-
100
- for (const [alias, expression] of Object.entries(select)) {
101
- if (alias.startsWith(`__SPREAD_SENTINEL__`)) {
102
- // Extract the table alias from the sentinel key
103
- const tableAlias = alias.replace(`__SPREAD_SENTINEL__`, ``)
104
- spreadAliases.push(tableAlias)
105
- } else {
106
- if (isAggregateExpression(expression)) {
107
- // Aggregates should be handled by GROUP BY processing, not here
108
- throw new Error(
109
- `Aggregate expressions in SELECT clause should be handled by GROUP BY processing`
110
- )
111
- }
112
- compiledSelect.push({
113
- alias,
114
- compiledExpression: compileExpression(expression as BasicExpression),
115
- })
116
- }
117
- }
118
-
119
- return pipeline.pipe(
120
- map(([key, namespacedRow]) => {
121
- const result: Record<string, any> = {}
122
-
123
- // First pass: spread table data for any spread sentinels
124
- for (const tableAlias of spreadAliases) {
125
- const tableData = namespacedRow[tableAlias]
126
- if (tableData && typeof tableData === `object`) {
127
- // Spread the table data into the result, but don't overwrite explicit fields
128
- for (const [fieldName, fieldValue] of Object.entries(tableData)) {
129
- if (!(fieldName in result)) {
130
- result[fieldName] = fieldValue
131
- }
132
- }
133
- }
134
- }
134
+ ): NamespacedAndKeyedStream {
135
+ // Build ordered operations to preserve authoring order (spreads and fields)
136
+ const ops: Array<SelectOp> = []
135
137
 
136
- // Second pass: evaluate all compiled select expressions
137
- for (const { alias, compiledExpression } of compiledSelect) {
138
- result[alias] = compiledExpression(namespacedRow)
139
- }
138
+ addFromObject([], select, ops)
140
139
 
141
- return [key, result] as [string, typeof result]
142
- })
143
- )
140
+ return pipeline.pipe(map((row) => processRow(row, ops)))
144
141
  }
145
142
 
146
143
  /**
@@ -171,3 +168,99 @@ export function processArgument(
171
168
 
172
169
  return value
173
170
  }
171
+
172
+ /**
173
+ * Helper function to check if an object is a nested select object
174
+ *
175
+ * .select({
176
+ * id: users.id,
177
+ * profile: { // <-- this is a nested select object
178
+ * name: users.name,
179
+ * email: users.email
180
+ * }
181
+ * })
182
+ */
183
+ function isNestedSelectObject(obj: any): boolean {
184
+ return obj && typeof obj === `object` && !isExpressionLike(obj)
185
+ }
186
+
187
+ /**
188
+ * Helper function to process select objects and build operations array
189
+ */
190
+ function addFromObject(
191
+ prefixPath: Array<string>,
192
+ obj: any,
193
+ ops: Array<SelectOp>
194
+ ) {
195
+ for (const [key, value] of Object.entries(obj)) {
196
+ if (key.startsWith(`__SPREAD_SENTINEL__`)) {
197
+ const rest = key.slice(`__SPREAD_SENTINEL__`.length)
198
+ const splitIndex = rest.lastIndexOf(`__`)
199
+ const pathStr = splitIndex >= 0 ? rest.slice(0, splitIndex) : rest
200
+ const isRefExpr =
201
+ value &&
202
+ typeof value === `object` &&
203
+ `type` in (value as any) &&
204
+ (value as any).type === `ref`
205
+ if (pathStr.includes(`.`) || isRefExpr) {
206
+ // Merge into the current destination (prefixPath) from the referenced source path
207
+ const targetPath = [...prefixPath]
208
+ const expr = isRefExpr
209
+ ? (value as BasicExpression)
210
+ : (new PropRef(pathStr.split(`.`)) as BasicExpression)
211
+ const compiled = compileExpression(expr)
212
+ ops.push({ kind: `merge`, targetPath, source: compiled })
213
+ } else {
214
+ // Table-level: pathStr is the alias; merge from namespaced row at the current prefix
215
+ const tableAlias = pathStr
216
+ const targetPath = [...prefixPath]
217
+ ops.push({
218
+ kind: `merge`,
219
+ targetPath,
220
+ source: (row) => (row as any)[tableAlias],
221
+ })
222
+ }
223
+ continue
224
+ }
225
+
226
+ const expression = value as any
227
+ if (isNestedSelectObject(expression)) {
228
+ // Nested selection object
229
+ addFromObject([...prefixPath, key], expression, ops)
230
+ continue
231
+ }
232
+
233
+ if (isAggregateExpression(expression)) {
234
+ // Placeholder for group-by processing later
235
+ ops.push({
236
+ kind: `field`,
237
+ alias: [...prefixPath, key].join(`.`),
238
+ compiled: () => null,
239
+ })
240
+ } else {
241
+ if (expression === undefined || !isExpressionLike(expression)) {
242
+ ops.push({
243
+ kind: `field`,
244
+ alias: [...prefixPath, key].join(`.`),
245
+ compiled: () => expression,
246
+ })
247
+ continue
248
+ }
249
+ // If the expression is a Value wrapper, embed the literal to avoid re-compilation mishaps
250
+ if (expression instanceof ValClass) {
251
+ const val = expression.value
252
+ ops.push({
253
+ kind: `field`,
254
+ alias: [...prefixPath, key].join(`.`),
255
+ compiled: () => val,
256
+ })
257
+ } else {
258
+ ops.push({
259
+ kind: `field`,
260
+ alias: [...prefixPath, key].join(`.`),
261
+ compiled: compileExpression(expression as BasicExpression),
262
+ })
263
+ }
264
+ }
265
+ }
266
+ }
package/src/query/ir.ts CHANGED
@@ -27,7 +27,7 @@ export interface QueryIR {
27
27
  export type From = CollectionRef | QueryRef
28
28
 
29
29
  export type Select = {
30
- [alias: string]: BasicExpression | Aggregate
30
+ [alias: string]: BasicExpression | Aggregate | Select
31
31
  }
32
32
 
33
33
  export type Join = Array<JoinClause>
@@ -131,6 +131,19 @@ export class Aggregate<T = any> extends BaseExpression<T> {
131
131
  }
132
132
  }
133
133
 
134
+ /**
135
+ * Runtime helper to detect IR expression-like objects.
136
+ * Prefer this over ad-hoc local implementations to keep behavior consistent.
137
+ */
138
+ export function isExpressionLike(value: any): boolean {
139
+ return (
140
+ value instanceof Aggregate ||
141
+ value instanceof Func ||
142
+ value instanceof PropRef ||
143
+ value instanceof Value
144
+ )
145
+ }
146
+
134
147
  /**
135
148
  * Helper functions for working with Where clauses
136
149
  */
@@ -125,13 +125,14 @@ import { CannotCombineEmptyExpressionListError } from "../errors.js"
125
125
  import {
126
126
  CollectionRef as CollectionRefClass,
127
127
  Func,
128
+ PropRef,
128
129
  QueryRef as QueryRefClass,
129
130
  createResidualWhere,
130
131
  getWhereExpression,
131
132
  isResidualWhere,
132
133
  } from "./ir.js"
133
134
  import { isConvertibleToCollectionFilter } from "./compiler/expressions.js"
134
- import type { BasicExpression, From, QueryIR, Where } from "./ir.js"
135
+ import type { BasicExpression, From, QueryIR, Select, Where } from "./ir.js"
135
136
 
136
137
  /**
137
138
  * Represents a WHERE clause after source analysis
@@ -660,6 +661,7 @@ function applyOptimizations(
660
661
  orderBy: query.orderBy ? [...query.orderBy] : undefined,
661
662
  limit: query.limit,
662
663
  offset: query.offset,
664
+ distinct: query.distinct,
663
665
  fnSelect: query.fnSelect,
664
666
  fnWhere: query.fnWhere ? [...query.fnWhere] : undefined,
665
667
  fnHaving: query.fnHaving ? [...query.fnHaving] : undefined,
@@ -763,7 +765,7 @@ function optimizeFromWithTracking(
763
765
  // SAFETY CHECK: Only check safety when pushing WHERE clauses into existing subqueries
764
766
  // We need to be careful about pushing WHERE clauses into subqueries that already have
765
767
  // aggregates, HAVING, or ORDER BY + LIMIT since that could change their semantics
766
- if (!isSafeToPushIntoExistingSubquery(from.query)) {
768
+ if (!isSafeToPushIntoExistingSubquery(from.query, whereClause, from.alias)) {
767
769
  // Return a copy without optimization to maintain immutability
768
770
  // Do NOT mark as optimized since we didn't actually optimize it
769
771
  return new QueryRefClass(deepCopyQuery(from.query), from.alias)
@@ -780,73 +782,143 @@ function optimizeFromWithTracking(
780
782
  return new QueryRefClass(optimizedSubQuery, from.alias)
781
783
  }
782
784
 
785
+ function unsafeSelect(
786
+ query: QueryIR,
787
+ whereClause: BasicExpression<boolean>,
788
+ outerAlias: string
789
+ ): boolean {
790
+ if (!query.select) return false
791
+
792
+ return (
793
+ selectHasAggregates(query.select) ||
794
+ whereReferencesComputedSelectFields(query.select, whereClause, outerAlias)
795
+ )
796
+ }
797
+
798
+ function unsafeGroupBy(query: QueryIR) {
799
+ return query.groupBy && query.groupBy.length > 0
800
+ }
801
+
802
+ function unsafeHaving(query: QueryIR) {
803
+ return query.having && query.having.length > 0
804
+ }
805
+
806
+ function unsafeOrderBy(query: QueryIR) {
807
+ return (
808
+ query.orderBy &&
809
+ query.orderBy.length > 0 &&
810
+ (query.limit !== undefined || query.offset !== undefined)
811
+ )
812
+ }
813
+
814
+ function unsafeFnSelect(query: QueryIR) {
815
+ return (
816
+ query.fnSelect ||
817
+ (query.fnWhere && query.fnWhere.length > 0) ||
818
+ (query.fnHaving && query.fnHaving.length > 0)
819
+ )
820
+ }
821
+
822
+ function isSafeToPushIntoExistingSubquery(
823
+ query: QueryIR,
824
+ whereClause: BasicExpression<boolean>,
825
+ outerAlias: string
826
+ ): boolean {
827
+ return !(
828
+ unsafeSelect(query, whereClause, outerAlias) ||
829
+ unsafeGroupBy(query) ||
830
+ unsafeHaving(query) ||
831
+ unsafeOrderBy(query) ||
832
+ unsafeFnSelect(query)
833
+ )
834
+ }
835
+
783
836
  /**
784
- * Determines if it's safe to push WHERE clauses into an existing subquery.
785
- *
786
- * Pushing WHERE clauses into existing subqueries can break semantics in several cases:
787
- *
788
- * 1. **Aggregates**: Pushing predicates before GROUP BY changes what gets aggregated
789
- * 2. **ORDER BY + LIMIT/OFFSET**: Pushing predicates before sorting+limiting changes the result set
790
- * 3. **HAVING clauses**: These operate on aggregated data, predicates should not be pushed past them
791
- * 4. **Functional operations**: fnSelect, fnWhere, fnHaving could have side effects
792
- *
793
- * Note: This safety check only applies when pushing WHERE clauses into existing subqueries.
794
- * Creating new subqueries from collection references is always safe.
795
- *
796
- * @param query - The existing subquery to check for safety
797
- * @returns True if it's safe to push WHERE clauses into this subquery, false otherwise
837
+ * Detects whether a SELECT projection contains any aggregate expressions.
838
+ * Recursively traverses nested select objects.
798
839
  *
799
- * @example
800
- * ```typescript
801
- * // UNSAFE: has GROUP BY - pushing WHERE could change aggregation
802
- * { from: users, groupBy: [dept], select: { count: agg('count', '*') } }
803
- *
804
- * // UNSAFE: has ORDER BY + LIMIT - pushing WHERE could change "top 10"
805
- * { from: users, orderBy: [salary desc], limit: 10 }
806
- *
807
- * // SAFE: plain SELECT without aggregates/limits
808
- * { from: users, select: { id, name } }
809
- * ```
840
+ * @param select - The SELECT object from the IR
841
+ * @returns True if any field is an aggregate, false otherwise
810
842
  */
811
- function isSafeToPushIntoExistingSubquery(query: QueryIR): boolean {
812
- // Check for aggregates in SELECT clause
813
- if (query.select) {
814
- const hasAggregates = Object.values(query.select).some(
815
- (expr) => expr.type === `agg`
816
- )
817
- if (hasAggregates) {
818
- return false
843
+ function selectHasAggregates(select: Select): boolean {
844
+ for (const value of Object.values(select)) {
845
+ if (typeof value === `object`) {
846
+ const v: any = value
847
+ if (v.type === `agg`) return true
848
+ if (!(`type` in v)) {
849
+ if (selectHasAggregates(v as unknown as Select)) return true
850
+ }
819
851
  }
820
852
  }
853
+ return false
854
+ }
821
855
 
822
- // Check for GROUP BY clause
823
- if (query.groupBy && query.groupBy.length > 0) {
824
- return false
825
- }
826
-
827
- // Check for HAVING clause
828
- if (query.having && query.having.length > 0) {
829
- return false
856
+ /**
857
+ * Recursively collects all PropRef references from an expression.
858
+ *
859
+ * @param expr - The expression to traverse
860
+ * @returns Array of PropRef references found in the expression
861
+ */
862
+ function collectRefs(expr: any): Array<PropRef> {
863
+ const refs: Array<PropRef> = []
864
+
865
+ if (expr == null || typeof expr !== `object`) return refs
866
+
867
+ switch (expr.type) {
868
+ case `ref`:
869
+ refs.push(expr as PropRef)
870
+ break
871
+ case `func`:
872
+ case `agg`:
873
+ for (const arg of expr.args ?? []) {
874
+ refs.push(...collectRefs(arg))
875
+ }
876
+ break
877
+ default:
878
+ break
830
879
  }
831
880
 
832
- // Check for ORDER BY with LIMIT or OFFSET (dangerous combination)
833
- if (query.orderBy && query.orderBy.length > 0) {
834
- if (query.limit !== undefined || query.offset !== undefined) {
835
- return false
836
- }
837
- }
881
+ return refs
882
+ }
838
883
 
839
- // Check for functional variants that might have side effects
840
- if (
841
- query.fnSelect ||
842
- (query.fnWhere && query.fnWhere.length > 0) ||
843
- (query.fnHaving && query.fnHaving.length > 0)
844
- ) {
845
- return false
884
+ /**
885
+ * Determines whether the provided WHERE clause references fields that are
886
+ * computed by a subquery SELECT rather than pass-through properties.
887
+ *
888
+ * If true, pushing the WHERE clause into the subquery could change semantics
889
+ * (since computed fields do not necessarily exist at the subquery input level),
890
+ * so predicate pushdown must be avoided.
891
+ *
892
+ * @param select - The subquery SELECT map
893
+ * @param whereClause - The WHERE expression to analyze
894
+ * @param outerAlias - The alias of the subquery in the outer query
895
+ * @returns True if WHERE references computed fields, otherwise false
896
+ */
897
+ function whereReferencesComputedSelectFields(
898
+ select: Select,
899
+ whereClause: BasicExpression<boolean>,
900
+ outerAlias: string
901
+ ): boolean {
902
+ // Build a set of computed field names at the top-level of the subquery output
903
+ const computed = new Set<string>()
904
+ for (const [key, value] of Object.entries(select)) {
905
+ if (key.startsWith(`__SPREAD_SENTINEL__`)) continue
906
+ if (value instanceof PropRef) continue
907
+ // Nested object or non-PropRef expression counts as computed
908
+ computed.add(key)
909
+ }
910
+
911
+ const refs = collectRefs(whereClause)
912
+
913
+ for (const ref of refs) {
914
+ const path = (ref as any).path as Array<string>
915
+ if (!Array.isArray(path) || path.length < 2) continue
916
+ const alias = path[0]
917
+ const field = path[1] as string
918
+ if (alias !== outerAlias) continue
919
+ if (computed.has(field)) return true
846
920
  }
847
-
848
- // If none of the unsafe conditions are present, it's safe to optimize
849
- return true
921
+ return false
850
922
  }
851
923
 
852
924
  /**