sqlite-zod-orm 3.9.0 → 3.11.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/dist/index.js +242 -72
- package/package.json +1 -1
- package/src/builder.ts +399 -0
- package/src/context.ts +9 -0
- package/src/crud.ts +44 -3
- package/src/database.ts +53 -3
- package/src/helpers.ts +11 -0
- package/src/index.ts +1 -1
- package/src/iqo.ts +198 -0
- package/src/proxy.ts +276 -0
- package/src/query.ts +23 -736
- package/src/types.ts +26 -3
package/src/query.ts
CHANGED
|
@@ -1,748 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* query.ts —
|
|
2
|
+
* query.ts — Barrel re-export + QueryBuilder factory
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Previously contained all query logic (IQO, QueryBuilder, proxy system).
|
|
5
|
+
* Now split into focused modules:
|
|
6
|
+
* - iqo.ts — IQO types and compiler
|
|
7
|
+
* - builder.ts — QueryBuilder class
|
|
8
|
+
* - proxy.ts — Proxy query system (ColumnNode, compileProxyQuery, etc.)
|
|
9
|
+
*
|
|
10
|
+
* This file re-exports everything for backwards compatibility and houses
|
|
11
|
+
* the createQueryBuilder factory that wires executors to the database.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
|
-
import type { z } from 'zod';
|
|
12
|
-
import {
|
|
13
|
-
type ASTNode, type WhereCallback, type TypedColumnProxy, type FunctionProxy, type Operators,
|
|
14
|
-
compileAST, wrapNode, createColumnProxy, createFunctionProxy, op,
|
|
15
|
-
} from './ast';
|
|
16
14
|
import { transformFromStorage } from './schema';
|
|
17
15
|
import type { DatabaseContext } from './context';
|
|
18
16
|
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
type OrderDirection = 'asc' | 'desc';
|
|
24
|
-
type WhereOperator = '$gt' | '$gte' | '$lt' | '$lte' | '$ne' | '$in' | '$like' | '$notIn' | '$between';
|
|
25
|
-
|
|
26
|
-
interface WhereCondition {
|
|
27
|
-
field: string;
|
|
28
|
-
operator: '=' | '>' | '>=' | '<' | '<=' | '!=' | 'IN' | 'LIKE' | 'NOT IN' | 'BETWEEN';
|
|
29
|
-
value: any;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
interface JoinClause {
|
|
33
|
-
table: string;
|
|
34
|
-
fromCol: string;
|
|
35
|
-
toCol: string;
|
|
36
|
-
columns: string[]; // columns to SELECT from the joined table
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
interface IQO {
|
|
40
|
-
selects: string[];
|
|
41
|
-
wheres: WhereCondition[];
|
|
42
|
-
whereOrs: WhereCondition[][]; // Each sub-array is an OR group
|
|
43
|
-
whereAST: ASTNode | null;
|
|
44
|
-
joins: JoinClause[];
|
|
45
|
-
groupBy: string[];
|
|
46
|
-
limit: number | null;
|
|
47
|
-
offset: number | null;
|
|
48
|
-
orderBy: { field: string; direction: OrderDirection }[];
|
|
49
|
-
includes: string[];
|
|
50
|
-
raw: boolean;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const OPERATOR_MAP: Record<WhereOperator, string> = {
|
|
54
|
-
$gt: '>',
|
|
55
|
-
$gte: '>=',
|
|
56
|
-
$lt: '<',
|
|
57
|
-
$lte: '<=',
|
|
58
|
-
$ne: '!=',
|
|
59
|
-
$in: 'IN',
|
|
60
|
-
$like: 'LIKE',
|
|
61
|
-
$notIn: 'NOT IN',
|
|
62
|
-
$between: 'BETWEEN',
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
function transformValueForStorage(value: any): any {
|
|
66
|
-
if (value instanceof Date) return value.toISOString();
|
|
67
|
-
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
68
|
-
return value;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Compile an Internal Query Object into executable SQL + params.
|
|
73
|
-
* Handles SELECT, JOIN, WHERE (object + AST + $or), ORDER BY, LIMIT, OFFSET.
|
|
74
|
-
*/
|
|
75
|
-
export function compileIQO(tableName: string, iqo: IQO): { sql: string; params: any[] } {
|
|
76
|
-
const params: any[] = [];
|
|
77
|
-
|
|
78
|
-
// SELECT clause
|
|
79
|
-
const selectParts: string[] = [];
|
|
80
|
-
if (iqo.selects.length > 0) {
|
|
81
|
-
selectParts.push(...iqo.selects.map(s => `${tableName}.${s}`));
|
|
82
|
-
} else {
|
|
83
|
-
selectParts.push(`${tableName}.*`);
|
|
84
|
-
}
|
|
85
|
-
for (const j of iqo.joins) {
|
|
86
|
-
if (j.columns.length > 0) {
|
|
87
|
-
selectParts.push(...j.columns.map(c => `${j.table}.${c} AS ${j.table}_${c}`));
|
|
88
|
-
} else {
|
|
89
|
-
selectParts.push(`${j.table}.*`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
let sql = `SELECT ${selectParts.join(', ')} FROM ${tableName}`;
|
|
94
|
-
|
|
95
|
-
// JOIN clauses
|
|
96
|
-
for (const j of iqo.joins) {
|
|
97
|
-
sql += ` JOIN ${j.table} ON ${tableName}.${j.fromCol} = ${j.table}.${j.toCol}`;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// WHERE clause — AST-based takes precedence if set
|
|
101
|
-
if (iqo.whereAST) {
|
|
102
|
-
const compiled = compileAST(iqo.whereAST);
|
|
103
|
-
sql += ` WHERE ${compiled.sql}`;
|
|
104
|
-
params.push(...compiled.params);
|
|
105
|
-
} else if (iqo.wheres.length > 0) {
|
|
106
|
-
const hasJoins = iqo.joins.length > 0;
|
|
107
|
-
const qualify = (field: string) =>
|
|
108
|
-
hasJoins && !field.includes('.') ? `${tableName}.${field}` : field;
|
|
109
|
-
|
|
110
|
-
const whereParts: string[] = [];
|
|
111
|
-
for (const w of iqo.wheres) {
|
|
112
|
-
if (w.operator === 'IN') {
|
|
113
|
-
const arr = w.value as any[];
|
|
114
|
-
if (arr.length === 0) {
|
|
115
|
-
whereParts.push('1 = 0');
|
|
116
|
-
} else {
|
|
117
|
-
const placeholders = arr.map(() => '?').join(', ');
|
|
118
|
-
whereParts.push(`${qualify(w.field)} IN (${placeholders})`);
|
|
119
|
-
params.push(...arr.map(transformValueForStorage));
|
|
120
|
-
}
|
|
121
|
-
} else if (w.operator === 'NOT IN') {
|
|
122
|
-
const arr = w.value as any[];
|
|
123
|
-
if (arr.length === 0) continue; // no-op
|
|
124
|
-
const placeholders = arr.map(() => '?').join(', ');
|
|
125
|
-
whereParts.push(`${qualify(w.field)} NOT IN (${placeholders})`);
|
|
126
|
-
params.push(...arr.map(transformValueForStorage));
|
|
127
|
-
} else if (w.operator === 'BETWEEN') {
|
|
128
|
-
const [min, max] = w.value as [any, any];
|
|
129
|
-
whereParts.push(`${qualify(w.field)} BETWEEN ? AND ?`);
|
|
130
|
-
params.push(transformValueForStorage(min), transformValueForStorage(max));
|
|
131
|
-
} else {
|
|
132
|
-
whereParts.push(`${qualify(w.field)} ${w.operator} ?`);
|
|
133
|
-
params.push(transformValueForStorage(w.value));
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
if (whereParts.length > 0) {
|
|
137
|
-
sql += ` WHERE ${whereParts.join(' AND ')}`;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Append OR groups (from $or)
|
|
142
|
-
if (iqo.whereOrs.length > 0) {
|
|
143
|
-
for (const orGroup of iqo.whereOrs) {
|
|
144
|
-
const orParts: string[] = [];
|
|
145
|
-
for (const w of orGroup) {
|
|
146
|
-
if (w.operator === 'IN') {
|
|
147
|
-
const arr = w.value as any[];
|
|
148
|
-
if (arr.length === 0) {
|
|
149
|
-
orParts.push('1 = 0');
|
|
150
|
-
} else {
|
|
151
|
-
orParts.push(`${w.field} IN (${arr.map(() => '?').join(', ')})`);
|
|
152
|
-
params.push(...arr.map(transformValueForStorage));
|
|
153
|
-
}
|
|
154
|
-
} else {
|
|
155
|
-
orParts.push(`${w.field} ${w.operator} ?`);
|
|
156
|
-
params.push(transformValueForStorage(w.value));
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
if (orParts.length > 0) {
|
|
160
|
-
const orClause = `(${orParts.join(' OR ')})`;
|
|
161
|
-
sql += sql.includes(' WHERE ') ? ` AND ${orClause}` : ` WHERE ${orClause}`;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// GROUP BY
|
|
167
|
-
if (iqo.groupBy.length > 0) {
|
|
168
|
-
sql += ` GROUP BY ${iqo.groupBy.join(', ')}`;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ORDER BY
|
|
172
|
-
if (iqo.orderBy.length > 0) {
|
|
173
|
-
const parts = iqo.orderBy.map(o => `${o.field} ${o.direction.toUpperCase()}`);
|
|
174
|
-
sql += ` ORDER BY ${parts.join(', ')}`;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (iqo.limit !== null) sql += ` LIMIT ${iqo.limit}`;
|
|
178
|
-
if (iqo.offset !== null) sql += ` OFFSET ${iqo.offset}`;
|
|
179
|
-
|
|
180
|
-
return { sql, params };
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// =============================================================================
|
|
184
|
-
// QueryBuilder Class
|
|
185
|
-
// =============================================================================
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* A Fluent Query Builder that accumulates query state via chaining
|
|
189
|
-
* and only executes when a terminal method is called (.all(), .get())
|
|
190
|
-
* or when it is `await`-ed (thenable).
|
|
191
|
-
*
|
|
192
|
-
* Supports two WHERE styles:
|
|
193
|
-
* - Object-style: `.where({ name: 'Alice', age: { $gt: 18 } })`
|
|
194
|
-
* - Callback-style (AST): `.where((c, f, op) => op.and(op.eq(c.name, 'Alice'), op.gt(c.age, 18)))`
|
|
195
|
-
*/
|
|
196
|
-
export class QueryBuilder<T extends Record<string, any>> {
|
|
197
|
-
private iqo: IQO;
|
|
198
|
-
private tableName: string;
|
|
199
|
-
private executor: (sql: string, params: any[], raw: boolean) => any[];
|
|
200
|
-
private singleExecutor: (sql: string, params: any[], raw: boolean) => any | null;
|
|
201
|
-
private joinResolver: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null;
|
|
202
|
-
private conditionResolver: ((conditions: Record<string, any>) => Record<string, any>) | null;
|
|
203
|
-
private eagerLoader: ((parentTable: string, relation: string, parentIds: number[]) => { key: string; groups: Map<number, any[]> } | null) | null;
|
|
204
|
-
|
|
205
|
-
constructor(
|
|
206
|
-
tableName: string,
|
|
207
|
-
executor: (sql: string, params: any[], raw: boolean) => any[],
|
|
208
|
-
singleExecutor: (sql: string, params: any[], raw: boolean) => any | null,
|
|
209
|
-
joinResolver?: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null,
|
|
210
|
-
conditionResolver?: ((conditions: Record<string, any>) => Record<string, any>) | null,
|
|
211
|
-
eagerLoader?: ((parentTable: string, relation: string, parentIds: number[]) => { key: string; groups: Map<number, any[]> } | null) | null,
|
|
212
|
-
) {
|
|
213
|
-
this.tableName = tableName;
|
|
214
|
-
this.executor = executor;
|
|
215
|
-
this.singleExecutor = singleExecutor;
|
|
216
|
-
this.joinResolver = joinResolver ?? null;
|
|
217
|
-
this.conditionResolver = conditionResolver ?? null;
|
|
218
|
-
this.eagerLoader = eagerLoader ?? null;
|
|
219
|
-
this.iqo = {
|
|
220
|
-
selects: [],
|
|
221
|
-
wheres: [],
|
|
222
|
-
whereOrs: [],
|
|
223
|
-
whereAST: null,
|
|
224
|
-
joins: [],
|
|
225
|
-
groupBy: [],
|
|
226
|
-
limit: null,
|
|
227
|
-
offset: null,
|
|
228
|
-
orderBy: [],
|
|
229
|
-
includes: [],
|
|
230
|
-
raw: false,
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** Specify which columns to select. If called with no arguments, defaults to `*`. */
|
|
235
|
-
select(...cols: (keyof T & string)[]): this {
|
|
236
|
-
this.iqo.selects.push(...cols);
|
|
237
|
-
return this;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Add WHERE conditions. Two calling styles:
|
|
242
|
-
*
|
|
243
|
-
* **Object-style** (simple equality and operators):
|
|
244
|
-
* ```ts
|
|
245
|
-
* .where({ name: 'Alice' })
|
|
246
|
-
* .where({ age: { $gt: 18 } })
|
|
247
|
-
* ```
|
|
248
|
-
*
|
|
249
|
-
* **Callback-style** (AST-based, full SQL expression power):
|
|
250
|
-
* ```ts
|
|
251
|
-
* .where((c, f, op) => op.and(
|
|
252
|
-
* op.eq(f.lower(c.name), 'alice'),
|
|
253
|
-
* op.gt(c.age, 18)
|
|
254
|
-
* ))
|
|
255
|
-
* ```
|
|
256
|
-
*/
|
|
257
|
-
where(criteriaOrCallback: (Partial<Record<keyof T & string, any>> & { $or?: Partial<Record<keyof T & string, any>>[] }) | WhereCallback<T>): this {
|
|
258
|
-
if (typeof criteriaOrCallback === 'function') {
|
|
259
|
-
const ast = (criteriaOrCallback as WhereCallback<T>)(
|
|
260
|
-
createColumnProxy<T>(),
|
|
261
|
-
createFunctionProxy(),
|
|
262
|
-
op,
|
|
263
|
-
);
|
|
264
|
-
if (this.iqo.whereAST) {
|
|
265
|
-
this.iqo.whereAST = { type: 'operator', op: 'AND', left: this.iqo.whereAST, right: ast };
|
|
266
|
-
} else {
|
|
267
|
-
this.iqo.whereAST = ast;
|
|
268
|
-
}
|
|
269
|
-
} else {
|
|
270
|
-
const resolved = this.conditionResolver
|
|
271
|
-
? this.conditionResolver(criteriaOrCallback as Record<string, any>)
|
|
272
|
-
: criteriaOrCallback;
|
|
273
|
-
|
|
274
|
-
for (const [key, value] of Object.entries(resolved)) {
|
|
275
|
-
if (key === '$or' && Array.isArray(value)) {
|
|
276
|
-
const orConditions: WhereCondition[] = [];
|
|
277
|
-
for (const branch of value as Record<string, any>[]) {
|
|
278
|
-
const resolvedBranch = this.conditionResolver
|
|
279
|
-
? this.conditionResolver(branch)
|
|
280
|
-
: branch;
|
|
281
|
-
for (const [bKey, bValue] of Object.entries(resolvedBranch)) {
|
|
282
|
-
if (typeof bValue === 'object' && bValue !== null && !Array.isArray(bValue) && !(bValue instanceof Date)) {
|
|
283
|
-
for (const [opKey, operand] of Object.entries(bValue)) {
|
|
284
|
-
const sqlOp = OPERATOR_MAP[opKey as WhereOperator];
|
|
285
|
-
if (sqlOp) orConditions.push({ field: bKey, operator: sqlOp as WhereCondition['operator'], value: operand });
|
|
286
|
-
}
|
|
287
|
-
} else {
|
|
288
|
-
orConditions.push({ field: bKey, operator: '=', value: bValue });
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
if (orConditions.length > 0) this.iqo.whereOrs.push(orConditions);
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
if (typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
|
|
297
|
-
for (const [opKey, operand] of Object.entries(value)) {
|
|
298
|
-
const sqlOp = OPERATOR_MAP[opKey as WhereOperator];
|
|
299
|
-
if (!sqlOp) throw new Error(`Unsupported query operator: '${opKey}' on field '${key}'.`);
|
|
300
|
-
if (opKey === '$between') {
|
|
301
|
-
if (!Array.isArray(operand) || operand.length !== 2) throw new Error(`$between for '${key}' requires [min, max]`);
|
|
302
|
-
}
|
|
303
|
-
this.iqo.wheres.push({
|
|
304
|
-
field: key,
|
|
305
|
-
operator: sqlOp as WhereCondition['operator'],
|
|
306
|
-
value: operand,
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
} else {
|
|
310
|
-
this.iqo.wheres.push({ field: key, operator: '=', value });
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
return this;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/** Set the maximum number of rows to return. */
|
|
318
|
-
limit(n: number): this {
|
|
319
|
-
this.iqo.limit = n;
|
|
320
|
-
return this;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/** Set the offset for pagination. */
|
|
324
|
-
offset(n: number): this {
|
|
325
|
-
this.iqo.offset = n;
|
|
326
|
-
return this;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/** Add ORDER BY clauses. */
|
|
330
|
-
orderBy(field: keyof T & string, direction: OrderDirection = 'asc'): this {
|
|
331
|
-
this.iqo.orderBy.push({ field, direction });
|
|
332
|
-
return this;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Join another table. Two calling styles:
|
|
337
|
-
*
|
|
338
|
-
* **Accessor-based** (auto-infers FK from relationships):
|
|
339
|
-
* ```ts
|
|
340
|
-
* db.trees.select('name').join(db.forests, ['name']).all()
|
|
341
|
-
* ```
|
|
342
|
-
*
|
|
343
|
-
* **String-based** (manual FK):
|
|
344
|
-
* ```ts
|
|
345
|
-
* db.trees.select('name').join('forests', 'forestId', ['name']).all()
|
|
346
|
-
* ```
|
|
347
|
-
*/
|
|
348
|
-
join(accessor: { _tableName: string }, columns?: string[]): this;
|
|
349
|
-
join(table: string, fk: string, columns?: string[], pk?: string): this;
|
|
350
|
-
join(tableOrAccessor: string | { _tableName: string }, fkOrCols?: string | string[], colsOrPk?: string[] | string, pk?: string): this {
|
|
351
|
-
let table: string;
|
|
352
|
-
let fromCol: string;
|
|
353
|
-
let toCol: string;
|
|
354
|
-
let columns: string[];
|
|
355
|
-
|
|
356
|
-
if (typeof tableOrAccessor === 'object' && '_tableName' in tableOrAccessor) {
|
|
357
|
-
table = tableOrAccessor._tableName;
|
|
358
|
-
columns = Array.isArray(fkOrCols) ? fkOrCols : [];
|
|
359
|
-
if (!this.joinResolver) throw new Error(`Cannot auto-resolve join: no relationship data available`);
|
|
360
|
-
const resolved = this.joinResolver(this.tableName, table);
|
|
361
|
-
if (!resolved) throw new Error(`No relationship found between '${this.tableName}' and '${table}'`);
|
|
362
|
-
fromCol = resolved.fk;
|
|
363
|
-
toCol = resolved.pk;
|
|
364
|
-
} else {
|
|
365
|
-
table = tableOrAccessor;
|
|
366
|
-
fromCol = fkOrCols as string;
|
|
367
|
-
columns = Array.isArray(colsOrPk) ? colsOrPk : [];
|
|
368
|
-
toCol = (typeof colsOrPk === 'string' ? colsOrPk : pk) ?? 'id';
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
this.iqo.joins.push({ table, fromCol, toCol, columns });
|
|
372
|
-
this.iqo.raw = true;
|
|
373
|
-
return this;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/** Skip Zod parsing and return raw SQLite row objects. */
|
|
377
|
-
raw(): this {
|
|
378
|
-
this.iqo.raw = true;
|
|
379
|
-
return this;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/**
|
|
383
|
-
* Eagerly load a related entity and attach as an array property.
|
|
384
|
-
*
|
|
385
|
-
* Runs a single batched query (WHERE fk IN (...)) per relation,
|
|
386
|
-
* avoiding the N+1 problem of lazy navigation.
|
|
387
|
-
*/
|
|
388
|
-
with(...relations: string[]): this {
|
|
389
|
-
this.iqo.includes.push(...relations);
|
|
390
|
-
return this;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/** Internal: apply eager loads to a set of results */
|
|
394
|
-
private _applyEagerLoads(results: T[]): T[] {
|
|
395
|
-
if (this.iqo.includes.length === 0 || !this.eagerLoader || results.length === 0) {
|
|
396
|
-
return results;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
const parentIds = results.map((r: any) => r.id).filter((id: any) => typeof id === 'number');
|
|
400
|
-
if (parentIds.length === 0) return results;
|
|
401
|
-
|
|
402
|
-
for (const relation of this.iqo.includes) {
|
|
403
|
-
const loaded = this.eagerLoader(this.tableName, relation, parentIds);
|
|
404
|
-
if (!loaded) continue;
|
|
405
|
-
|
|
406
|
-
for (const row of results as any[]) {
|
|
407
|
-
row[loaded.key] = loaded.groups.get(row.id) ?? [];
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
return results;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// ---------- Terminal / Execution Methods ----------
|
|
415
|
-
|
|
416
|
-
/** Execute the query and return all matching rows. */
|
|
417
|
-
all(): T[] {
|
|
418
|
-
const { sql, params } = compileIQO(this.tableName, this.iqo);
|
|
419
|
-
const results = this.executor(sql, params, this.iqo.raw);
|
|
420
|
-
return this._applyEagerLoads(results);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/** Execute the query and return the first matching row, or null. */
|
|
424
|
-
get(): T | null {
|
|
425
|
-
this.iqo.limit = 1;
|
|
426
|
-
const { sql, params } = compileIQO(this.tableName, this.iqo);
|
|
427
|
-
const result = this.singleExecutor(sql, params, this.iqo.raw);
|
|
428
|
-
if (!result) return null;
|
|
429
|
-
const [loaded] = this._applyEagerLoads([result]);
|
|
430
|
-
return loaded ?? null;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/** Execute the query and return the count of matching rows. */
|
|
434
|
-
count(): number {
|
|
435
|
-
// Reuse compileIQO to avoid duplicating WHERE logic
|
|
436
|
-
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
437
|
-
// Replace "SELECT ... FROM" with "SELECT COUNT(*) as count FROM"
|
|
438
|
-
const countSql = selectSql.replace(/^SELECT .+? FROM/, 'SELECT COUNT(*) as count FROM');
|
|
439
|
-
const results = this.executor(countSql, params, true);
|
|
440
|
-
return (results[0] as any)?.count ?? 0;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/** Alias for get() — returns the first matching row or null. */
|
|
444
|
-
first(): T | null {
|
|
445
|
-
return this.get();
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/** Returns true if at least one row matches the query. */
|
|
449
|
-
exists(): boolean {
|
|
450
|
-
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
451
|
-
const existsSql = selectSql.replace(/^SELECT .+? FROM/, 'SELECT 1 FROM').replace(/ LIMIT \d+/, '') + ' LIMIT 1';
|
|
452
|
-
const results = this.executor(existsSql, params, true);
|
|
453
|
-
return results.length > 0;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
/** Group results by one or more columns. */
|
|
457
|
-
groupBy(...fields: string[]): this {
|
|
458
|
-
this.iqo.groupBy.push(...fields);
|
|
459
|
-
return this;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
// ---------- Thenable (async/await support) ----------
|
|
465
|
-
|
|
466
|
-
then<TResult1 = T[], TResult2 = never>(
|
|
467
|
-
onfulfilled?: ((value: T[]) => TResult1 | PromiseLike<TResult1>) | null,
|
|
468
|
-
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
|
|
469
|
-
): Promise<TResult1 | TResult2> {
|
|
470
|
-
try {
|
|
471
|
-
const result = this.all();
|
|
472
|
-
return Promise.resolve(result).then(onfulfilled, onrejected);
|
|
473
|
-
} catch (err) {
|
|
474
|
-
return Promise.reject(err).then(onfulfilled, onrejected);
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// =============================================================================
|
|
480
|
-
// Proxy Query System
|
|
481
|
-
// =============================================================================
|
|
482
|
-
|
|
483
|
-
/**
|
|
484
|
-
* Represents a reference to a specific table column with an alias.
|
|
485
|
-
* Used as a building block for SQL query construction.
|
|
486
|
-
*/
|
|
487
|
-
export class ColumnNode {
|
|
488
|
-
readonly _type = 'COL' as const;
|
|
489
|
-
constructor(
|
|
490
|
-
readonly table: string,
|
|
491
|
-
readonly column: string,
|
|
492
|
-
readonly alias: string,
|
|
493
|
-
) { }
|
|
494
|
-
|
|
495
|
-
/** Quoted alias.column for use as computed property key */
|
|
496
|
-
toString(): string {
|
|
497
|
-
return `"${this.alias}"."${this.column}"`;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
[Symbol.toPrimitive](): string {
|
|
501
|
-
return this.toString();
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// ---------- SQL Quoting Helpers ----------
|
|
506
|
-
|
|
507
|
-
function q(name: string): string {
|
|
508
|
-
return `"${name}"`;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
function qRef(alias: string, column: string): string {
|
|
512
|
-
return `"${alias}"."${column}"`;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// ---------- Table Proxy ----------
|
|
516
|
-
|
|
517
|
-
function createTableProxy(
|
|
518
|
-
tableName: string,
|
|
519
|
-
alias: string,
|
|
520
|
-
columns: Set<string>,
|
|
521
|
-
): Record<string, ColumnNode> {
|
|
522
|
-
return new Proxy({} as Record<string, ColumnNode>, {
|
|
523
|
-
get(_target, prop: string): ColumnNode | undefined {
|
|
524
|
-
if (prop === Symbol.toPrimitive as any || prop === 'toString' || prop === 'valueOf') {
|
|
525
|
-
return undefined;
|
|
526
|
-
}
|
|
527
|
-
return new ColumnNode(tableName, prop, alias);
|
|
528
|
-
},
|
|
529
|
-
ownKeys() {
|
|
530
|
-
return [...columns];
|
|
531
|
-
},
|
|
532
|
-
getOwnPropertyDescriptor(_target, prop) {
|
|
533
|
-
if (columns.has(prop as string)) {
|
|
534
|
-
return { configurable: true, enumerable: true, value: new ColumnNode(tableName, prop as string, alias) };
|
|
535
|
-
}
|
|
536
|
-
return undefined;
|
|
537
|
-
},
|
|
538
|
-
});
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// ---------- Context Proxy ----------
|
|
542
|
-
|
|
543
|
-
interface AliasEntry {
|
|
544
|
-
tableName: string;
|
|
545
|
-
alias: string;
|
|
546
|
-
proxy: Record<string, ColumnNode>;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
export function createContextProxy(
|
|
550
|
-
schemas: Record<string, z.ZodType<any>>,
|
|
551
|
-
): { proxy: Record<string, Record<string, ColumnNode>>; aliasMap: Map<string, AliasEntry[]> } {
|
|
552
|
-
const aliases = new Map<string, AliasEntry[]>();
|
|
553
|
-
let aliasCounter = 0;
|
|
554
|
-
|
|
555
|
-
const proxy = new Proxy({} as Record<string, Record<string, ColumnNode>>, {
|
|
556
|
-
get(_target, tableName: string) {
|
|
557
|
-
if (typeof tableName !== 'string') return undefined;
|
|
558
|
-
|
|
559
|
-
const schema = schemas[tableName];
|
|
560
|
-
const shape = schema
|
|
561
|
-
? (schema as unknown as z.ZodObject<any>).shape
|
|
562
|
-
: {};
|
|
563
|
-
const columns = new Set(Object.keys(shape));
|
|
564
|
-
|
|
565
|
-
aliasCounter++;
|
|
566
|
-
const alias = `t${aliasCounter}`;
|
|
567
|
-
const tableProxy = createTableProxy(tableName, alias, columns);
|
|
568
|
-
|
|
569
|
-
const entries = aliases.get(tableName) || [];
|
|
570
|
-
entries.push({ tableName, alias, proxy: tableProxy });
|
|
571
|
-
aliases.set(tableName, entries);
|
|
572
|
-
|
|
573
|
-
return tableProxy;
|
|
574
|
-
},
|
|
575
|
-
});
|
|
576
|
-
|
|
577
|
-
return { proxy, aliasMap: aliases };
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// ---------- Proxy Query Result ----------
|
|
17
|
+
// Re-export all public API from split modules
|
|
18
|
+
export { compileIQO, OPERATOR_MAP, transformValueForStorage } from './iqo';
|
|
19
|
+
export type { IQO, WhereCondition, JoinClause, OrderDirection, WhereOperator } from './iqo';
|
|
581
20
|
|
|
582
|
-
|
|
21
|
+
export { QueryBuilder } from './builder';
|
|
22
|
+
import { QueryBuilder } from './builder';
|
|
583
23
|
|
|
584
|
-
export
|
|
585
|
-
|
|
586
|
-
join?: [AnyColumn | undefined, AnyColumn | undefined] | [AnyColumn | undefined, AnyColumn | undefined][];
|
|
587
|
-
where?: Record<string, any>;
|
|
588
|
-
orderBy?: Record<string, 'asc' | 'desc'>;
|
|
589
|
-
limit?: number;
|
|
590
|
-
offset?: number;
|
|
591
|
-
groupBy?: (AnyColumn | undefined)[];
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// ---------- Proxy Query Compiler ----------
|
|
595
|
-
|
|
596
|
-
function isColumnNode(val: any): val is ColumnNode {
|
|
597
|
-
return val && typeof val === 'object' && val._type === 'COL';
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
export function compileProxyQuery(
|
|
601
|
-
queryResult: ProxyQueryResult,
|
|
602
|
-
aliasMap: Map<string, AliasEntry[]>,
|
|
603
|
-
): { sql: string; params: any[] } {
|
|
604
|
-
const params: any[] = [];
|
|
605
|
-
|
|
606
|
-
const tablesUsed = new Map<string, { tableName: string; alias: string }>();
|
|
607
|
-
for (const [tableName, entries] of aliasMap) {
|
|
608
|
-
for (const entry of entries) {
|
|
609
|
-
tablesUsed.set(entry.alias, { tableName, alias: entry.alias });
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
// SELECT
|
|
614
|
-
const selectParts: string[] = [];
|
|
615
|
-
for (const [outputName, colOrValue] of Object.entries(queryResult.select)) {
|
|
616
|
-
if (isColumnNode(colOrValue)) {
|
|
617
|
-
if (outputName === colOrValue.column) {
|
|
618
|
-
selectParts.push(qRef(colOrValue.alias, colOrValue.column));
|
|
619
|
-
} else {
|
|
620
|
-
selectParts.push(`${qRef(colOrValue.alias, colOrValue.column)} AS ${q(outputName)}`);
|
|
621
|
-
}
|
|
622
|
-
} else {
|
|
623
|
-
selectParts.push(`? AS ${q(outputName)}`);
|
|
624
|
-
params.push(colOrValue);
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// FROM / JOIN
|
|
629
|
-
const allAliases = [...tablesUsed.values()];
|
|
630
|
-
if (allAliases.length === 0) throw new Error('No tables referenced in query.');
|
|
631
|
-
|
|
632
|
-
const primaryAlias = allAliases[0]!;
|
|
633
|
-
let sql = `SELECT ${selectParts.join(', ')} FROM ${q(primaryAlias.tableName)} ${q(primaryAlias.alias)}`;
|
|
634
|
-
|
|
635
|
-
if (queryResult.join) {
|
|
636
|
-
const joins: [ColumnNode, ColumnNode][] = Array.isArray(queryResult.join[0])
|
|
637
|
-
? queryResult.join as [ColumnNode, ColumnNode][]
|
|
638
|
-
: [queryResult.join as [ColumnNode, ColumnNode]];
|
|
639
|
-
|
|
640
|
-
for (const [left, right] of joins) {
|
|
641
|
-
const leftTable = tablesUsed.get(left.alias);
|
|
642
|
-
const rightTable = tablesUsed.get(right.alias);
|
|
643
|
-
if (!leftTable || !rightTable) throw new Error('Join references unknown table alias.');
|
|
644
|
-
const joinAlias = leftTable.alias === primaryAlias.alias ? rightTable : leftTable;
|
|
645
|
-
sql += ` JOIN ${q(joinAlias.tableName)} ${q(joinAlias.alias)} ON ${qRef(left.alias, left.column)} = ${qRef(right.alias, right.column)}`;
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// WHERE
|
|
650
|
-
if (queryResult.where && Object.keys(queryResult.where).length > 0) {
|
|
651
|
-
const whereParts: string[] = [];
|
|
652
|
-
|
|
653
|
-
for (const [key, value] of Object.entries(queryResult.where)) {
|
|
654
|
-
let fieldRef: string;
|
|
655
|
-
const quotedMatch = key.match(/^"([^"]+)"\.\"([^"]+)"$/);
|
|
656
|
-
if (quotedMatch && tablesUsed.has(quotedMatch[1]!)) {
|
|
657
|
-
fieldRef = key;
|
|
658
|
-
} else {
|
|
659
|
-
fieldRef = qRef(primaryAlias.alias, key);
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
if (isColumnNode(value)) {
|
|
663
|
-
whereParts.push(`${fieldRef} = ${qRef(value.alias, value.column)}`);
|
|
664
|
-
} else if (Array.isArray(value)) {
|
|
665
|
-
if (value.length === 0) {
|
|
666
|
-
whereParts.push('1 = 0');
|
|
667
|
-
} else {
|
|
668
|
-
const placeholders = value.map(() => '?').join(', ');
|
|
669
|
-
whereParts.push(`${fieldRef} IN (${placeholders})`);
|
|
670
|
-
params.push(...value);
|
|
671
|
-
}
|
|
672
|
-
} else if (typeof value === 'object' && value !== null && !(value instanceof Date)) {
|
|
673
|
-
for (const [pOp, operand] of Object.entries(value)) {
|
|
674
|
-
if (pOp === '$in') {
|
|
675
|
-
const arr = operand as any[];
|
|
676
|
-
if (arr.length === 0) {
|
|
677
|
-
whereParts.push('1 = 0');
|
|
678
|
-
} else {
|
|
679
|
-
const placeholders = arr.map(() => '?').join(', ');
|
|
680
|
-
whereParts.push(`${fieldRef} IN (${placeholders})`);
|
|
681
|
-
params.push(...arr);
|
|
682
|
-
}
|
|
683
|
-
continue;
|
|
684
|
-
}
|
|
685
|
-
const opMap: Record<string, string> = {
|
|
686
|
-
$gt: '>', $gte: '>=', $lt: '<', $lte: '<=', $ne: '!=',
|
|
687
|
-
};
|
|
688
|
-
const sqlOp = opMap[pOp];
|
|
689
|
-
if (!sqlOp) throw new Error(`Unsupported where operator: ${pOp}`);
|
|
690
|
-
whereParts.push(`${fieldRef} ${sqlOp} ?`);
|
|
691
|
-
params.push(operand);
|
|
692
|
-
}
|
|
693
|
-
} else {
|
|
694
|
-
whereParts.push(`${fieldRef} = ?`);
|
|
695
|
-
params.push(value instanceof Date ? value.toISOString() : value);
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
if (whereParts.length > 0) {
|
|
700
|
-
sql += ` WHERE ${whereParts.join(' AND ')}`;
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
// ORDER BY
|
|
705
|
-
if (queryResult.orderBy) {
|
|
706
|
-
const parts: string[] = [];
|
|
707
|
-
for (const [key, dir] of Object.entries(queryResult.orderBy)) {
|
|
708
|
-
let fieldRef: string;
|
|
709
|
-
const quotedMatch = key.match(/^"([^"]+)"\.\"([^"]+)"$/);
|
|
710
|
-
if (quotedMatch && tablesUsed.has(quotedMatch[1]!)) {
|
|
711
|
-
fieldRef = key;
|
|
712
|
-
} else {
|
|
713
|
-
fieldRef = qRef(primaryAlias.alias, key);
|
|
714
|
-
}
|
|
715
|
-
parts.push(`${fieldRef} ${dir.toUpperCase()}`);
|
|
716
|
-
}
|
|
717
|
-
if (parts.length > 0) {
|
|
718
|
-
sql += ` ORDER BY ${parts.join(', ')}`;
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
// GROUP BY
|
|
723
|
-
if (queryResult.groupBy && queryResult.groupBy.length > 0) {
|
|
724
|
-
const parts = queryResult.groupBy.filter(Boolean).map(col => qRef(col!.alias, col!.column));
|
|
725
|
-
sql += ` GROUP BY ${parts.join(', ')}`;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
// LIMIT / OFFSET
|
|
729
|
-
if (queryResult.limit !== undefined) sql += ` LIMIT ${queryResult.limit}`;
|
|
730
|
-
if (queryResult.offset !== undefined) sql += ` OFFSET ${queryResult.offset}`;
|
|
731
|
-
|
|
732
|
-
return { sql, params };
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
/** The main `db.query(c => {...})` entry point. */
|
|
736
|
-
export function executeProxyQuery<T>(
|
|
737
|
-
schemas: Record<string, z.ZodType<any>>,
|
|
738
|
-
callback: (ctx: any) => ProxyQueryResult,
|
|
739
|
-
executor: (sql: string, params: any[]) => T[],
|
|
740
|
-
): T[] {
|
|
741
|
-
const { proxy, aliasMap } = createContextProxy(schemas);
|
|
742
|
-
const queryResult = callback(proxy);
|
|
743
|
-
const { sql, params } = compileProxyQuery(queryResult, aliasMap);
|
|
744
|
-
return executor(sql, params);
|
|
745
|
-
}
|
|
24
|
+
export { ColumnNode, createContextProxy, compileProxyQuery, executeProxyQuery } from './proxy';
|
|
25
|
+
export type { ProxyQueryResult } from './proxy';
|
|
746
26
|
|
|
747
27
|
// =============================================================================
|
|
748
28
|
// QueryBuilder Factory
|
|
@@ -759,6 +39,7 @@ export function createQueryBuilder(ctx: DatabaseContext, entityName: string, ini
|
|
|
759
39
|
const schema = ctx.schemas[entityName]!;
|
|
760
40
|
|
|
761
41
|
const executor = (sql: string, params: any[], raw: boolean): any[] => {
|
|
42
|
+
if (ctx.debug) console.log('[satidb]', sql, params);
|
|
762
43
|
const rows = ctx.db.query(sql).all(...params);
|
|
763
44
|
if (raw) return rows;
|
|
764
45
|
return rows.map((row: any) => ctx.attachMethods(entityName, transformFromStorage(row, schema)));
|
|
@@ -852,5 +133,11 @@ export function createQueryBuilder(ctx: DatabaseContext, entityName: string, ini
|
|
|
852
133
|
|
|
853
134
|
const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, eagerLoader);
|
|
854
135
|
if (initialCols.length > 0) builder.select(...initialCols);
|
|
136
|
+
|
|
137
|
+
// Auto-filter soft-deleted rows unless withTrashed() is called
|
|
138
|
+
if (ctx.softDeletes) {
|
|
139
|
+
builder.where({ deletedAt: { $isNull: true } });
|
|
140
|
+
}
|
|
141
|
+
|
|
855
142
|
return builder;
|
|
856
143
|
}
|