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.
Files changed (66) hide show
  1. package/README.md +72 -0
  2. package/package.json +59 -0
  3. package/src/api/auth.ts +76 -0
  4. package/src/api/index.ts +10 -0
  5. package/src/api/resource.ts +177 -0
  6. package/src/api/route.ts +301 -0
  7. package/src/app/index.ts +6 -0
  8. package/src/app/loader.ts +218 -0
  9. package/src/auth/auth.ts +247 -0
  10. package/src/auth/index.ts +2 -0
  11. package/src/cli/args.ts +40 -0
  12. package/src/cli/bin.ts +12 -0
  13. package/src/cli/commands/add-api.ts +32 -0
  14. package/src/cli/commands/add-doctype.ts +43 -0
  15. package/src/cli/commands/add-page.ts +33 -0
  16. package/src/cli/commands/add-user.ts +96 -0
  17. package/src/cli/commands/dev.ts +71 -0
  18. package/src/cli/commands/drop-site.ts +27 -0
  19. package/src/cli/commands/init.ts +98 -0
  20. package/src/cli/commands/migrate.ts +110 -0
  21. package/src/cli/commands/new-site.ts +61 -0
  22. package/src/cli/commands/routes.ts +56 -0
  23. package/src/cli/commands/use.ts +30 -0
  24. package/src/cli/index.ts +73 -0
  25. package/src/cli/log.ts +13 -0
  26. package/src/cli/scaffold/templates.ts +189 -0
  27. package/src/context.ts +162 -0
  28. package/src/core/doctype/migration/migration.ts +17 -0
  29. package/src/core/doctype/role/role.ts +7 -0
  30. package/src/core/doctype/session/session.ts +16 -0
  31. package/src/core/doctype/user/user.controller.ts +11 -0
  32. package/src/core/doctype/user/user.ts +22 -0
  33. package/src/core/doctype/user_role/user_role.ts +9 -0
  34. package/src/core/doctypes.ts +25 -0
  35. package/src/core/index.ts +1 -0
  36. package/src/database/database.ts +359 -0
  37. package/src/database/filters.ts +131 -0
  38. package/src/database/index.ts +30 -0
  39. package/src/database/query-builder.ts +1118 -0
  40. package/src/database/schema.ts +188 -0
  41. package/src/doctype/define.ts +45 -0
  42. package/src/doctype/discovery.ts +57 -0
  43. package/src/doctype/field.ts +160 -0
  44. package/src/doctype/index.ts +20 -0
  45. package/src/doctype/layout.ts +62 -0
  46. package/src/doctype/query-builder-stub.ts +16 -0
  47. package/src/doctype/registry.ts +106 -0
  48. package/src/doctype/types.ts +407 -0
  49. package/src/document/document.ts +593 -0
  50. package/src/document/index.ts +6 -0
  51. package/src/document/naming.ts +56 -0
  52. package/src/errors.ts +53 -0
  53. package/src/frappe.d.ts +128 -0
  54. package/src/globals.ts +72 -0
  55. package/src/index.ts +112 -0
  56. package/src/migrations/index.ts +11 -0
  57. package/src/migrations/runner.ts +256 -0
  58. package/src/permissions/index.ts +265 -0
  59. package/src/response.ts +100 -0
  60. package/src/server.ts +210 -0
  61. package/src/site.ts +126 -0
  62. package/src/ssr/handler.ts +56 -0
  63. package/src/ssr/index.ts +11 -0
  64. package/src/ssr/page-loader.ts +200 -0
  65. package/src/ssr/renderer.ts +94 -0
  66. 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 }