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/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
- constructor(pool: pg.Pool, table: string, schema: SchemaMetadata, middlewares?: MiddlewareFn[]);
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
- /** Get the Postgres array type for a column (used by UNNEST in createMany) */
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 Map();
119
+ sqlCache = new LRUCache(1000);
76
120
  middlewares;
77
- constructor(pool, table, schema, middlewares) {
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.pool.query(deferred.sql, deferred.params);
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.pool.query(deferred.sql, deferred.params);
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.pool.query(deferred.sql, deferred.params);
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.pool.query(deferred.sql, deferred.params);
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.pool.query(deferred.sql, deferred.params);
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.pool.query(deferred.sql, deferred.params);
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.pool.query(deferred.sql, deferred.params);
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.pool.query(deferred.sql, deferred.params);
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.pool.query(deferred.sql, deferred.params);
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.pool.query(deferred.sql, deferred.params);
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.pool.query(deferred.sql, deferred.params);
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.pool.query(deferred.sql, deferred.params);
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.pool.query(deferred.sql, deferred.params);
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.pool.query(deferred.sql, deferred.params);
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.pool.query(deferred.sql, deferred.params);
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} LIKE $${params.length} ESCAPE '\\'`);
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} LIKE $${params.length} ESCAPE '\\'`);
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} LIKE $${params.length} ESCAPE '\\'`);
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
- const col = this.tableMeta.columns.find((c) => c.name === column);
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
- /** Get the Postgres array type for a column (used by UNNEST in createMany) */
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
- // Find the column metadata
1452
- const col = this.tableMeta.columns.find((c) => c.name === column);
1453
- if (col)
1454
- return col.pgArrayType;
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'))
@@ -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
- // Recognize function calls like now(), gen_random_uuid(), etc.
143
- if (/^[a-z_]+\(\)$/i.test(val)) {
144
- return val.toUpperCase();
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
- return val;
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
  }