@tanstack/db 0.1.12 → 0.2.1
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/dist/cjs/errors.cjs +18 -6
- package/dist/cjs/errors.cjs.map +1 -1
- package/dist/cjs/errors.d.cts +9 -3
- package/dist/cjs/index.cjs +5 -1
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/query/builder/functions.cjs +12 -1
- package/dist/cjs/query/builder/functions.cjs.map +1 -1
- package/dist/cjs/query/builder/functions.d.cts +36 -21
- package/dist/cjs/query/builder/index.cjs +25 -16
- package/dist/cjs/query/builder/index.cjs.map +1 -1
- package/dist/cjs/query/builder/index.d.cts +8 -8
- package/dist/cjs/query/builder/ref-proxy.cjs +12 -8
- package/dist/cjs/query/builder/ref-proxy.cjs.map +1 -1
- package/dist/cjs/query/builder/ref-proxy.d.cts +2 -1
- package/dist/cjs/query/builder/types.d.cts +493 -28
- package/dist/cjs/query/compiler/evaluators.cjs +15 -0
- package/dist/cjs/query/compiler/evaluators.cjs.map +1 -1
- package/dist/cjs/query/compiler/group-by.cjs +4 -2
- package/dist/cjs/query/compiler/group-by.cjs.map +1 -1
- package/dist/cjs/query/compiler/index.cjs +13 -4
- package/dist/cjs/query/compiler/index.cjs.map +1 -1
- package/dist/cjs/query/compiler/joins.cjs +70 -60
- package/dist/cjs/query/compiler/joins.cjs.map +1 -1
- package/dist/cjs/query/compiler/select.cjs +131 -42
- package/dist/cjs/query/compiler/select.cjs.map +1 -1
- package/dist/cjs/query/compiler/select.d.cts +1 -5
- package/dist/cjs/query/index.d.cts +1 -1
- package/dist/cjs/query/ir.cjs +4 -0
- package/dist/cjs/query/ir.cjs.map +1 -1
- package/dist/cjs/query/ir.d.cts +6 -1
- package/dist/cjs/query/optimizer.cjs +61 -20
- package/dist/cjs/query/optimizer.cjs.map +1 -1
- package/dist/esm/errors.d.ts +9 -3
- package/dist/esm/errors.js +18 -6
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.js +7 -3
- package/dist/esm/query/builder/functions.d.ts +36 -21
- package/dist/esm/query/builder/functions.js +12 -1
- package/dist/esm/query/builder/functions.js.map +1 -1
- package/dist/esm/query/builder/index.d.ts +8 -8
- package/dist/esm/query/builder/index.js +27 -18
- package/dist/esm/query/builder/index.js.map +1 -1
- package/dist/esm/query/builder/ref-proxy.d.ts +2 -1
- package/dist/esm/query/builder/ref-proxy.js +12 -8
- package/dist/esm/query/builder/ref-proxy.js.map +1 -1
- package/dist/esm/query/builder/types.d.ts +493 -28
- package/dist/esm/query/compiler/evaluators.js +15 -0
- package/dist/esm/query/compiler/evaluators.js.map +1 -1
- package/dist/esm/query/compiler/group-by.js +4 -2
- package/dist/esm/query/compiler/group-by.js.map +1 -1
- package/dist/esm/query/compiler/index.js +15 -6
- package/dist/esm/query/compiler/index.js.map +1 -1
- package/dist/esm/query/compiler/joins.js +71 -61
- package/dist/esm/query/compiler/joins.js.map +1 -1
- package/dist/esm/query/compiler/select.d.ts +1 -5
- package/dist/esm/query/compiler/select.js +131 -42
- package/dist/esm/query/compiler/select.js.map +1 -1
- package/dist/esm/query/index.d.ts +1 -1
- package/dist/esm/query/ir.d.ts +6 -1
- package/dist/esm/query/ir.js +4 -0
- package/dist/esm/query/ir.js.map +1 -1
- package/dist/esm/query/optimizer.js +62 -21
- package/dist/esm/query/optimizer.js.map +1 -1
- package/package.json +2 -2
- package/src/errors.ts +17 -10
- package/src/query/builder/functions.ts +166 -108
- package/src/query/builder/index.ts +68 -48
- package/src/query/builder/ref-proxy.ts +14 -20
- package/src/query/builder/types.ts +622 -110
- package/src/query/compiler/evaluators.ts +16 -0
- package/src/query/compiler/group-by.ts +6 -1
- package/src/query/compiler/index.ts +23 -6
- package/src/query/compiler/joins.ts +132 -101
- package/src/query/compiler/select.ts +206 -113
- package/src/query/index.ts +2 -0
- package/src/query/ir.ts +14 -1
- 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
|
-
*
|
|
12
|
-
* while preserving the original namespaced row for ORDER BY access
|
|
12
|
+
* Type for operations array used in select processing
|
|
13
13
|
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
):
|
|
93
|
-
//
|
|
94
|
-
const
|
|
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
|
-
|
|
137
|
-
for (const { alias, compiledExpression } of compiledSelect) {
|
|
138
|
-
result[alias] = compiledExpression(namespacedRow)
|
|
139
|
-
}
|
|
138
|
+
addFromObject([], select, ops)
|
|
140
139
|
|
|
141
|
-
|
|
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/index.ts
CHANGED
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
|
*/
|
package/src/query/optimizer.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
* @
|
|
800
|
-
*
|
|
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
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
(
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
if (query.limit !== undefined || query.offset !== undefined) {
|
|
835
|
-
return false
|
|
836
|
-
}
|
|
837
|
-
}
|
|
881
|
+
return refs
|
|
882
|
+
}
|
|
838
883
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
/**
|