turbine-orm 0.4.0 → 0.7.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 (57) hide show
  1. package/README.md +243 -26
  2. package/dist/cjs/cli/config.js +151 -0
  3. package/dist/cjs/cli/index.js +1176 -0
  4. package/dist/cjs/cli/migrate.js +446 -0
  5. package/dist/cjs/cli/ui.js +233 -0
  6. package/dist/cjs/client.js +512 -0
  7. package/dist/cjs/errors.js +293 -0
  8. package/dist/cjs/generate.js +321 -0
  9. package/dist/cjs/index.js +94 -0
  10. package/dist/cjs/introspect.js +287 -0
  11. package/dist/cjs/package.json +1 -0
  12. package/dist/cjs/pipeline.js +78 -0
  13. package/dist/cjs/query.js +1891 -0
  14. package/dist/cjs/schema-builder.js +238 -0
  15. package/dist/cjs/schema-sql.js +509 -0
  16. package/dist/cjs/schema.js +140 -0
  17. package/dist/cjs/serverless.js +110 -0
  18. package/dist/cli/config.js +6 -16
  19. package/dist/cli/index.js +256 -49
  20. package/dist/cli/migrate.d.ts +35 -6
  21. package/dist/cli/migrate.js +124 -76
  22. package/dist/cli/ui.js +5 -9
  23. package/dist/client.d.ts +87 -3
  24. package/dist/client.js +122 -46
  25. package/dist/errors.d.ts +138 -0
  26. package/dist/errors.js +278 -0
  27. package/dist/generate.js +37 -11
  28. package/dist/index.d.ts +10 -8
  29. package/dist/index.js +15 -11
  30. package/dist/introspect.js +3 -5
  31. package/dist/pipeline.js +8 -1
  32. package/dist/query.d.ts +310 -45
  33. package/dist/query.js +565 -237
  34. package/dist/schema-builder.js +91 -23
  35. package/dist/schema-sql.d.ts +6 -2
  36. package/dist/schema-sql.js +180 -26
  37. package/dist/schema.js +4 -1
  38. package/dist/serverless.d.ts +91 -139
  39. package/dist/serverless.js +86 -173
  40. package/package.json +44 -21
  41. package/dist/cli/config.d.ts.map +0 -1
  42. package/dist/cli/index.d.ts.map +0 -1
  43. package/dist/cli/migrate.d.ts.map +0 -1
  44. package/dist/cli/ui.d.ts.map +0 -1
  45. package/dist/client.d.ts.map +0 -1
  46. package/dist/generate.d.ts.map +0 -1
  47. package/dist/index.d.ts.map +0 -1
  48. package/dist/introspect.d.ts.map +0 -1
  49. package/dist/pipeline.d.ts.map +0 -1
  50. package/dist/query.d.ts.map +0 -1
  51. package/dist/schema-builder.d.ts.map +0 -1
  52. package/dist/schema-sql.d.ts.map +0 -1
  53. package/dist/schema.d.ts.map +0 -1
  54. package/dist/serverless.d.ts.map +0 -1
  55. package/dist/types.d.ts +0 -93
  56. package/dist/types.d.ts.map +0 -1
  57. package/dist/types.js +0 -126
@@ -0,0 +1,1891 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm — Query builder
4
+ *
5
+ * Each table accessor (db.users, db.posts, etc.) returns a QueryInterface<T>
6
+ * that builds parameterized SQL and executes it through the connection pool.
7
+ *
8
+ * Nested relations use json_build_object + json_agg subqueries for single-query
9
+ * resolution — a PostgreSQL-native approach that eliminates N+1 query patterns.
10
+ *
11
+ * Schema-driven: all column names, types, and relations come from introspected
12
+ * metadata — nothing is hardcoded.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.QueryInterface = void 0;
16
+ exports.quoteIdent = quoteIdent;
17
+ const errors_js_1 = require("./errors.js");
18
+ const schema_js_1 = require("./schema.js");
19
+ // ---------------------------------------------------------------------------
20
+ // Identifier quoting — prevents SQL injection via table/column names
21
+ // ---------------------------------------------------------------------------
22
+ /**
23
+ * Quote a SQL identifier (table name, column name) using Postgres double-quote
24
+ * rules: wrap in double quotes, escape internal double quotes by doubling them.
25
+ *
26
+ * @example
27
+ * quoteIdent('users') → '"users"'
28
+ * quoteIdent('my"table') → '"my""table"'
29
+ * quoteIdent('user name') → '"user name"'
30
+ */
31
+ function quoteIdent(name) {
32
+ return `"${name.replace(/"/g, '""')}"`;
33
+ }
34
+ /**
35
+ * Escape single quotes for use as string keys in json_build_object().
36
+ * Doubles single quotes per SQL quoting rules.
37
+ */
38
+ function escSingleQuote(s) {
39
+ return s.replace(/'/g, "''");
40
+ }
41
+ /**
42
+ * Escape LIKE pattern metacharacters: %, _, and \.
43
+ * Must be used with `ESCAPE '\'` in the LIKE clause.
44
+ */
45
+ function escapeLike(value) {
46
+ return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
47
+ }
48
+ /** Known operator keys — used to detect operator objects vs plain values */
49
+ const OPERATOR_KEYS = new Set([
50
+ 'gt',
51
+ 'gte',
52
+ 'lt',
53
+ 'lte',
54
+ 'not',
55
+ 'in',
56
+ 'notIn',
57
+ 'contains',
58
+ 'startsWith',
59
+ 'endsWith',
60
+ 'mode',
61
+ ]);
62
+ /** Check if a value is a where operator object (has at least one known operator key) */
63
+ function isWhereOperator(value) {
64
+ if (value === null ||
65
+ value === undefined ||
66
+ typeof value !== 'object' ||
67
+ Array.isArray(value) ||
68
+ value instanceof Date) {
69
+ return false;
70
+ }
71
+ const keys = Object.keys(value);
72
+ return keys.length > 0 && keys.every((k) => OPERATOR_KEYS.has(k));
73
+ }
74
+ /** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
75
+ const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
76
+ /** Known JSONB operator keys */
77
+ const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
78
+ /** Check if a value is a JSONB filter object */
79
+ function isJsonFilter(value) {
80
+ if (value === null ||
81
+ value === undefined ||
82
+ typeof value !== 'object' ||
83
+ Array.isArray(value) ||
84
+ value instanceof Date) {
85
+ return false;
86
+ }
87
+ const keys = Object.keys(value);
88
+ return keys.length > 0 && keys.some((k) => JSONB_OPERATOR_KEYS.has(k));
89
+ }
90
+ /** Known Array operator keys */
91
+ const ARRAY_OPERATOR_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
92
+ /** Check if a value is an Array filter object */
93
+ function isArrayFilter(value) {
94
+ if (value === null ||
95
+ value === undefined ||
96
+ typeof value !== 'object' ||
97
+ Array.isArray(value) ||
98
+ value instanceof Date) {
99
+ return false;
100
+ }
101
+ const keys = Object.keys(value);
102
+ return keys.length > 0 && keys.some((k) => ARRAY_OPERATOR_KEYS.has(k));
103
+ }
104
+ // ---------------------------------------------------------------------------
105
+ // LRU cache — bounded SQL template cache to prevent memory leaks
106
+ // ---------------------------------------------------------------------------
107
+ /**
108
+ * Simple LRU (Least Recently Used) cache with a fixed maximum size.
109
+ * When the cache exceeds maxSize, the oldest (least recently used) entry is evicted.
110
+ * Uses Map insertion order for O(1) eviction.
111
+ */
112
+ class LRUCache {
113
+ maxSize;
114
+ cache = new Map();
115
+ constructor(maxSize) {
116
+ this.maxSize = maxSize;
117
+ }
118
+ get(key) {
119
+ const value = this.cache.get(key);
120
+ if (value !== undefined) {
121
+ // Move to end (most recently used)
122
+ this.cache.delete(key);
123
+ this.cache.set(key, value);
124
+ }
125
+ return value;
126
+ }
127
+ set(key, value) {
128
+ if (this.cache.has(key)) {
129
+ this.cache.delete(key);
130
+ }
131
+ else if (this.cache.size >= this.maxSize) {
132
+ // Delete oldest (first) entry
133
+ const firstKey = this.cache.keys().next().value;
134
+ if (firstKey !== undefined)
135
+ this.cache.delete(firstKey);
136
+ }
137
+ this.cache.set(key, value);
138
+ }
139
+ get size() {
140
+ return this.cache.size;
141
+ }
142
+ }
143
+ class QueryInterface {
144
+ pool;
145
+ table;
146
+ schema;
147
+ tableMeta;
148
+ /** SQL template cache: cacheKey → sql string (params are always positional $1,$2,...) */
149
+ sqlCache = new LRUCache(1000);
150
+ middlewares;
151
+ defaultLimit;
152
+ warnOnUnlimited;
153
+ /** Pre-computed column type lookups (avoids linear scans per query) */
154
+ columnPgTypeMap;
155
+ columnArrayTypeMap;
156
+ constructor(pool, table, schema, middlewares, options) {
157
+ this.pool = pool;
158
+ this.table = table;
159
+ this.schema = schema;
160
+ const meta = schema.tables[table];
161
+ if (!meta) {
162
+ throw new errors_js_1.ValidationError(`[turbine] Unknown table "${table}". Available: ${Object.keys(schema.tables).join(', ')}`);
163
+ }
164
+ this.tableMeta = meta;
165
+ this.middlewares = middlewares ?? [];
166
+ this.defaultLimit = options?.defaultLimit;
167
+ this.warnOnUnlimited = options?.warnOnUnlimited ?? false;
168
+ // Pre-compute column type lookup maps (TASK-26)
169
+ this.columnPgTypeMap = new Map();
170
+ this.columnArrayTypeMap = new Map();
171
+ for (const col of this.tableMeta.columns) {
172
+ this.columnPgTypeMap.set(col.name, col.pgType);
173
+ this.columnArrayTypeMap.set(col.name, col.pgArrayType);
174
+ }
175
+ }
176
+ /**
177
+ * Execute a pool.query with an optional timeout.
178
+ * If timeout is set, races the query against a timer and rejects on expiry.
179
+ * pg driver errors are translated to typed Turbine errors via wrapPgError.
180
+ */
181
+ async queryWithTimeout(sql, params, timeout) {
182
+ if (!timeout) {
183
+ try {
184
+ return await this.pool.query(sql, params);
185
+ }
186
+ catch (err) {
187
+ throw (0, errors_js_1.wrapPgError)(err);
188
+ }
189
+ }
190
+ let timer;
191
+ const timeoutPromise = new Promise((_, reject) => {
192
+ timer = setTimeout(() => reject(new errors_js_1.TimeoutError(timeout)), timeout);
193
+ });
194
+ try {
195
+ return await Promise.race([this.pool.query(sql, params), timeoutPromise]);
196
+ }
197
+ catch (err) {
198
+ throw (0, errors_js_1.wrapPgError)(err);
199
+ }
200
+ finally {
201
+ clearTimeout(timer);
202
+ }
203
+ }
204
+ /**
205
+ * Execute a query through the middleware chain.
206
+ * If no middlewares are registered, executes directly.
207
+ *
208
+ * Middleware can inspect and log query parameters, modify results after execution,
209
+ * and measure timing. Note: query SQL is generated before middleware runs, so
210
+ * modifying params.args in middleware will NOT affect the executed SQL.
211
+ * To intercept queries before SQL generation, use the raw() method instead.
212
+ */
213
+ async executeWithMiddleware(action, args, executor) {
214
+ if (this.middlewares.length === 0) {
215
+ return executor();
216
+ }
217
+ const params = { model: this.table, action, args: { ...args } };
218
+ // Build middleware chain
219
+ let index = 0;
220
+ const next = async (p) => {
221
+ if (index < this.middlewares.length) {
222
+ const mw = this.middlewares[index++];
223
+ return mw(p, next);
224
+ }
225
+ // End of chain — execute the actual query
226
+ return executor();
227
+ };
228
+ return next(params);
229
+ }
230
+ // -------------------------------------------------------------------------
231
+ // findUnique
232
+ // -------------------------------------------------------------------------
233
+ async findUnique(args) {
234
+ return this.executeWithMiddleware('findUnique', args, async () => {
235
+ const deferred = this.buildFindUnique(args);
236
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
237
+ return deferred.transform(result);
238
+ });
239
+ }
240
+ buildFindUnique(args) {
241
+ const columnsList = this.resolveColumns(args.select, args.omit);
242
+ const whereObj = args.where;
243
+ // Check if all where values are simple (plain equality, no operators/null/OR)
244
+ const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
245
+ const isSimpleWhere = !whereObj.OR &&
246
+ whereKeys.every((k) => {
247
+ const v = whereObj[k];
248
+ return v !== null && !isWhereOperator(v);
249
+ });
250
+ // For simple queries (no nested with, no operators), use cached SQL template
251
+ if (!args.with && isSimpleWhere) {
252
+ const colKey = columnsList ? columnsList.join(',') : '*';
253
+ const ck = `fu:${whereKeys.sort().join(',')}:c=${colKey}`;
254
+ let sql = this.sqlCache.get(ck);
255
+ const params = whereKeys.map((k) => whereObj[k]);
256
+ if (!sql) {
257
+ const qt = quoteIdent(this.table);
258
+ const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = $${i + 1}`);
259
+ const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
260
+ const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
261
+ sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
262
+ this.sqlCache.set(ck, sql);
263
+ }
264
+ return {
265
+ sql,
266
+ params,
267
+ transform: (result) => {
268
+ const row = result.rows[0];
269
+ return row ? this.parseRow(row, this.table) : null;
270
+ },
271
+ tag: `${this.table}.findUnique`,
272
+ };
273
+ }
274
+ // General path: supports operators, null, OR, nested with
275
+ const { sql: whereSql, params } = this.buildWhere(args.where);
276
+ if (!args.with) {
277
+ const qt = quoteIdent(this.table);
278
+ const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
279
+ const sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
280
+ return {
281
+ sql,
282
+ params,
283
+ transform: (result) => {
284
+ const row = result.rows[0];
285
+ return row ? this.parseRow(row, this.table) : null;
286
+ },
287
+ tag: `${this.table}.findUnique`,
288
+ };
289
+ }
290
+ // Nested queries: build fresh each time (with clause affects params)
291
+ const selectClause = this.buildSelectWithRelations(this.table, args.with, params, columnsList);
292
+ const sql = `SELECT ${selectClause} FROM ${quoteIdent(this.table)}${whereSql} LIMIT 1`;
293
+ return {
294
+ sql,
295
+ params,
296
+ transform: (result) => {
297
+ const row = result.rows[0];
298
+ return row ? this.parseNestedRow(row, this.table) : null;
299
+ },
300
+ tag: `${this.table}.findUnique`,
301
+ };
302
+ }
303
+ // -------------------------------------------------------------------------
304
+ // findMany
305
+ // -------------------------------------------------------------------------
306
+ async findMany(args) {
307
+ // Warn if no limit specified and warnOnUnlimited is enabled
308
+ const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined;
309
+ if (this.warnOnUnlimited && !hasExplicitLimit) {
310
+ console.warn(`[turbine] findMany() called without limit on table "${this.table}". Set defaultLimit in config to prevent unbounded queries.`);
311
+ }
312
+ return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
313
+ const deferred = this.buildFindMany(args);
314
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
315
+ return deferred.transform(result);
316
+ });
317
+ }
318
+ buildFindMany(args) {
319
+ const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
320
+ const columnsList = this.resolveColumns(args?.select, args?.omit);
321
+ const qt = quoteIdent(this.table);
322
+ // Distinct support
323
+ let distinctPrefix = '';
324
+ if (args?.distinct && args.distinct.length > 0) {
325
+ const distinctCols = args.distinct.map((k) => this.toSqlColumn(k));
326
+ distinctPrefix = `DISTINCT ON (${distinctCols.join(', ')}) `;
327
+ }
328
+ let selectClause;
329
+ if (args?.with) {
330
+ selectClause = this.buildSelectWithRelations(this.table, args.with, params, columnsList);
331
+ }
332
+ else if (columnsList) {
333
+ selectClause = columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ');
334
+ }
335
+ else {
336
+ selectClause = `${qt}.*`;
337
+ }
338
+ let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${whereSql}`;
339
+ // Cursor-based pagination: add WHERE condition for cursor
340
+ if (args?.cursor) {
341
+ const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
342
+ if (cursorEntries.length > 0) {
343
+ // Determine direction from orderBy (default 'asc')
344
+ const cursorConditions = cursorEntries.map(([k, v]) => {
345
+ const col = this.toSqlColumn(k);
346
+ const dir = args.orderBy?.[k] ?? 'asc';
347
+ const op = dir === 'desc' ? '<' : '>';
348
+ params.push(v);
349
+ return `${qt}.${col} ${op} $${params.length}`;
350
+ });
351
+ // Append to existing WHERE or create new one
352
+ if (whereSql) {
353
+ sql += ` AND ${cursorConditions.join(' AND ')}`;
354
+ }
355
+ else {
356
+ sql += ` WHERE ${cursorConditions.join(' AND ')}`;
357
+ }
358
+ }
359
+ }
360
+ if (args?.orderBy) {
361
+ sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
362
+ }
363
+ // take overrides limit when cursor pagination is used; fall back to defaultLimit
364
+ const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
365
+ if (effectiveLimit !== undefined) {
366
+ params.push(Number(effectiveLimit));
367
+ sql += ` LIMIT $${params.length}`;
368
+ }
369
+ if (args?.offset !== undefined) {
370
+ params.push(Number(args.offset));
371
+ sql += ` OFFSET $${params.length}`;
372
+ }
373
+ return {
374
+ sql,
375
+ params,
376
+ transform: (result) => result.rows.map((row) => args?.with ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table)),
377
+ tag: `${this.table}.findMany`,
378
+ };
379
+ }
380
+ // -------------------------------------------------------------------------
381
+ // findManyStream — async iterable using PostgreSQL cursors
382
+ // -------------------------------------------------------------------------
383
+ /**
384
+ * Stream rows from a findMany query using PostgreSQL cursors.
385
+ * Returns an AsyncIterable that yields individual rows, fetching in batches internally.
386
+ *
387
+ * Uses DECLARE CURSOR within a dedicated transaction on a single pooled connection.
388
+ * The cursor is automatically closed and the connection released when iteration
389
+ * completes or is terminated early (e.g. `break` from `for await`).
390
+ *
391
+ * @example
392
+ * ```ts
393
+ * for await (const user of db.users.findManyStream({ where: { orgId: 1 }, batchSize: 500 })) {
394
+ * process.stdout.write(`${user.email}\n`);
395
+ * }
396
+ * ```
397
+ */
398
+ async *findManyStream(args) {
399
+ const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 100)));
400
+ const deferred = this.buildFindMany(args);
401
+ const hasRelations = !!args?.with;
402
+ // Acquire a dedicated connection — cursors require a single connection in a transaction
403
+ const client = await this.pool.connect();
404
+ const cursorName = `turbine_cursor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
405
+ const quotedCursor = quoteIdent(cursorName);
406
+ try {
407
+ await client.query('BEGIN');
408
+ await client.query(`DECLARE ${quotedCursor} NO SCROLL CURSOR FOR ${deferred.sql}`, deferred.params);
409
+ while (true) {
410
+ const batch = await client.query(`FETCH ${batchSize} FROM ${quotedCursor}`);
411
+ if (batch.rows.length === 0)
412
+ break;
413
+ for (const row of batch.rows) {
414
+ yield (hasRelations ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table));
415
+ }
416
+ if (batch.rows.length < batchSize)
417
+ break;
418
+ }
419
+ await client.query(`CLOSE ${quotedCursor}`);
420
+ await client.query('COMMIT');
421
+ }
422
+ catch (err) {
423
+ // Rollback on error (also closes cursor implicitly)
424
+ try {
425
+ await client.query('ROLLBACK');
426
+ }
427
+ catch {
428
+ // Connection may already be broken — ignore rollback error
429
+ }
430
+ // Wrap pg constraint errors so streaming surfaces typed errors like the rest of the API
431
+ throw (0, errors_js_1.wrapPgError)(err);
432
+ }
433
+ finally {
434
+ client.release();
435
+ }
436
+ }
437
+ // -------------------------------------------------------------------------
438
+ // findFirst — like findMany but returns a single row or null
439
+ // -------------------------------------------------------------------------
440
+ async findFirst(args) {
441
+ return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
442
+ const deferred = this.buildFindFirst(args);
443
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
444
+ return deferred.transform(result);
445
+ });
446
+ }
447
+ buildFindFirst(args) {
448
+ // Reuse findMany's SQL builder but force LIMIT 1
449
+ const findManyArgs = { ...args, limit: 1 };
450
+ const deferred = this.buildFindMany(findManyArgs);
451
+ return {
452
+ sql: deferred.sql,
453
+ params: deferred.params,
454
+ transform: (result) => {
455
+ const rows = deferred.transform(result);
456
+ return rows.length > 0 ? rows[0] : null;
457
+ },
458
+ tag: `${this.table}.findFirst`,
459
+ };
460
+ }
461
+ // -------------------------------------------------------------------------
462
+ // findFirstOrThrow — like findFirst but throws if no record found
463
+ // -------------------------------------------------------------------------
464
+ async findFirstOrThrow(args) {
465
+ return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
466
+ const deferred = this.buildFindFirstOrThrow(args);
467
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
468
+ return deferred.transform(result);
469
+ });
470
+ }
471
+ buildFindFirstOrThrow(args) {
472
+ const inner = this.buildFindFirst(args);
473
+ return {
474
+ sql: inner.sql,
475
+ params: inner.params,
476
+ transform: (result) => {
477
+ const row = inner.transform(result);
478
+ if (row === null) {
479
+ throw new errors_js_1.NotFoundError({
480
+ table: this.table,
481
+ where: args?.where,
482
+ operation: 'findFirstOrThrow',
483
+ });
484
+ }
485
+ return row;
486
+ },
487
+ tag: `${this.table}.findFirstOrThrow`,
488
+ };
489
+ }
490
+ // -------------------------------------------------------------------------
491
+ // findUniqueOrThrow — like findUnique but throws if no record found
492
+ // -------------------------------------------------------------------------
493
+ async findUniqueOrThrow(args) {
494
+ return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
495
+ const deferred = this.buildFindUniqueOrThrow(args);
496
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
497
+ return deferred.transform(result);
498
+ });
499
+ }
500
+ buildFindUniqueOrThrow(args) {
501
+ const inner = this.buildFindUnique(args);
502
+ return {
503
+ sql: inner.sql,
504
+ params: inner.params,
505
+ transform: (result) => {
506
+ const row = inner.transform(result);
507
+ if (row === null) {
508
+ throw new errors_js_1.NotFoundError({
509
+ table: this.table,
510
+ where: args.where,
511
+ operation: 'findUniqueOrThrow',
512
+ });
513
+ }
514
+ return row;
515
+ },
516
+ tag: `${this.table}.findUniqueOrThrow`,
517
+ };
518
+ }
519
+ // -------------------------------------------------------------------------
520
+ // create
521
+ // -------------------------------------------------------------------------
522
+ async create(args) {
523
+ return this.executeWithMiddleware('create', args, async () => {
524
+ const deferred = this.buildCreate(args);
525
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
526
+ return deferred.transform(result);
527
+ });
528
+ }
529
+ buildCreate(args) {
530
+ const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
531
+ const columns = entries.map(([k]) => this.toSqlColumn(k));
532
+ const params = entries.map(([, v]) => v);
533
+ const placeholders = entries.map((_, i) => `$${i + 1}`);
534
+ const sql = `INSERT INTO ${quoteIdent(this.table)} (${columns.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`;
535
+ return {
536
+ sql,
537
+ params,
538
+ transform: (result) => {
539
+ const row = result.rows[0];
540
+ if (!row) {
541
+ throw new errors_js_1.NotFoundError({
542
+ table: this.table,
543
+ operation: 'create',
544
+ message: `[turbine] create on "${this.table}" returned no row from RETURNING * — this should never happen.`,
545
+ });
546
+ }
547
+ return this.parseRow(row, this.table);
548
+ },
549
+ tag: `${this.table}.create`,
550
+ };
551
+ }
552
+ // -------------------------------------------------------------------------
553
+ // createMany — uses UNNEST for performance
554
+ // -------------------------------------------------------------------------
555
+ async createMany(args) {
556
+ return this.executeWithMiddleware('createMany', args, async () => {
557
+ const deferred = this.buildCreateMany(args);
558
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
559
+ return deferred.transform(result);
560
+ });
561
+ }
562
+ buildCreateMany(args) {
563
+ const qt = quoteIdent(this.table);
564
+ if (args.data.length === 0) {
565
+ return {
566
+ sql: `SELECT * FROM ${qt} WHERE false`,
567
+ params: [],
568
+ transform: () => [],
569
+ tag: `${this.table}.createMany`,
570
+ };
571
+ }
572
+ const keys = Object.keys(args.data[0]).filter((k) => args.data[0][k] !== undefined);
573
+ const columns = keys.map((k) => this.toColumn(k));
574
+ // Build column arrays for UNNEST
575
+ const columnArrays = keys.map(() => []);
576
+ for (const row of args.data) {
577
+ const record = row;
578
+ keys.forEach((key, i) => {
579
+ columnArrays[i].push(record[key]);
580
+ });
581
+ }
582
+ // Use actual Postgres types for array casts
583
+ const typeCasts = columns.map((col) => this.getColumnArrayType(col));
584
+ const unnestArgs = columnArrays.map((_, i) => `$${i + 1}::${typeCasts[i]}`);
585
+ const quotedColumns = columns.map((c) => quoteIdent(c));
586
+ let sql = `INSERT INTO ${qt} (${quotedColumns.join(', ')}) SELECT * FROM UNNEST(${unnestArgs.join(', ')})`;
587
+ // skipDuplicates: add ON CONFLICT DO NOTHING
588
+ if (args.skipDuplicates) {
589
+ sql += ` ON CONFLICT DO NOTHING`;
590
+ }
591
+ sql += ` RETURNING *`;
592
+ return {
593
+ sql,
594
+ params: columnArrays,
595
+ transform: (result) => result.rows.map((row) => this.parseRow(row, this.table)),
596
+ tag: `${this.table}.createMany`,
597
+ };
598
+ }
599
+ // -------------------------------------------------------------------------
600
+ // update
601
+ // -------------------------------------------------------------------------
602
+ async update(args) {
603
+ return this.executeWithMiddleware('update', args, async () => {
604
+ const deferred = this.buildUpdate(args);
605
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
606
+ return deferred.transform(result);
607
+ });
608
+ }
609
+ buildUpdate(args) {
610
+ const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
611
+ // Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
612
+ const params = [];
613
+ const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
614
+ // Build WHERE using the shared params array (continues numbering after SET params)
615
+ const whereClause = this.buildWhereClause(args.where, params);
616
+ const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
617
+ this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
618
+ const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
619
+ return {
620
+ sql,
621
+ params,
622
+ transform: (result) => {
623
+ const row = result.rows[0];
624
+ if (!row) {
625
+ throw new errors_js_1.NotFoundError({
626
+ table: this.table,
627
+ where: args.where,
628
+ operation: 'update',
629
+ });
630
+ }
631
+ return this.parseRow(row, this.table);
632
+ },
633
+ tag: `${this.table}.update`,
634
+ };
635
+ }
636
+ // -------------------------------------------------------------------------
637
+ // delete
638
+ // -------------------------------------------------------------------------
639
+ async delete(args) {
640
+ return this.executeWithMiddleware('delete', args, async () => {
641
+ const deferred = this.buildDelete(args);
642
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
643
+ return deferred.transform(result);
644
+ });
645
+ }
646
+ buildDelete(args) {
647
+ const { sql: whereSql, params } = this.buildWhere(args.where);
648
+ this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
649
+ const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql} RETURNING *`;
650
+ return {
651
+ sql,
652
+ params,
653
+ transform: (result) => {
654
+ const row = result.rows[0];
655
+ if (!row) {
656
+ throw new errors_js_1.NotFoundError({
657
+ table: this.table,
658
+ where: args.where,
659
+ operation: 'delete',
660
+ });
661
+ }
662
+ return this.parseRow(row, this.table);
663
+ },
664
+ tag: `${this.table}.delete`,
665
+ };
666
+ }
667
+ // -------------------------------------------------------------------------
668
+ // upsert — INSERT ... ON CONFLICT ... DO UPDATE
669
+ // -------------------------------------------------------------------------
670
+ async upsert(args) {
671
+ return this.executeWithMiddleware('upsert', args, async () => {
672
+ const deferred = this.buildUpsert(args);
673
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
674
+ return deferred.transform(result);
675
+ });
676
+ }
677
+ buildUpsert(args) {
678
+ // Build the INSERT part from create data
679
+ const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
680
+ const columns = createEntries.map(([k]) => this.toSqlColumn(k));
681
+ const createParams = createEntries.map(([, v]) => v);
682
+ const placeholders = createEntries.map((_, i) => `$${i + 1}`);
683
+ // The conflict target comes from `where` keys — must be unique/PK columns
684
+ const conflictKeys = Object.keys(args.where).filter((k) => args.where[k] !== undefined);
685
+ const conflictColumns = conflictKeys.map((k) => this.toSqlColumn(k));
686
+ // Build the UPDATE SET part
687
+ const updateEntries = Object.entries(args.update).filter(([, v]) => v !== undefined);
688
+ let paramIdx = createParams.length + 1;
689
+ const setClauses = updateEntries.map(([k]) => {
690
+ const clause = `${this.toSqlColumn(k)} = $${paramIdx}`;
691
+ paramIdx++;
692
+ return clause;
693
+ });
694
+ const updateParams = updateEntries.map(([, v]) => v);
695
+ const params = [...createParams, ...updateParams];
696
+ const sql = `INSERT INTO ${quoteIdent(this.table)} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})` +
697
+ ` ON CONFLICT (${conflictColumns.join(', ')}) DO UPDATE SET ${setClauses.join(', ')}` +
698
+ ` RETURNING *`;
699
+ return {
700
+ sql,
701
+ params,
702
+ transform: (result) => {
703
+ const row = result.rows[0];
704
+ if (!row) {
705
+ throw new errors_js_1.NotFoundError({
706
+ table: this.table,
707
+ where: args.where,
708
+ operation: 'upsert',
709
+ message: `[turbine] upsert on "${this.table}" returned no row from RETURNING * — this should never happen.`,
710
+ });
711
+ }
712
+ return this.parseRow(row, this.table);
713
+ },
714
+ tag: `${this.table}.upsert`,
715
+ };
716
+ }
717
+ // -------------------------------------------------------------------------
718
+ // updateMany — UPDATE ... WHERE ... returning count
719
+ // -------------------------------------------------------------------------
720
+ async updateMany(args) {
721
+ return this.executeWithMiddleware('updateMany', args, async () => {
722
+ const deferred = this.buildUpdateMany(args);
723
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
724
+ return deferred.transform(result);
725
+ });
726
+ }
727
+ buildUpdateMany(args) {
728
+ const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
729
+ // Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
730
+ const params = [];
731
+ const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
732
+ // Build WHERE using the shared params array (continues numbering after SET params)
733
+ const whereClause = this.buildWhereClause(args.where, params);
734
+ const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
735
+ this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
736
+ const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
737
+ return {
738
+ sql,
739
+ params,
740
+ transform: (result) => ({ count: result.rowCount ?? 0 }),
741
+ tag: `${this.table}.updateMany`,
742
+ };
743
+ }
744
+ // -------------------------------------------------------------------------
745
+ // deleteMany — DELETE ... WHERE ... returning count
746
+ // -------------------------------------------------------------------------
747
+ async deleteMany(args) {
748
+ return this.executeWithMiddleware('deleteMany', args, async () => {
749
+ const deferred = this.buildDeleteMany(args);
750
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
751
+ return deferred.transform(result);
752
+ });
753
+ }
754
+ buildDeleteMany(args) {
755
+ const { sql: whereSql, params } = this.buildWhere(args.where);
756
+ this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
757
+ const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql}`;
758
+ return {
759
+ sql,
760
+ params,
761
+ transform: (result) => ({ count: result.rowCount ?? 0 }),
762
+ tag: `${this.table}.deleteMany`,
763
+ };
764
+ }
765
+ // -------------------------------------------------------------------------
766
+ // count
767
+ // -------------------------------------------------------------------------
768
+ async count(args) {
769
+ return this.executeWithMiddleware('count', (args ?? {}), async () => {
770
+ const deferred = this.buildCount(args);
771
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
772
+ return deferred.transform(result);
773
+ });
774
+ }
775
+ buildCount(args) {
776
+ const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
777
+ const sql = `SELECT COUNT(*)::int AS count FROM ${quoteIdent(this.table)}${whereSql}`;
778
+ return {
779
+ sql,
780
+ params,
781
+ transform: (result) => result.rows[0].count,
782
+ tag: `${this.table}.count`,
783
+ };
784
+ }
785
+ // -------------------------------------------------------------------------
786
+ // groupBy (with aggregate functions)
787
+ // -------------------------------------------------------------------------
788
+ async groupBy(args) {
789
+ return this.executeWithMiddleware('groupBy', args, async () => {
790
+ const deferred = this.buildGroupBy(args);
791
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
792
+ return deferred.transform(result);
793
+ });
794
+ }
795
+ buildGroupBy(args) {
796
+ const meta = this.schema.tables[this.table];
797
+ if (meta) {
798
+ for (const key of args.by) {
799
+ if (!(key in meta.columnMap)) {
800
+ throw new errors_js_1.ValidationError(`Unknown column "${key}" in groupBy for table "${this.table}"`);
801
+ }
802
+ }
803
+ }
804
+ const groupColsRaw = args.by.map((k) => this.toColumn(k));
805
+ const groupCols = groupColsRaw.map((c) => quoteIdent(c));
806
+ const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
807
+ // Build SELECT expressions: group-by columns + aggregate functions
808
+ const selectExprs = [...groupCols];
809
+ // _count
810
+ if (args._count === true || args._count === undefined) {
811
+ // default: always include count
812
+ selectExprs.push('COUNT(*)::int AS _count');
813
+ }
814
+ // _sum
815
+ if (args._sum) {
816
+ for (const [field, enabled] of Object.entries(args._sum)) {
817
+ if (enabled) {
818
+ const col = this.toColumn(field);
819
+ selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent('_sum_' + col)}`);
820
+ }
821
+ }
822
+ }
823
+ // _avg
824
+ if (args._avg) {
825
+ for (const [field, enabled] of Object.entries(args._avg)) {
826
+ if (enabled) {
827
+ const col = this.toColumn(field);
828
+ selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent('_avg_' + col)}`);
829
+ }
830
+ }
831
+ }
832
+ // _min
833
+ if (args._min) {
834
+ for (const [field, enabled] of Object.entries(args._min)) {
835
+ if (enabled) {
836
+ const col = this.toColumn(field);
837
+ selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent('_min_' + col)}`);
838
+ }
839
+ }
840
+ }
841
+ // _max
842
+ if (args._max) {
843
+ for (const [field, enabled] of Object.entries(args._max)) {
844
+ if (enabled) {
845
+ const col = this.toColumn(field);
846
+ selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent('_max_' + col)}`);
847
+ }
848
+ }
849
+ }
850
+ let sql = `SELECT ${selectExprs.join(', ')} FROM ${quoteIdent(this.table)}${whereSql} GROUP BY ${groupCols.join(', ')}`;
851
+ // ORDER BY
852
+ if (args.orderBy) {
853
+ sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
854
+ }
855
+ return {
856
+ sql,
857
+ params,
858
+ transform: (result) => result.rows.map((row) => {
859
+ const parsed = this.parseRow(row, this.table);
860
+ // Restructure aggregate results into nested objects (Prisma-style)
861
+ const restructured = {};
862
+ // Copy group-by fields
863
+ for (const field of args.by) {
864
+ restructured[field] = parsed[field];
865
+ }
866
+ // _count
867
+ if ('_count' in row) {
868
+ restructured._count = row._count;
869
+ }
870
+ else if ('count' in row) {
871
+ restructured._count = row.count;
872
+ }
873
+ // Collect aggregates into nested objects
874
+ const sumObj = {};
875
+ const avgObj = {};
876
+ const minObj = {};
877
+ const maxObj = {};
878
+ let hasSums = false, hasAvgs = false, hasMins = false, hasMaxs = false;
879
+ for (const [rawKey, rawValue] of Object.entries(row)) {
880
+ if (rawKey.startsWith('_sum_')) {
881
+ const col = rawKey.slice(5);
882
+ const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
883
+ sumObj[field] = rawValue !== null ? Number(rawValue) : null;
884
+ hasSums = true;
885
+ }
886
+ else if (rawKey.startsWith('_avg_')) {
887
+ const col = rawKey.slice(5);
888
+ const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
889
+ avgObj[field] = rawValue !== null ? Number(rawValue) : null;
890
+ hasAvgs = true;
891
+ }
892
+ else if (rawKey.startsWith('_min_')) {
893
+ const col = rawKey.slice(5);
894
+ const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
895
+ minObj[field] = rawValue;
896
+ hasMins = true;
897
+ }
898
+ else if (rawKey.startsWith('_max_')) {
899
+ const col = rawKey.slice(5);
900
+ const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
901
+ maxObj[field] = rawValue;
902
+ hasMaxs = true;
903
+ }
904
+ }
905
+ if (hasSums)
906
+ restructured._sum = sumObj;
907
+ if (hasAvgs)
908
+ restructured._avg = avgObj;
909
+ if (hasMins)
910
+ restructured._min = minObj;
911
+ if (hasMaxs)
912
+ restructured._max = maxObj;
913
+ return restructured;
914
+ }),
915
+ tag: `${this.table}.groupBy`,
916
+ };
917
+ }
918
+ // -------------------------------------------------------------------------
919
+ // aggregate — standalone aggregation without groupBy
920
+ // -------------------------------------------------------------------------
921
+ async aggregate(args) {
922
+ return this.executeWithMiddleware('aggregate', args, async () => {
923
+ const deferred = this.buildAggregate(args);
924
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
925
+ return deferred.transform(result);
926
+ });
927
+ }
928
+ buildAggregate(args) {
929
+ const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
930
+ const meta = this.schema.tables[this.table];
931
+ if (meta) {
932
+ for (const group of [args._sum, args._avg, args._min, args._max]) {
933
+ if (group && typeof group === 'object') {
934
+ for (const key of Object.keys(group)) {
935
+ if (!(key in meta.columnMap)) {
936
+ throw new errors_js_1.ValidationError(`Unknown column "${key}" in aggregate for table "${this.table}"`);
937
+ }
938
+ }
939
+ }
940
+ }
941
+ if (args._count && typeof args._count === 'object') {
942
+ for (const key of Object.keys(args._count)) {
943
+ if (!(key in meta.columnMap)) {
944
+ throw new errors_js_1.ValidationError(`Unknown column "${key}" in aggregate for table "${this.table}"`);
945
+ }
946
+ }
947
+ }
948
+ }
949
+ const selectExprs = [];
950
+ // _count
951
+ if (args._count === true) {
952
+ selectExprs.push('COUNT(*)::int AS _count');
953
+ }
954
+ else if (args._count && typeof args._count === 'object') {
955
+ for (const [field, enabled] of Object.entries(args._count)) {
956
+ if (enabled) {
957
+ const col = this.toColumn(field);
958
+ selectExprs.push(`COUNT(${quoteIdent(col)})::int AS ${quoteIdent('_count_' + col)}`);
959
+ }
960
+ }
961
+ }
962
+ // _sum
963
+ if (args._sum) {
964
+ for (const [field, enabled] of Object.entries(args._sum)) {
965
+ if (enabled) {
966
+ const col = this.toColumn(field);
967
+ selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent('_sum_' + col)}`);
968
+ }
969
+ }
970
+ }
971
+ // _avg
972
+ if (args._avg) {
973
+ for (const [field, enabled] of Object.entries(args._avg)) {
974
+ if (enabled) {
975
+ const col = this.toColumn(field);
976
+ selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent('_avg_' + col)}`);
977
+ }
978
+ }
979
+ }
980
+ // _min
981
+ if (args._min) {
982
+ for (const [field, enabled] of Object.entries(args._min)) {
983
+ if (enabled) {
984
+ const col = this.toColumn(field);
985
+ selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent('_min_' + col)}`);
986
+ }
987
+ }
988
+ }
989
+ // _max
990
+ if (args._max) {
991
+ for (const [field, enabled] of Object.entries(args._max)) {
992
+ if (enabled) {
993
+ const col = this.toColumn(field);
994
+ selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent('_max_' + col)}`);
995
+ }
996
+ }
997
+ }
998
+ if (selectExprs.length === 0) {
999
+ selectExprs.push('COUNT(*)::int AS _count');
1000
+ }
1001
+ const sql = `SELECT ${selectExprs.join(', ')} FROM ${quoteIdent(this.table)}${whereSql}`;
1002
+ return {
1003
+ sql,
1004
+ params,
1005
+ transform: (result) => {
1006
+ const row = result.rows[0];
1007
+ const aggResult = {};
1008
+ // _count
1009
+ if (row._count !== undefined) {
1010
+ aggResult._count = row._count;
1011
+ }
1012
+ else {
1013
+ // Check for per-column counts
1014
+ const countObj = {};
1015
+ let hasCountFields = false;
1016
+ for (const [key, val] of Object.entries(row)) {
1017
+ if (key.startsWith('_count_')) {
1018
+ const col = key.slice(7);
1019
+ const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1020
+ countObj[field] = val;
1021
+ hasCountFields = true;
1022
+ }
1023
+ }
1024
+ if (hasCountFields)
1025
+ aggResult._count = countObj;
1026
+ }
1027
+ // Build nested aggregate objects
1028
+ const sumObj = {};
1029
+ const avgObj = {};
1030
+ const minObj = {};
1031
+ const maxObj = {};
1032
+ let hasSums = false, hasAvgs = false, hasMins = false, hasMaxs = false;
1033
+ for (const [key, val] of Object.entries(row)) {
1034
+ if (key.startsWith('_sum_')) {
1035
+ const col = key.slice(5);
1036
+ const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1037
+ sumObj[field] = val !== null ? Number(val) : null;
1038
+ hasSums = true;
1039
+ }
1040
+ else if (key.startsWith('_avg_')) {
1041
+ const col = key.slice(5);
1042
+ const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1043
+ avgObj[field] = val !== null ? Number(val) : null;
1044
+ hasAvgs = true;
1045
+ }
1046
+ else if (key.startsWith('_min_')) {
1047
+ const col = key.slice(5);
1048
+ const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1049
+ minObj[field] = val;
1050
+ hasMins = true;
1051
+ }
1052
+ else if (key.startsWith('_max_')) {
1053
+ const col = key.slice(5);
1054
+ const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1055
+ maxObj[field] = val;
1056
+ hasMaxs = true;
1057
+ }
1058
+ }
1059
+ if (hasSums)
1060
+ aggResult._sum = sumObj;
1061
+ if (hasAvgs)
1062
+ aggResult._avg = avgObj;
1063
+ if (hasMins)
1064
+ aggResult._min = minObj;
1065
+ if (hasMaxs)
1066
+ aggResult._max = maxObj;
1067
+ return aggResult;
1068
+ },
1069
+ tag: `${this.table}.aggregate`,
1070
+ };
1071
+ }
1072
+ // =========================================================================
1073
+ // Internal helpers
1074
+ // =========================================================================
1075
+ /**
1076
+ * Resolve select/omit options into a list of snake_case column names.
1077
+ * Returns null if neither is provided (meaning all columns).
1078
+ */
1079
+ resolveColumns(select, omit) {
1080
+ if (select) {
1081
+ // Only include columns where value is true
1082
+ return Object.entries(select)
1083
+ .filter(([, v]) => v)
1084
+ .map(([k]) => this.toColumn(k));
1085
+ }
1086
+ if (omit) {
1087
+ // Include all columns except those where value is true
1088
+ const omitCols = new Set(Object.entries(omit)
1089
+ .filter(([, v]) => v)
1090
+ .map(([k]) => this.toColumn(k)));
1091
+ return this.tableMeta.allColumns.filter((col) => !omitCols.has(col));
1092
+ }
1093
+ return null;
1094
+ }
1095
+ /** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
1096
+ toColumn(field) {
1097
+ const mapped = this.tableMeta.columnMap[field];
1098
+ if (mapped)
1099
+ return mapped;
1100
+ return (0, schema_js_1.camelToSnake)(field);
1101
+ }
1102
+ /** Convert camelCase field name to a double-quoted SQL identifier */
1103
+ toSqlColumn(field) {
1104
+ return quoteIdent(this.toColumn(field));
1105
+ }
1106
+ /**
1107
+ * Build a single SET clause entry for update/updateMany.
1108
+ *
1109
+ * Supports plain values and atomic operator objects ({ set, increment,
1110
+ * decrement, multiply, divide }). An operator object is detected ONLY when
1111
+ * it has EXACTLY one key that is one of the 5 operator keys — this avoids
1112
+ * misinterpreting JSON column values like `{ set: 'x' }` as operators
1113
+ * (real operator objects always have exactly one key, and a plain JSON
1114
+ * payload that happens to have a single `set` key is extremely unusual).
1115
+ * Multi-key objects are always treated as plain (JSON) values.
1116
+ *
1117
+ * Returns the SQL fragment (e.g., `"view_count" = "view_count" + $3`) and
1118
+ * pushes any required params onto the shared params array so that WHERE
1119
+ * clause numbering continues correctly afterward.
1120
+ */
1121
+ buildSetClause(key, value, params) {
1122
+ const col = this.toSqlColumn(key);
1123
+ // Detect atomic-operator object: plain object (not null, not array, not
1124
+ // Date, not Buffer) with EXACTLY one key matching an operator name.
1125
+ if (value !== null &&
1126
+ typeof value === 'object' &&
1127
+ !Array.isArray(value) &&
1128
+ !(value instanceof Date) &&
1129
+ !Buffer.isBuffer(value)) {
1130
+ const v = value;
1131
+ const keys = Object.keys(v);
1132
+ if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
1133
+ const op = keys[0];
1134
+ const opValue = v[op];
1135
+ if (op === 'set') {
1136
+ params.push(opValue);
1137
+ return `${col} = $${params.length}`;
1138
+ }
1139
+ // Arithmetic operators: must be finite numbers
1140
+ if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
1141
+ throw new errors_js_1.ValidationError(`[turbine] update operator "${op}" on "${this.table}.${key}" requires a finite number, got ${typeof opValue}`);
1142
+ }
1143
+ if (op === 'increment') {
1144
+ params.push(opValue);
1145
+ return `${col} = ${col} + $${params.length}`;
1146
+ }
1147
+ if (op === 'decrement') {
1148
+ params.push(opValue);
1149
+ return `${col} = ${col} - $${params.length}`;
1150
+ }
1151
+ if (op === 'multiply') {
1152
+ params.push(opValue);
1153
+ return `${col} = ${col} * $${params.length}`;
1154
+ }
1155
+ if (op === 'divide') {
1156
+ params.push(opValue);
1157
+ return `${col} = ${col} / $${params.length}`;
1158
+ }
1159
+ }
1160
+ // Fall through: multi-key objects or non-operator single-key objects
1161
+ // are treated as plain values (e.g., JSONB column payloads).
1162
+ }
1163
+ // Plain value (including null, Date, Buffer, arrays, JSON objects)
1164
+ params.push(value);
1165
+ return `${col} = $${params.length}`;
1166
+ }
1167
+ /** Build WHERE clause from a where object (supports operators, NULL, OR) */
1168
+ buildWhere(where) {
1169
+ const params = [];
1170
+ const clause = this.buildWhereClause(where, params);
1171
+ if (!clause)
1172
+ return { sql: '', params: [] };
1173
+ return { sql: ` WHERE ${clause}`, params };
1174
+ }
1175
+ /**
1176
+ * Refuse mutations with an empty predicate unless explicitly opted in.
1177
+ *
1178
+ * An empty `where` (e.g. `{}` or `{ id: undefined }`) resolves to a
1179
+ * mutation with no filter — a common footgun when a caller's filter
1180
+ * value accidentally resolves to `undefined`. This guard throws
1181
+ * `ValidationError` in that case unless `allowFullTableScan: true`.
1182
+ */
1183
+ assertMutationHasPredicate(operation, whereSql, allowFullTableScan) {
1184
+ if (whereSql.length > 0)
1185
+ return;
1186
+ if (allowFullTableScan === true)
1187
+ return;
1188
+ throw new errors_js_1.ValidationError(`[turbine] ${operation} on "${this.table}" refused: the \`where\` clause is empty. ` +
1189
+ `Pass \`allowFullTableScan: true\` to opt in, or check that your filter values are defined.`);
1190
+ }
1191
+ /**
1192
+ * Build the inner WHERE expression (without the WHERE keyword).
1193
+ * Returns null if no conditions exist.
1194
+ * Supports: equality, operators, NULL, OR, AND, NOT, relation filters (some/every/none).
1195
+ */
1196
+ buildWhereClause(where, params) {
1197
+ const keys = Object.keys(where);
1198
+ if (keys.length === 0)
1199
+ return null;
1200
+ const andClauses = [];
1201
+ for (const key of keys) {
1202
+ const value = where[key];
1203
+ if (value === undefined)
1204
+ continue;
1205
+ // Handle OR special key
1206
+ if (key === 'OR') {
1207
+ const orConditions = value;
1208
+ if (!Array.isArray(orConditions) || orConditions.length === 0)
1209
+ continue;
1210
+ const orClauses = [];
1211
+ for (const orCond of orConditions) {
1212
+ const sub = this.buildWhereClause(orCond, params);
1213
+ if (sub)
1214
+ orClauses.push(sub);
1215
+ }
1216
+ if (orClauses.length > 0) {
1217
+ andClauses.push(`(${orClauses.join(' OR ')})`);
1218
+ }
1219
+ continue;
1220
+ }
1221
+ // Handle AND special key
1222
+ if (key === 'AND') {
1223
+ const andConditions = value;
1224
+ if (!Array.isArray(andConditions) || andConditions.length === 0)
1225
+ continue;
1226
+ for (const andCond of andConditions) {
1227
+ const sub = this.buildWhereClause(andCond, params);
1228
+ if (sub)
1229
+ andClauses.push(sub);
1230
+ }
1231
+ continue;
1232
+ }
1233
+ // Handle NOT special key
1234
+ if (key === 'NOT') {
1235
+ const notCond = value;
1236
+ const sub = this.buildWhereClause(notCond, params);
1237
+ if (sub)
1238
+ andClauses.push(`NOT (${sub})`);
1239
+ continue;
1240
+ }
1241
+ // Handle relation filters: { posts: { some: { published: true } } }
1242
+ const relationDef = this.tableMeta.relations[key];
1243
+ if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1244
+ const filterObj = value;
1245
+ // Check if this is a relation filter (has some/every/none keys)
1246
+ if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
1247
+ const relClause = this.buildRelationFilter(key, relationDef, filterObj, params);
1248
+ if (relClause)
1249
+ andClauses.push(relClause);
1250
+ continue;
1251
+ }
1252
+ }
1253
+ const rawColumn = this.toColumn(key);
1254
+ const column = quoteIdent(rawColumn);
1255
+ // Handle null → IS NULL
1256
+ if (value === null) {
1257
+ andClauses.push(`${column} IS NULL`);
1258
+ continue;
1259
+ }
1260
+ // Handle JSONB filter operators (for json/jsonb columns)
1261
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1262
+ const colType = this.getColumnPgType(rawColumn);
1263
+ if (colType === 'json' || colType === 'jsonb') {
1264
+ const jsonClauses = this.buildJsonFilterClauses(column, value, params);
1265
+ andClauses.push(...jsonClauses);
1266
+ continue;
1267
+ }
1268
+ }
1269
+ // Handle Array filter operators (for array columns)
1270
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
1271
+ const colType = this.getColumnPgType(rawColumn);
1272
+ if (colType.startsWith('_')) {
1273
+ const arrayClauses = this.buildArrayFilterClauses(column, value, params, colType);
1274
+ andClauses.push(...arrayClauses);
1275
+ continue;
1276
+ }
1277
+ }
1278
+ // Handle operator objects
1279
+ if (isWhereOperator(value)) {
1280
+ const opClauses = this.buildOperatorClauses(column, value, params);
1281
+ andClauses.push(...opClauses);
1282
+ continue;
1283
+ }
1284
+ // Plain equality
1285
+ params.push(value);
1286
+ andClauses.push(`${column} = $${params.length}`);
1287
+ }
1288
+ if (andClauses.length === 0)
1289
+ return null;
1290
+ return andClauses.join(' AND ');
1291
+ }
1292
+ /**
1293
+ * Build relation filter SQL: WHERE EXISTS / NOT EXISTS subquery
1294
+ * Supports: some (EXISTS), every (NOT EXISTS ... NOT), none (NOT EXISTS)
1295
+ */
1296
+ buildRelationFilter(_relName, relDef, filterObj, params) {
1297
+ const targetTable = relDef.to;
1298
+ const targetMeta = this.schema.tables[targetTable];
1299
+ if (!targetMeta)
1300
+ return null;
1301
+ const qt = quoteIdent(targetTable);
1302
+ const qSelf = quoteIdent(this.table);
1303
+ const clauses = [];
1304
+ // Correlation: link child table to parent table
1305
+ let correlation;
1306
+ if (relDef.type === 'hasMany' || relDef.type === 'hasOne') {
1307
+ // parent.pk = child.fk
1308
+ correlation = `${qt}.${quoteIdent(relDef.foreignKey)} = ${qSelf}.${quoteIdent(relDef.referenceKey)}`;
1309
+ }
1310
+ else {
1311
+ // belongsTo: parent.fk = child.pk
1312
+ correlation = `${qt}.${quoteIdent(relDef.referenceKey)} = ${qSelf}.${quoteIdent(relDef.foreignKey)}`;
1313
+ }
1314
+ // "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
1315
+ if (filterObj.some !== undefined) {
1316
+ const subWhere = filterObj.some;
1317
+ const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
1318
+ const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
1319
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
1320
+ }
1321
+ // "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
1322
+ if (filterObj.none !== undefined) {
1323
+ const subWhere = filterObj.none;
1324
+ const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
1325
+ const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
1326
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
1327
+ }
1328
+ // "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND NOT (filter))
1329
+ if (filterObj.every !== undefined) {
1330
+ const subWhere = filterObj.every;
1331
+ const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
1332
+ if (filterClause) {
1333
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation} AND NOT (${filterClause}))`);
1334
+ }
1335
+ else {
1336
+ // "every" with empty filter = true (all match trivially)
1337
+ }
1338
+ }
1339
+ return clauses.length > 0 ? clauses.join(' AND ') : null;
1340
+ }
1341
+ /**
1342
+ * Build WHERE clause conditions for a relation filter subquery.
1343
+ * Uses the target table's column mapping to resolve field names.
1344
+ */
1345
+ buildSubWhereForRelation(targetTable, subWhere, params) {
1346
+ const meta = this.schema.tables[targetTable];
1347
+ if (!meta)
1348
+ return null;
1349
+ const qt = quoteIdent(targetTable);
1350
+ const conditions = [];
1351
+ for (const [field, value] of Object.entries(subWhere)) {
1352
+ if (value === undefined)
1353
+ continue;
1354
+ const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
1355
+ const qCol = `${qt}.${quoteIdent(col)}`;
1356
+ if (value === null) {
1357
+ conditions.push(`${qCol} IS NULL`);
1358
+ continue;
1359
+ }
1360
+ if (isWhereOperator(value)) {
1361
+ const opClauses = this.buildOperatorClauses(qCol, value, params);
1362
+ conditions.push(...opClauses);
1363
+ continue;
1364
+ }
1365
+ params.push(value);
1366
+ conditions.push(`${qCol} = $${params.length}`);
1367
+ }
1368
+ return conditions.length > 0 ? conditions.join(' AND ') : null;
1369
+ }
1370
+ /**
1371
+ * Build SQL clauses for a single operator object on a column.
1372
+ * Each operator key becomes its own clause, all ANDed together.
1373
+ */
1374
+ buildOperatorClauses(column, op, params) {
1375
+ const clauses = [];
1376
+ if (op.gt !== undefined) {
1377
+ params.push(op.gt);
1378
+ clauses.push(`${column} > $${params.length}`);
1379
+ }
1380
+ if (op.gte !== undefined) {
1381
+ params.push(op.gte);
1382
+ clauses.push(`${column} >= $${params.length}`);
1383
+ }
1384
+ if (op.lt !== undefined) {
1385
+ params.push(op.lt);
1386
+ clauses.push(`${column} < $${params.length}`);
1387
+ }
1388
+ if (op.lte !== undefined) {
1389
+ params.push(op.lte);
1390
+ clauses.push(`${column} <= $${params.length}`);
1391
+ }
1392
+ if (op.not !== undefined) {
1393
+ if (op.not === null) {
1394
+ clauses.push(`${column} IS NOT NULL`);
1395
+ }
1396
+ else {
1397
+ params.push(op.not);
1398
+ clauses.push(`${column} != $${params.length}`);
1399
+ }
1400
+ }
1401
+ if (op.in !== undefined) {
1402
+ params.push(op.in);
1403
+ clauses.push(`${column} = ANY($${params.length})`);
1404
+ }
1405
+ if (op.notIn !== undefined) {
1406
+ params.push(op.notIn);
1407
+ clauses.push(`${column} != ALL($${params.length})`);
1408
+ }
1409
+ // Use ILIKE for case-insensitive mode, LIKE otherwise
1410
+ const likeOp = op.mode === 'insensitive' ? 'ILIKE' : 'LIKE';
1411
+ if (op.contains !== undefined) {
1412
+ params.push(`%${escapeLike(op.contains)}%`);
1413
+ clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
1414
+ }
1415
+ if (op.startsWith !== undefined) {
1416
+ params.push(`${escapeLike(op.startsWith)}%`);
1417
+ clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
1418
+ }
1419
+ if (op.endsWith !== undefined) {
1420
+ params.push(`%${escapeLike(op.endsWith)}`);
1421
+ clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
1422
+ }
1423
+ return clauses;
1424
+ }
1425
+ /** Build ORDER BY clause from an object */
1426
+ buildOrderBy(orderBy) {
1427
+ const meta = this.schema.tables[this.table];
1428
+ return Object.entries(orderBy)
1429
+ .map(([key, dir]) => {
1430
+ if (meta && !(key in meta.columnMap)) {
1431
+ throw new errors_js_1.ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
1432
+ }
1433
+ const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
1434
+ return `${this.toSqlColumn(key)} ${safeDir}`;
1435
+ })
1436
+ .join(', ');
1437
+ }
1438
+ /** Parse a flat row: convert snake_case to camelCase + Date coercion */
1439
+ parseRow(row, table) {
1440
+ const parsed = {};
1441
+ const meta = this.schema.tables[table];
1442
+ if (meta) {
1443
+ // Fast path: use pre-computed maps (avoids regex per column per row)
1444
+ const reverseMap = meta.reverseColumnMap;
1445
+ const dateCols = meta.dateColumns;
1446
+ const keys = Object.keys(row);
1447
+ for (let i = 0; i < keys.length; i++) {
1448
+ const col = keys[i];
1449
+ const value = row[col];
1450
+ const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
1451
+ if (dateCols.has(col) && value !== null && !(value instanceof Date)) {
1452
+ parsed[field] = new Date(value);
1453
+ }
1454
+ else {
1455
+ parsed[field] = value;
1456
+ }
1457
+ }
1458
+ }
1459
+ else {
1460
+ // Fallback: no metadata, use regex conversion
1461
+ for (const [col, value] of Object.entries(row)) {
1462
+ parsed[(0, schema_js_1.snakeToCamel)(col)] = value;
1463
+ }
1464
+ }
1465
+ return parsed;
1466
+ }
1467
+ /** Parse a row that may contain JSON nested relation columns */
1468
+ parseNestedRow(row, table) {
1469
+ const parsed = this.parseRow(row, table);
1470
+ const meta = this.schema.tables[table];
1471
+ if (!meta)
1472
+ return parsed;
1473
+ for (const [relName, relDef] of Object.entries(meta.relations)) {
1474
+ const rawValue = row[relName];
1475
+ if (rawValue !== undefined) {
1476
+ if (typeof rawValue === 'string') {
1477
+ try {
1478
+ parsed[relName] = JSON.parse(rawValue);
1479
+ }
1480
+ catch {
1481
+ console.warn(`[turbine] Warning: Failed to parse JSON for relation "${relName}" on table "${this.table}". Using raw value.`);
1482
+ parsed[relName] = rawValue;
1483
+ }
1484
+ }
1485
+ else if (Array.isArray(rawValue)) {
1486
+ parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
1487
+ ? this.parseRow(item, relDef.to)
1488
+ : item);
1489
+ }
1490
+ else if (typeof rawValue === 'object' && rawValue !== null) {
1491
+ parsed[relName] = this.parseRow(rawValue, relDef.to);
1492
+ }
1493
+ else {
1494
+ parsed[relName] = rawValue;
1495
+ }
1496
+ }
1497
+ }
1498
+ return parsed;
1499
+ }
1500
+ /**
1501
+ * Build a SELECT clause that includes both base columns and nested relation subqueries.
1502
+ *
1503
+ * For each relation specified in the `with` clause, this method generates a correlated
1504
+ * subquery using PostgreSQL's `json_agg(json_build_object(...))` pattern. The result
1505
+ * is a single SQL SELECT clause that resolves the full object tree in one query --
1506
+ * no N+1 problem.
1507
+ *
1508
+ * **How it works:**
1509
+ * 1. Resolves the base columns for the root table (all columns, or a subset via `columnsList`).
1510
+ * 2. Iterates over each key in the `with` clause, looking up the relation definition.
1511
+ * 3. For each relation, delegates to {@link buildRelationSubquery} to generate a
1512
+ * correlated subquery that returns JSON (array for hasMany, object for belongsTo/hasOne).
1513
+ * 4. Each subquery is aliased as the relation name in the final SELECT.
1514
+ *
1515
+ * **aliasCounter:** A shared `{ n: number }` object is passed through all nesting levels.
1516
+ * Each call to `buildRelationSubquery` increments it to produce unique table aliases
1517
+ * (`t0`, `t1`, `t2`, ...) across arbitrarily deep relation trees, preventing alias
1518
+ * collisions in the generated SQL.
1519
+ *
1520
+ * **Example output:**
1521
+ * ```sql
1522
+ * "users"."id", "users"."name", "users"."email",
1523
+ * (SELECT COALESCE(json_agg(json_build_object('id', t0."id", 'title', t0."title")), '[]'::json)
1524
+ * FROM "posts" t0 WHERE t0."user_id" = "users"."id") AS "posts"
1525
+ * ```
1526
+ *
1527
+ * @param table - The root table name (e.g. `"users"`).
1528
+ * @param withClause - An object mapping relation names to their include specs
1529
+ * (`true` for default inclusion, or `WithOptions` for select/omit/where/orderBy/limit).
1530
+ * @param params - Shared parameter array for parameterized values (`$1`, `$2`, ...).
1531
+ * Nested where/limit values are pushed here to prevent SQL injection.
1532
+ * @param columnsList - Optional subset of columns to include in the SELECT. When `null`
1533
+ * or omitted, all columns from the table's schema metadata are used.
1534
+ * @param depth - Current nesting depth, passed through to {@link buildRelationSubquery}
1535
+ * for circular-relation detection. Defaults to `0` at the top level.
1536
+ * @param path - Breadcrumb trail of relation names traversed so far, used in error
1537
+ * messages when circular or too-deep nesting is detected.
1538
+ * @returns A complete SELECT clause string (without the `SELECT` keyword) containing
1539
+ * base columns and relation subqueries.
1540
+ */
1541
+ buildSelectWithRelations(table, withClause, params, columnsList, depth, path) {
1542
+ const meta = this.schema.tables[table];
1543
+ if (!meta)
1544
+ throw new errors_js_1.ValidationError(`[turbine] Unknown table "${table}"`);
1545
+ const cols = columnsList ?? meta.allColumns;
1546
+ const qtbl = quoteIdent(table);
1547
+ const baseCols = cols.map((col) => `${qtbl}.${quoteIdent(col)}`).join(', ');
1548
+ const relationSelects = [];
1549
+ const aliasCounter = { n: 0 };
1550
+ for (const [relName, relSpec] of Object.entries(withClause)) {
1551
+ const relDef = meta.relations[relName];
1552
+ if (!relDef) {
1553
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
1554
+ `Available: ${Object.keys(meta.relations).join(', ')}`);
1555
+ }
1556
+ // The main table is not aliased, so pass table name as parentRef
1557
+ const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
1558
+ relationSelects.push(`(${subquery}) AS ${quoteIdent(relName)}`);
1559
+ }
1560
+ return [baseCols, ...relationSelects].join(', ');
1561
+ }
1562
+ /**
1563
+ * Generate a correlated subquery that returns JSON for a single relation.
1564
+ *
1565
+ * This is the core of Turbine's single-query nested relation strategy. For a given
1566
+ * relation (e.g. `posts` on a `users` query), it produces a self-contained SQL subquery
1567
+ * that PostgreSQL evaluates per parent row, returning either a JSON array (hasMany) or
1568
+ * a single JSON object (belongsTo / hasOne).
1569
+ *
1570
+ * ### Algorithm overview
1571
+ *
1572
+ * 1. **Alias generation:** Allocates a unique alias (`t0`, `t1`, ...) from the shared
1573
+ * `aliasCounter` so that deeply nested subqueries never collide.
1574
+ *
1575
+ * 2. **Column resolution:** Honors `select` / `omit` options to control which columns
1576
+ * appear in the output JSON.
1577
+ *
1578
+ * 3. **`json_build_object`:** Builds a JSON object for each row by mapping camelCase
1579
+ * field names to their column values:
1580
+ * ```sql
1581
+ * json_build_object('id', t0."id", 'title', t0."title", 'createdAt', t0."created_at")
1582
+ * ```
1583
+ *
1584
+ * 4. **`json_agg` wrapping (hasMany):** For one-to-many relations, wraps the
1585
+ * `json_build_object` call in `json_agg(...)` to aggregate all matching child rows
1586
+ * into a JSON array. Uses `COALESCE(..., '[]'::json)` so the result is never NULL.
1587
+ * For belongsTo / hasOne, no aggregation is used -- just the single JSON object
1588
+ * with `LIMIT 1`.
1589
+ *
1590
+ * 5. **Correlation (WHERE clause):** Links the subquery to the parent row:
1591
+ * - **hasMany:** `alias.foreignKey = parentRef.referenceKey`
1592
+ * (e.g. `t0."user_id" = "users"."id"` -- child FK points to parent PK)
1593
+ * - **belongsTo / hasOne:** `alias.referenceKey = parentRef.foreignKey`
1594
+ * (e.g. `t0."id" = "posts"."author_id"` -- parent FK points to child PK)
1595
+ *
1596
+ * 6. **Recursion:** If the spec includes a nested `with` clause, this method calls
1597
+ * itself recursively for each nested relation, passing the current alias as
1598
+ * `parentRef`. The nested subquery appears as an additional key in the
1599
+ * `json_build_object` call, wrapped in `COALESCE(..., '[]'::json)`.
1600
+ * Depth is incremented and capped at 10 to guard against circular relations.
1601
+ *
1602
+ * 7. **LIMIT / ORDER BY wrapping:** For hasMany relations with `limit` or `orderBy`,
1603
+ * the query is restructured into a two-level form:
1604
+ * ```sql
1605
+ * SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
1606
+ * FROM (
1607
+ * SELECT t0.* FROM "posts" t0
1608
+ * WHERE t0."user_id" = "users"."id"
1609
+ * ORDER BY t0."created_at" DESC
1610
+ * LIMIT $1
1611
+ * ) t0i
1612
+ * ```
1613
+ * This ensures LIMIT and ORDER BY apply to the raw rows *before* `json_agg`
1614
+ * aggregation. Without the inner subquery, LIMIT would be meaningless because
1615
+ * `json_agg` produces a single aggregated row.
1616
+ *
1617
+ * 8. **Parameter threading:** All user-supplied values (where filters, limit) are
1618
+ * pushed to the shared `params` array with `$N` placeholders. No string
1619
+ * interpolation of user data ever occurs -- all identifiers go through
1620
+ * `quoteIdent()` and all values are parameterized.
1621
+ *
1622
+ * ### Example output (hasMany with nested relation)
1623
+ * ```sql
1624
+ * SELECT COALESCE(json_agg(json_build_object(
1625
+ * 'id', t0."id",
1626
+ * 'title', t0."title",
1627
+ * 'comments', COALESCE((
1628
+ * SELECT COALESCE(json_agg(json_build_object('id', t1."id", 'body', t1."body")), '[]'::json)
1629
+ * FROM "comments" t1 WHERE t1."post_id" = t0."id"
1630
+ * ), '[]'::json)
1631
+ * )), '[]'::json) FROM "posts" t0 WHERE t0."user_id" = "users"."id"
1632
+ * ```
1633
+ *
1634
+ * @param relDef - The relation definition from schema metadata (contains `to`, `type`,
1635
+ * `foreignKey`, `referenceKey`).
1636
+ * @param spec - Either `true` (include with defaults) or a `WithOptions` object that
1637
+ * can specify `select`, `omit`, `where`, `orderBy`, `limit`, and nested `with`.
1638
+ * @param params - Shared parameter array. User-supplied values are pushed here and
1639
+ * referenced as `$1`, `$2`, etc. in the generated SQL.
1640
+ * @param parentRef - The alias (e.g. `"t0"`) or table name (e.g. `"users"`) of the
1641
+ * parent query. Used to build the correlated WHERE clause that ties
1642
+ * child rows to their parent row.
1643
+ * @param aliasCounter - Shared mutable counter (`{ n: number }`) for generating unique
1644
+ * table aliases (`t0`, `t1`, `t2`, ...) across all nesting levels.
1645
+ * Each call increments `n` by 1.
1646
+ * @param depth - Current nesting depth (starts at `0`). Incremented on each recursive
1647
+ * call. If it reaches 10, a {@link CircularRelationError} is thrown.
1648
+ * @param path - Breadcrumb trail of relation/table names traversed so far
1649
+ * (e.g. `["users", "posts", "comments"]`). Used in the error message
1650
+ * when circular or too-deep nesting is detected.
1651
+ * @returns A complete SQL subquery string (without surrounding parentheses) that
1652
+ * evaluates to a JSON array (hasMany) or a JSON object (belongsTo/hasOne).
1653
+ */
1654
+ buildRelationSubquery(relDef, spec, params, parentRef, aliasCounter, depth, path) {
1655
+ const currentDepth = depth ?? 0;
1656
+ const currentPath = path ?? [this.table];
1657
+ const targetTable = relDef.to;
1658
+ // Hard depth cap — the `with` clause is a finite JSON structure so users can't
1659
+ // create true infinite recursion, but extremely deep nesting (10+ levels) produces
1660
+ // unmanageably large SQL. Back-references (e.g. posts → user → posts) are allowed
1661
+ // since they are legitimate queries (Prisma supports the same pattern).
1662
+ if (currentDepth >= 10) {
1663
+ throw new errors_js_1.CircularRelationError([...currentPath, targetTable]);
1664
+ }
1665
+ const targetMeta = this.schema.tables[targetTable];
1666
+ if (!targetMeta)
1667
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${targetTable}"`);
1668
+ // Generate a unique alias: t0, t1, t2, ...
1669
+ const alias = `t${aliasCounter.n++}`;
1670
+ // Resolve which columns to include based on select/omit
1671
+ let targetColumns = targetMeta.allColumns;
1672
+ if (spec !== true && spec.select) {
1673
+ const selectedFields = Object.entries(spec.select)
1674
+ .filter(([, v]) => v)
1675
+ .map(([k]) => targetMeta.columnMap[k] ?? (0, schema_js_1.camelToSnake)(k));
1676
+ targetColumns = selectedFields.filter((col) => targetMeta.allColumns.includes(col));
1677
+ }
1678
+ else if (spec !== true && spec.omit) {
1679
+ const omittedFields = new Set(Object.entries(spec.omit)
1680
+ .filter(([, v]) => v)
1681
+ .map(([k]) => targetMeta.columnMap[k] ?? (0, schema_js_1.camelToSnake)(k)));
1682
+ targetColumns = targetMeta.allColumns.filter((col) => !omittedFields.has(col));
1683
+ }
1684
+ // Build json_build_object pairs for resolved columns
1685
+ const jsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col))}', ${alias}.${quoteIdent(col)}`);
1686
+ // Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
1687
+ // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
1688
+ // so we must NOT build them here (they would push orphaned params).
1689
+ const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
1690
+ // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
1691
+ if (!willWrap && spec !== true && spec.with) {
1692
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
1693
+ const nestedRelDef = targetMeta.relations[nestedRelName];
1694
+ if (!nestedRelDef) {
1695
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1696
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
1697
+ }
1698
+ // Recursively build nested subquery, passing THIS alias as the parent reference
1699
+ const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
1700
+ // Use '[]'::json for hasMany (empty array), NULL for belongsTo/hasOne (no object)
1701
+ const fallback = nestedRelDef.type === 'hasMany' ? "'[]'::json" : 'NULL';
1702
+ jsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSubquery}), ${fallback})`);
1703
+ }
1704
+ }
1705
+ const jsonObj = `json_build_object(${jsonPairs.join(', ')})`;
1706
+ // Quote parent ref — can be a table name or auto-generated alias
1707
+ const qParent = quoteIdent(parentRef);
1708
+ const qTarget = quoteIdent(targetTable);
1709
+ // Build ORDER BY for json_agg
1710
+ let orderClause = '';
1711
+ if (spec !== true && spec.orderBy) {
1712
+ const orders = Object.entries(spec.orderBy)
1713
+ .map(([k, dir]) => {
1714
+ const col = (0, schema_js_1.camelToSnake)(k);
1715
+ if (!targetMeta.allColumns.includes(col)) {
1716
+ throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
1717
+ }
1718
+ const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
1719
+ return `${alias}.${quoteIdent(col)} ${safeDir}`;
1720
+ })
1721
+ .join(', ');
1722
+ orderClause = ` ORDER BY ${orders}`;
1723
+ }
1724
+ // Build WHERE — correlate to parent via parentRef (alias or table name).
1725
+ // For hasMany: target has FK, so alias.fk = parentRef.pk
1726
+ // For belongsTo: source has FK, so alias.pk = parentRef.fk (reversed)
1727
+ let whereClause;
1728
+ if (relDef.type === 'belongsTo' || relDef.type === 'hasOne') {
1729
+ whereClause = `${alias}.${quoteIdent(relDef.referenceKey)} = ${qParent}.${quoteIdent(relDef.foreignKey)}`;
1730
+ }
1731
+ else {
1732
+ whereClause = `${alias}.${quoteIdent(relDef.foreignKey)} = ${qParent}.${quoteIdent(relDef.referenceKey)}`;
1733
+ }
1734
+ // Additional filters — properly parameterized
1735
+ if (spec !== true && spec.where) {
1736
+ for (const [k, v] of Object.entries(spec.where)) {
1737
+ const col = (0, schema_js_1.camelToSnake)(k);
1738
+ if (!targetMeta.allColumns.includes(col)) {
1739
+ throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
1740
+ }
1741
+ params.push(v);
1742
+ whereClause += ` AND ${alias}.${quoteIdent(col)} = $${params.length}`;
1743
+ }
1744
+ }
1745
+ // LIMIT
1746
+ let limitClause = '';
1747
+ if (spec !== true && spec.limit) {
1748
+ params.push(Number(spec.limit));
1749
+ limitClause = ` LIMIT $${params.length}`;
1750
+ }
1751
+ if (relDef.type === 'hasMany') {
1752
+ // When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
1753
+ // BEFORE json_agg aggregation (otherwise LIMIT on aggregated result is meaningless)
1754
+ if (limitClause || orderClause) {
1755
+ const innerAlias = `${alias}i`;
1756
+ // Rewrite: SELECT json_agg(json_build_object(...)) FROM (SELECT * FROM table WHERE ... ORDER BY ... LIMIT N) AS alias
1757
+ // Inner SELECT always needs all columns for WHERE/ORDER to work; json_build_object filters later
1758
+ const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${quoteIdent(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
1759
+ // For the json_build_object, reference the inner alias — only include resolved columns
1760
+ const innerJsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col))}', ${innerAlias}.${quoteIdent(col)}`);
1761
+ // Build nested relation subqueries referencing innerAlias
1762
+ if (spec !== true && spec.with) {
1763
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
1764
+ const nestedRelDef = targetMeta.relations[nestedRelName];
1765
+ if (!nestedRelDef) {
1766
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1767
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
1768
+ }
1769
+ const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
1770
+ const fallback = nestedRelDef.type === 'hasMany' ? "'[]'::json" : 'NULL';
1771
+ innerJsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSub}), ${fallback})`);
1772
+ }
1773
+ }
1774
+ const innerJsonObj = `json_build_object(${innerJsonPairs.join(', ')})`;
1775
+ return `SELECT COALESCE(json_agg(${innerJsonObj}), '[]'::json) FROM (${innerSql}) ${innerAlias}`;
1776
+ }
1777
+ return `SELECT COALESCE(json_agg(${jsonObj}${orderClause}), '[]'::json) FROM ${qTarget} ${alias} WHERE ${whereClause}`;
1778
+ }
1779
+ // belongsTo / hasOne — return single object
1780
+ return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
1781
+ }
1782
+ /**
1783
+ * Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
1784
+ * Used to detect JSONB/array columns for specialized operators.
1785
+ * Uses pre-computed Map for O(1) lookup instead of linear scan.
1786
+ */
1787
+ getColumnPgType(column) {
1788
+ return this.columnPgTypeMap.get(column) ?? 'text';
1789
+ }
1790
+ /**
1791
+ * Get the Postgres base element type for an array column.
1792
+ * E.g. '_text' → 'text', '_int4' → 'integer'
1793
+ */
1794
+ getArrayElementType(pgType) {
1795
+ const baseType = pgType.startsWith('_') ? pgType.slice(1) : pgType;
1796
+ const typeMap = {
1797
+ int2: 'smallint',
1798
+ int4: 'integer',
1799
+ int8: 'bigint',
1800
+ float4: 'real',
1801
+ float8: 'double precision',
1802
+ bool: 'boolean',
1803
+ text: 'text',
1804
+ varchar: 'text',
1805
+ uuid: 'uuid',
1806
+ timestamptz: 'timestamptz',
1807
+ timestamp: 'timestamp',
1808
+ jsonb: 'jsonb',
1809
+ json: 'json',
1810
+ };
1811
+ return typeMap[baseType] ?? 'text';
1812
+ }
1813
+ /**
1814
+ * Build SQL clauses for JSONB filter operators on a column.
1815
+ * Supports: path, equals, contains, hasKey.
1816
+ */
1817
+ buildJsonFilterClauses(column, filter, params) {
1818
+ const clauses = [];
1819
+ if (filter.path !== undefined && filter.equals !== undefined) {
1820
+ // Path access + equals: column #>> $N::text[] = $M
1821
+ params.push(filter.path);
1822
+ const pathParam = params.length;
1823
+ params.push(String(filter.equals));
1824
+ clauses.push(`${column} #>> $${pathParam}::text[] = $${params.length}`);
1825
+ }
1826
+ else if (filter.equals !== undefined) {
1827
+ // Containment equality: column @> $N::jsonb
1828
+ params.push(JSON.stringify(filter.equals));
1829
+ clauses.push(`${column} @> $${params.length}::jsonb`);
1830
+ }
1831
+ if (filter.contains !== undefined) {
1832
+ // Containment: column @> $N::jsonb
1833
+ params.push(JSON.stringify(filter.contains));
1834
+ clauses.push(`${column} @> $${params.length}::jsonb`);
1835
+ }
1836
+ if (filter.hasKey !== undefined) {
1837
+ // Key existence: column ? $N
1838
+ params.push(filter.hasKey);
1839
+ clauses.push(`${column} ? $${params.length}`);
1840
+ }
1841
+ return clauses;
1842
+ }
1843
+ /**
1844
+ * Build SQL clauses for Array filter operators on a column.
1845
+ * Supports: has, hasEvery, hasSome, isEmpty.
1846
+ */
1847
+ buildArrayFilterClauses(column, filter, params, pgType) {
1848
+ const clauses = [];
1849
+ const elementType = this.getArrayElementType(pgType);
1850
+ if (filter.has !== undefined) {
1851
+ // value = ANY(column)
1852
+ params.push(filter.has);
1853
+ clauses.push(`$${params.length} = ANY(${column})`);
1854
+ }
1855
+ if (filter.hasEvery !== undefined) {
1856
+ // column @> ARRAY[...]::type[]
1857
+ params.push(filter.hasEvery);
1858
+ clauses.push(`${column} @> $${params.length}::${elementType}[]`);
1859
+ }
1860
+ if (filter.hasSome !== undefined) {
1861
+ // column && ARRAY[...]::type[]
1862
+ params.push(filter.hasSome);
1863
+ clauses.push(`${column} && $${params.length}::${elementType}[]`);
1864
+ }
1865
+ if (filter.isEmpty === true) {
1866
+ // array_length(column, 1) IS NULL
1867
+ clauses.push(`array_length(${column}, 1) IS NULL`);
1868
+ }
1869
+ else if (filter.isEmpty === false) {
1870
+ // array_length(column, 1) IS NOT NULL
1871
+ clauses.push(`array_length(${column}, 1) IS NOT NULL`);
1872
+ }
1873
+ return clauses;
1874
+ }
1875
+ /**
1876
+ * Get the Postgres array type for a column (used by UNNEST in createMany).
1877
+ * Uses pre-computed Map for O(1) lookup instead of linear scan.
1878
+ */
1879
+ getColumnArrayType(column) {
1880
+ const arrayType = this.columnArrayTypeMap.get(column);
1881
+ if (arrayType)
1882
+ return arrayType;
1883
+ // Fallback heuristic for unknown columns
1884
+ if (column === 'id' || column.endsWith('_id'))
1885
+ return 'bigint[]';
1886
+ if (column.endsWith('_at'))
1887
+ return 'timestamptz[]';
1888
+ return 'text[]';
1889
+ }
1890
+ }
1891
+ exports.QueryInterface = QueryInterface;