turbine-orm 0.3.1 → 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/index.js +14 -6
- package/dist/cli/migrate.d.ts +29 -5
- package/dist/cli/migrate.js +58 -35
- package/dist/client.d.ts +13 -2
- package/dist/client.js +26 -13
- package/dist/generate.js +7 -1
- package/dist/query.d.ts +54 -2
- package/dist/query.js +126 -35
- package/dist/schema-sql.js +30 -14
- package/package.json +14 -9
- 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
package/dist/query.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface WhereOperator<V = unknown> {
|
|
|
35
35
|
contains?: string;
|
|
36
36
|
startsWith?: string;
|
|
37
37
|
endsWith?: string;
|
|
38
|
+
/** Set to 'insensitive' to use ILIKE instead of LIKE for string comparisons */
|
|
39
|
+
mode?: 'default' | 'insensitive';
|
|
38
40
|
}
|
|
39
41
|
/**
|
|
40
42
|
* A where value can be:
|
|
@@ -77,6 +79,8 @@ export interface FindUniqueArgs<T> {
|
|
|
77
79
|
select?: Record<string, boolean>;
|
|
78
80
|
omit?: Record<string, boolean>;
|
|
79
81
|
with?: WithClause;
|
|
82
|
+
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
83
|
+
timeout?: number;
|
|
80
84
|
}
|
|
81
85
|
export interface FindManyArgs<T> {
|
|
82
86
|
where?: WhereClause<T>;
|
|
@@ -92,36 +96,54 @@ export interface FindManyArgs<T> {
|
|
|
92
96
|
take?: number;
|
|
93
97
|
/** De-duplicate results by specified fields */
|
|
94
98
|
distinct?: (keyof T & string)[];
|
|
99
|
+
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
100
|
+
timeout?: number;
|
|
95
101
|
}
|
|
96
102
|
export interface CreateArgs<T> {
|
|
97
103
|
data: Partial<T>;
|
|
104
|
+
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
105
|
+
timeout?: number;
|
|
98
106
|
}
|
|
99
107
|
export interface CreateManyArgs<T> {
|
|
100
108
|
data: Partial<T>[];
|
|
101
109
|
/** When true, adds ON CONFLICT DO NOTHING to skip duplicate rows */
|
|
102
110
|
skipDuplicates?: boolean;
|
|
111
|
+
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
112
|
+
timeout?: number;
|
|
103
113
|
}
|
|
104
114
|
export interface UpdateArgs<T> {
|
|
105
115
|
where: WhereClause<T>;
|
|
106
116
|
data: Partial<T>;
|
|
117
|
+
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
118
|
+
timeout?: number;
|
|
107
119
|
}
|
|
108
120
|
export interface UpdateManyArgs<T> {
|
|
109
121
|
where: WhereClause<T>;
|
|
110
122
|
data: Partial<T>;
|
|
123
|
+
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
124
|
+
timeout?: number;
|
|
111
125
|
}
|
|
112
126
|
export interface DeleteArgs<T> {
|
|
113
127
|
where: WhereClause<T>;
|
|
128
|
+
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
129
|
+
timeout?: number;
|
|
114
130
|
}
|
|
115
131
|
export interface DeleteManyArgs<T> {
|
|
116
132
|
where: WhereClause<T>;
|
|
133
|
+
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
134
|
+
timeout?: number;
|
|
117
135
|
}
|
|
118
136
|
export interface UpsertArgs<T> {
|
|
119
137
|
where: WhereClause<T>;
|
|
120
138
|
create: Partial<T>;
|
|
121
139
|
update: Partial<T>;
|
|
140
|
+
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
141
|
+
timeout?: number;
|
|
122
142
|
}
|
|
123
143
|
export interface CountArgs<T> {
|
|
124
144
|
where?: WhereClause<T>;
|
|
145
|
+
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
146
|
+
timeout?: number;
|
|
125
147
|
}
|
|
126
148
|
export interface GroupByArgs<T> {
|
|
127
149
|
by: (keyof T & string)[];
|
|
@@ -140,6 +162,8 @@ export interface GroupByArgs<T> {
|
|
|
140
162
|
having?: Record<string, unknown>;
|
|
141
163
|
/** Order groups */
|
|
142
164
|
orderBy?: Record<string, OrderDirection>;
|
|
165
|
+
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
166
|
+
timeout?: number;
|
|
143
167
|
}
|
|
144
168
|
/** Arguments for the standalone aggregate method */
|
|
145
169
|
export interface AggregateArgs<T> {
|
|
@@ -154,6 +178,8 @@ export interface AggregateArgs<T> {
|
|
|
154
178
|
_min?: Partial<Record<keyof T & string, boolean>>;
|
|
155
179
|
/** Maximum value of fields */
|
|
156
180
|
_max?: Partial<Record<keyof T & string, boolean>>;
|
|
181
|
+
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
182
|
+
timeout?: number;
|
|
157
183
|
}
|
|
158
184
|
/** Result type for aggregate queries */
|
|
159
185
|
export interface AggregateResult<T> {
|
|
@@ -211,6 +237,13 @@ type MiddlewareFn = (params: {
|
|
|
211
237
|
action: string;
|
|
212
238
|
args: Record<string, unknown>;
|
|
213
239
|
}) => Promise<unknown>) => Promise<unknown>;
|
|
240
|
+
/** Options passed from TurbineClient to QueryInterface */
|
|
241
|
+
export interface QueryInterfaceOptions {
|
|
242
|
+
/** Default LIMIT applied to findMany() when no limit is specified */
|
|
243
|
+
defaultLimit?: number;
|
|
244
|
+
/** Log a warning when findMany() is called without a limit */
|
|
245
|
+
warnOnUnlimited?: boolean;
|
|
246
|
+
}
|
|
214
247
|
export declare class QueryInterface<T extends object> {
|
|
215
248
|
private readonly pool;
|
|
216
249
|
private readonly table;
|
|
@@ -219,10 +252,25 @@ export declare class QueryInterface<T extends object> {
|
|
|
219
252
|
/** SQL template cache: cacheKey → sql string (params are always positional $1,$2,...) */
|
|
220
253
|
private readonly sqlCache;
|
|
221
254
|
private readonly middlewares;
|
|
222
|
-
|
|
255
|
+
private readonly defaultLimit?;
|
|
256
|
+
private readonly warnOnUnlimited;
|
|
257
|
+
/** Pre-computed column type lookups (avoids linear scans per query) */
|
|
258
|
+
private readonly columnPgTypeMap;
|
|
259
|
+
private readonly columnArrayTypeMap;
|
|
260
|
+
constructor(pool: pg.Pool, table: string, schema: SchemaMetadata, middlewares?: MiddlewareFn[], options?: QueryInterfaceOptions);
|
|
261
|
+
/**
|
|
262
|
+
* Execute a pool.query with an optional timeout.
|
|
263
|
+
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
264
|
+
*/
|
|
265
|
+
private queryWithTimeout;
|
|
223
266
|
/**
|
|
224
267
|
* Execute a query through the middleware chain.
|
|
225
268
|
* If no middlewares are registered, executes directly.
|
|
269
|
+
*
|
|
270
|
+
* Middleware can inspect and log query parameters, modify results after execution,
|
|
271
|
+
* and measure timing. Note: query SQL is generated before middleware runs, so
|
|
272
|
+
* modifying params.args in middleware will NOT affect the executed SQL.
|
|
273
|
+
* To intercept queries before SQL generation, use the raw() method instead.
|
|
226
274
|
*/
|
|
227
275
|
private executeWithMiddleware;
|
|
228
276
|
/**
|
|
@@ -330,6 +378,7 @@ export declare class QueryInterface<T extends object> {
|
|
|
330
378
|
/**
|
|
331
379
|
* Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
|
|
332
380
|
* Used to detect JSONB/array columns for specialized operators.
|
|
381
|
+
* Uses pre-computed Map for O(1) lookup instead of linear scan.
|
|
333
382
|
*/
|
|
334
383
|
private getColumnPgType;
|
|
335
384
|
/**
|
|
@@ -347,7 +396,10 @@ export declare class QueryInterface<T extends object> {
|
|
|
347
396
|
* Supports: has, hasEvery, hasSome, isEmpty.
|
|
348
397
|
*/
|
|
349
398
|
private buildArrayFilterClauses;
|
|
350
|
-
/**
|
|
399
|
+
/**
|
|
400
|
+
* Get the Postgres array type for a column (used by UNNEST in createMany).
|
|
401
|
+
* Uses pre-computed Map for O(1) lookup instead of linear scan.
|
|
402
|
+
*/
|
|
351
403
|
private getColumnArrayType;
|
|
352
404
|
}
|
|
353
405
|
export {};
|
package/dist/query.js
CHANGED
|
@@ -26,6 +26,13 @@ import { snakeToCamel, camelToSnake } from './schema.js';
|
|
|
26
26
|
export function quoteIdent(name) {
|
|
27
27
|
return `"${name.replace(/"/g, '""')}"`;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Escape single quotes for use as string keys in json_build_object().
|
|
31
|
+
* Doubles single quotes per SQL quoting rules.
|
|
32
|
+
*/
|
|
33
|
+
function escSingleQuote(s) {
|
|
34
|
+
return s.replace(/'/g, "''");
|
|
35
|
+
}
|
|
29
36
|
/**
|
|
30
37
|
* Escape LIKE pattern metacharacters: %, _, and \.
|
|
31
38
|
* Must be used with `ESCAPE '\'` in the LIKE clause.
|
|
@@ -36,7 +43,7 @@ function escapeLike(value) {
|
|
|
36
43
|
/** Known operator keys — used to detect operator objects vs plain values */
|
|
37
44
|
const OPERATOR_KEYS = new Set([
|
|
38
45
|
'gt', 'gte', 'lt', 'lte', 'not', 'in', 'notIn',
|
|
39
|
-
'contains', 'startsWith', 'endsWith',
|
|
46
|
+
'contains', 'startsWith', 'endsWith', 'mode',
|
|
40
47
|
]);
|
|
41
48
|
/** Check if a value is a where operator object (has at least one known operator key) */
|
|
42
49
|
function isWhereOperator(value) {
|
|
@@ -66,15 +73,57 @@ function isArrayFilter(value) {
|
|
|
66
73
|
const keys = Object.keys(value);
|
|
67
74
|
return keys.length > 0 && keys.some((k) => ARRAY_OPERATOR_KEYS.has(k));
|
|
68
75
|
}
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// LRU cache — bounded SQL template cache to prevent memory leaks
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
/**
|
|
80
|
+
* Simple LRU (Least Recently Used) cache with a fixed maximum size.
|
|
81
|
+
* When the cache exceeds maxSize, the oldest (least recently used) entry is evicted.
|
|
82
|
+
* Uses Map insertion order for O(1) eviction.
|
|
83
|
+
*/
|
|
84
|
+
class LRUCache {
|
|
85
|
+
maxSize;
|
|
86
|
+
cache = new Map();
|
|
87
|
+
constructor(maxSize) {
|
|
88
|
+
this.maxSize = maxSize;
|
|
89
|
+
}
|
|
90
|
+
get(key) {
|
|
91
|
+
const value = this.cache.get(key);
|
|
92
|
+
if (value !== undefined) {
|
|
93
|
+
// Move to end (most recently used)
|
|
94
|
+
this.cache.delete(key);
|
|
95
|
+
this.cache.set(key, value);
|
|
96
|
+
}
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
set(key, value) {
|
|
100
|
+
if (this.cache.has(key)) {
|
|
101
|
+
this.cache.delete(key);
|
|
102
|
+
}
|
|
103
|
+
else if (this.cache.size >= this.maxSize) {
|
|
104
|
+
// Delete oldest (first) entry
|
|
105
|
+
const firstKey = this.cache.keys().next().value;
|
|
106
|
+
if (firstKey !== undefined)
|
|
107
|
+
this.cache.delete(firstKey);
|
|
108
|
+
}
|
|
109
|
+
this.cache.set(key, value);
|
|
110
|
+
}
|
|
111
|
+
get size() { return this.cache.size; }
|
|
112
|
+
}
|
|
69
113
|
export class QueryInterface {
|
|
70
114
|
pool;
|
|
71
115
|
table;
|
|
72
116
|
schema;
|
|
73
117
|
tableMeta;
|
|
74
118
|
/** SQL template cache: cacheKey → sql string (params are always positional $1,$2,...) */
|
|
75
|
-
sqlCache = new
|
|
119
|
+
sqlCache = new LRUCache(1000);
|
|
76
120
|
middlewares;
|
|
77
|
-
|
|
121
|
+
defaultLimit;
|
|
122
|
+
warnOnUnlimited;
|
|
123
|
+
/** Pre-computed column type lookups (avoids linear scans per query) */
|
|
124
|
+
columnPgTypeMap;
|
|
125
|
+
columnArrayTypeMap;
|
|
126
|
+
constructor(pool, table, schema, middlewares, options) {
|
|
78
127
|
this.pool = pool;
|
|
79
128
|
this.table = table;
|
|
80
129
|
this.schema = schema;
|
|
@@ -84,10 +133,43 @@ export class QueryInterface {
|
|
|
84
133
|
}
|
|
85
134
|
this.tableMeta = meta;
|
|
86
135
|
this.middlewares = middlewares ?? [];
|
|
136
|
+
this.defaultLimit = options?.defaultLimit;
|
|
137
|
+
this.warnOnUnlimited = options?.warnOnUnlimited ?? false;
|
|
138
|
+
// Pre-compute column type lookup maps (TASK-26)
|
|
139
|
+
this.columnPgTypeMap = new Map();
|
|
140
|
+
this.columnArrayTypeMap = new Map();
|
|
141
|
+
for (const col of this.tableMeta.columns) {
|
|
142
|
+
this.columnPgTypeMap.set(col.name, col.pgType);
|
|
143
|
+
this.columnArrayTypeMap.set(col.name, col.pgArrayType);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Execute a pool.query with an optional timeout.
|
|
148
|
+
* If timeout is set, races the query against a timer and rejects on expiry.
|
|
149
|
+
*/
|
|
150
|
+
async queryWithTimeout(sql, params, timeout) {
|
|
151
|
+
if (!timeout) {
|
|
152
|
+
return this.pool.query(sql, params);
|
|
153
|
+
}
|
|
154
|
+
let timer;
|
|
155
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
156
|
+
timer = setTimeout(() => reject(new Error(`[turbine] Query timed out after ${timeout}ms`)), timeout);
|
|
157
|
+
});
|
|
158
|
+
try {
|
|
159
|
+
return await Promise.race([this.pool.query(sql, params), timeoutPromise]);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
}
|
|
87
164
|
}
|
|
88
165
|
/**
|
|
89
166
|
* Execute a query through the middleware chain.
|
|
90
167
|
* If no middlewares are registered, executes directly.
|
|
168
|
+
*
|
|
169
|
+
* Middleware can inspect and log query parameters, modify results after execution,
|
|
170
|
+
* and measure timing. Note: query SQL is generated before middleware runs, so
|
|
171
|
+
* modifying params.args in middleware will NOT affect the executed SQL.
|
|
172
|
+
* To intercept queries before SQL generation, use the raw() method instead.
|
|
91
173
|
*/
|
|
92
174
|
async executeWithMiddleware(action, args, executor) {
|
|
93
175
|
if (this.middlewares.length === 0) {
|
|
@@ -124,7 +206,7 @@ export class QueryInterface {
|
|
|
124
206
|
async findUnique(args) {
|
|
125
207
|
return this.executeWithMiddleware('findUnique', args, async () => {
|
|
126
208
|
const deferred = this.buildFindUnique(args);
|
|
127
|
-
const result = await this.
|
|
209
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
128
210
|
return deferred.transform(result);
|
|
129
211
|
});
|
|
130
212
|
}
|
|
@@ -198,9 +280,14 @@ export class QueryInterface {
|
|
|
198
280
|
// findMany
|
|
199
281
|
// -------------------------------------------------------------------------
|
|
200
282
|
async findMany(args) {
|
|
283
|
+
// Warn if no limit specified and warnOnUnlimited is enabled
|
|
284
|
+
const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined;
|
|
285
|
+
if (this.warnOnUnlimited && !hasExplicitLimit) {
|
|
286
|
+
console.warn(`[turbine] findMany() called without limit on table "${this.table}". Set defaultLimit in config to prevent unbounded queries.`);
|
|
287
|
+
}
|
|
201
288
|
return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
|
|
202
289
|
const deferred = this.buildFindMany(args);
|
|
203
|
-
const result = await this.
|
|
290
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
|
|
204
291
|
return deferred.transform(result);
|
|
205
292
|
});
|
|
206
293
|
}
|
|
@@ -251,8 +338,8 @@ export class QueryInterface {
|
|
|
251
338
|
if (args?.orderBy) {
|
|
252
339
|
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
|
|
253
340
|
}
|
|
254
|
-
// take overrides limit when cursor pagination is used
|
|
255
|
-
const effectiveLimit = args?.take ?? args?.limit;
|
|
341
|
+
// take overrides limit when cursor pagination is used; fall back to defaultLimit
|
|
342
|
+
const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
|
|
256
343
|
if (effectiveLimit !== undefined) {
|
|
257
344
|
params.push(Number(effectiveLimit));
|
|
258
345
|
sql += ` LIMIT $${params.length}`;
|
|
@@ -276,7 +363,7 @@ export class QueryInterface {
|
|
|
276
363
|
async findFirst(args) {
|
|
277
364
|
return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
|
|
278
365
|
const deferred = this.buildFindFirst(args);
|
|
279
|
-
const result = await this.
|
|
366
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
|
|
280
367
|
return deferred.transform(result);
|
|
281
368
|
});
|
|
282
369
|
}
|
|
@@ -300,7 +387,7 @@ export class QueryInterface {
|
|
|
300
387
|
async findFirstOrThrow(args) {
|
|
301
388
|
return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
|
|
302
389
|
const deferred = this.buildFindFirstOrThrow(args);
|
|
303
|
-
const result = await this.
|
|
390
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
|
|
304
391
|
return deferred.transform(result);
|
|
305
392
|
});
|
|
306
393
|
}
|
|
@@ -325,7 +412,7 @@ export class QueryInterface {
|
|
|
325
412
|
async findUniqueOrThrow(args) {
|
|
326
413
|
return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
|
|
327
414
|
const deferred = this.buildFindUniqueOrThrow(args);
|
|
328
|
-
const result = await this.
|
|
415
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
329
416
|
return deferred.transform(result);
|
|
330
417
|
});
|
|
331
418
|
}
|
|
@@ -350,7 +437,7 @@ export class QueryInterface {
|
|
|
350
437
|
async create(args) {
|
|
351
438
|
return this.executeWithMiddleware('create', args, async () => {
|
|
352
439
|
const deferred = this.buildCreate(args);
|
|
353
|
-
const result = await this.
|
|
440
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
354
441
|
return deferred.transform(result);
|
|
355
442
|
});
|
|
356
443
|
}
|
|
@@ -378,7 +465,7 @@ export class QueryInterface {
|
|
|
378
465
|
async createMany(args) {
|
|
379
466
|
return this.executeWithMiddleware('createMany', args, async () => {
|
|
380
467
|
const deferred = this.buildCreateMany(args);
|
|
381
|
-
const result = await this.
|
|
468
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
382
469
|
return deferred.transform(result);
|
|
383
470
|
});
|
|
384
471
|
}
|
|
@@ -425,7 +512,7 @@ export class QueryInterface {
|
|
|
425
512
|
async update(args) {
|
|
426
513
|
return this.executeWithMiddleware('update', args, async () => {
|
|
427
514
|
const deferred = this.buildUpdate(args);
|
|
428
|
-
const result = await this.
|
|
515
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
429
516
|
return deferred.transform(result);
|
|
430
517
|
});
|
|
431
518
|
}
|
|
@@ -459,7 +546,7 @@ export class QueryInterface {
|
|
|
459
546
|
async delete(args) {
|
|
460
547
|
return this.executeWithMiddleware('delete', args, async () => {
|
|
461
548
|
const deferred = this.buildDelete(args);
|
|
462
|
-
const result = await this.
|
|
549
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
463
550
|
return deferred.transform(result);
|
|
464
551
|
});
|
|
465
552
|
}
|
|
@@ -484,7 +571,7 @@ export class QueryInterface {
|
|
|
484
571
|
async upsert(args) {
|
|
485
572
|
return this.executeWithMiddleware('upsert', args, async () => {
|
|
486
573
|
const deferred = this.buildUpsert(args);
|
|
487
|
-
const result = await this.
|
|
574
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
488
575
|
return deferred.transform(result);
|
|
489
576
|
});
|
|
490
577
|
}
|
|
@@ -528,7 +615,7 @@ export class QueryInterface {
|
|
|
528
615
|
async updateMany(args) {
|
|
529
616
|
return this.executeWithMiddleware('updateMany', args, async () => {
|
|
530
617
|
const deferred = this.buildUpdateMany(args);
|
|
531
|
-
const result = await this.
|
|
618
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
532
619
|
return deferred.transform(result);
|
|
533
620
|
});
|
|
534
621
|
}
|
|
@@ -557,7 +644,7 @@ export class QueryInterface {
|
|
|
557
644
|
async deleteMany(args) {
|
|
558
645
|
return this.executeWithMiddleware('deleteMany', args, async () => {
|
|
559
646
|
const deferred = this.buildDeleteMany(args);
|
|
560
|
-
const result = await this.
|
|
647
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
561
648
|
return deferred.transform(result);
|
|
562
649
|
});
|
|
563
650
|
}
|
|
@@ -577,7 +664,7 @@ export class QueryInterface {
|
|
|
577
664
|
async count(args) {
|
|
578
665
|
return this.executeWithMiddleware('count', (args ?? {}), async () => {
|
|
579
666
|
const deferred = this.buildCount(args);
|
|
580
|
-
const result = await this.
|
|
667
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
|
|
581
668
|
return deferred.transform(result);
|
|
582
669
|
});
|
|
583
670
|
}
|
|
@@ -599,7 +686,7 @@ export class QueryInterface {
|
|
|
599
686
|
async groupBy(args) {
|
|
600
687
|
return this.executeWithMiddleware('groupBy', args, async () => {
|
|
601
688
|
const deferred = this.buildGroupBy(args);
|
|
602
|
-
const result = await this.
|
|
689
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
603
690
|
return deferred.transform(result);
|
|
604
691
|
});
|
|
605
692
|
}
|
|
@@ -726,7 +813,7 @@ export class QueryInterface {
|
|
|
726
813
|
async aggregate(args) {
|
|
727
814
|
return this.executeWithMiddleware('aggregate', args, async () => {
|
|
728
815
|
const deferred = this.buildAggregate(args);
|
|
729
|
-
const result = await this.
|
|
816
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
|
|
730
817
|
return deferred.transform(result);
|
|
731
818
|
});
|
|
732
819
|
}
|
|
@@ -1117,17 +1204,19 @@ export class QueryInterface {
|
|
|
1117
1204
|
params.push(op.notIn);
|
|
1118
1205
|
clauses.push(`${column} != ALL($${params.length})`);
|
|
1119
1206
|
}
|
|
1207
|
+
// Use ILIKE for case-insensitive mode, LIKE otherwise
|
|
1208
|
+
const likeOp = op.mode === 'insensitive' ? 'ILIKE' : 'LIKE';
|
|
1120
1209
|
if (op.contains !== undefined) {
|
|
1121
1210
|
params.push(`%${escapeLike(op.contains)}%`);
|
|
1122
|
-
clauses.push(`${column}
|
|
1211
|
+
clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
|
|
1123
1212
|
}
|
|
1124
1213
|
if (op.startsWith !== undefined) {
|
|
1125
1214
|
params.push(`${escapeLike(op.startsWith)}%`);
|
|
1126
|
-
clauses.push(`${column}
|
|
1215
|
+
clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
|
|
1127
1216
|
}
|
|
1128
1217
|
if (op.endsWith !== undefined) {
|
|
1129
1218
|
params.push(`%${escapeLike(op.endsWith)}`);
|
|
1130
|
-
clauses.push(`${column}
|
|
1219
|
+
clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
|
|
1131
1220
|
}
|
|
1132
1221
|
return clauses;
|
|
1133
1222
|
}
|
|
@@ -1265,7 +1354,7 @@ export class QueryInterface {
|
|
|
1265
1354
|
targetColumns = targetMeta.allColumns.filter((col) => !omittedFields.has(col));
|
|
1266
1355
|
}
|
|
1267
1356
|
// Build json_build_object pairs for resolved columns
|
|
1268
|
-
const jsonPairs = targetColumns.map((col) => `'${targetMeta.reverseColumnMap[col] ?? snakeToCamel(col)}', ${alias}.${quoteIdent(col)}`);
|
|
1357
|
+
const jsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? snakeToCamel(col))}', ${alias}.${quoteIdent(col)}`);
|
|
1269
1358
|
// Nested relations?
|
|
1270
1359
|
if (spec !== true && spec.with) {
|
|
1271
1360
|
for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
|
|
@@ -1276,7 +1365,7 @@ export class QueryInterface {
|
|
|
1276
1365
|
}
|
|
1277
1366
|
// Recursively build nested subquery, passing THIS alias as the parent reference
|
|
1278
1367
|
const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter);
|
|
1279
|
-
jsonPairs.push(`'${nestedRelName}', COALESCE((${nestedSubquery}), '[]'::json)`);
|
|
1368
|
+
jsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSubquery}), '[]'::json)`);
|
|
1280
1369
|
}
|
|
1281
1370
|
}
|
|
1282
1371
|
const jsonObj = `json_build_object(${jsonPairs.join(', ')})`;
|
|
@@ -1334,14 +1423,14 @@ export class QueryInterface {
|
|
|
1334
1423
|
// Inner SELECT always needs all columns for WHERE/ORDER to work; json_build_object filters later
|
|
1335
1424
|
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${quoteIdent(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
1336
1425
|
// For the json_build_object, reference the inner alias — only include resolved columns
|
|
1337
|
-
const innerJsonPairs = targetColumns.map((col) => `'${targetMeta.reverseColumnMap[col] ?? snakeToCamel(col)}', ${innerAlias}.${quoteIdent(col)}`);
|
|
1426
|
+
const innerJsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? snakeToCamel(col))}', ${innerAlias}.${quoteIdent(col)}`);
|
|
1338
1427
|
// Re-add nested relation subqueries referencing innerAlias
|
|
1339
1428
|
if (spec !== true && spec.with) {
|
|
1340
1429
|
for (const [nestedRelName] of Object.entries(spec.with)) {
|
|
1341
1430
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
1342
1431
|
if (nestedRelDef) {
|
|
1343
1432
|
const nestedSub = this.buildRelationSubquery(nestedRelDef, spec.with[nestedRelName], params, innerAlias, aliasCounter);
|
|
1344
|
-
innerJsonPairs.push(`'${nestedRelName}', COALESCE((${nestedSub}), '[]'::json)`);
|
|
1433
|
+
innerJsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSub}), '[]'::json)`);
|
|
1345
1434
|
}
|
|
1346
1435
|
}
|
|
1347
1436
|
}
|
|
@@ -1356,10 +1445,10 @@ export class QueryInterface {
|
|
|
1356
1445
|
/**
|
|
1357
1446
|
* Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
|
|
1358
1447
|
* Used to detect JSONB/array columns for specialized operators.
|
|
1448
|
+
* Uses pre-computed Map for O(1) lookup instead of linear scan.
|
|
1359
1449
|
*/
|
|
1360
1450
|
getColumnPgType(column) {
|
|
1361
|
-
|
|
1362
|
-
return col?.pgType ?? 'text';
|
|
1451
|
+
return this.columnPgTypeMap.get(column) ?? 'text';
|
|
1363
1452
|
}
|
|
1364
1453
|
/**
|
|
1365
1454
|
* Get the Postgres base element type for an array column.
|
|
@@ -1446,13 +1535,15 @@ export class QueryInterface {
|
|
|
1446
1535
|
}
|
|
1447
1536
|
return clauses;
|
|
1448
1537
|
}
|
|
1449
|
-
/**
|
|
1538
|
+
/**
|
|
1539
|
+
* Get the Postgres array type for a column (used by UNNEST in createMany).
|
|
1540
|
+
* Uses pre-computed Map for O(1) lookup instead of linear scan.
|
|
1541
|
+
*/
|
|
1450
1542
|
getColumnArrayType(column) {
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
// Fallback heuristic
|
|
1543
|
+
const arrayType = this.columnArrayTypeMap.get(column);
|
|
1544
|
+
if (arrayType)
|
|
1545
|
+
return arrayType;
|
|
1546
|
+
// Fallback heuristic for unknown columns
|
|
1456
1547
|
if (column === 'id' || column.endsWith('_id'))
|
|
1457
1548
|
return 'bigint[]';
|
|
1458
1549
|
if (column.endsWith('_at'))
|
package/dist/schema-sql.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import pg from 'pg';
|
|
8
8
|
import { camelToSnake } from './schema.js';
|
|
9
|
+
import { quoteIdent } from './query.js';
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
10
11
|
// SQL Generation — SchemaDef → CREATE TABLE statements
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
@@ -17,7 +18,6 @@ import { camelToSnake } from './schema.js';
|
|
|
17
18
|
*/
|
|
18
19
|
export function schemaToSQL(schema) {
|
|
19
20
|
const statements = [];
|
|
20
|
-
const tableNames = Object.keys(schema.tables);
|
|
21
21
|
// Topologically sort tables by their foreign key references
|
|
22
22
|
const sorted = topologicalSort(schema);
|
|
23
23
|
// Generate CREATE TABLE statements
|
|
@@ -81,14 +81,14 @@ function generateCreateTable(table) {
|
|
|
81
81
|
columnDefs.push(generateColumnDef(fieldName, config));
|
|
82
82
|
}
|
|
83
83
|
const body = columnDefs.map((d) => ` ${d}`).join(',\n');
|
|
84
|
-
return `CREATE TABLE ${tableName} (\n${body}\n);`;
|
|
84
|
+
return `CREATE TABLE ${quoteIdent(tableName)} (\n${body}\n);`;
|
|
85
85
|
}
|
|
86
86
|
/**
|
|
87
87
|
* Generate a single column definition line (e.g. "id BIGSERIAL PRIMARY KEY").
|
|
88
88
|
*/
|
|
89
89
|
function generateColumnDef(fieldName, config) {
|
|
90
90
|
const snakeName = camelToSnake(fieldName);
|
|
91
|
-
const parts = [snakeName];
|
|
91
|
+
const parts = [quoteIdent(snakeName)];
|
|
92
92
|
// Type
|
|
93
93
|
if (config.type === 'VARCHAR' && config.maxLength != null) {
|
|
94
94
|
parts.push(`VARCHAR(${config.maxLength})`);
|
|
@@ -124,7 +124,7 @@ function generateColumnDef(fieldName, config) {
|
|
|
124
124
|
if (config.referencesTarget) {
|
|
125
125
|
const refParts = config.referencesTarget.split('.');
|
|
126
126
|
if (refParts.length === 2) {
|
|
127
|
-
parts.push(`REFERENCES ${refParts[0]}(${refParts[1]})`);
|
|
127
|
+
parts.push(`REFERENCES ${quoteIdent(refParts[0])}(${quoteIdent(refParts[1])})`);
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
return parts.join(' ');
|
|
@@ -139,11 +139,27 @@ function generateColumnDef(fieldName, config) {
|
|
|
139
139
|
* '0' → 0
|
|
140
140
|
*/
|
|
141
141
|
function normalizeDefault(val) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
const upper = val.toUpperCase().trim();
|
|
143
|
+
// Known SQL constants
|
|
144
|
+
if (['TRUE', 'FALSE', 'NULL'].includes(upper)) {
|
|
145
|
+
return upper;
|
|
146
|
+
}
|
|
147
|
+
// Known SQL function calls: NOW(), CURRENT_TIMESTAMP, CURRENT_DATE, CURRENT_TIME, GEN_RANDOM_UUID()
|
|
148
|
+
const allowedFunctions = [
|
|
149
|
+
'NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME', 'GEN_RANDOM_UUID()',
|
|
150
|
+
];
|
|
151
|
+
if (allowedFunctions.includes(upper)) {
|
|
152
|
+
return upper;
|
|
153
|
+
}
|
|
154
|
+
// Numeric literals (integer or decimal, optionally negative)
|
|
155
|
+
if (/^-?\d+(\.\d+)?$/.test(val.trim())) {
|
|
156
|
+
return val.trim();
|
|
157
|
+
}
|
|
158
|
+
// Simple single-quoted string literals (no nested quotes)
|
|
159
|
+
if (/^'[^']*'$/.test(val.trim())) {
|
|
160
|
+
return val.trim();
|
|
145
161
|
}
|
|
146
|
-
|
|
162
|
+
throw new Error(`Unsupported default value: ${val}. Use a SQL function, numeric, string literal, or NULL.`);
|
|
147
163
|
}
|
|
148
164
|
/**
|
|
149
165
|
* Generate CREATE INDEX statements for foreign key columns.
|
|
@@ -155,7 +171,7 @@ function generateForeignKeyIndexes(table) {
|
|
|
155
171
|
if (config.referencesTarget) {
|
|
156
172
|
const snakeName = camelToSnake(fieldName);
|
|
157
173
|
const indexName = `idx_${table.name}_${snakeName}`;
|
|
158
|
-
indexes.push(`CREATE INDEX ${indexName} ON ${table.name}(${snakeName});`);
|
|
174
|
+
indexes.push(`CREATE INDEX ${quoteIdent(indexName)} ON ${quoteIdent(table.name)}(${quoteIdent(snakeName)});`);
|
|
159
175
|
}
|
|
160
176
|
}
|
|
161
177
|
return indexes;
|
|
@@ -224,7 +240,7 @@ export async function schemaDiff(schema, connectionString) {
|
|
|
224
240
|
if (!dbCol) {
|
|
225
241
|
// Column exists in schema but not in DB — ADD COLUMN
|
|
226
242
|
const colDef = generateColumnDef(fieldName, config);
|
|
227
|
-
const sql = `ALTER TABLE ${tableName} ADD COLUMN ${colDef};`;
|
|
243
|
+
const sql = `ALTER TABLE ${quoteIdent(tableName)} ADD COLUMN ${colDef};`;
|
|
228
244
|
alterDef.columns.push({ column: snakeName, action: 'add', sql });
|
|
229
245
|
result.statements.push(sql);
|
|
230
246
|
continue;
|
|
@@ -235,7 +251,7 @@ export async function schemaDiff(schema, connectionString) {
|
|
|
235
251
|
const sqlType = config.type === 'VARCHAR' && config.maxLength
|
|
236
252
|
? `VARCHAR(${config.maxLength})`
|
|
237
253
|
: config.type;
|
|
238
|
-
const sql = `ALTER TABLE ${tableName} ALTER COLUMN ${snakeName} TYPE ${sqlType};`;
|
|
254
|
+
const sql = `ALTER TABLE ${quoteIdent(tableName)} ALTER COLUMN ${quoteIdent(snakeName)} TYPE ${sqlType};`;
|
|
239
255
|
alterDef.columns.push({ column: snakeName, action: 'alter_type', sql });
|
|
240
256
|
result.statements.push(sql);
|
|
241
257
|
}
|
|
@@ -243,12 +259,12 @@ export async function schemaDiff(schema, connectionString) {
|
|
|
243
259
|
const shouldBeNotNull = config.isNotNull || config.isPrimaryKey || config.type === 'BIGSERIAL';
|
|
244
260
|
const isCurrentlyNullable = dbCol.isNullable;
|
|
245
261
|
if (shouldBeNotNull && isCurrentlyNullable && !config.isNullable) {
|
|
246
|
-
const sql = `ALTER TABLE ${tableName} ALTER COLUMN ${snakeName} SET NOT NULL;`;
|
|
262
|
+
const sql = `ALTER TABLE ${quoteIdent(tableName)} ALTER COLUMN ${quoteIdent(snakeName)} SET NOT NULL;`;
|
|
247
263
|
alterDef.columns.push({ column: snakeName, action: 'set_not_null', sql });
|
|
248
264
|
result.statements.push(sql);
|
|
249
265
|
}
|
|
250
266
|
else if (!shouldBeNotNull && !isCurrentlyNullable && config.isNullable) {
|
|
251
|
-
const sql = `ALTER TABLE ${tableName} ALTER COLUMN ${snakeName} DROP NOT NULL;`;
|
|
267
|
+
const sql = `ALTER TABLE ${quoteIdent(tableName)} ALTER COLUMN ${quoteIdent(snakeName)} DROP NOT NULL;`;
|
|
252
268
|
alterDef.columns.push({ column: snakeName, action: 'drop_not_null', sql });
|
|
253
269
|
result.statements.push(sql);
|
|
254
270
|
}
|
|
@@ -257,7 +273,7 @@ export async function schemaDiff(schema, connectionString) {
|
|
|
257
273
|
for (const dbColName of Object.keys(dbCols)) {
|
|
258
274
|
const hasField = Object.entries(tableDef.columns).some(([fieldName]) => camelToSnake(fieldName) === dbColName);
|
|
259
275
|
if (!hasField) {
|
|
260
|
-
const sql = `ALTER TABLE ${tableName} DROP COLUMN ${dbColName};`;
|
|
276
|
+
const sql = `ALTER TABLE ${quoteIdent(tableName)} DROP COLUMN ${quoteIdent(dbColName)};`;
|
|
261
277
|
alterDef.columns.push({ column: dbColName, action: 'drop', sql });
|
|
262
278
|
// Don't auto-add drops to statements for safety — user must opt in
|
|
263
279
|
}
|