turbine-orm 0.3.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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/dist/cli/config.d.ts +58 -0
  4. package/dist/cli/config.d.ts.map +1 -0
  5. package/dist/cli/config.js +123 -0
  6. package/dist/cli/config.js.map +1 -0
  7. package/dist/cli/index.d.ts +23 -0
  8. package/dist/cli/index.d.ts.map +1 -0
  9. package/dist/cli/index.js +935 -0
  10. package/dist/cli/index.js.map +1 -0
  11. package/dist/cli/migrate.d.ts +94 -0
  12. package/dist/cli/migrate.d.ts.map +1 -0
  13. package/dist/cli/migrate.js +383 -0
  14. package/dist/cli/migrate.js.map +1 -0
  15. package/dist/cli/ui.d.ts +74 -0
  16. package/dist/cli/ui.d.ts.map +1 -0
  17. package/dist/cli/ui.js +220 -0
  18. package/dist/cli/ui.js.map +1 -0
  19. package/dist/client.d.ts +212 -0
  20. package/dist/client.d.ts.map +1 -0
  21. package/dist/client.js +423 -0
  22. package/dist/client.js.map +1 -0
  23. package/dist/generate.d.ts +24 -0
  24. package/dist/generate.d.ts.map +1 -0
  25. package/dist/generate.js +289 -0
  26. package/dist/generate.js.map +1 -0
  27. package/dist/index.d.ts +44 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +53 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/introspect.d.ts +22 -0
  32. package/dist/introspect.d.ts.map +1 -0
  33. package/dist/introspect.js +284 -0
  34. package/dist/introspect.js.map +1 -0
  35. package/dist/pipeline.d.ts +44 -0
  36. package/dist/pipeline.d.ts.map +1 -0
  37. package/dist/pipeline.js +69 -0
  38. package/dist/pipeline.js.map +1 -0
  39. package/dist/query.d.ts +342 -0
  40. package/dist/query.d.ts.map +1 -0
  41. package/dist/query.js +1396 -0
  42. package/dist/query.js.map +1 -0
  43. package/dist/schema-builder.d.ts +127 -0
  44. package/dist/schema-builder.d.ts.map +1 -0
  45. package/dist/schema-builder.js +164 -0
  46. package/dist/schema-builder.js.map +1 -0
  47. package/dist/schema-sql.d.ts +71 -0
  48. package/dist/schema-sql.d.ts.map +1 -0
  49. package/dist/schema-sql.js +347 -0
  50. package/dist/schema-sql.js.map +1 -0
  51. package/dist/schema.d.ts +90 -0
  52. package/dist/schema.d.ts.map +1 -0
  53. package/dist/schema.js +129 -0
  54. package/dist/schema.js.map +1 -0
  55. package/dist/serverless.d.ts +162 -0
  56. package/dist/serverless.d.ts.map +1 -0
  57. package/dist/serverless.js +195 -0
  58. package/dist/serverless.js.map +1 -0
  59. package/dist/types.d.ts +93 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +126 -0
  62. package/dist/types.js.map +1 -0
  63. package/package.json +74 -0
package/dist/query.js ADDED
@@ -0,0 +1,1396 @@
1
+ /**
2
+ * @batadata/turbine — Query builder
3
+ *
4
+ * Each table accessor (db.users, db.posts, etc.) returns a QueryInterface<T>
5
+ * that builds parameterized SQL and executes it through the connection pool.
6
+ *
7
+ * Nested relations use json_build_object + json_agg subqueries for single-query
8
+ * resolution — the technique that benchmarks proved 2-3x faster than Prisma.
9
+ *
10
+ * Schema-driven: all column names, types, and relations come from introspected
11
+ * metadata — nothing is hardcoded.
12
+ */
13
+ import { snakeToCamel, camelToSnake } from './schema.js';
14
+ /** Known operator keys — used to detect operator objects vs plain values */
15
+ const OPERATOR_KEYS = new Set([
16
+ 'gt', 'gte', 'lt', 'lte', 'not', 'in', 'notIn',
17
+ 'contains', 'startsWith', 'endsWith',
18
+ ]);
19
+ /** Check if a value is a where operator object (has at least one known operator key) */
20
+ function isWhereOperator(value) {
21
+ if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
22
+ return false;
23
+ }
24
+ const keys = Object.keys(value);
25
+ return keys.length > 0 && keys.every((k) => OPERATOR_KEYS.has(k));
26
+ }
27
+ /** Known JSONB operator keys */
28
+ const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
29
+ /** Check if a value is a JSONB filter object */
30
+ function isJsonFilter(value) {
31
+ if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
32
+ return false;
33
+ }
34
+ const keys = Object.keys(value);
35
+ return keys.length > 0 && keys.some((k) => JSONB_OPERATOR_KEYS.has(k));
36
+ }
37
+ /** Known Array operator keys */
38
+ const ARRAY_OPERATOR_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
39
+ /** Check if a value is an Array filter object */
40
+ function isArrayFilter(value) {
41
+ if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
42
+ return false;
43
+ }
44
+ const keys = Object.keys(value);
45
+ return keys.length > 0 && keys.some((k) => ARRAY_OPERATOR_KEYS.has(k));
46
+ }
47
+ export class QueryInterface {
48
+ pool;
49
+ table;
50
+ schema;
51
+ tableMeta;
52
+ /** SQL template cache: cacheKey → sql string (params are always positional $1,$2,...) */
53
+ sqlCache = new Map();
54
+ middlewares;
55
+ constructor(pool, table, schema, middlewares) {
56
+ this.pool = pool;
57
+ this.table = table;
58
+ this.schema = schema;
59
+ const meta = schema.tables[table];
60
+ if (!meta) {
61
+ throw new Error(`[turbine] Unknown table "${table}". Available: ${Object.keys(schema.tables).join(', ')}`);
62
+ }
63
+ this.tableMeta = meta;
64
+ this.middlewares = middlewares ?? [];
65
+ }
66
+ /**
67
+ * Execute a query through the middleware chain.
68
+ * If no middlewares are registered, executes directly.
69
+ */
70
+ async executeWithMiddleware(action, args, executor) {
71
+ if (this.middlewares.length === 0) {
72
+ return executor();
73
+ }
74
+ const params = { model: this.table, action, args: { ...args } };
75
+ // Build middleware chain
76
+ let index = 0;
77
+ const next = async (p) => {
78
+ if (index < this.middlewares.length) {
79
+ const mw = this.middlewares[index++];
80
+ return mw(p, next);
81
+ }
82
+ // End of chain — execute the actual query
83
+ return executor();
84
+ };
85
+ return next(params);
86
+ }
87
+ /**
88
+ * Generate a cache key for a query shape.
89
+ * Same where-keys + same with-clause = same SQL template.
90
+ */
91
+ cacheKey(op, whereKeys, withClause, extra) {
92
+ let key = `${op}:${whereKeys.sort().join(',')}`;
93
+ if (withClause)
94
+ key += `:w=${JSON.stringify(Object.keys(withClause).sort())}`;
95
+ if (extra)
96
+ key += `:${extra}`;
97
+ return key;
98
+ }
99
+ // -------------------------------------------------------------------------
100
+ // findUnique
101
+ // -------------------------------------------------------------------------
102
+ async findUnique(args) {
103
+ return this.executeWithMiddleware('findUnique', args, async () => {
104
+ const deferred = this.buildFindUnique(args);
105
+ const result = await this.pool.query(deferred.sql, deferred.params);
106
+ return deferred.transform(result);
107
+ });
108
+ }
109
+ buildFindUnique(args) {
110
+ const columnsList = this.resolveColumns(args.select, args.omit);
111
+ const whereObj = args.where;
112
+ // Check if all where values are simple (plain equality, no operators/null/OR)
113
+ const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
114
+ const isSimpleWhere = !whereObj['OR'] && whereKeys.every((k) => {
115
+ const v = whereObj[k];
116
+ return v !== null && !isWhereOperator(v);
117
+ });
118
+ // For simple queries (no nested with, no operators), use cached SQL template
119
+ if (!args.with && isSimpleWhere) {
120
+ const colKey = columnsList ? columnsList.join(',') : '*';
121
+ const ck = `fu:${whereKeys.sort().join(',')}:c=${colKey}`;
122
+ let sql = this.sqlCache.get(ck);
123
+ const params = whereKeys.map((k) => whereObj[k]);
124
+ if (!sql) {
125
+ const whereClauses = whereKeys.map((k, i) => `${this.toColumn(k)} = $${i + 1}`);
126
+ const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
127
+ const selectExpr = columnsList
128
+ ? columnsList.map((c) => `${this.table}.${c}`).join(', ')
129
+ : `${this.table}.*`;
130
+ sql = `SELECT ${selectExpr} FROM ${this.table}${whereSql} LIMIT 1`;
131
+ this.sqlCache.set(ck, sql);
132
+ }
133
+ return {
134
+ sql,
135
+ params,
136
+ transform: (result) => {
137
+ const row = result.rows[0];
138
+ return row ? this.parseRow(row, this.table) : null;
139
+ },
140
+ tag: `${this.table}.findUnique`,
141
+ };
142
+ }
143
+ // General path: supports operators, null, OR, nested with
144
+ const { sql: whereSql, params } = this.buildWhere(args.where);
145
+ if (!args.with) {
146
+ const selectExpr = columnsList
147
+ ? columnsList.map((c) => `${this.table}.${c}`).join(', ')
148
+ : `${this.table}.*`;
149
+ const sql = `SELECT ${selectExpr} FROM ${this.table}${whereSql} LIMIT 1`;
150
+ return {
151
+ sql,
152
+ params,
153
+ transform: (result) => {
154
+ const row = result.rows[0];
155
+ return row ? this.parseRow(row, this.table) : null;
156
+ },
157
+ tag: `${this.table}.findUnique`,
158
+ };
159
+ }
160
+ // Nested queries: build fresh each time (with clause affects params)
161
+ const selectClause = this.buildSelectWithRelations(this.table, args.with, params, columnsList);
162
+ const sql = `SELECT ${selectClause} FROM ${this.table}${whereSql} LIMIT 1`;
163
+ return {
164
+ sql,
165
+ params,
166
+ transform: (result) => {
167
+ const row = result.rows[0];
168
+ return row ? this.parseNestedRow(row, this.table) : null;
169
+ },
170
+ tag: `${this.table}.findUnique`,
171
+ };
172
+ }
173
+ // -------------------------------------------------------------------------
174
+ // findMany
175
+ // -------------------------------------------------------------------------
176
+ async findMany(args) {
177
+ return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
178
+ const deferred = this.buildFindMany(args);
179
+ const result = await this.pool.query(deferred.sql, deferred.params);
180
+ return deferred.transform(result);
181
+ });
182
+ }
183
+ buildFindMany(args) {
184
+ const { sql: whereSql, params } = args?.where
185
+ ? this.buildWhere(args.where)
186
+ : { sql: '', params: [] };
187
+ const columnsList = this.resolveColumns(args?.select, args?.omit);
188
+ // Distinct support
189
+ let distinctPrefix = '';
190
+ if (args?.distinct && args.distinct.length > 0) {
191
+ const distinctCols = args.distinct.map((k) => this.toColumn(k));
192
+ distinctPrefix = `DISTINCT ON (${distinctCols.join(', ')}) `;
193
+ }
194
+ let selectClause;
195
+ if (args?.with) {
196
+ selectClause = this.buildSelectWithRelations(this.table, args.with, params, columnsList);
197
+ }
198
+ else if (columnsList) {
199
+ selectClause = columnsList.map((c) => `${this.table}.${c}`).join(', ');
200
+ }
201
+ else {
202
+ selectClause = `${this.table}.*`;
203
+ }
204
+ let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${this.table}${whereSql}`;
205
+ // Cursor-based pagination: add WHERE condition for cursor
206
+ if (args?.cursor) {
207
+ const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
208
+ if (cursorEntries.length > 0) {
209
+ // Determine direction from orderBy (default 'asc')
210
+ const cursorConditions = cursorEntries.map(([k, v]) => {
211
+ const col = this.toColumn(k);
212
+ const dir = args.orderBy?.[k] ?? 'asc';
213
+ const op = dir === 'desc' ? '<' : '>';
214
+ params.push(v);
215
+ return `${this.table}.${col} ${op} $${params.length}`;
216
+ });
217
+ // Append to existing WHERE or create new one
218
+ if (whereSql) {
219
+ sql += ` AND ${cursorConditions.join(' AND ')}`;
220
+ }
221
+ else {
222
+ sql += ` WHERE ${cursorConditions.join(' AND ')}`;
223
+ }
224
+ }
225
+ }
226
+ if (args?.orderBy) {
227
+ sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
228
+ }
229
+ // take overrides limit when cursor pagination is used
230
+ const effectiveLimit = args?.take ?? args?.limit;
231
+ if (effectiveLimit !== undefined) {
232
+ sql += ` LIMIT ${Number(effectiveLimit)}`;
233
+ }
234
+ if (args?.offset !== undefined) {
235
+ sql += ` OFFSET ${Number(args.offset)}`;
236
+ }
237
+ return {
238
+ sql,
239
+ params,
240
+ transform: (result) => result.rows.map((row) => args?.with
241
+ ? this.parseNestedRow(row, this.table)
242
+ : this.parseRow(row, this.table)),
243
+ tag: `${this.table}.findMany`,
244
+ };
245
+ }
246
+ // -------------------------------------------------------------------------
247
+ // findFirst — like findMany but returns a single row or null
248
+ // -------------------------------------------------------------------------
249
+ async findFirst(args) {
250
+ return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
251
+ const deferred = this.buildFindFirst(args);
252
+ const result = await this.pool.query(deferred.sql, deferred.params);
253
+ return deferred.transform(result);
254
+ });
255
+ }
256
+ buildFindFirst(args) {
257
+ // Reuse findMany's SQL builder but force LIMIT 1
258
+ const findManyArgs = { ...args, limit: 1 };
259
+ const deferred = this.buildFindMany(findManyArgs);
260
+ return {
261
+ sql: deferred.sql,
262
+ params: deferred.params,
263
+ transform: (result) => {
264
+ const rows = deferred.transform(result);
265
+ return rows.length > 0 ? rows[0] : null;
266
+ },
267
+ tag: `${this.table}.findFirst`,
268
+ };
269
+ }
270
+ // -------------------------------------------------------------------------
271
+ // findFirstOrThrow — like findFirst but throws if no record found
272
+ // -------------------------------------------------------------------------
273
+ async findFirstOrThrow(args) {
274
+ return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
275
+ const deferred = this.buildFindFirstOrThrow(args);
276
+ const result = await this.pool.query(deferred.sql, deferred.params);
277
+ return deferred.transform(result);
278
+ });
279
+ }
280
+ buildFindFirstOrThrow(args) {
281
+ const inner = this.buildFindFirst(args);
282
+ return {
283
+ sql: inner.sql,
284
+ params: inner.params,
285
+ transform: (result) => {
286
+ const row = inner.transform(result);
287
+ if (row === null) {
288
+ throw new Error('Record not found');
289
+ }
290
+ return row;
291
+ },
292
+ tag: `${this.table}.findFirstOrThrow`,
293
+ };
294
+ }
295
+ // -------------------------------------------------------------------------
296
+ // findUniqueOrThrow — like findUnique but throws if no record found
297
+ // -------------------------------------------------------------------------
298
+ async findUniqueOrThrow(args) {
299
+ return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
300
+ const deferred = this.buildFindUniqueOrThrow(args);
301
+ const result = await this.pool.query(deferred.sql, deferred.params);
302
+ return deferred.transform(result);
303
+ });
304
+ }
305
+ buildFindUniqueOrThrow(args) {
306
+ const inner = this.buildFindUnique(args);
307
+ return {
308
+ sql: inner.sql,
309
+ params: inner.params,
310
+ transform: (result) => {
311
+ const row = inner.transform(result);
312
+ if (row === null) {
313
+ throw new Error('Record not found');
314
+ }
315
+ return row;
316
+ },
317
+ tag: `${this.table}.findUniqueOrThrow`,
318
+ };
319
+ }
320
+ // -------------------------------------------------------------------------
321
+ // create
322
+ // -------------------------------------------------------------------------
323
+ async create(args) {
324
+ return this.executeWithMiddleware('create', args, async () => {
325
+ const deferred = this.buildCreate(args);
326
+ const result = await this.pool.query(deferred.sql, deferred.params);
327
+ return deferred.transform(result);
328
+ });
329
+ }
330
+ buildCreate(args) {
331
+ const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
332
+ const columns = entries.map(([k]) => this.toColumn(k));
333
+ const params = entries.map(([, v]) => v);
334
+ const placeholders = entries.map((_, i) => `$${i + 1}`);
335
+ const sql = `INSERT INTO ${this.table} (${columns.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`;
336
+ return {
337
+ sql,
338
+ params,
339
+ transform: (result) => this.parseRow(result.rows[0], this.table),
340
+ tag: `${this.table}.create`,
341
+ };
342
+ }
343
+ // -------------------------------------------------------------------------
344
+ // createMany — uses UNNEST for performance
345
+ // -------------------------------------------------------------------------
346
+ async createMany(args) {
347
+ return this.executeWithMiddleware('createMany', args, async () => {
348
+ const deferred = this.buildCreateMany(args);
349
+ const result = await this.pool.query(deferred.sql, deferred.params);
350
+ return deferred.transform(result);
351
+ });
352
+ }
353
+ buildCreateMany(args) {
354
+ if (args.data.length === 0) {
355
+ return {
356
+ sql: `SELECT * FROM ${this.table} WHERE false`,
357
+ params: [],
358
+ transform: () => [],
359
+ tag: `${this.table}.createMany`,
360
+ };
361
+ }
362
+ const keys = Object.keys(args.data[0]).filter((k) => args.data[0][k] !== undefined);
363
+ const columns = keys.map((k) => this.toColumn(k));
364
+ // Build column arrays for UNNEST
365
+ const columnArrays = keys.map(() => []);
366
+ for (const row of args.data) {
367
+ const record = row;
368
+ keys.forEach((key, i) => {
369
+ columnArrays[i].push(record[key]);
370
+ });
371
+ }
372
+ // Use actual Postgres types for array casts
373
+ const typeCasts = columns.map((col) => this.getColumnArrayType(col));
374
+ const unnestArgs = columnArrays.map((_, i) => `$${i + 1}::${typeCasts[i]}`);
375
+ let sql = `INSERT INTO ${this.table} (${columns.join(', ')}) SELECT * FROM UNNEST(${unnestArgs.join(', ')})`;
376
+ // skipDuplicates: add ON CONFLICT DO NOTHING
377
+ if (args.skipDuplicates) {
378
+ sql += ` ON CONFLICT DO NOTHING`;
379
+ }
380
+ sql += ` RETURNING *`;
381
+ return {
382
+ sql,
383
+ params: columnArrays,
384
+ transform: (result) => result.rows.map((row) => this.parseRow(row, this.table)),
385
+ tag: `${this.table}.createMany`,
386
+ };
387
+ }
388
+ // -------------------------------------------------------------------------
389
+ // update
390
+ // -------------------------------------------------------------------------
391
+ async update(args) {
392
+ return this.executeWithMiddleware('update', args, async () => {
393
+ const deferred = this.buildUpdate(args);
394
+ const result = await this.pool.query(deferred.sql, deferred.params);
395
+ return deferred.transform(result);
396
+ });
397
+ }
398
+ buildUpdate(args) {
399
+ const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
400
+ // Build SET params first
401
+ const params = [];
402
+ const setClauses = setEntries.map(([k, v]) => {
403
+ params.push(v);
404
+ return `${this.toColumn(k)} = $${params.length}`;
405
+ });
406
+ // Build WHERE using the shared params array (continues numbering after SET params)
407
+ const whereClause = this.buildWhereClause(args.where, params);
408
+ const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
409
+ const sql = `UPDATE ${this.table} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
410
+ return {
411
+ sql,
412
+ params,
413
+ transform: (result) => this.parseRow(result.rows[0], this.table),
414
+ tag: `${this.table}.update`,
415
+ };
416
+ }
417
+ // -------------------------------------------------------------------------
418
+ // delete
419
+ // -------------------------------------------------------------------------
420
+ async delete(args) {
421
+ return this.executeWithMiddleware('delete', args, async () => {
422
+ const deferred = this.buildDelete(args);
423
+ const result = await this.pool.query(deferred.sql, deferred.params);
424
+ return deferred.transform(result);
425
+ });
426
+ }
427
+ buildDelete(args) {
428
+ const { sql: whereSql, params } = this.buildWhere(args.where);
429
+ const sql = `DELETE FROM ${this.table}${whereSql} RETURNING *`;
430
+ return {
431
+ sql,
432
+ params,
433
+ transform: (result) => this.parseRow(result.rows[0], this.table),
434
+ tag: `${this.table}.delete`,
435
+ };
436
+ }
437
+ // -------------------------------------------------------------------------
438
+ // upsert — INSERT ... ON CONFLICT ... DO UPDATE
439
+ // -------------------------------------------------------------------------
440
+ async upsert(args) {
441
+ return this.executeWithMiddleware('upsert', args, async () => {
442
+ const deferred = this.buildUpsert(args);
443
+ const result = await this.pool.query(deferred.sql, deferred.params);
444
+ return deferred.transform(result);
445
+ });
446
+ }
447
+ buildUpsert(args) {
448
+ // Build the INSERT part from create data
449
+ const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
450
+ const columns = createEntries.map(([k]) => this.toColumn(k));
451
+ const createParams = createEntries.map(([, v]) => v);
452
+ const placeholders = createEntries.map((_, i) => `$${i + 1}`);
453
+ // The conflict target comes from `where` keys — must be unique/PK columns
454
+ const conflictKeys = Object.keys(args.where).filter((k) => args.where[k] !== undefined);
455
+ const conflictColumns = conflictKeys.map((k) => this.toColumn(k));
456
+ // Build the UPDATE SET part
457
+ const updateEntries = Object.entries(args.update).filter(([, v]) => v !== undefined);
458
+ let paramIdx = createParams.length + 1;
459
+ const setClauses = updateEntries.map(([k]) => {
460
+ const clause = `${this.toColumn(k)} = $${paramIdx}`;
461
+ paramIdx++;
462
+ return clause;
463
+ });
464
+ const updateParams = updateEntries.map(([, v]) => v);
465
+ const params = [...createParams, ...updateParams];
466
+ const sql = `INSERT INTO ${this.table} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})` +
467
+ ` ON CONFLICT (${conflictColumns.join(', ')}) DO UPDATE SET ${setClauses.join(', ')}` +
468
+ ` RETURNING *`;
469
+ return {
470
+ sql,
471
+ params,
472
+ transform: (result) => this.parseRow(result.rows[0], this.table),
473
+ tag: `${this.table}.upsert`,
474
+ };
475
+ }
476
+ // -------------------------------------------------------------------------
477
+ // updateMany — UPDATE ... WHERE ... returning count
478
+ // -------------------------------------------------------------------------
479
+ async updateMany(args) {
480
+ return this.executeWithMiddleware('updateMany', args, async () => {
481
+ const deferred = this.buildUpdateMany(args);
482
+ const result = await this.pool.query(deferred.sql, deferred.params);
483
+ return deferred.transform(result);
484
+ });
485
+ }
486
+ buildUpdateMany(args) {
487
+ const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
488
+ // Build SET params first
489
+ const params = [];
490
+ const setClauses = setEntries.map(([k, v]) => {
491
+ params.push(v);
492
+ return `${this.toColumn(k)} = $${params.length}`;
493
+ });
494
+ // Build WHERE using the shared params array (continues numbering after SET params)
495
+ const whereClause = this.buildWhereClause(args.where, params);
496
+ const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
497
+ const sql = `UPDATE ${this.table} SET ${setClauses.join(', ')}${whereSql}`;
498
+ return {
499
+ sql,
500
+ params,
501
+ transform: (result) => ({ count: result.rowCount ?? 0 }),
502
+ tag: `${this.table}.updateMany`,
503
+ };
504
+ }
505
+ // -------------------------------------------------------------------------
506
+ // deleteMany — DELETE ... WHERE ... returning count
507
+ // -------------------------------------------------------------------------
508
+ async deleteMany(args) {
509
+ return this.executeWithMiddleware('deleteMany', args, async () => {
510
+ const deferred = this.buildDeleteMany(args);
511
+ const result = await this.pool.query(deferred.sql, deferred.params);
512
+ return deferred.transform(result);
513
+ });
514
+ }
515
+ buildDeleteMany(args) {
516
+ const { sql: whereSql, params } = this.buildWhere(args.where);
517
+ const sql = `DELETE FROM ${this.table}${whereSql}`;
518
+ return {
519
+ sql,
520
+ params,
521
+ transform: (result) => ({ count: result.rowCount ?? 0 }),
522
+ tag: `${this.table}.deleteMany`,
523
+ };
524
+ }
525
+ // -------------------------------------------------------------------------
526
+ // count
527
+ // -------------------------------------------------------------------------
528
+ async count(args) {
529
+ return this.executeWithMiddleware('count', (args ?? {}), async () => {
530
+ const deferred = this.buildCount(args);
531
+ const result = await this.pool.query(deferred.sql, deferred.params);
532
+ return deferred.transform(result);
533
+ });
534
+ }
535
+ buildCount(args) {
536
+ const { sql: whereSql, params } = args?.where
537
+ ? this.buildWhere(args.where)
538
+ : { sql: '', params: [] };
539
+ const sql = `SELECT COUNT(*)::int AS count FROM ${this.table}${whereSql}`;
540
+ return {
541
+ sql,
542
+ params,
543
+ transform: (result) => result.rows[0].count,
544
+ tag: `${this.table}.count`,
545
+ };
546
+ }
547
+ // -------------------------------------------------------------------------
548
+ // groupBy (with aggregate functions)
549
+ // -------------------------------------------------------------------------
550
+ async groupBy(args) {
551
+ return this.executeWithMiddleware('groupBy', args, async () => {
552
+ const deferred = this.buildGroupBy(args);
553
+ const result = await this.pool.query(deferred.sql, deferred.params);
554
+ return deferred.transform(result);
555
+ });
556
+ }
557
+ buildGroupBy(args) {
558
+ const groupCols = args.by.map((k) => this.toColumn(k));
559
+ const { sql: whereSql, params } = args.where
560
+ ? this.buildWhere(args.where)
561
+ : { sql: '', params: [] };
562
+ // Build SELECT expressions: group-by columns + aggregate functions
563
+ const selectExprs = [...groupCols];
564
+ // _count
565
+ if (args._count === true || args._count === undefined) {
566
+ // default: always include count
567
+ selectExprs.push('COUNT(*)::int AS _count');
568
+ }
569
+ // _sum
570
+ if (args._sum) {
571
+ for (const [field, enabled] of Object.entries(args._sum)) {
572
+ if (enabled) {
573
+ const col = this.toColumn(field);
574
+ selectExprs.push(`SUM(${col}) AS _sum_${col}`);
575
+ }
576
+ }
577
+ }
578
+ // _avg
579
+ if (args._avg) {
580
+ for (const [field, enabled] of Object.entries(args._avg)) {
581
+ if (enabled) {
582
+ const col = this.toColumn(field);
583
+ selectExprs.push(`AVG(${col})::float AS _avg_${col}`);
584
+ }
585
+ }
586
+ }
587
+ // _min
588
+ if (args._min) {
589
+ for (const [field, enabled] of Object.entries(args._min)) {
590
+ if (enabled) {
591
+ const col = this.toColumn(field);
592
+ selectExprs.push(`MIN(${col}) AS _min_${col}`);
593
+ }
594
+ }
595
+ }
596
+ // _max
597
+ if (args._max) {
598
+ for (const [field, enabled] of Object.entries(args._max)) {
599
+ if (enabled) {
600
+ const col = this.toColumn(field);
601
+ selectExprs.push(`MAX(${col}) AS _max_${col}`);
602
+ }
603
+ }
604
+ }
605
+ let sql = `SELECT ${selectExprs.join(', ')} FROM ${this.table}${whereSql} GROUP BY ${groupCols.join(', ')}`;
606
+ // ORDER BY
607
+ if (args.orderBy) {
608
+ sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
609
+ }
610
+ return {
611
+ sql,
612
+ params,
613
+ transform: (result) => result.rows.map((row) => {
614
+ const parsed = this.parseRow(row, this.table);
615
+ // Restructure aggregate results into nested objects (Prisma-style)
616
+ const restructured = {};
617
+ // Copy group-by fields
618
+ for (const field of args.by) {
619
+ restructured[field] = parsed[field];
620
+ }
621
+ // _count
622
+ if ('_count' in row) {
623
+ restructured._count = row._count;
624
+ }
625
+ else if ('count' in row) {
626
+ restructured._count = row.count;
627
+ }
628
+ // Collect aggregates into nested objects
629
+ const sumObj = {};
630
+ const avgObj = {};
631
+ const minObj = {};
632
+ const maxObj = {};
633
+ let hasSums = false, hasAvgs = false, hasMins = false, hasMaxs = false;
634
+ for (const [rawKey, rawValue] of Object.entries(row)) {
635
+ if (rawKey.startsWith('_sum_')) {
636
+ const col = rawKey.slice(5);
637
+ const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
638
+ sumObj[field] = rawValue !== null ? Number(rawValue) : null;
639
+ hasSums = true;
640
+ }
641
+ else if (rawKey.startsWith('_avg_')) {
642
+ const col = rawKey.slice(5);
643
+ const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
644
+ avgObj[field] = rawValue !== null ? Number(rawValue) : null;
645
+ hasAvgs = true;
646
+ }
647
+ else if (rawKey.startsWith('_min_')) {
648
+ const col = rawKey.slice(5);
649
+ const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
650
+ minObj[field] = rawValue;
651
+ hasMins = true;
652
+ }
653
+ else if (rawKey.startsWith('_max_')) {
654
+ const col = rawKey.slice(5);
655
+ const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
656
+ maxObj[field] = rawValue;
657
+ hasMaxs = true;
658
+ }
659
+ }
660
+ if (hasSums)
661
+ restructured._sum = sumObj;
662
+ if (hasAvgs)
663
+ restructured._avg = avgObj;
664
+ if (hasMins)
665
+ restructured._min = minObj;
666
+ if (hasMaxs)
667
+ restructured._max = maxObj;
668
+ return restructured;
669
+ }),
670
+ tag: `${this.table}.groupBy`,
671
+ };
672
+ }
673
+ // -------------------------------------------------------------------------
674
+ // aggregate — standalone aggregation without groupBy
675
+ // -------------------------------------------------------------------------
676
+ async aggregate(args) {
677
+ return this.executeWithMiddleware('aggregate', args, async () => {
678
+ const deferred = this.buildAggregate(args);
679
+ const result = await this.pool.query(deferred.sql, deferred.params);
680
+ return deferred.transform(result);
681
+ });
682
+ }
683
+ buildAggregate(args) {
684
+ const { sql: whereSql, params } = args.where
685
+ ? this.buildWhere(args.where)
686
+ : { sql: '', params: [] };
687
+ const selectExprs = [];
688
+ // _count
689
+ if (args._count === true) {
690
+ selectExprs.push('COUNT(*)::int AS _count');
691
+ }
692
+ else if (args._count && typeof args._count === 'object') {
693
+ for (const [field, enabled] of Object.entries(args._count)) {
694
+ if (enabled) {
695
+ const col = this.toColumn(field);
696
+ selectExprs.push(`COUNT(${col})::int AS _count_${col}`);
697
+ }
698
+ }
699
+ }
700
+ // _sum
701
+ if (args._sum) {
702
+ for (const [field, enabled] of Object.entries(args._sum)) {
703
+ if (enabled) {
704
+ const col = this.toColumn(field);
705
+ selectExprs.push(`SUM(${col}) AS _sum_${col}`);
706
+ }
707
+ }
708
+ }
709
+ // _avg
710
+ if (args._avg) {
711
+ for (const [field, enabled] of Object.entries(args._avg)) {
712
+ if (enabled) {
713
+ const col = this.toColumn(field);
714
+ selectExprs.push(`AVG(${col})::float AS _avg_${col}`);
715
+ }
716
+ }
717
+ }
718
+ // _min
719
+ if (args._min) {
720
+ for (const [field, enabled] of Object.entries(args._min)) {
721
+ if (enabled) {
722
+ const col = this.toColumn(field);
723
+ selectExprs.push(`MIN(${col}) AS _min_${col}`);
724
+ }
725
+ }
726
+ }
727
+ // _max
728
+ if (args._max) {
729
+ for (const [field, enabled] of Object.entries(args._max)) {
730
+ if (enabled) {
731
+ const col = this.toColumn(field);
732
+ selectExprs.push(`MAX(${col}) AS _max_${col}`);
733
+ }
734
+ }
735
+ }
736
+ if (selectExprs.length === 0) {
737
+ selectExprs.push('COUNT(*)::int AS _count');
738
+ }
739
+ const sql = `SELECT ${selectExprs.join(', ')} FROM ${this.table}${whereSql}`;
740
+ return {
741
+ sql,
742
+ params,
743
+ transform: (result) => {
744
+ const row = result.rows[0];
745
+ const aggResult = {};
746
+ // _count
747
+ if (row._count !== undefined) {
748
+ aggResult._count = row._count;
749
+ }
750
+ else {
751
+ // Check for per-column counts
752
+ const countObj = {};
753
+ let hasCountFields = false;
754
+ for (const [key, val] of Object.entries(row)) {
755
+ if (key.startsWith('_count_')) {
756
+ const col = key.slice(7);
757
+ const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
758
+ countObj[field] = val;
759
+ hasCountFields = true;
760
+ }
761
+ }
762
+ if (hasCountFields)
763
+ aggResult._count = countObj;
764
+ }
765
+ // Build nested aggregate objects
766
+ const sumObj = {};
767
+ const avgObj = {};
768
+ const minObj = {};
769
+ const maxObj = {};
770
+ let hasSums = false, hasAvgs = false, hasMins = false, hasMaxs = false;
771
+ for (const [key, val] of Object.entries(row)) {
772
+ if (key.startsWith('_sum_')) {
773
+ const col = key.slice(5);
774
+ const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
775
+ sumObj[field] = val !== null ? Number(val) : null;
776
+ hasSums = true;
777
+ }
778
+ else if (key.startsWith('_avg_')) {
779
+ const col = key.slice(5);
780
+ const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
781
+ avgObj[field] = val !== null ? Number(val) : null;
782
+ hasAvgs = true;
783
+ }
784
+ else if (key.startsWith('_min_')) {
785
+ const col = key.slice(5);
786
+ const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
787
+ minObj[field] = val;
788
+ hasMins = true;
789
+ }
790
+ else if (key.startsWith('_max_')) {
791
+ const col = key.slice(5);
792
+ const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
793
+ maxObj[field] = val;
794
+ hasMaxs = true;
795
+ }
796
+ }
797
+ if (hasSums)
798
+ aggResult._sum = sumObj;
799
+ if (hasAvgs)
800
+ aggResult._avg = avgObj;
801
+ if (hasMins)
802
+ aggResult._min = minObj;
803
+ if (hasMaxs)
804
+ aggResult._max = maxObj;
805
+ return aggResult;
806
+ },
807
+ tag: `${this.table}.aggregate`,
808
+ };
809
+ }
810
+ // =========================================================================
811
+ // Internal helpers
812
+ // =========================================================================
813
+ /**
814
+ * Resolve select/omit options into a list of snake_case column names.
815
+ * Returns null if neither is provided (meaning all columns).
816
+ */
817
+ resolveColumns(select, omit) {
818
+ if (select) {
819
+ // Only include columns where value is true
820
+ return Object.entries(select)
821
+ .filter(([, v]) => v)
822
+ .map(([k]) => this.toColumn(k));
823
+ }
824
+ if (omit) {
825
+ // Include all columns except those where value is true
826
+ const omitCols = new Set(Object.entries(omit)
827
+ .filter(([, v]) => v)
828
+ .map(([k]) => this.toColumn(k)));
829
+ return this.tableMeta.allColumns.filter((col) => !omitCols.has(col));
830
+ }
831
+ return null;
832
+ }
833
+ /** Convert camelCase field name to snake_case column name */
834
+ toColumn(field) {
835
+ const mapped = this.tableMeta.columnMap[field];
836
+ if (mapped)
837
+ return mapped;
838
+ return camelToSnake(field);
839
+ }
840
+ /** Build WHERE clause from a where object (supports operators, NULL, OR) */
841
+ buildWhere(where) {
842
+ const params = [];
843
+ const clause = this.buildWhereClause(where, params);
844
+ if (!clause)
845
+ return { sql: '', params: [] };
846
+ return { sql: ` WHERE ${clause}`, params };
847
+ }
848
+ /**
849
+ * Build the inner WHERE expression (without the WHERE keyword).
850
+ * Returns null if no conditions exist.
851
+ * Supports: equality, operators, NULL, OR, AND, NOT, relation filters (some/every/none).
852
+ */
853
+ buildWhereClause(where, params) {
854
+ const keys = Object.keys(where);
855
+ if (keys.length === 0)
856
+ return null;
857
+ const andClauses = [];
858
+ for (const key of keys) {
859
+ const value = where[key];
860
+ if (value === undefined)
861
+ continue;
862
+ // Handle OR special key
863
+ if (key === 'OR') {
864
+ const orConditions = value;
865
+ if (!Array.isArray(orConditions) || orConditions.length === 0)
866
+ continue;
867
+ const orClauses = [];
868
+ for (const orCond of orConditions) {
869
+ const sub = this.buildWhereClause(orCond, params);
870
+ if (sub)
871
+ orClauses.push(sub);
872
+ }
873
+ if (orClauses.length > 0) {
874
+ andClauses.push(`(${orClauses.join(' OR ')})`);
875
+ }
876
+ continue;
877
+ }
878
+ // Handle AND special key
879
+ if (key === 'AND') {
880
+ const andConditions = value;
881
+ if (!Array.isArray(andConditions) || andConditions.length === 0)
882
+ continue;
883
+ for (const andCond of andConditions) {
884
+ const sub = this.buildWhereClause(andCond, params);
885
+ if (sub)
886
+ andClauses.push(sub);
887
+ }
888
+ continue;
889
+ }
890
+ // Handle NOT special key
891
+ if (key === 'NOT') {
892
+ const notCond = value;
893
+ const sub = this.buildWhereClause(notCond, params);
894
+ if (sub)
895
+ andClauses.push(`NOT (${sub})`);
896
+ continue;
897
+ }
898
+ // Handle relation filters: { posts: { some: { published: true } } }
899
+ const relationDef = this.tableMeta.relations[key];
900
+ if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
901
+ const filterObj = value;
902
+ // Check if this is a relation filter (has some/every/none keys)
903
+ if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
904
+ const relClause = this.buildRelationFilter(key, relationDef, filterObj, params);
905
+ if (relClause)
906
+ andClauses.push(relClause);
907
+ continue;
908
+ }
909
+ }
910
+ const column = this.toColumn(key);
911
+ // Handle null → IS NULL
912
+ if (value === null) {
913
+ andClauses.push(`${column} IS NULL`);
914
+ continue;
915
+ }
916
+ // Handle JSONB filter operators (for json/jsonb columns)
917
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
918
+ const colType = this.getColumnPgType(column);
919
+ if (colType === 'json' || colType === 'jsonb') {
920
+ const jsonClauses = this.buildJsonFilterClauses(column, value, params);
921
+ andClauses.push(...jsonClauses);
922
+ continue;
923
+ }
924
+ }
925
+ // Handle Array filter operators (for array columns)
926
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
927
+ const colType = this.getColumnPgType(column);
928
+ if (colType.startsWith('_')) {
929
+ const arrayClauses = this.buildArrayFilterClauses(column, value, params, colType);
930
+ andClauses.push(...arrayClauses);
931
+ continue;
932
+ }
933
+ }
934
+ // Handle operator objects
935
+ if (isWhereOperator(value)) {
936
+ const opClauses = this.buildOperatorClauses(column, value, params);
937
+ andClauses.push(...opClauses);
938
+ continue;
939
+ }
940
+ // Plain equality
941
+ params.push(value);
942
+ andClauses.push(`${column} = $${params.length}`);
943
+ }
944
+ if (andClauses.length === 0)
945
+ return null;
946
+ return andClauses.join(' AND ');
947
+ }
948
+ /**
949
+ * Build relation filter SQL: WHERE EXISTS / NOT EXISTS subquery
950
+ * Supports: some (EXISTS), every (NOT EXISTS ... NOT), none (NOT EXISTS)
951
+ */
952
+ buildRelationFilter(_relName, relDef, filterObj, params) {
953
+ const targetTable = relDef.to;
954
+ const targetMeta = this.schema.tables[targetTable];
955
+ if (!targetMeta)
956
+ return null;
957
+ const clauses = [];
958
+ // Correlation: link child table to parent table
959
+ let correlation;
960
+ if (relDef.type === 'hasMany' || relDef.type === 'hasOne') {
961
+ // parent.pk = child.fk
962
+ correlation = `${targetTable}.${relDef.foreignKey} = ${this.table}.${relDef.referenceKey}`;
963
+ }
964
+ else {
965
+ // belongsTo: parent.fk = child.pk
966
+ correlation = `${targetTable}.${relDef.referenceKey} = ${this.table}.${relDef.foreignKey}`;
967
+ }
968
+ // "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
969
+ if (filterObj.some !== undefined) {
970
+ const subWhere = filterObj.some;
971
+ const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
972
+ const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
973
+ clauses.push(`EXISTS (SELECT 1 FROM ${targetTable} WHERE ${fullWhere})`);
974
+ }
975
+ // "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
976
+ if (filterObj.none !== undefined) {
977
+ const subWhere = filterObj.none;
978
+ const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
979
+ const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
980
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${targetTable} WHERE ${fullWhere})`);
981
+ }
982
+ // "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND NOT (filter))
983
+ if (filterObj.every !== undefined) {
984
+ const subWhere = filterObj.every;
985
+ const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
986
+ if (filterClause) {
987
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${targetTable} WHERE ${correlation} AND NOT (${filterClause}))`);
988
+ }
989
+ else {
990
+ // "every" with empty filter = true (all match trivially)
991
+ }
992
+ }
993
+ return clauses.length > 0 ? clauses.join(' AND ') : null;
994
+ }
995
+ /**
996
+ * Build WHERE clause conditions for a relation filter subquery.
997
+ * Uses the target table's column mapping to resolve field names.
998
+ */
999
+ buildSubWhereForRelation(targetTable, subWhere, params) {
1000
+ const meta = this.schema.tables[targetTable];
1001
+ if (!meta)
1002
+ return null;
1003
+ const conditions = [];
1004
+ for (const [field, value] of Object.entries(subWhere)) {
1005
+ if (value === undefined)
1006
+ continue;
1007
+ const col = meta.columnMap[field] ?? camelToSnake(field);
1008
+ if (value === null) {
1009
+ conditions.push(`${targetTable}.${col} IS NULL`);
1010
+ continue;
1011
+ }
1012
+ if (isWhereOperator(value)) {
1013
+ const opClauses = this.buildOperatorClauses(`${targetTable}.${col}`, value, params);
1014
+ conditions.push(...opClauses);
1015
+ continue;
1016
+ }
1017
+ params.push(value);
1018
+ conditions.push(`${targetTable}.${col} = $${params.length}`);
1019
+ }
1020
+ return conditions.length > 0 ? conditions.join(' AND ') : null;
1021
+ }
1022
+ /**
1023
+ * Build SQL clauses for a single operator object on a column.
1024
+ * Each operator key becomes its own clause, all ANDed together.
1025
+ */
1026
+ buildOperatorClauses(column, op, params) {
1027
+ const clauses = [];
1028
+ if (op.gt !== undefined) {
1029
+ params.push(op.gt);
1030
+ clauses.push(`${column} > $${params.length}`);
1031
+ }
1032
+ if (op.gte !== undefined) {
1033
+ params.push(op.gte);
1034
+ clauses.push(`${column} >= $${params.length}`);
1035
+ }
1036
+ if (op.lt !== undefined) {
1037
+ params.push(op.lt);
1038
+ clauses.push(`${column} < $${params.length}`);
1039
+ }
1040
+ if (op.lte !== undefined) {
1041
+ params.push(op.lte);
1042
+ clauses.push(`${column} <= $${params.length}`);
1043
+ }
1044
+ if (op.not !== undefined) {
1045
+ if (op.not === null) {
1046
+ clauses.push(`${column} IS NOT NULL`);
1047
+ }
1048
+ else {
1049
+ params.push(op.not);
1050
+ clauses.push(`${column} != $${params.length}`);
1051
+ }
1052
+ }
1053
+ if (op.in !== undefined) {
1054
+ params.push(op.in);
1055
+ clauses.push(`${column} = ANY($${params.length})`);
1056
+ }
1057
+ if (op.notIn !== undefined) {
1058
+ params.push(op.notIn);
1059
+ clauses.push(`${column} != ALL($${params.length})`);
1060
+ }
1061
+ if (op.contains !== undefined) {
1062
+ params.push(`%${op.contains}%`);
1063
+ clauses.push(`${column} LIKE $${params.length}`);
1064
+ }
1065
+ if (op.startsWith !== undefined) {
1066
+ params.push(`${op.startsWith}%`);
1067
+ clauses.push(`${column} LIKE $${params.length}`);
1068
+ }
1069
+ if (op.endsWith !== undefined) {
1070
+ params.push(`%${op.endsWith}`);
1071
+ clauses.push(`${column} LIKE $${params.length}`);
1072
+ }
1073
+ return clauses;
1074
+ }
1075
+ /** Build ORDER BY clause from an object */
1076
+ buildOrderBy(orderBy) {
1077
+ return Object.entries(orderBy)
1078
+ .map(([key, dir]) => `${this.toColumn(key)} ${dir.toUpperCase()}`)
1079
+ .join(', ');
1080
+ }
1081
+ /** Parse a flat row: convert snake_case to camelCase + Date coercion */
1082
+ parseRow(row, table) {
1083
+ const parsed = {};
1084
+ const meta = this.schema.tables[table];
1085
+ if (meta) {
1086
+ // Fast path: use pre-computed maps (avoids regex per column per row)
1087
+ const reverseMap = meta.reverseColumnMap;
1088
+ const dateCols = meta.dateColumns;
1089
+ const keys = Object.keys(row);
1090
+ for (let i = 0; i < keys.length; i++) {
1091
+ const col = keys[i];
1092
+ const value = row[col];
1093
+ const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
1094
+ if (dateCols.has(col) && value !== null && !(value instanceof Date)) {
1095
+ parsed[field] = new Date(value);
1096
+ }
1097
+ else {
1098
+ parsed[field] = value;
1099
+ }
1100
+ }
1101
+ }
1102
+ else {
1103
+ // Fallback: no metadata, use regex conversion
1104
+ for (const [col, value] of Object.entries(row)) {
1105
+ parsed[snakeToCamel(col)] = value;
1106
+ }
1107
+ }
1108
+ return parsed;
1109
+ }
1110
+ /** Parse a row that may contain JSON nested relation columns */
1111
+ parseNestedRow(row, table) {
1112
+ const parsed = this.parseRow(row, table);
1113
+ const meta = this.schema.tables[table];
1114
+ if (!meta)
1115
+ return parsed;
1116
+ for (const [relName, relDef] of Object.entries(meta.relations)) {
1117
+ const rawValue = row[relName];
1118
+ if (rawValue !== undefined) {
1119
+ if (typeof rawValue === 'string') {
1120
+ try {
1121
+ parsed[relName] = JSON.parse(rawValue);
1122
+ }
1123
+ catch {
1124
+ parsed[relName] = rawValue;
1125
+ }
1126
+ }
1127
+ else if (Array.isArray(rawValue)) {
1128
+ parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
1129
+ ? this.parseRow(item, relDef.to)
1130
+ : item);
1131
+ }
1132
+ else if (typeof rawValue === 'object' && rawValue !== null) {
1133
+ parsed[relName] = this.parseRow(rawValue, relDef.to);
1134
+ }
1135
+ else {
1136
+ parsed[relName] = rawValue;
1137
+ }
1138
+ }
1139
+ }
1140
+ return parsed;
1141
+ }
1142
+ /**
1143
+ * Build a SELECT clause with nested relation subqueries.
1144
+ *
1145
+ * Uses json_build_object + json_agg — the same approach as the raw-pg benchmark
1146
+ * queries. Generates a single SQL statement that resolves the full object tree.
1147
+ *
1148
+ * Nested where values are parameterized via the shared params array to prevent
1149
+ * SQL injection.
1150
+ */
1151
+ buildSelectWithRelations(table, withClause, params, columnsList) {
1152
+ const meta = this.schema.tables[table];
1153
+ if (!meta)
1154
+ throw new Error(`[turbine] Unknown table "${table}"`);
1155
+ const cols = columnsList ?? meta.allColumns;
1156
+ const baseCols = cols
1157
+ .map((col) => `${table}.${col}`)
1158
+ .join(', ');
1159
+ const relationSelects = [];
1160
+ const aliasCounter = { n: 0 };
1161
+ for (const [relName, relSpec] of Object.entries(withClause)) {
1162
+ const relDef = meta.relations[relName];
1163
+ if (!relDef) {
1164
+ throw new Error(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
1165
+ `Available: ${Object.keys(meta.relations).join(', ')}`);
1166
+ }
1167
+ // The main table is not aliased, so pass table name as parentRef
1168
+ const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter);
1169
+ relationSelects.push(`(${subquery}) AS ${relName}`);
1170
+ }
1171
+ return [baseCols, ...relationSelects].join(', ');
1172
+ }
1173
+ /**
1174
+ * Build a json_agg subquery for a relation.
1175
+ *
1176
+ * All user-supplied values in nested where clauses are parameterized
1177
+ * through the shared params array — no string interpolation.
1178
+ *
1179
+ * @param parentRef - The alias (or table name) of the parent in the outer query.
1180
+ * Used for the correlated WHERE clause: `child.fk = parentRef.pk`.
1181
+ * @param aliasCounter - Shared counter for generating unique aliases across nested levels.
1182
+ */
1183
+ buildRelationSubquery(relDef, spec, params, parentRef, aliasCounter) {
1184
+ const targetTable = relDef.to;
1185
+ const targetMeta = this.schema.tables[targetTable];
1186
+ if (!targetMeta)
1187
+ throw new Error(`[turbine] Unknown relation target "${targetTable}"`);
1188
+ // Generate a unique alias: t0, t1, t2, ...
1189
+ const alias = `t${aliasCounter.n++}`;
1190
+ // Resolve which columns to include based on select/omit
1191
+ let targetColumns = targetMeta.allColumns;
1192
+ if (spec !== true && spec.select) {
1193
+ const selectedFields = Object.entries(spec.select)
1194
+ .filter(([, v]) => v)
1195
+ .map(([k]) => targetMeta.columnMap[k] ?? camelToSnake(k));
1196
+ targetColumns = selectedFields.filter((col) => targetMeta.allColumns.includes(col));
1197
+ }
1198
+ else if (spec !== true && spec.omit) {
1199
+ const omittedFields = new Set(Object.entries(spec.omit)
1200
+ .filter(([, v]) => v)
1201
+ .map(([k]) => targetMeta.columnMap[k] ?? camelToSnake(k)));
1202
+ targetColumns = targetMeta.allColumns.filter((col) => !omittedFields.has(col));
1203
+ }
1204
+ // Build json_build_object pairs for resolved columns
1205
+ const jsonPairs = targetColumns.map((col) => `'${targetMeta.reverseColumnMap[col] ?? snakeToCamel(col)}', ${alias}.${col}`);
1206
+ // Nested relations?
1207
+ if (spec !== true && spec.with) {
1208
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
1209
+ const nestedRelDef = targetMeta.relations[nestedRelName];
1210
+ if (!nestedRelDef) {
1211
+ throw new Error(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1212
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
1213
+ }
1214
+ // Recursively build nested subquery, passing THIS alias as the parent reference
1215
+ const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter);
1216
+ jsonPairs.push(`'${nestedRelName}', COALESCE((${nestedSubquery}), '[]'::json)`);
1217
+ }
1218
+ }
1219
+ const jsonObj = `json_build_object(${jsonPairs.join(', ')})`;
1220
+ // Build ORDER BY for json_agg
1221
+ let orderClause = '';
1222
+ if (spec !== true && spec.orderBy) {
1223
+ const orders = Object.entries(spec.orderBy)
1224
+ .map(([k, dir]) => {
1225
+ const col = camelToSnake(k);
1226
+ if (!targetMeta.allColumns.includes(col)) {
1227
+ throw new Error(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
1228
+ }
1229
+ const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
1230
+ return `${alias}.${col} ${safeDir}`;
1231
+ })
1232
+ .join(', ');
1233
+ orderClause = ` ORDER BY ${orders}`;
1234
+ }
1235
+ // Build WHERE — correlate to parent via parentRef (alias or table name).
1236
+ // For hasMany: target has FK, so alias.fk = parentRef.pk
1237
+ // For belongsTo: source has FK, so alias.pk = parentRef.fk (reversed)
1238
+ let whereClause;
1239
+ if (relDef.type === 'belongsTo' || relDef.type === 'hasOne') {
1240
+ whereClause = `${alias}.${relDef.referenceKey} = ${parentRef}.${relDef.foreignKey}`;
1241
+ }
1242
+ else {
1243
+ whereClause = `${alias}.${relDef.foreignKey} = ${parentRef}.${relDef.referenceKey}`;
1244
+ }
1245
+ // Additional filters — properly parameterized
1246
+ if (spec !== true && spec.where) {
1247
+ for (const [k, v] of Object.entries(spec.where)) {
1248
+ const col = camelToSnake(k);
1249
+ if (!targetMeta.allColumns.includes(col)) {
1250
+ throw new Error(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
1251
+ }
1252
+ params.push(v);
1253
+ whereClause += ` AND ${alias}.${col} = $${params.length}`;
1254
+ }
1255
+ }
1256
+ // LIMIT
1257
+ let limitClause = '';
1258
+ if (spec !== true && spec.limit) {
1259
+ limitClause = ` LIMIT ${Number(spec.limit)}`;
1260
+ }
1261
+ if (relDef.type === 'hasMany') {
1262
+ // When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
1263
+ // BEFORE json_agg aggregation (otherwise LIMIT on aggregated result is meaningless)
1264
+ if (limitClause || orderClause) {
1265
+ const innerAlias = `${alias}i`;
1266
+ // Rewrite: SELECT json_agg(json_build_object(...)) FROM (SELECT * FROM table WHERE ... ORDER BY ... LIMIT N) AS alias
1267
+ // Inner SELECT always needs all columns for WHERE/ORDER to work; json_build_object filters later
1268
+ const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${c}`).join(', ')} FROM ${targetTable} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
1269
+ // For the json_build_object, reference the inner alias — only include resolved columns
1270
+ const innerJsonPairs = targetColumns.map((col) => `'${targetMeta.reverseColumnMap[col] ?? snakeToCamel(col)}', ${innerAlias}.${col}`);
1271
+ // Re-add nested relation subqueries referencing innerAlias
1272
+ if (spec !== true && spec.with) {
1273
+ for (const [nestedRelName] of Object.entries(spec.with)) {
1274
+ const nestedRelDef = targetMeta.relations[nestedRelName];
1275
+ if (nestedRelDef) {
1276
+ const nestedSub = this.buildRelationSubquery(nestedRelDef, spec.with[nestedRelName], params, innerAlias, aliasCounter);
1277
+ innerJsonPairs.push(`'${nestedRelName}', COALESCE((${nestedSub}), '[]'::json)`);
1278
+ }
1279
+ }
1280
+ }
1281
+ const innerJsonObj = `json_build_object(${innerJsonPairs.join(', ')})`;
1282
+ return `SELECT COALESCE(json_agg(${innerJsonObj}), '[]'::json) FROM (${innerSql}) ${innerAlias}`;
1283
+ }
1284
+ return `SELECT COALESCE(json_agg(${jsonObj}${orderClause}), '[]'::json) FROM ${targetTable} ${alias} WHERE ${whereClause}`;
1285
+ }
1286
+ // belongsTo / hasOne — return single object
1287
+ return `SELECT ${jsonObj} FROM ${targetTable} ${alias} WHERE ${whereClause} LIMIT 1`;
1288
+ }
1289
+ /**
1290
+ * Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
1291
+ * Used to detect JSONB/array columns for specialized operators.
1292
+ */
1293
+ getColumnPgType(column) {
1294
+ const col = this.tableMeta.columns.find((c) => c.name === column);
1295
+ return col?.pgType ?? 'text';
1296
+ }
1297
+ /**
1298
+ * Get the Postgres base element type for an array column.
1299
+ * E.g. '_text' → 'text', '_int4' → 'integer'
1300
+ */
1301
+ getArrayElementType(pgType) {
1302
+ const baseType = pgType.startsWith('_') ? pgType.slice(1) : pgType;
1303
+ const typeMap = {
1304
+ int2: 'smallint',
1305
+ int4: 'integer',
1306
+ int8: 'bigint',
1307
+ float4: 'real',
1308
+ float8: 'double precision',
1309
+ bool: 'boolean',
1310
+ text: 'text',
1311
+ varchar: 'text',
1312
+ uuid: 'uuid',
1313
+ timestamptz: 'timestamptz',
1314
+ timestamp: 'timestamp',
1315
+ jsonb: 'jsonb',
1316
+ json: 'json',
1317
+ };
1318
+ return typeMap[baseType] ?? 'text';
1319
+ }
1320
+ /**
1321
+ * Build SQL clauses for JSONB filter operators on a column.
1322
+ * Supports: path, equals, contains, hasKey.
1323
+ */
1324
+ buildJsonFilterClauses(column, filter, params) {
1325
+ const clauses = [];
1326
+ if (filter.path !== undefined && filter.equals !== undefined) {
1327
+ // Path access + equals: column #>> $N::text[] = $M
1328
+ params.push(filter.path);
1329
+ const pathParam = params.length;
1330
+ params.push(String(filter.equals));
1331
+ clauses.push(`${column} #>> $${pathParam}::text[] = $${params.length}`);
1332
+ }
1333
+ else if (filter.equals !== undefined) {
1334
+ // Containment equality: column @> $N::jsonb
1335
+ params.push(JSON.stringify(filter.equals));
1336
+ clauses.push(`${column} @> $${params.length}::jsonb`);
1337
+ }
1338
+ if (filter.contains !== undefined) {
1339
+ // Containment: column @> $N::jsonb
1340
+ params.push(JSON.stringify(filter.contains));
1341
+ clauses.push(`${column} @> $${params.length}::jsonb`);
1342
+ }
1343
+ if (filter.hasKey !== undefined) {
1344
+ // Key existence: column ? $N
1345
+ params.push(filter.hasKey);
1346
+ clauses.push(`${column} ? $${params.length}`);
1347
+ }
1348
+ return clauses;
1349
+ }
1350
+ /**
1351
+ * Build SQL clauses for Array filter operators on a column.
1352
+ * Supports: has, hasEvery, hasSome, isEmpty.
1353
+ */
1354
+ buildArrayFilterClauses(column, filter, params, pgType) {
1355
+ const clauses = [];
1356
+ const elementType = this.getArrayElementType(pgType);
1357
+ if (filter.has !== undefined) {
1358
+ // value = ANY(column)
1359
+ params.push(filter.has);
1360
+ clauses.push(`$${params.length} = ANY(${column})`);
1361
+ }
1362
+ if (filter.hasEvery !== undefined) {
1363
+ // column @> ARRAY[...]::type[]
1364
+ params.push(filter.hasEvery);
1365
+ clauses.push(`${column} @> $${params.length}::${elementType}[]`);
1366
+ }
1367
+ if (filter.hasSome !== undefined) {
1368
+ // column && ARRAY[...]::type[]
1369
+ params.push(filter.hasSome);
1370
+ clauses.push(`${column} && $${params.length}::${elementType}[]`);
1371
+ }
1372
+ if (filter.isEmpty === true) {
1373
+ // array_length(column, 1) IS NULL
1374
+ clauses.push(`array_length(${column}, 1) IS NULL`);
1375
+ }
1376
+ else if (filter.isEmpty === false) {
1377
+ // array_length(column, 1) IS NOT NULL
1378
+ clauses.push(`array_length(${column}, 1) IS NOT NULL`);
1379
+ }
1380
+ return clauses;
1381
+ }
1382
+ /** Get the Postgres array type for a column (used by UNNEST in createMany) */
1383
+ getColumnArrayType(column) {
1384
+ // Find the column metadata
1385
+ const col = this.tableMeta.columns.find((c) => c.name === column);
1386
+ if (col)
1387
+ return col.pgArrayType;
1388
+ // Fallback heuristic
1389
+ if (column === 'id' || column.endsWith('_id'))
1390
+ return 'bigint[]';
1391
+ if (column.endsWith('_at'))
1392
+ return 'timestamptz[]';
1393
+ return 'text[]';
1394
+ }
1395
+ }
1396
+ //# sourceMappingURL=query.js.map