frappebun 0.0.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.
- package/README.md +72 -0
- package/package.json +59 -0
- package/src/api/auth.ts +76 -0
- package/src/api/index.ts +10 -0
- package/src/api/resource.ts +177 -0
- package/src/api/route.ts +301 -0
- package/src/app/index.ts +6 -0
- package/src/app/loader.ts +218 -0
- package/src/auth/auth.ts +247 -0
- package/src/auth/index.ts +2 -0
- package/src/cli/args.ts +40 -0
- package/src/cli/bin.ts +12 -0
- package/src/cli/commands/add-api.ts +32 -0
- package/src/cli/commands/add-doctype.ts +43 -0
- package/src/cli/commands/add-page.ts +33 -0
- package/src/cli/commands/add-user.ts +96 -0
- package/src/cli/commands/dev.ts +71 -0
- package/src/cli/commands/drop-site.ts +27 -0
- package/src/cli/commands/init.ts +98 -0
- package/src/cli/commands/migrate.ts +110 -0
- package/src/cli/commands/new-site.ts +61 -0
- package/src/cli/commands/routes.ts +56 -0
- package/src/cli/commands/use.ts +30 -0
- package/src/cli/index.ts +73 -0
- package/src/cli/log.ts +13 -0
- package/src/cli/scaffold/templates.ts +189 -0
- package/src/context.ts +162 -0
- package/src/core/doctype/migration/migration.ts +17 -0
- package/src/core/doctype/role/role.ts +7 -0
- package/src/core/doctype/session/session.ts +16 -0
- package/src/core/doctype/user/user.controller.ts +11 -0
- package/src/core/doctype/user/user.ts +22 -0
- package/src/core/doctype/user_role/user_role.ts +9 -0
- package/src/core/doctypes.ts +25 -0
- package/src/core/index.ts +1 -0
- package/src/database/database.ts +359 -0
- package/src/database/filters.ts +131 -0
- package/src/database/index.ts +30 -0
- package/src/database/query-builder.ts +1118 -0
- package/src/database/schema.ts +188 -0
- package/src/doctype/define.ts +45 -0
- package/src/doctype/discovery.ts +57 -0
- package/src/doctype/field.ts +160 -0
- package/src/doctype/index.ts +20 -0
- package/src/doctype/layout.ts +62 -0
- package/src/doctype/query-builder-stub.ts +16 -0
- package/src/doctype/registry.ts +106 -0
- package/src/doctype/types.ts +407 -0
- package/src/document/document.ts +593 -0
- package/src/document/index.ts +6 -0
- package/src/document/naming.ts +56 -0
- package/src/errors.ts +53 -0
- package/src/frappe.d.ts +128 -0
- package/src/globals.ts +72 -0
- package/src/index.ts +112 -0
- package/src/migrations/index.ts +11 -0
- package/src/migrations/runner.ts +256 -0
- package/src/permissions/index.ts +265 -0
- package/src/response.ts +100 -0
- package/src/server.ts +210 -0
- package/src/site.ts +126 -0
- package/src/ssr/handler.ts +56 -0
- package/src/ssr/index.ts +11 -0
- package/src/ssr/page-loader.ts +200 -0
- package/src/ssr/renderer.ts +94 -0
- package/src/ssr/use-context.ts +41 -0
|
@@ -0,0 +1,1118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QueryBuilder — type-safe, immutable, dialect-agnostic query construction.
|
|
3
|
+
*
|
|
4
|
+
* Entry point: `frappe.query(doctype)` returns a new QueryBuilder.
|
|
5
|
+
* Every method returns a *new* instance — builders are safe to branch.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Database as BunSQLite } from "bun:sqlite"
|
|
9
|
+
import type { SQLQueryBindings } from "bun:sqlite"
|
|
10
|
+
|
|
11
|
+
import { toTableName, getDocTypeMeta } from "../doctype/registry"
|
|
12
|
+
import type { FilterInput } from "../doctype/types"
|
|
13
|
+
import { DoesNotExistError } from "../errors"
|
|
14
|
+
import { parseFilters } from "./filters"
|
|
15
|
+
|
|
16
|
+
type Binding = SQLQueryBindings
|
|
17
|
+
export type Connector = "AND" | "OR"
|
|
18
|
+
|
|
19
|
+
// ─── AST node types ───────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export type WhereNode =
|
|
22
|
+
| { kind: "val"; col: string; op: string; value: unknown }
|
|
23
|
+
| { kind: "col_col"; col1: string; op: string; col2: string }
|
|
24
|
+
| { kind: "in"; col: string; values: unknown[] | QueryBuilder; negate: boolean }
|
|
25
|
+
| { kind: "between"; col: string; min: unknown; max: unknown; negate: boolean }
|
|
26
|
+
| { kind: "like"; col: string; pattern: string; ilike: boolean }
|
|
27
|
+
| { kind: "null"; col: string; negate: boolean }
|
|
28
|
+
| { kind: "exists"; subquery: QueryBuilder; negate: boolean }
|
|
29
|
+
| { kind: "group"; nodes: ConnectedWhere[] }
|
|
30
|
+
| { kind: "filters"; input: FilterInput }
|
|
31
|
+
|
|
32
|
+
export interface ConnectedWhere {
|
|
33
|
+
node: WhereNode
|
|
34
|
+
connector: Connector // how this node connects to the previous
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface OrderByNode {
|
|
38
|
+
field?: string
|
|
39
|
+
subquery?: QueryBuilder
|
|
40
|
+
fn?: FnExpr
|
|
41
|
+
dir: "asc" | "desc"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface HavingNode {
|
|
45
|
+
expr: string | FnExpr
|
|
46
|
+
op: string
|
|
47
|
+
value: unknown
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface JoinDef {
|
|
51
|
+
type: "inner" | "left"
|
|
52
|
+
doctype: string
|
|
53
|
+
alias?: string
|
|
54
|
+
condition: (on: OnCondition) => OnCondition
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CteDef {
|
|
58
|
+
name: string
|
|
59
|
+
builder: QueryBuilder
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface UnionDef {
|
|
63
|
+
query: QueryBuilder
|
|
64
|
+
all: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface BuilderState {
|
|
68
|
+
doctype: string
|
|
69
|
+
tableAlias?: string
|
|
70
|
+
fromTable?: string // override FROM (for CTE refs)
|
|
71
|
+
selects: (string | FnExpr | QueryBuilder)[]
|
|
72
|
+
isDistinct: boolean
|
|
73
|
+
distinctFields: string[]
|
|
74
|
+
wheres: ConnectedWhere[]
|
|
75
|
+
joins: JoinDef[]
|
|
76
|
+
orderBys: OrderByNode[]
|
|
77
|
+
groupBys: string[]
|
|
78
|
+
havings: HavingNode[]
|
|
79
|
+
limitVal?: number
|
|
80
|
+
offsetVal?: number
|
|
81
|
+
unions: UnionDef[]
|
|
82
|
+
ctes: CteDef[]
|
|
83
|
+
forUpdate: boolean
|
|
84
|
+
skipLocked: boolean
|
|
85
|
+
noWait: boolean
|
|
86
|
+
timeoutMs?: number
|
|
87
|
+
subqueryAlias?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function emptyState(doctype = ""): BuilderState {
|
|
91
|
+
return {
|
|
92
|
+
doctype,
|
|
93
|
+
selects: [],
|
|
94
|
+
isDistinct: false,
|
|
95
|
+
distinctFields: [],
|
|
96
|
+
wheres: [],
|
|
97
|
+
joins: [],
|
|
98
|
+
orderBys: [],
|
|
99
|
+
groupBys: [],
|
|
100
|
+
havings: [],
|
|
101
|
+
unions: [],
|
|
102
|
+
ctes: [],
|
|
103
|
+
forUpdate: false,
|
|
104
|
+
skipLocked: false,
|
|
105
|
+
noWait: false,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── FnExpr — aggregation expression ─────────────────────
|
|
110
|
+
|
|
111
|
+
export class FnExpr {
|
|
112
|
+
constructor(
|
|
113
|
+
public readonly func: string,
|
|
114
|
+
public readonly arg?: string,
|
|
115
|
+
public readonly fallback?: unknown,
|
|
116
|
+
public readonly alias?: string,
|
|
117
|
+
) {}
|
|
118
|
+
|
|
119
|
+
as(alias: string): FnExpr {
|
|
120
|
+
return new FnExpr(this.func, this.arg, this.fallback, alias)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Compile to SQL, pushing any needed bindings. */
|
|
124
|
+
toSQL(bindings: unknown[]): string {
|
|
125
|
+
let inner: string
|
|
126
|
+
switch (this.func) {
|
|
127
|
+
case "COUNT":
|
|
128
|
+
inner = this.arg && this.arg !== "*" ? `COUNT("${this.arg}")` : "COUNT(*)"
|
|
129
|
+
break
|
|
130
|
+
case "SUM":
|
|
131
|
+
case "AVG":
|
|
132
|
+
case "MIN":
|
|
133
|
+
case "MAX":
|
|
134
|
+
inner = `${this.func}("${this.arg}")`
|
|
135
|
+
break
|
|
136
|
+
case "COALESCE":
|
|
137
|
+
case "IFNULL":
|
|
138
|
+
bindings.push(this.fallback)
|
|
139
|
+
inner = `${this.func}("${this.arg}", ?)`
|
|
140
|
+
break
|
|
141
|
+
default:
|
|
142
|
+
inner = `${this.func}("${this.arg}")`
|
|
143
|
+
}
|
|
144
|
+
return this.alias ? `${inner} AS "${this.alias}"` : inner
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Aggregation factory ──────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function parseFnAlias(arg: string): [string, string | undefined] {
|
|
151
|
+
const m = arg.match(/^(.+?)\s+as\s+(\w+)$/i)
|
|
152
|
+
return m ? [m[1]!.trim(), m[2]!.trim()] : [arg.trim(), undefined]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const fn = {
|
|
156
|
+
count(field?: string): FnExpr {
|
|
157
|
+
return new FnExpr("COUNT", field ?? "*")
|
|
158
|
+
},
|
|
159
|
+
sum(arg: string): FnExpr {
|
|
160
|
+
const [f, a] = parseFnAlias(arg)
|
|
161
|
+
return new FnExpr("SUM", f, undefined, a)
|
|
162
|
+
},
|
|
163
|
+
avg(arg: string): FnExpr {
|
|
164
|
+
const [f, a] = parseFnAlias(arg)
|
|
165
|
+
return new FnExpr("AVG", f, undefined, a)
|
|
166
|
+
},
|
|
167
|
+
min(arg: string): FnExpr {
|
|
168
|
+
const [f, a] = parseFnAlias(arg)
|
|
169
|
+
return new FnExpr("MIN", f, undefined, a)
|
|
170
|
+
},
|
|
171
|
+
max(arg: string): FnExpr {
|
|
172
|
+
const [f, a] = parseFnAlias(arg)
|
|
173
|
+
return new FnExpr("MAX", f, undefined, a)
|
|
174
|
+
},
|
|
175
|
+
coalesce(field: string, fallback: unknown): FnExpr {
|
|
176
|
+
return new FnExpr("COALESCE", field, fallback)
|
|
177
|
+
},
|
|
178
|
+
ifNull(field: string, fallback: unknown): FnExpr {
|
|
179
|
+
return new FnExpr("IFNULL", field, fallback)
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The `q` ambient namespace — `q.fn.sum("grandTotal")` etc. */
|
|
184
|
+
export const q = { fn }
|
|
185
|
+
|
|
186
|
+
// ─── OnCondition — for explicit JOIN conditions ───────────
|
|
187
|
+
|
|
188
|
+
export class OnCondition {
|
|
189
|
+
private parts: string[] = []
|
|
190
|
+
|
|
191
|
+
eq(col1: string, col2: string): this {
|
|
192
|
+
this.parts.push(`${qualifyCol(col1)} = ${qualifyCol(col2)}`)
|
|
193
|
+
return this
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
andOnVal(col: string, op: string, value: unknown): this {
|
|
197
|
+
this.parts.push(`${qualifyCol(col)} ${op} ${sqlLiteral(value)}`)
|
|
198
|
+
return this
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
toSQL(): string {
|
|
202
|
+
return this.parts.join(" AND ")
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─── Paginator ────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
export class Paginator<T> {
|
|
209
|
+
constructor(
|
|
210
|
+
public readonly data: T[],
|
|
211
|
+
public readonly total: number,
|
|
212
|
+
public readonly currentPage: number,
|
|
213
|
+
public readonly perPage: number,
|
|
214
|
+
) {}
|
|
215
|
+
|
|
216
|
+
get lastPage(): number {
|
|
217
|
+
return Math.max(1, Math.ceil(this.total / this.perPage))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
get hasMorePages(): boolean {
|
|
221
|
+
return this.currentPage < this.lastPage
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── QueryBuilder ─────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
export class QueryBuilder {
|
|
228
|
+
constructor(
|
|
229
|
+
/** Raw BunSQLite connection — null for subquery-only builders. */
|
|
230
|
+
private readonly _db: BunSQLite | null,
|
|
231
|
+
/** Immutable query state. */
|
|
232
|
+
public readonly _state: BuilderState,
|
|
233
|
+
) {}
|
|
234
|
+
|
|
235
|
+
// ── clone helpers ───────────────────────────────────
|
|
236
|
+
|
|
237
|
+
private clone(patch: Partial<BuilderState>): QueryBuilder {
|
|
238
|
+
return new QueryBuilder(this._db, { ...this._state, ...patch })
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private addWhere(node: WhereNode, connector: Connector = "AND"): QueryBuilder {
|
|
242
|
+
return this.clone({ wheres: [...this._state.wheres, { node, connector }] })
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── SELECT ──────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
select(
|
|
248
|
+
...exprs: (string | FnExpr | QueryBuilder | (string | FnExpr | QueryBuilder)[])[]
|
|
249
|
+
): QueryBuilder {
|
|
250
|
+
return this.clone({ selects: exprs.flat() as (string | FnExpr | QueryBuilder)[] })
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
distinct(...fields: string[]): QueryBuilder {
|
|
254
|
+
return fields.length === 0
|
|
255
|
+
? this.clone({ isDistinct: true })
|
|
256
|
+
: this.clone({ isDistinct: true, distinctFields: fields })
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── WHERE ────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
where(field: string, value: unknown): QueryBuilder
|
|
262
|
+
where(field: string, op: string, value: unknown): QueryBuilder
|
|
263
|
+
where(callback: (q: QueryBuilder) => QueryBuilder): QueryBuilder
|
|
264
|
+
where(filters: FilterInput): QueryBuilder
|
|
265
|
+
where(
|
|
266
|
+
a: string | ((q: QueryBuilder) => QueryBuilder) | FilterInput,
|
|
267
|
+
b?: unknown,
|
|
268
|
+
c?: unknown,
|
|
269
|
+
): QueryBuilder {
|
|
270
|
+
return this._where(a, b, c, "AND")
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
orWhere(field: string, value: unknown): QueryBuilder
|
|
274
|
+
orWhere(field: string, op: string, value: unknown): QueryBuilder
|
|
275
|
+
orWhere(callback: (q: QueryBuilder) => QueryBuilder): QueryBuilder
|
|
276
|
+
orWhere(a: string | ((q: QueryBuilder) => QueryBuilder), b?: unknown, c?: unknown): QueryBuilder {
|
|
277
|
+
return this._where(a, b, c, "OR")
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private _where(a: unknown, b: unknown, c: unknown, connector: Connector): QueryBuilder {
|
|
281
|
+
// Callback → grouped conditions
|
|
282
|
+
if (typeof a === "function") {
|
|
283
|
+
const inner = new QueryBuilder(null, emptyState(this._state.doctype))
|
|
284
|
+
const result = (a as (q: QueryBuilder) => QueryBuilder)(inner)
|
|
285
|
+
return this.addWhere({ kind: "group", nodes: result._state.wheres }, connector)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// FilterInput (object or array)
|
|
289
|
+
if (a !== null && (typeof a === "object" || Array.isArray(a))) {
|
|
290
|
+
return this.addWhere({ kind: "filters", input: a as FilterInput }, connector)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const field = a as string
|
|
294
|
+
|
|
295
|
+
if (c === undefined) {
|
|
296
|
+
// where(field, value) — equality or operator alias
|
|
297
|
+
const val = b
|
|
298
|
+
return this.addWhere({ kind: "val", col: field, op: "=", value: val }, connector)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// where(field, op, value)
|
|
302
|
+
const op = b as string
|
|
303
|
+
const value = c
|
|
304
|
+
|
|
305
|
+
// Shorthand operators forwarded to dedicated node types
|
|
306
|
+
const opLower = op.toLowerCase()
|
|
307
|
+
if (opLower === "in")
|
|
308
|
+
return this.addWhere(
|
|
309
|
+
{ kind: "in", col: field, values: value as unknown[], negate: false },
|
|
310
|
+
connector,
|
|
311
|
+
)
|
|
312
|
+
if (opLower === "not in")
|
|
313
|
+
return this.addWhere(
|
|
314
|
+
{ kind: "in", col: field, values: value as unknown[], negate: true },
|
|
315
|
+
connector,
|
|
316
|
+
)
|
|
317
|
+
if (opLower === "between") {
|
|
318
|
+
const [min, max] = value as [unknown, unknown]
|
|
319
|
+
return this.addWhere({ kind: "between", col: field, min, max, negate: false }, connector)
|
|
320
|
+
}
|
|
321
|
+
if (opLower === "like")
|
|
322
|
+
return this.addWhere(
|
|
323
|
+
{ kind: "like", col: field, pattern: value as string, ilike: false },
|
|
324
|
+
connector,
|
|
325
|
+
)
|
|
326
|
+
if (opLower === "ilike")
|
|
327
|
+
return this.addWhere(
|
|
328
|
+
{ kind: "like", col: field, pattern: value as string, ilike: true },
|
|
329
|
+
connector,
|
|
330
|
+
)
|
|
331
|
+
if (opLower === "is") {
|
|
332
|
+
if (value === "set")
|
|
333
|
+
return this.addWhere({ kind: "null", col: field, negate: true }, connector)
|
|
334
|
+
if (value === "not set")
|
|
335
|
+
return this.addWhere({ kind: "null", col: field, negate: false }, connector)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return this.addWhere({ kind: "val", col: field, op, value }, connector)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
whereIn(field: string, values: unknown[] | QueryBuilder): QueryBuilder {
|
|
342
|
+
return this.addWhere({ kind: "in", col: field, values, negate: false })
|
|
343
|
+
}
|
|
344
|
+
whereNotIn(field: string, values: unknown[] | QueryBuilder): QueryBuilder {
|
|
345
|
+
return this.addWhere({ kind: "in", col: field, values, negate: true })
|
|
346
|
+
}
|
|
347
|
+
orWhereIn(field: string, values: unknown[] | QueryBuilder): QueryBuilder {
|
|
348
|
+
return this.addWhere({ kind: "in", col: field, values, negate: false }, "OR")
|
|
349
|
+
}
|
|
350
|
+
orWhereNotIn(field: string, values: unknown[] | QueryBuilder): QueryBuilder {
|
|
351
|
+
return this.addWhere({ kind: "in", col: field, values, negate: true }, "OR")
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
whereBetween(field: string, [min, max]: [unknown, unknown]): QueryBuilder {
|
|
355
|
+
return this.addWhere({ kind: "between", col: field, min, max, negate: false })
|
|
356
|
+
}
|
|
357
|
+
whereNotBetween(field: string, [min, max]: [unknown, unknown]): QueryBuilder {
|
|
358
|
+
return this.addWhere({ kind: "between", col: field, min, max, negate: true })
|
|
359
|
+
}
|
|
360
|
+
orWhereBetween(field: string, range: [unknown, unknown]): QueryBuilder {
|
|
361
|
+
return this.addWhere(
|
|
362
|
+
{ kind: "between", col: field, min: range[0], max: range[1], negate: false },
|
|
363
|
+
"OR",
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
whereLike(field: string, pattern: string): QueryBuilder {
|
|
368
|
+
return this.addWhere({ kind: "like", col: field, pattern, ilike: false })
|
|
369
|
+
}
|
|
370
|
+
whereILike(field: string, pattern: string): QueryBuilder {
|
|
371
|
+
return this.addWhere({ kind: "like", col: field, pattern, ilike: true })
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
whereNull(field: string): QueryBuilder {
|
|
375
|
+
return this.addWhere({ kind: "null", col: field, negate: false })
|
|
376
|
+
}
|
|
377
|
+
whereNotNull(field: string): QueryBuilder {
|
|
378
|
+
return this.addWhere({ kind: "null", col: field, negate: true })
|
|
379
|
+
}
|
|
380
|
+
orWhereNull(field: string): QueryBuilder {
|
|
381
|
+
return this.addWhere({ kind: "null", col: field, negate: false }, "OR")
|
|
382
|
+
}
|
|
383
|
+
orWhereNotNull(field: string): QueryBuilder {
|
|
384
|
+
return this.addWhere({ kind: "null", col: field, negate: true }, "OR")
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
whereColumn(field1: string, opOrField2: string, field2?: string): QueryBuilder {
|
|
388
|
+
return field2 === undefined
|
|
389
|
+
? this.addWhere({ kind: "col_col", col1: field1, op: "=", col2: opOrField2 })
|
|
390
|
+
: this.addWhere({ kind: "col_col", col1: field1, op: opOrField2, col2: field2 })
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
whereExists(subquery: QueryBuilder): QueryBuilder {
|
|
394
|
+
return this.addWhere({ kind: "exists", subquery, negate: false })
|
|
395
|
+
}
|
|
396
|
+
whereNotExists(subquery: QueryBuilder): QueryBuilder {
|
|
397
|
+
return this.addWhere({ kind: "exists", subquery, negate: true })
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── ORDER BY ────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
orderBy(
|
|
403
|
+
fieldOrArrayOrExpr: string | OrderByNode[] | QueryBuilder | FnExpr,
|
|
404
|
+
dir?: "asc" | "desc",
|
|
405
|
+
): QueryBuilder {
|
|
406
|
+
if (Array.isArray(fieldOrArrayOrExpr)) return this.clone({ orderBys: fieldOrArrayOrExpr })
|
|
407
|
+
const node: OrderByNode =
|
|
408
|
+
fieldOrArrayOrExpr instanceof QueryBuilder
|
|
409
|
+
? { subquery: fieldOrArrayOrExpr, dir: dir ?? "asc" }
|
|
410
|
+
: fieldOrArrayOrExpr instanceof FnExpr
|
|
411
|
+
? { fn: fieldOrArrayOrExpr, dir: dir ?? "asc" }
|
|
412
|
+
: { field: fieldOrArrayOrExpr, dir: dir ?? "asc" }
|
|
413
|
+
return this.clone({ orderBys: [...this._state.orderBys, node] })
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── LIMIT / OFFSET ──────────────────────────────────
|
|
417
|
+
|
|
418
|
+
limit(n: number): QueryBuilder {
|
|
419
|
+
return this.clone({ limitVal: n })
|
|
420
|
+
}
|
|
421
|
+
offset(n: number): QueryBuilder {
|
|
422
|
+
return this.clone({ offsetVal: n })
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── GROUP BY / HAVING ───────────────────────────────
|
|
426
|
+
|
|
427
|
+
groupBy(...fields: string[]): QueryBuilder {
|
|
428
|
+
return this.clone({ groupBys: [...this._state.groupBys, ...fields] })
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
having(exprOrField: string | FnExpr, op: string, value: unknown): QueryBuilder {
|
|
432
|
+
return this.clone({ havings: [...this._state.havings, { expr: exprOrField, op, value }] })
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── EXPLICIT JOINS ──────────────────────────────────
|
|
436
|
+
|
|
437
|
+
join(
|
|
438
|
+
doctype: string,
|
|
439
|
+
aliasOrCond: string | ((on: OnCondition) => OnCondition),
|
|
440
|
+
condFn?: (on: OnCondition) => OnCondition,
|
|
441
|
+
): QueryBuilder {
|
|
442
|
+
return this._join("inner", doctype, aliasOrCond, condFn)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
leftJoin(
|
|
446
|
+
doctype: string,
|
|
447
|
+
aliasOrCond: string | ((on: OnCondition) => OnCondition),
|
|
448
|
+
condFn?: (on: OnCondition) => OnCondition,
|
|
449
|
+
): QueryBuilder {
|
|
450
|
+
return this._join("left", doctype, aliasOrCond, condFn)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private _join(
|
|
454
|
+
type: "inner" | "left",
|
|
455
|
+
doctype: string,
|
|
456
|
+
aliasOrCond: string | ((on: OnCondition) => OnCondition),
|
|
457
|
+
condFn?: (on: OnCondition) => OnCondition,
|
|
458
|
+
): QueryBuilder {
|
|
459
|
+
const alias = typeof aliasOrCond === "string" ? aliasOrCond : undefined
|
|
460
|
+
const condition = typeof aliasOrCond === "function" ? aliasOrCond : condFn!
|
|
461
|
+
return this.clone({ joins: [...this._state.joins, { type, doctype, alias, condition }] })
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── UNION ────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
union(query: QueryBuilder): QueryBuilder {
|
|
467
|
+
return this.clone({ unions: [...this._state.unions, { query, all: false }] })
|
|
468
|
+
}
|
|
469
|
+
unionAll(query: QueryBuilder): QueryBuilder {
|
|
470
|
+
return this.clone({ unions: [...this._state.unions, { query, all: true }] })
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── CTE ──────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
with(name: string, cb: (q: QueryBuilder) => QueryBuilder): QueryBuilder {
|
|
476
|
+
const result = cb(new QueryBuilder(this._db, emptyState()))
|
|
477
|
+
return this.clone({ ctes: [...this._state.ctes, { name, builder: result }] })
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
from(table: string): QueryBuilder {
|
|
481
|
+
return this.clone({ fromTable: table })
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ── LOCKING (no-op on SQLite) ────────────────────────
|
|
485
|
+
|
|
486
|
+
forUpdate(): QueryBuilder {
|
|
487
|
+
return this.clone({ forUpdate: true })
|
|
488
|
+
}
|
|
489
|
+
skipLocked(): QueryBuilder {
|
|
490
|
+
return this.clone({ skipLocked: true })
|
|
491
|
+
}
|
|
492
|
+
noWait(): QueryBuilder {
|
|
493
|
+
return this.clone({ noWait: true })
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── MISC ─────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
timeout(ms: number): QueryBuilder {
|
|
499
|
+
return this.clone({ timeoutMs: ms })
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Set alias for when this builder is used as a subquery in SELECT. */
|
|
503
|
+
as(alias: string): QueryBuilder {
|
|
504
|
+
return this.clone({ subqueryAlias: alias })
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ── TERMINALS ────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
async all<T extends Record<string, unknown> = Record<string, unknown>>(): Promise<T[]> {
|
|
510
|
+
const { sql, bindings } = this.toSQL()
|
|
511
|
+
return this._exec<T>(sql, bindings)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async first<T extends Record<string, unknown> = Record<string, unknown>>(): Promise<T | null> {
|
|
515
|
+
const { sql, bindings } = this.limit(1).toSQL()
|
|
516
|
+
const rows = await this._exec<T>(sql, bindings)
|
|
517
|
+
return rows[0] ?? null
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async firstOrFail<T extends Record<string, unknown> = Record<string, unknown>>(): Promise<T> {
|
|
521
|
+
const row = await this.first<T>()
|
|
522
|
+
if (row == null) throw new DoesNotExistError(`${this._state.doctype} not found`)
|
|
523
|
+
return row
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async pluck(field: string): Promise<unknown[]> {
|
|
527
|
+
const rows = await this.select(field).all()
|
|
528
|
+
return rows.map((r) => r[field])
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async count(): Promise<number> {
|
|
532
|
+
const { sql, bindings } = this.clone({
|
|
533
|
+
selects: [fn.count().as("_count")],
|
|
534
|
+
orderBys: [],
|
|
535
|
+
limitVal: undefined,
|
|
536
|
+
offsetVal: undefined,
|
|
537
|
+
}).toSQL()
|
|
538
|
+
const rows = await this._exec<{ _count: number }>(sql, bindings)
|
|
539
|
+
return rows[0]?._count ?? 0
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async exists(): Promise<boolean> {
|
|
543
|
+
const { sql, bindings } = this.clone({
|
|
544
|
+
selects: ["1 as _e"],
|
|
545
|
+
orderBys: [],
|
|
546
|
+
limitVal: 1,
|
|
547
|
+
offsetVal: undefined,
|
|
548
|
+
}).toSQL()
|
|
549
|
+
const rows = await this._exec(sql, bindings)
|
|
550
|
+
return rows.length > 0
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async sum(field: string): Promise<number> {
|
|
554
|
+
const { sql, bindings } = this.clone({
|
|
555
|
+
selects: [fn.sum(field).as("_sum")],
|
|
556
|
+
orderBys: [],
|
|
557
|
+
limitVal: undefined,
|
|
558
|
+
offsetVal: undefined,
|
|
559
|
+
}).toSQL()
|
|
560
|
+
const rows = await this._exec<{ _sum: number | null }>(sql, bindings)
|
|
561
|
+
return rows[0]?._sum ?? 0
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async avg(field: string): Promise<number> {
|
|
565
|
+
const { sql, bindings } = this.clone({
|
|
566
|
+
selects: [fn.avg(field).as("_avg")],
|
|
567
|
+
orderBys: [],
|
|
568
|
+
limitVal: undefined,
|
|
569
|
+
offsetVal: undefined,
|
|
570
|
+
}).toSQL()
|
|
571
|
+
const rows = await this._exec<{ _avg: number | null }>(sql, bindings)
|
|
572
|
+
return rows[0]?._avg ?? 0
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async min(field: string): Promise<unknown> {
|
|
576
|
+
const { sql, bindings } = this.clone({
|
|
577
|
+
selects: [fn.min(field).as("_min")],
|
|
578
|
+
orderBys: [],
|
|
579
|
+
limitVal: undefined,
|
|
580
|
+
offsetVal: undefined,
|
|
581
|
+
}).toSQL()
|
|
582
|
+
const rows = await this._exec<{ _min: unknown }>(sql, bindings)
|
|
583
|
+
return rows[0]?._min
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async max(field: string): Promise<unknown> {
|
|
587
|
+
const { sql, bindings } = this.clone({
|
|
588
|
+
selects: [fn.max(field).as("_max")],
|
|
589
|
+
orderBys: [],
|
|
590
|
+
limitVal: undefined,
|
|
591
|
+
offsetVal: undefined,
|
|
592
|
+
}).toSQL()
|
|
593
|
+
const rows = await this._exec<{ _max: unknown }>(sql, bindings)
|
|
594
|
+
return rows[0]?._max
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async paginate<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
598
|
+
page: number,
|
|
599
|
+
perPage: number,
|
|
600
|
+
): Promise<Paginator<T>> {
|
|
601
|
+
const [total, data] = await Promise.all([
|
|
602
|
+
this.count(),
|
|
603
|
+
this.limit(perPage)
|
|
604
|
+
.offset((page - 1) * perPage)
|
|
605
|
+
.all<T>(),
|
|
606
|
+
])
|
|
607
|
+
return new Paginator<T>(data, total, page, perPage)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async increment(fieldOrDict: string | Record<string, number>, amount = 1): Promise<number> {
|
|
611
|
+
return this._applyDelta(fieldOrDict, amount)
|
|
612
|
+
}
|
|
613
|
+
async decrement(fieldOrDict: string | Record<string, number>, amount = 1): Promise<number> {
|
|
614
|
+
return this._applyDelta(fieldOrDict, -amount)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private async _applyDelta(
|
|
618
|
+
fieldOrDict: string | Record<string, number>,
|
|
619
|
+
delta: number,
|
|
620
|
+
): Promise<number> {
|
|
621
|
+
const db = this._requireDb()
|
|
622
|
+
const tableName = toTableName(this._state.doctype)
|
|
623
|
+
|
|
624
|
+
const updates: Record<string, number> =
|
|
625
|
+
typeof fieldOrDict === "string"
|
|
626
|
+
? { [fieldOrDict]: delta }
|
|
627
|
+
: Object.fromEntries(Object.entries(fieldOrDict).map(([k, v]) => [k, v * Math.sign(delta)]))
|
|
628
|
+
|
|
629
|
+
const setClauses = Object.entries(updates)
|
|
630
|
+
.map(([f, v]) => `"${f}" = "${f}" + (${v})`)
|
|
631
|
+
.join(", ")
|
|
632
|
+
|
|
633
|
+
const { whereSql, bindings } = compileWheresOnly(this._state.wheres)
|
|
634
|
+
const result = db.run(
|
|
635
|
+
`UPDATE "${tableName}" SET ${setClauses} WHERE ${whereSql}`,
|
|
636
|
+
bindings as Binding[],
|
|
637
|
+
)
|
|
638
|
+
return result.changes
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Inspect the generated SQL without executing. */
|
|
642
|
+
toSQL(): { sql: string; bindings: unknown[] } {
|
|
643
|
+
return compile(this._state)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ── private execution ────────────────────────────────
|
|
647
|
+
|
|
648
|
+
private _requireDb(): BunSQLite {
|
|
649
|
+
if (!this._db)
|
|
650
|
+
throw new Error("QueryBuilder: no database connection (subquery used as terminal)")
|
|
651
|
+
return this._db
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private async _exec<T extends Record<string, unknown>>(
|
|
655
|
+
sql: string,
|
|
656
|
+
bindings: unknown[],
|
|
657
|
+
): Promise<T[]> {
|
|
658
|
+
const db = this._requireDb()
|
|
659
|
+
return bindings.length > 0
|
|
660
|
+
? db.query<T, Binding[]>(sql).all(...(bindings as Binding[]))
|
|
661
|
+
: db.query<T, []>(sql).all()
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ═══════════════════════════════════════════════════════════
|
|
666
|
+
// COMPILER
|
|
667
|
+
// ═══════════════════════════════════════════════════════════
|
|
668
|
+
|
|
669
|
+
// ─── Field helpers ────────────────────────────────────────
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Parse dot-notation: "linkField.targetField [as alias]"
|
|
673
|
+
* Returns null if not dot-notation or if it's a raw expression.
|
|
674
|
+
*/
|
|
675
|
+
function parseDot(expr: string): { prefix: string; field: string; alias?: string } | null {
|
|
676
|
+
// Only match simple word.word patterns (link-field traversal)
|
|
677
|
+
const m = expr.match(/^(\w+)\.(\w+)(?:\s+as\s+(\w+))?$/i)
|
|
678
|
+
if (!m) return null
|
|
679
|
+
return { prefix: m[1]!, field: m[2]!, alias: m[3] }
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Parse "field [as alias]" from a plain string select expression. */
|
|
683
|
+
function parseFieldAlias(expr: string): { field: string; alias?: string } {
|
|
684
|
+
const m = expr.match(/^(\w+)\s+as\s+(\w+)$/i)
|
|
685
|
+
return m ? { field: m[1]!, alias: m[2]! } : { field: expr.trim() }
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Quote a column reference that may be table-qualified.
|
|
690
|
+
* "Customer.name" → `"Customer"."name"`
|
|
691
|
+
* "name" → `"name"`
|
|
692
|
+
*
|
|
693
|
+
* Handles DocType names with spaces via toTableName.
|
|
694
|
+
*/
|
|
695
|
+
function qualifyCol(col: string): string {
|
|
696
|
+
const dotIdx = col.indexOf(".")
|
|
697
|
+
if (dotIdx === -1) return `"${col}"`
|
|
698
|
+
const tbl = toTableName(col.slice(0, dotIdx).trim())
|
|
699
|
+
const field = col.slice(dotIdx + 1).trim()
|
|
700
|
+
return `"${tbl}"."${field}"`
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function sqlLiteral(v: unknown): string {
|
|
704
|
+
if (v === null || v === undefined) return "NULL"
|
|
705
|
+
if (typeof v === "boolean") return v ? "1" : "0"
|
|
706
|
+
if (typeof v === "number") return String(v)
|
|
707
|
+
return `'${String(v).replace(/'/g, "''")}'`
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ─── Auto-join collection (link-field dot-notation) ───────
|
|
711
|
+
|
|
712
|
+
interface AutoJoin {
|
|
713
|
+
linkField: string // e.g. "customer"
|
|
714
|
+
targetTable: string // e.g. "Customer"
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Scan all references in the query state for dot-notation that maps to
|
|
719
|
+
* a Link field in the primary DocType's schema. Returns a map from
|
|
720
|
+
* linkField name → AutoJoin descriptor.
|
|
721
|
+
*/
|
|
722
|
+
function collectAutoJoins(state: BuilderState): Map<string, AutoJoin> {
|
|
723
|
+
const result = new Map<string, AutoJoin>()
|
|
724
|
+
const meta = getDocTypeMeta(state.doctype)
|
|
725
|
+
if (!meta) return result
|
|
726
|
+
|
|
727
|
+
function consider(col: string) {
|
|
728
|
+
const dot = parseDot(col)
|
|
729
|
+
if (!dot) return
|
|
730
|
+
if (result.has(dot.prefix)) return // already registered
|
|
731
|
+
|
|
732
|
+
const fieldDef = meta!.schema.fields[dot.prefix]
|
|
733
|
+
if (!fieldDef || fieldDef.type !== "Link") return
|
|
734
|
+
|
|
735
|
+
const targetDocType =
|
|
736
|
+
fieldDef.doctype ?? (typeof fieldDef.options === "string" ? fieldDef.options : undefined)
|
|
737
|
+
if (!targetDocType) return
|
|
738
|
+
|
|
739
|
+
result.set(dot.prefix, {
|
|
740
|
+
linkField: dot.prefix,
|
|
741
|
+
targetTable: toTableName(targetDocType),
|
|
742
|
+
})
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Selects
|
|
746
|
+
for (const s of state.selects) {
|
|
747
|
+
if (typeof s === "string") consider(s)
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Wheres
|
|
751
|
+
scanWheres(state.wheres, consider)
|
|
752
|
+
|
|
753
|
+
// OrderBys
|
|
754
|
+
for (const o of state.orderBys) {
|
|
755
|
+
if (o.field) consider(o.field)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// GroupBys
|
|
759
|
+
for (const g of state.groupBys) consider(g)
|
|
760
|
+
|
|
761
|
+
return result
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function scanWheres(wheres: ConnectedWhere[], cb: (col: string) => void) {
|
|
765
|
+
for (const { node } of wheres) {
|
|
766
|
+
switch (node.kind) {
|
|
767
|
+
case "val":
|
|
768
|
+
case "null":
|
|
769
|
+
case "like":
|
|
770
|
+
case "in":
|
|
771
|
+
case "between":
|
|
772
|
+
cb(node.col)
|
|
773
|
+
break
|
|
774
|
+
case "col_col":
|
|
775
|
+
cb(node.col1)
|
|
776
|
+
cb(node.col2)
|
|
777
|
+
break
|
|
778
|
+
case "group":
|
|
779
|
+
scanWheres(node.nodes, cb)
|
|
780
|
+
break
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ─── Column reference resolution ─────────────────────────
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Resolve a column expression to SQL, table-qualifying if necessary.
|
|
789
|
+
*
|
|
790
|
+
* - "customer.territory" → `"customer"."territory"` (auto-join alias)
|
|
791
|
+
* - "name" → `"T"."name"` when there are joins, else `"name"`
|
|
792
|
+
* - "total" → `"total"` (alias from SELECT — never qualify)
|
|
793
|
+
*/
|
|
794
|
+
function resolveCol(
|
|
795
|
+
col: string,
|
|
796
|
+
autoJoins: Map<string, AutoJoin>,
|
|
797
|
+
primaryAlias: string,
|
|
798
|
+
hasJoins: boolean,
|
|
799
|
+
): string {
|
|
800
|
+
const dot = parseDot(col)
|
|
801
|
+
if (dot) {
|
|
802
|
+
// Dot-notation: always `"prefix"."field"` (auto-join alias OR explicit)
|
|
803
|
+
return `"${dot.prefix}"."${dot.field}"`
|
|
804
|
+
}
|
|
805
|
+
// Plain column — qualify with primary table when joins are present
|
|
806
|
+
if (hasJoins) {
|
|
807
|
+
return `"${primaryAlias}"."${col}"`
|
|
808
|
+
}
|
|
809
|
+
return `"${col}"`
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ─── SELECT clause ────────────────────────────────────────
|
|
813
|
+
|
|
814
|
+
function compileSelects(
|
|
815
|
+
selects: (string | FnExpr | QueryBuilder)[],
|
|
816
|
+
autoJoins: Map<string, AutoJoin>,
|
|
817
|
+
primaryAlias: string,
|
|
818
|
+
hasJoins: boolean,
|
|
819
|
+
bindings: unknown[],
|
|
820
|
+
): string {
|
|
821
|
+
if (selects.length === 0) {
|
|
822
|
+
return hasJoins ? `"${primaryAlias}"."name"` : `"name"`
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return selects
|
|
826
|
+
.map((s) => {
|
|
827
|
+
if (typeof s === "string") {
|
|
828
|
+
// Wildcard
|
|
829
|
+
if (s === "*") return "*"
|
|
830
|
+
|
|
831
|
+
// Raw expressions (start with digit, quote, or already has spaces+operators)
|
|
832
|
+
// e.g. "1 as _e" — pass through
|
|
833
|
+
if (/^[0-9'(]/.test(s) || (/\s+as\s+/i.test(s) && !/^\w+\.?\w*\s+as\s+\w+$/i.test(s))) {
|
|
834
|
+
return s
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Dot-notation: "linkField.targetField [as alias]"
|
|
838
|
+
const dot = parseDot(s)
|
|
839
|
+
if (dot) {
|
|
840
|
+
const col = `"${dot.prefix}"."${dot.field}"`
|
|
841
|
+
const alias =
|
|
842
|
+
dot.alias ?? `${dot.prefix}${dot.field.charAt(0).toUpperCase()}${dot.field.slice(1)}`
|
|
843
|
+
return `${col} AS "${alias}"`
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Plain field [as alias]
|
|
847
|
+
const { field, alias } = parseFieldAlias(s)
|
|
848
|
+
const col = hasJoins ? `"${primaryAlias}"."${field}"` : `"${field}"`
|
|
849
|
+
return alias ? `${col} AS "${alias}"` : col
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (s instanceof FnExpr) return s.toSQL(bindings)
|
|
853
|
+
|
|
854
|
+
// Subquery
|
|
855
|
+
const { sql: subSql, bindings: subB } = s.toSQL()
|
|
856
|
+
bindings.push(...subB)
|
|
857
|
+
return s._state.subqueryAlias ? `(${subSql}) AS "${s._state.subqueryAlias}"` : `(${subSql})`
|
|
858
|
+
})
|
|
859
|
+
.join(", ")
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// ─── WHERE clause ─────────────────────────────────────────
|
|
863
|
+
|
|
864
|
+
function compileWhereNode(
|
|
865
|
+
node: WhereNode,
|
|
866
|
+
autoJoins: Map<string, AutoJoin>,
|
|
867
|
+
primaryAlias: string,
|
|
868
|
+
hasJoins: boolean,
|
|
869
|
+
bindings: unknown[],
|
|
870
|
+
): string {
|
|
871
|
+
const col = (c: string) => resolveCol(c, autoJoins, primaryAlias, hasJoins)
|
|
872
|
+
|
|
873
|
+
switch (node.kind) {
|
|
874
|
+
case "val": {
|
|
875
|
+
if (node.value === null || node.value === undefined) {
|
|
876
|
+
return node.op === "!=" ? `${col(node.col)} IS NOT NULL` : `${col(node.col)} IS NULL`
|
|
877
|
+
}
|
|
878
|
+
bindings.push(node.value)
|
|
879
|
+
return `${col(node.col)} ${node.op} ?`
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
case "col_col":
|
|
883
|
+
return `${col(node.col1)} ${node.op} ${col(node.col2)}`
|
|
884
|
+
|
|
885
|
+
case "in": {
|
|
886
|
+
const c = col(node.col)
|
|
887
|
+
const kw = node.negate ? "NOT IN" : "IN"
|
|
888
|
+
if (node.values instanceof QueryBuilder) {
|
|
889
|
+
const { sql: sub, bindings: sb } = node.values.toSQL()
|
|
890
|
+
bindings.push(...sb)
|
|
891
|
+
return `${c} ${kw} (${sub})`
|
|
892
|
+
}
|
|
893
|
+
const vals = node.values as unknown[]
|
|
894
|
+
if (vals.length === 0) return node.negate ? "1=1" : "0=1"
|
|
895
|
+
bindings.push(...vals)
|
|
896
|
+
return `${c} ${kw} (${vals.map(() => "?").join(", ")})`
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
case "between": {
|
|
900
|
+
bindings.push(node.min, node.max)
|
|
901
|
+
const kw = node.negate ? "NOT BETWEEN" : "BETWEEN"
|
|
902
|
+
return `${col(node.col)} ${kw} ? AND ?`
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
case "like":
|
|
906
|
+
// SQLite LIKE is case-insensitive by default → both LIKE and ILIKE map to LIKE
|
|
907
|
+
bindings.push(node.pattern)
|
|
908
|
+
return `${col(node.col)} LIKE ?`
|
|
909
|
+
|
|
910
|
+
case "null":
|
|
911
|
+
return node.negate
|
|
912
|
+
? `${col(node.col)} IS NOT NULL`
|
|
913
|
+
: `(${col(node.col)} IS NULL OR ${col(node.col)} = '')`
|
|
914
|
+
|
|
915
|
+
case "exists": {
|
|
916
|
+
const { sql: sub, bindings: sb } = node.subquery.toSQL()
|
|
917
|
+
bindings.push(...sb)
|
|
918
|
+
return node.negate ? `NOT EXISTS (${sub})` : `EXISTS (${sub})`
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
case "group": {
|
|
922
|
+
const inner = compileConnectedWheres(node.nodes, autoJoins, primaryAlias, hasJoins, bindings)
|
|
923
|
+
return `(${inner})`
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
case "filters": {
|
|
927
|
+
const parsed = parseFilters(node.input)
|
|
928
|
+
bindings.push(...parsed.values)
|
|
929
|
+
return parsed.sql
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function compileConnectedWheres(
|
|
935
|
+
wheres: ConnectedWhere[],
|
|
936
|
+
autoJoins: Map<string, AutoJoin>,
|
|
937
|
+
primaryAlias: string,
|
|
938
|
+
hasJoins: boolean,
|
|
939
|
+
bindings: unknown[],
|
|
940
|
+
): string {
|
|
941
|
+
if (wheres.length === 0) return "1=1"
|
|
942
|
+
|
|
943
|
+
return wheres
|
|
944
|
+
.map(({ node, connector }, i) => {
|
|
945
|
+
const sql = compileWhereNode(node, autoJoins, primaryAlias, hasJoins, bindings)
|
|
946
|
+
return i === 0 ? sql : `${connector} ${sql}`
|
|
947
|
+
})
|
|
948
|
+
.join(" ")
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** Compile only the WHERE portion (used by increment/decrement). */
|
|
952
|
+
function compileWheresOnly(wheres: ConnectedWhere[]): { whereSql: string; bindings: unknown[] } {
|
|
953
|
+
const bindings: unknown[] = []
|
|
954
|
+
const whereSql = compileConnectedWheres(wheres, new Map(), "", false, bindings)
|
|
955
|
+
return { whereSql, bindings }
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// ─── Main compiler ────────────────────────────────────────
|
|
959
|
+
|
|
960
|
+
function compile(state: BuilderState): { sql: string; bindings: unknown[] } {
|
|
961
|
+
const bindings: unknown[] = []
|
|
962
|
+
|
|
963
|
+
const tableName = state.fromTable ?? toTableName(state.doctype)
|
|
964
|
+
const primaryAlias = state.tableAlias ?? tableName
|
|
965
|
+
|
|
966
|
+
// Auto-joins from dot-notation
|
|
967
|
+
const autoJoins = collectAutoJoins(state)
|
|
968
|
+
|
|
969
|
+
// Determine if any joins exist (auto or explicit)
|
|
970
|
+
const hasJoins = autoJoins.size > 0 || state.joins.length > 0 || state.tableAlias !== undefined
|
|
971
|
+
|
|
972
|
+
// ── CTEs ──
|
|
973
|
+
let ctePrefix = ""
|
|
974
|
+
if (state.ctes.length > 0) {
|
|
975
|
+
const parts = state.ctes.map(({ name, builder }) => {
|
|
976
|
+
const { sql: s, bindings: b } = builder.toSQL()
|
|
977
|
+
bindings.push(...b)
|
|
978
|
+
return `"${name}" AS (${s})`
|
|
979
|
+
})
|
|
980
|
+
ctePrefix = `WITH ${parts.join(",\n")} `
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ── SELECT ──
|
|
984
|
+
const selectExpr = compileSelects(state.selects, autoJoins, primaryAlias, hasJoins, bindings)
|
|
985
|
+
const distinctPfx = state.isDistinct ? "DISTINCT " : ""
|
|
986
|
+
|
|
987
|
+
// ── FROM ──
|
|
988
|
+
const fromExpr = state.tableAlias ? `"${tableName}" AS "${primaryAlias}"` : `"${tableName}"`
|
|
989
|
+
|
|
990
|
+
// ── JOINs ──
|
|
991
|
+
const joinLines: string[] = []
|
|
992
|
+
|
|
993
|
+
// Auto-joins (left join per link field)
|
|
994
|
+
for (const [linkField, { targetTable }] of autoJoins) {
|
|
995
|
+
const primaryRef = `"${primaryAlias}"`
|
|
996
|
+
joinLines.push(
|
|
997
|
+
`LEFT JOIN "${targetTable}" AS "${linkField}" ON ${primaryRef}."${linkField}" = "${linkField}"."name"`,
|
|
998
|
+
)
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Explicit joins
|
|
1002
|
+
for (const { type, doctype, alias, condition } of state.joins) {
|
|
1003
|
+
const targetTable = toTableName(doctype)
|
|
1004
|
+
const joinAlias = alias ?? targetTable
|
|
1005
|
+
const on = new OnCondition()
|
|
1006
|
+
condition(on)
|
|
1007
|
+
const kw = type === "inner" ? "JOIN" : "LEFT JOIN"
|
|
1008
|
+
joinLines.push(`${kw} "${targetTable}" AS "${joinAlias}" ON ${on.toSQL()}`)
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// ── WHERE ──
|
|
1012
|
+
const whereSql = compileConnectedWheres(state.wheres, autoJoins, primaryAlias, hasJoins, bindings)
|
|
1013
|
+
|
|
1014
|
+
// ── GROUP BY ──
|
|
1015
|
+
let groupBySql = ""
|
|
1016
|
+
if (state.groupBys.length > 0) {
|
|
1017
|
+
groupBySql =
|
|
1018
|
+
"GROUP BY " +
|
|
1019
|
+
state.groupBys
|
|
1020
|
+
.map((g) => {
|
|
1021
|
+
const dot = parseDot(g)
|
|
1022
|
+
return dot
|
|
1023
|
+
? `"${dot.prefix}"."${dot.field}"`
|
|
1024
|
+
: hasJoins
|
|
1025
|
+
? `"${primaryAlias}"."${g}"`
|
|
1026
|
+
: `"${g}"`
|
|
1027
|
+
})
|
|
1028
|
+
.join(", ")
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// ── HAVING ──
|
|
1032
|
+
let havingSql = ""
|
|
1033
|
+
if (state.havings.length > 0) {
|
|
1034
|
+
havingSql =
|
|
1035
|
+
"HAVING " +
|
|
1036
|
+
state.havings
|
|
1037
|
+
.map(({ expr, op, value }) => {
|
|
1038
|
+
const lhs = typeof expr === "string" ? `"${expr}"` : expr.toSQL(bindings)
|
|
1039
|
+
bindings.push(value)
|
|
1040
|
+
return `${lhs} ${op} ?`
|
|
1041
|
+
})
|
|
1042
|
+
.join(" AND ")
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// ── ORDER BY ──
|
|
1046
|
+
let orderBySql = ""
|
|
1047
|
+
if (state.orderBys.length > 0) {
|
|
1048
|
+
orderBySql =
|
|
1049
|
+
"ORDER BY " +
|
|
1050
|
+
state.orderBys
|
|
1051
|
+
.map(({ field, subquery, fn: fnExpr, dir }) => {
|
|
1052
|
+
const d = dir.toUpperCase()
|
|
1053
|
+
if (subquery) {
|
|
1054
|
+
const { sql: s, bindings: b } = subquery.toSQL()
|
|
1055
|
+
bindings.push(...b)
|
|
1056
|
+
return `(${s}) ${d}`
|
|
1057
|
+
}
|
|
1058
|
+
if (fnExpr) return `${fnExpr.toSQL(bindings)} ${d}`
|
|
1059
|
+
// Qualify plain column with primary table alias when joins are present
|
|
1060
|
+
const dot = field ? parseDot(field) : null
|
|
1061
|
+
const colSql = dot
|
|
1062
|
+
? `"${dot.prefix}"."${dot.field}"`
|
|
1063
|
+
: hasJoins
|
|
1064
|
+
? `"${primaryAlias}"."${field}"`
|
|
1065
|
+
: `"${field}"`
|
|
1066
|
+
return `${colSql} ${d}`
|
|
1067
|
+
})
|
|
1068
|
+
.join(", ")
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// ── LIMIT / OFFSET ──
|
|
1072
|
+
let limitSql = ""
|
|
1073
|
+
if (state.limitVal !== undefined) {
|
|
1074
|
+
bindings.push(state.limitVal)
|
|
1075
|
+
limitSql = "LIMIT ?"
|
|
1076
|
+
}
|
|
1077
|
+
let offsetSql = ""
|
|
1078
|
+
if (state.offsetVal !== undefined) {
|
|
1079
|
+
bindings.push(state.offsetVal)
|
|
1080
|
+
offsetSql = "OFFSET ?"
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// ── Assemble ──
|
|
1084
|
+
const parts = [
|
|
1085
|
+
`${ctePrefix}SELECT ${distinctPfx}${selectExpr}`,
|
|
1086
|
+
`FROM ${fromExpr}`,
|
|
1087
|
+
...joinLines,
|
|
1088
|
+
`WHERE ${whereSql}`,
|
|
1089
|
+
groupBySql,
|
|
1090
|
+
havingSql,
|
|
1091
|
+
orderBySql,
|
|
1092
|
+
limitSql,
|
|
1093
|
+
offsetSql,
|
|
1094
|
+
].filter(Boolean)
|
|
1095
|
+
|
|
1096
|
+
let sql = parts.join("\n")
|
|
1097
|
+
|
|
1098
|
+
// ── UNION ──
|
|
1099
|
+
for (const { query, all } of state.unions) {
|
|
1100
|
+
const { sql: uSql, bindings: uB } = query.toSQL()
|
|
1101
|
+
bindings.push(...uB)
|
|
1102
|
+
sql += `\n${all ? "UNION ALL" : "UNION"}\n${uSql}`
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
return { sql, bindings }
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// ─── Public factory helpers ───────────────────────────────
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Create a new QueryBuilder for the given DocType, bound to a database.
|
|
1112
|
+
* Called by `frappe.query(doctype)` on the context.
|
|
1113
|
+
*/
|
|
1114
|
+
export function createQueryBuilder(db: BunSQLite, doctype: string): QueryBuilder {
|
|
1115
|
+
return new QueryBuilder(db, emptyState(doctype))
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
export { emptyState }
|