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