@objectstack/driver-sql 3.2.9

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.
@@ -0,0 +1,1240 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * SQL Driver for ObjectStack
5
+ *
6
+ * Implements the standard DriverInterface from @objectstack/spec via Knex.js.
7
+ * Supports PostgreSQL, MySQL, SQLite, and other SQL databases.
8
+ */
9
+
10
+ import type { QueryInput, DriverOptions } from '@objectstack/spec/data';
11
+ import type { DriverInterface } from '@objectstack/core';
12
+ import knex, { Knex } from 'knex';
13
+ import { nanoid } from 'nanoid';
14
+
15
+ /**
16
+ * Default ID length for auto-generated IDs.
17
+ */
18
+ const DEFAULT_ID_LENGTH = 16;
19
+
20
+ // ── Introspection Types ──────────────────────────────────────────────────────
21
+
22
+ export interface IntrospectedColumn {
23
+ name: string;
24
+ type: string;
25
+ nullable: boolean;
26
+ defaultValue?: unknown;
27
+ isPrimary?: boolean;
28
+ isUnique?: boolean;
29
+ maxLength?: number;
30
+ }
31
+
32
+ export interface IntrospectedForeignKey {
33
+ columnName: string;
34
+ referencedTable: string;
35
+ referencedColumn: string;
36
+ constraintName?: string;
37
+ }
38
+
39
+ export interface IntrospectedTable {
40
+ name: string;
41
+ columns: IntrospectedColumn[];
42
+ foreignKeys: IntrospectedForeignKey[];
43
+ primaryKeys: string[];
44
+ }
45
+
46
+ export interface IntrospectedSchema {
47
+ tables: Record<string, IntrospectedTable>;
48
+ }
49
+
50
+ // ── Configuration Types ──────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * SqlDriver configuration — passed directly to Knex.
54
+ * See https://knexjs.org/guide/#configuration-options
55
+ */
56
+ export type SqlDriverConfig = Knex.Config;
57
+
58
+ // ── SQL Driver ───────────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * SQL Driver for ObjectStack.
62
+ *
63
+ * Implements the DriverInterface contract via Knex.js for optimal SQL
64
+ * generation against PostgreSQL, MySQL, SQLite and other SQL databases.
65
+ */
66
+ export class SqlDriver implements DriverInterface {
67
+ // DriverInterface metadata
68
+ public readonly name = 'com.objectstack.driver.sql';
69
+ public readonly version = '1.0.0';
70
+ public readonly supports = {
71
+ transactions: true,
72
+ joins: true,
73
+ fullTextSearch: false,
74
+ jsonFields: true,
75
+ arrayFields: true,
76
+ queryFilters: true,
77
+ queryAggregations: true,
78
+ querySorting: true,
79
+ queryPagination: true,
80
+ queryWindowFunctions: true,
81
+ querySubqueries: true,
82
+ };
83
+
84
+ private knex: Knex;
85
+ private config: Knex.Config;
86
+ private jsonFields: Record<string, string[]> = {};
87
+ private booleanFields: Record<string, string[]> = {};
88
+ private tablesWithTimestamps: Set<string> = new Set();
89
+
90
+ /** Whether the underlying database is a SQLite variant (sqlite3 or better-sqlite3). */
91
+ private get isSqlite(): boolean {
92
+ const c = (this.config as any).client;
93
+ return c === 'sqlite3' || c === 'better-sqlite3';
94
+ }
95
+
96
+ /** Whether the underlying database is PostgreSQL. */
97
+ private get isPostgres(): boolean {
98
+ const c = (this.config as any).client;
99
+ return c === 'pg' || c === 'postgresql';
100
+ }
101
+
102
+ /** Whether the underlying database is MySQL. */
103
+ private get isMysql(): boolean {
104
+ const c = (this.config as any).client;
105
+ return c === 'mysql' || c === 'mysql2';
106
+ }
107
+
108
+ constructor(config: SqlDriverConfig) {
109
+ this.config = config;
110
+ this.knex = knex(config);
111
+ }
112
+
113
+ // ===================================
114
+ // Lifecycle
115
+ // ===================================
116
+
117
+ async connect(): Promise<void> {
118
+ return Promise.resolve();
119
+ }
120
+
121
+ async checkHealth(): Promise<boolean> {
122
+ try {
123
+ await this.knex.raw('SELECT 1');
124
+ return true;
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ async disconnect(): Promise<void> {
131
+ await this.knex.destroy();
132
+ }
133
+
134
+ // ===================================
135
+ // CRUD — DriverInterface core
136
+ // ===================================
137
+
138
+ async find(object: string, query: QueryInput, options?: DriverOptions): Promise<any[]> {
139
+ const builder = this.getBuilder(object, options);
140
+
141
+ // SELECT
142
+ if (query.fields) {
143
+ builder.select((query.fields as string[]).map((f: string) => this.mapSortField(f)));
144
+ } else {
145
+ builder.select('*');
146
+ }
147
+
148
+ // WHERE — support both `where` (standard) and `filters` (legacy)
149
+ const filterCondition = query.where || (query as any).filters;
150
+ if (filterCondition) {
151
+ this.applyFilters(builder, filterCondition);
152
+ }
153
+
154
+ // ORDER BY — support both `orderBy` (standard) and `sort` (legacy)
155
+ const sortArray = query.orderBy || (query as any).sort;
156
+ if (sortArray && Array.isArray(sortArray)) {
157
+ for (const item of sortArray) {
158
+ const field = item.field || item[0];
159
+ const dir = item.order || item[1] || 'asc';
160
+ if (field) {
161
+ builder.orderBy(this.mapSortField(field), dir);
162
+ }
163
+ }
164
+ }
165
+
166
+ // PAGINATION — support both offset/limit (standard) and skip/top (legacy)
167
+ const offsetValue = query.offset ?? (query as any).skip;
168
+ const limitValue = query.limit ?? (query as any).top;
169
+
170
+ if (offsetValue !== undefined) builder.offset(offsetValue);
171
+ if (limitValue !== undefined) builder.limit(limitValue);
172
+
173
+ let results: any[];
174
+ try {
175
+ results = await builder;
176
+ } catch (error: any) {
177
+ if (
178
+ error.message &&
179
+ (error.message.includes('no such column') ||
180
+ (error.message.includes('column') && error.message.includes('does not exist')))
181
+ ) {
182
+ return [];
183
+ }
184
+ throw error;
185
+ }
186
+
187
+ if (!Array.isArray(results)) {
188
+ return [];
189
+ }
190
+
191
+ if (this.isSqlite) {
192
+ for (const row of results) {
193
+ this.formatOutput(object, row);
194
+ }
195
+ }
196
+ return results;
197
+ }
198
+
199
+ async findOne(object: string, query: QueryInput, options?: DriverOptions): Promise<any> {
200
+ // When called with a string/number id fall back gracefully
201
+ if (typeof query === 'string' || typeof query === 'number') {
202
+ const res = await this.getBuilder(object, options).where('id', query).first();
203
+ return this.formatOutput(object, res) || null;
204
+ }
205
+
206
+ if (query && typeof query === 'object') {
207
+ const results = await this.find(object, { ...query, limit: 1 }, options);
208
+ return results[0] || null;
209
+ }
210
+
211
+ return null;
212
+ }
213
+
214
+ async create(object: string, data: Record<string, any>, options?: DriverOptions): Promise<any> {
215
+ const { _id, ...rest } = data;
216
+ const toInsert = { ...rest };
217
+
218
+ if (_id !== undefined && toInsert.id === undefined) {
219
+ toInsert.id = _id;
220
+ } else if (toInsert.id === undefined) {
221
+ toInsert.id = nanoid(DEFAULT_ID_LENGTH);
222
+ }
223
+
224
+ const builder = this.getBuilder(object, options);
225
+ const formatted = this.formatInput(object, toInsert);
226
+
227
+ const result = await builder.insert(formatted).returning('*');
228
+ return this.formatOutput(object, result[0]);
229
+ }
230
+
231
+ async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<any> {
232
+ const builder = this.getBuilder(object, options);
233
+ const formatted = this.formatInput(object, data);
234
+
235
+ if (this.tablesWithTimestamps.has(object)) {
236
+ if (this.isSqlite) {
237
+ const now = new Date();
238
+ formatted.updated_at = now.toISOString().replace('T', ' ').replace('Z', '');
239
+ } else {
240
+ formatted.updated_at = this.knex.fn.now();
241
+ }
242
+ }
243
+
244
+ await builder.where('id', id).update(formatted);
245
+
246
+ const updated = await this.getBuilder(object, options).where('id', id).first();
247
+ return this.formatOutput(object, updated) || null;
248
+ }
249
+
250
+ async delete(object: string, id: string | number, options?: DriverOptions): Promise<any> {
251
+ const builder = this.getBuilder(object, options);
252
+ return await builder.where('id', id).delete();
253
+ }
254
+
255
+ // ===================================
256
+ // Optional — bulk & batch
257
+ // ===================================
258
+
259
+ async bulkCreate(object: string, data: any[], options?: DriverOptions): Promise<any> {
260
+ const builder = this.getBuilder(object, options);
261
+ return await builder.insert(data).returning('*');
262
+ }
263
+
264
+ async updateMany(object: string, query: QueryInput, data: any, options?: DriverOptions): Promise<any> {
265
+ const builder = this.getBuilder(object, options);
266
+ const filters = query.where || (query as any).filters || query;
267
+ if (filters) this.applyFilters(builder, filters);
268
+ const count = await builder.update(data);
269
+ return { modifiedCount: count || 0 };
270
+ }
271
+
272
+ async deleteMany(object: string, query: QueryInput, options?: DriverOptions): Promise<any> {
273
+ const builder = this.getBuilder(object, options);
274
+ const filters = query.where || (query as any).filters || query;
275
+ if (filters) this.applyFilters(builder, filters);
276
+ const count = await builder.delete();
277
+ return { deletedCount: count || 0 };
278
+ }
279
+
280
+ async count(object: string, query: QueryInput, options?: DriverOptions): Promise<number> {
281
+ const builder = this.getBuilder(object, options);
282
+
283
+ let actualFilters = query as any;
284
+ if (query && (query.where || (query as any).filters)) {
285
+ actualFilters = query.where || (query as any).filters;
286
+ }
287
+
288
+ if (actualFilters) {
289
+ this.applyFilters(builder, actualFilters);
290
+ }
291
+
292
+ const result = await builder.count<{ count: number }[]>('* as count');
293
+ if (result && result.length > 0) {
294
+ const row: any = result[0];
295
+ return Number(row.count || row['count(*)']);
296
+ }
297
+ return 0;
298
+ }
299
+
300
+ // ===================================
301
+ // Raw Execution
302
+ // ===================================
303
+
304
+ async execute(command: any, params?: any[], options?: DriverOptions): Promise<any> {
305
+ if (typeof command !== 'string') {
306
+ return command;
307
+ }
308
+
309
+ const builder =
310
+ options?.transaction
311
+ ? this.knex.raw(command, params || []).transacting(options.transaction as Knex.Transaction)
312
+ : this.knex.raw(command, params || []);
313
+
314
+ return await builder;
315
+ }
316
+
317
+ // ===================================
318
+ // Transactions
319
+ // ===================================
320
+
321
+ async beginTransaction(): Promise<Knex.Transaction> {
322
+ return await this.knex.transaction();
323
+ }
324
+
325
+ async commitTransaction(trx: Knex.Transaction): Promise<void> {
326
+ await trx.commit();
327
+ }
328
+
329
+ async rollbackTransaction(trx: Knex.Transaction): Promise<void> {
330
+ await trx.rollback();
331
+ }
332
+
333
+ // ===================================
334
+ // Aggregation
335
+ // ===================================
336
+
337
+ async aggregate(object: string, query: any, options?: DriverOptions): Promise<any> {
338
+ const builder = this.getBuilder(object, options);
339
+
340
+ if (query.where) {
341
+ this.applyFilters(builder, query.where);
342
+ }
343
+
344
+ if (query.groupBy) {
345
+ builder.groupBy(query.groupBy);
346
+ for (const field of query.groupBy) {
347
+ builder.select(field);
348
+ }
349
+ }
350
+
351
+ const aggregates = query.aggregations || query.aggregate;
352
+ if (aggregates) {
353
+ for (const agg of aggregates) {
354
+ const funcName = agg.function || agg.func;
355
+ const rawFunc = this.mapAggregateFunc(funcName);
356
+ if (agg.alias) {
357
+ builder.select(this.knex.raw(`${rawFunc}(??) as ??`, [agg.field, agg.alias]));
358
+ } else {
359
+ builder.select(this.knex.raw(`${rawFunc}(??)`, [agg.field]));
360
+ }
361
+ }
362
+ }
363
+
364
+ return await builder;
365
+ }
366
+
367
+ // ===================================
368
+ // Distinct
369
+ // ===================================
370
+
371
+ async distinct(object: string, field: string, filters?: any, options?: DriverOptions): Promise<any[]> {
372
+ const builder = this.getBuilder(object, options);
373
+
374
+ if (filters) {
375
+ this.applyFilters(builder, filters);
376
+ }
377
+
378
+ builder.distinct(field);
379
+ const results = await builder;
380
+ return results.map((row: any) => row[field]);
381
+ }
382
+
383
+ // ===================================
384
+ // Window Functions
385
+ // ===================================
386
+
387
+ async findWithWindowFunctions(object: string, query: any, options?: DriverOptions): Promise<any[]> {
388
+ const builder = this.getBuilder(object, options);
389
+
390
+ builder.select('*');
391
+
392
+ if (query.where) {
393
+ this.applyFilters(builder, query.where);
394
+ }
395
+
396
+ if (query.windowFunctions && Array.isArray(query.windowFunctions)) {
397
+ for (const wf of query.windowFunctions) {
398
+ const windowFunc = this.buildWindowFunction(wf);
399
+ builder.select(this.knex.raw(`${windowFunc} as ??`, [wf.alias]));
400
+ }
401
+ }
402
+
403
+ if (query.orderBy && Array.isArray(query.orderBy)) {
404
+ for (const sort of query.orderBy) {
405
+ builder.orderBy(this.mapSortField(sort.field), sort.order || 'asc');
406
+ }
407
+ }
408
+
409
+ if (query.limit) builder.limit(query.limit);
410
+ if (query.offset) builder.offset(query.offset);
411
+
412
+ return await builder;
413
+ }
414
+
415
+ // ===================================
416
+ // Query Plan Analysis
417
+ // ===================================
418
+
419
+ async analyzeQuery(object: string, query: any, options?: DriverOptions): Promise<any> {
420
+ const builder = this.getBuilder(object, options);
421
+
422
+ if (query.fields) {
423
+ builder.select(query.fields);
424
+ } else {
425
+ builder.select('*');
426
+ }
427
+
428
+ if (query.where) {
429
+ this.applyFilters(builder, query.where);
430
+ }
431
+
432
+ if (query.orderBy && Array.isArray(query.orderBy)) {
433
+ for (const sort of query.orderBy) {
434
+ builder.orderBy(this.mapSortField(sort.field), sort.order || 'asc');
435
+ }
436
+ }
437
+
438
+ if (query.limit) builder.limit(query.limit);
439
+ if (query.offset) builder.offset(query.offset);
440
+
441
+ const sql = builder.toSQL();
442
+ const client = (this.config as any).client;
443
+ let explainResults: any;
444
+
445
+ try {
446
+ if (this.isPostgres) {
447
+ explainResults = await this.knex.raw(`EXPLAIN (FORMAT JSON, ANALYZE) ${sql.sql}`, sql.bindings);
448
+ } else if (this.isMysql) {
449
+ explainResults = await this.knex.raw(`EXPLAIN FORMAT=JSON ${sql.sql}`, sql.bindings);
450
+ } else if (this.isSqlite) {
451
+ explainResults = await this.knex.raw(`EXPLAIN QUERY PLAN ${sql.sql}`, sql.bindings);
452
+ } else {
453
+ return {
454
+ sql: sql.sql,
455
+ bindings: sql.bindings,
456
+ client,
457
+ note: 'EXPLAIN not supported for this database client',
458
+ };
459
+ }
460
+
461
+ return { sql: sql.sql, bindings: sql.bindings, client, plan: explainResults };
462
+ } catch (error: any) {
463
+ return {
464
+ sql: sql.sql,
465
+ bindings: sql.bindings,
466
+ client,
467
+ error: error.message,
468
+ note: 'Failed to execute EXPLAIN.',
469
+ };
470
+ }
471
+ }
472
+
473
+ // ===================================
474
+ // Schema Sync (syncSchema / init)
475
+ // ===================================
476
+
477
+ async syncSchema(object: string, schema: unknown, _options?: DriverOptions): Promise<void> {
478
+ const objectDef = schema as { name: string; fields?: Record<string, any> };
479
+ await this.initObjects([objectDef]);
480
+ }
481
+
482
+ /**
483
+ * Batch-initialise tables from an array of object definitions.
484
+ */
485
+ async initObjects(objects: Array<{ name: string; fields?: Record<string, any> }>): Promise<void> {
486
+ await this.ensureDatabaseExists();
487
+
488
+ for (const obj of objects) {
489
+ const tableName = obj.name;
490
+
491
+ const jsonCols: string[] = [];
492
+ const booleanCols: string[] = [];
493
+ if (obj.fields) {
494
+ for (const [name, field] of Object.entries<any>(obj.fields)) {
495
+ const type = field.type || 'string';
496
+ if (this.isJsonField(type, field)) {
497
+ jsonCols.push(name);
498
+ }
499
+ if (type === 'boolean') {
500
+ booleanCols.push(name);
501
+ }
502
+ }
503
+ }
504
+ this.jsonFields[tableName] = jsonCols;
505
+ this.booleanFields[tableName] = booleanCols;
506
+
507
+ let exists = await this.knex.schema.hasTable(tableName);
508
+
509
+ if (exists) {
510
+ const columnInfo = await this.knex(tableName).columnInfo();
511
+ const existingColumns = Object.keys(columnInfo);
512
+
513
+ if (existingColumns.includes('_id') && !existingColumns.includes('id')) {
514
+ await this.knex.schema.dropTable(tableName);
515
+ exists = false;
516
+ }
517
+ }
518
+
519
+ if (!exists) {
520
+ await this.knex.schema.createTable(tableName, (table) => {
521
+ table.string('id').primary();
522
+ table.timestamp('created_at').defaultTo(this.knex.fn.now());
523
+ table.timestamp('updated_at').defaultTo(this.knex.fn.now());
524
+ if (obj.fields) {
525
+ for (const [name, field] of Object.entries(obj.fields)) {
526
+ this.createColumn(table, name, field);
527
+ }
528
+ }
529
+ });
530
+ this.tablesWithTimestamps.add(tableName);
531
+ } else {
532
+ const columnInfo = await this.knex(tableName).columnInfo();
533
+ const existingColumns = Object.keys(columnInfo);
534
+
535
+ if (existingColumns.includes('updated_at')) {
536
+ this.tablesWithTimestamps.add(tableName);
537
+ }
538
+
539
+ await this.knex.schema.alterTable(tableName, (table) => {
540
+ if (obj.fields) {
541
+ for (const [name, field] of Object.entries(obj.fields)) {
542
+ if (!existingColumns.includes(name)) {
543
+ this.createColumn(table, name, field);
544
+ }
545
+ }
546
+ }
547
+ });
548
+ }
549
+ }
550
+ }
551
+
552
+ // ===================================
553
+ // Schema Introspection
554
+ // ===================================
555
+
556
+ async introspectSchema(): Promise<IntrospectedSchema> {
557
+ const tables: Record<string, IntrospectedTable> = {};
558
+ let tableNames: string[] = [];
559
+
560
+ if (this.isPostgres) {
561
+ const result = await this.knex.raw(`
562
+ SELECT table_name
563
+ FROM information_schema.tables
564
+ WHERE table_schema = 'public'
565
+ AND table_type = 'BASE TABLE'
566
+ `);
567
+ tableNames = result.rows.map((row: any) => row.table_name);
568
+ } else if (this.isMysql) {
569
+ const result = await this.knex.raw(`
570
+ SELECT table_name
571
+ FROM information_schema.tables
572
+ WHERE table_schema = DATABASE()
573
+ AND table_type = 'BASE TABLE'
574
+ `);
575
+ tableNames = result[0].map((row: any) => row.TABLE_NAME);
576
+ } else if (this.isSqlite) {
577
+ const result = await this.knex.raw(`
578
+ SELECT name as table_name
579
+ FROM sqlite_master
580
+ WHERE type='table'
581
+ AND name NOT LIKE 'sqlite_%'
582
+ `);
583
+ tableNames = result.map((row: any) => row.table_name);
584
+ }
585
+
586
+ for (const tableName of tableNames) {
587
+ const columns = await this.introspectColumns(tableName);
588
+ const foreignKeys = await this.introspectForeignKeys(tableName);
589
+ const primaryKeys = await this.introspectPrimaryKeys(tableName);
590
+ const uniqueConstraints = await this.introspectUniqueConstraints(tableName);
591
+
592
+ for (const col of columns) {
593
+ if (primaryKeys.includes(col.name)) col.isPrimary = true;
594
+ if (uniqueConstraints.includes(col.name)) col.isUnique = true;
595
+ }
596
+
597
+ tables[tableName] = { name: tableName, columns, foreignKeys, primaryKeys };
598
+ }
599
+
600
+ return { tables };
601
+ }
602
+
603
+ // ===================================
604
+ // Internal helpers
605
+ // ===================================
606
+
607
+ /** Expose the underlying Knex instance for advanced usage. */
608
+ getKnex(): Knex {
609
+ return this.knex;
610
+ }
611
+
612
+ private getBuilder(object: string, options?: DriverOptions) {
613
+ let builder = this.knex(object);
614
+ if (options?.transaction) {
615
+ builder = builder.transacting(options.transaction as Knex.Transaction);
616
+ }
617
+ return builder;
618
+ }
619
+
620
+ // ── Filter helpers ──────────────────────────────────────────────────────────
621
+
622
+ private applyFilters(builder: Knex.QueryBuilder, filters: any) {
623
+ if (!filters) return;
624
+
625
+ if (!Array.isArray(filters) && typeof filters === 'object') {
626
+ const hasMongoOperators = Object.keys(filters).some(
627
+ (k) =>
628
+ k.startsWith('$') ||
629
+ (typeof filters[k] === 'object' &&
630
+ filters[k] !== null &&
631
+ Object.keys(filters[k]).some((op) => op.startsWith('$'))),
632
+ );
633
+
634
+ if (hasMongoOperators) {
635
+ this.applyFilterCondition(builder, filters);
636
+ return;
637
+ }
638
+
639
+ for (const [key, value] of Object.entries(filters)) {
640
+ if (['filters', 'sort', 'limit', 'skip', 'offset', 'fields', 'orderBy'].includes(key)) continue;
641
+ builder.where(key, value as any);
642
+ }
643
+ return;
644
+ }
645
+
646
+ if (!Array.isArray(filters) || filters.length === 0) return;
647
+
648
+ let nextJoin: 'and' | 'or' = 'and';
649
+
650
+ for (const item of filters) {
651
+ if (typeof item === 'string') {
652
+ if (item.toLowerCase() === 'or') nextJoin = 'or';
653
+ else if (item.toLowerCase() === 'and') nextJoin = 'and';
654
+ continue;
655
+ }
656
+
657
+ if (Array.isArray(item)) {
658
+ const [fieldRaw, op, value] = item;
659
+ const isCriterion = typeof fieldRaw === 'string' && typeof op === 'string';
660
+
661
+ if (isCriterion) {
662
+ const field = this.mapSortField(fieldRaw);
663
+ const apply = (b: any) => {
664
+ const method = nextJoin === 'or' ? 'orWhere' : 'where';
665
+ const methodIn = nextJoin === 'or' ? 'orWhereIn' : 'whereIn';
666
+ const methodNotIn = nextJoin === 'or' ? 'orWhereNotIn' : 'whereNotIn';
667
+
668
+ if (op === 'contains') {
669
+ b[method](field, 'like', `%${value}%`);
670
+ return;
671
+ }
672
+
673
+ switch (op) {
674
+ case '=':
675
+ b[method](field, value);
676
+ break;
677
+ case '!=':
678
+ b[method](field, '<>', value);
679
+ break;
680
+ case 'in':
681
+ b[methodIn](field, value);
682
+ break;
683
+ case 'nin':
684
+ b[methodNotIn](field, value);
685
+ break;
686
+ default:
687
+ b[method](field, op, value);
688
+ }
689
+ };
690
+ apply(builder);
691
+ } else {
692
+ const method = nextJoin === 'or' ? 'orWhere' : 'where';
693
+ (builder as any)[method]((qb: any) => {
694
+ this.applyFilters(qb, item);
695
+ });
696
+ }
697
+
698
+ nextJoin = 'and';
699
+ }
700
+ }
701
+ }
702
+
703
+ private applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp: 'and' | 'or' = 'and') {
704
+ if (!condition || typeof condition !== 'object') return;
705
+
706
+ for (const [key, value] of Object.entries(condition)) {
707
+ if (key === '$and' && Array.isArray(value)) {
708
+ builder.where((qb) => {
709
+ for (const sub of value) {
710
+ qb.where((subQb) => {
711
+ this.applyFilterCondition(subQb, sub, 'and');
712
+ });
713
+ }
714
+ });
715
+ } else if (key === '$or' && Array.isArray(value)) {
716
+ const method = logicalOp === 'or' ? 'orWhere' : 'where';
717
+ (builder as any)[method]((qb: any) => {
718
+ for (const sub of value) {
719
+ qb.orWhere((subQb: any) => {
720
+ this.applyFilterCondition(subQb, sub, 'or');
721
+ });
722
+ }
723
+ });
724
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
725
+ const field = this.mapSortField(key);
726
+ for (const [op, opValue] of Object.entries(value as Record<string, any>)) {
727
+ const method = logicalOp === 'or' ? 'orWhere' : 'where';
728
+ switch (op) {
729
+ case '$eq':
730
+ (builder as any)[method](field, opValue);
731
+ break;
732
+ case '$ne':
733
+ (builder as any)[method](field, '<>', opValue);
734
+ break;
735
+ case '$gt':
736
+ (builder as any)[method](field, '>', opValue);
737
+ break;
738
+ case '$gte':
739
+ (builder as any)[method](field, '>=', opValue);
740
+ break;
741
+ case '$lt':
742
+ (builder as any)[method](field, '<', opValue);
743
+ break;
744
+ case '$lte':
745
+ (builder as any)[method](field, '<=', opValue);
746
+ break;
747
+ case '$in': {
748
+ const mIn = logicalOp === 'or' ? 'orWhereIn' : 'whereIn';
749
+ (builder as any)[mIn](field, opValue as any[]);
750
+ break;
751
+ }
752
+ case '$nin': {
753
+ const mNotIn = logicalOp === 'or' ? 'orWhereNotIn' : 'whereNotIn';
754
+ (builder as any)[mNotIn](field, opValue as any[]);
755
+ break;
756
+ }
757
+ case '$contains':
758
+ (builder as any)[method](field, 'like', `%${opValue}%`);
759
+ break;
760
+ default:
761
+ (builder as any)[method](field, opValue);
762
+ }
763
+ }
764
+ } else {
765
+ const field = this.mapSortField(key);
766
+ const method = logicalOp === 'or' ? 'orWhere' : 'where';
767
+ (builder as any)[method](field, value as any);
768
+ }
769
+ }
770
+ }
771
+
772
+ // ── Field mapping ───────────────────────────────────────────────────────────
773
+
774
+ private mapSortField(field: string): string {
775
+ if (field === 'createdAt') return 'created_at';
776
+ if (field === 'updatedAt') return 'updated_at';
777
+ return field;
778
+ }
779
+
780
+ private mapAggregateFunc(func: string): string {
781
+ switch (func) {
782
+ case 'count':
783
+ return 'count';
784
+ case 'sum':
785
+ return 'sum';
786
+ case 'avg':
787
+ return 'avg';
788
+ case 'min':
789
+ return 'min';
790
+ case 'max':
791
+ return 'max';
792
+ default:
793
+ throw new Error(`Unsupported aggregate function: ${func}`);
794
+ }
795
+ }
796
+
797
+ // ── Window function builder ─────────────────────────────────────────────────
798
+
799
+ private buildWindowFunction(spec: any): string {
800
+ const func = spec.function.toUpperCase();
801
+ let sql = `${func}()`;
802
+
803
+ const overParts: string[] = [];
804
+
805
+ if (spec.partitionBy && Array.isArray(spec.partitionBy) && spec.partitionBy.length > 0) {
806
+ const partitionFields = spec.partitionBy.map((f: string) => this.mapSortField(f)).join(', ');
807
+ overParts.push(`PARTITION BY ${partitionFields}`);
808
+ }
809
+
810
+ if (spec.orderBy && Array.isArray(spec.orderBy) && spec.orderBy.length > 0) {
811
+ const orderFields = spec.orderBy
812
+ .map((s: any) => {
813
+ const field = this.mapSortField(s.field);
814
+ const order = (s.order || 'asc').toUpperCase();
815
+ return `${field} ${order}`;
816
+ })
817
+ .join(', ');
818
+ overParts.push(`ORDER BY ${orderFields}`);
819
+ }
820
+
821
+ sql += overParts.length > 0 ? ` OVER (${overParts.join(' ')})` : ` OVER ()`;
822
+ return sql;
823
+ }
824
+
825
+ // ── Column creation helper ──────────────────────────────────────────────────
826
+
827
+ private createColumn(table: Knex.CreateTableBuilder, name: string, field: any) {
828
+ if (field.multiple) {
829
+ table.json(name);
830
+ return;
831
+ }
832
+
833
+ const type = field.type || 'string';
834
+ let col: any;
835
+ switch (type) {
836
+ case 'string':
837
+ case 'email':
838
+ case 'url':
839
+ case 'phone':
840
+ case 'password':
841
+ col = table.string(name);
842
+ break;
843
+ case 'text':
844
+ case 'textarea':
845
+ case 'html':
846
+ case 'markdown':
847
+ col = table.text(name);
848
+ break;
849
+ case 'integer':
850
+ case 'int':
851
+ col = table.integer(name);
852
+ break;
853
+ case 'float':
854
+ case 'number':
855
+ case 'currency':
856
+ case 'percent':
857
+ col = table.float(name);
858
+ break;
859
+ case 'boolean':
860
+ col = table.boolean(name);
861
+ break;
862
+ case 'date':
863
+ col = table.date(name);
864
+ break;
865
+ case 'datetime':
866
+ col = table.timestamp(name);
867
+ break;
868
+ case 'time':
869
+ col = table.time(name);
870
+ break;
871
+ case 'json':
872
+ case 'object':
873
+ case 'array':
874
+ case 'image':
875
+ case 'file':
876
+ case 'avatar':
877
+ case 'location':
878
+ col = table.json(name);
879
+ break;
880
+ case 'lookup':
881
+ col = table.string(name);
882
+ if (field.reference_to) {
883
+ table.foreign(name).references('id').inTable(field.reference_to);
884
+ }
885
+ break;
886
+ case 'summary':
887
+ col = table.float(name);
888
+ break;
889
+ case 'auto_number':
890
+ col = table.string(name);
891
+ break;
892
+ case 'formula':
893
+ return; // Virtual — no column
894
+ default:
895
+ col = table.string(name);
896
+ }
897
+
898
+ if (col) {
899
+ if (field.unique) col.unique();
900
+ if (field.required) col.notNullable();
901
+ }
902
+ }
903
+
904
+ // ── Database helpers ────────────────────────────────────────────────────────
905
+
906
+ private async ensureDatabaseExists() {
907
+ if (!this.isPostgres) return;
908
+
909
+ try {
910
+ await this.knex.raw('SELECT 1');
911
+ } catch (e: any) {
912
+ if (e.code === '3D000') {
913
+ await this.createDatabase();
914
+ } else {
915
+ throw e;
916
+ }
917
+ }
918
+ }
919
+
920
+ private async createDatabase() {
921
+ const config = this.config as any;
922
+ const connection = config.connection;
923
+ let dbName = '';
924
+ const adminConfig = { ...config };
925
+
926
+ if (typeof connection === 'string') {
927
+ const url = new URL(connection);
928
+ dbName = url.pathname.slice(1);
929
+ url.pathname = '/postgres';
930
+ adminConfig.connection = url.toString();
931
+ } else {
932
+ dbName = connection.database;
933
+ adminConfig.connection = { ...connection, database: 'postgres' };
934
+ }
935
+
936
+ const adminKnex = knex(adminConfig);
937
+ try {
938
+ await adminKnex.raw(`CREATE DATABASE "${dbName}"`);
939
+ } finally {
940
+ await adminKnex.destroy();
941
+ }
942
+ }
943
+
944
+ private isJsonField(type: string, field: any): boolean {
945
+ return ['json', 'object', 'array', 'image', 'file', 'avatar', 'location'].includes(type) || field.multiple;
946
+ }
947
+
948
+ // ── SQLite serialisation ────────────────────────────────────────────────────
949
+
950
+ private formatInput(object: string, data: any): any {
951
+ if (!this.isSqlite) return data;
952
+
953
+ const fields = this.jsonFields[object];
954
+ if (!fields || fields.length === 0) return data;
955
+
956
+ const copy = { ...data };
957
+ for (const field of fields) {
958
+ if (copy[field] !== undefined && typeof copy[field] === 'object' && copy[field] !== null) {
959
+ copy[field] = JSON.stringify(copy[field]);
960
+ }
961
+ }
962
+ return copy;
963
+ }
964
+
965
+ private formatOutput(object: string, data: any): any {
966
+ if (!data) return data;
967
+
968
+ if (this.isSqlite) {
969
+ const jsonFields = this.jsonFields[object];
970
+ if (jsonFields && jsonFields.length > 0) {
971
+ for (const field of jsonFields) {
972
+ if (data[field] !== undefined && typeof data[field] === 'string') {
973
+ try {
974
+ data[field] = JSON.parse(data[field]);
975
+ } catch {
976
+ // keep as string
977
+ }
978
+ }
979
+ }
980
+ }
981
+
982
+ const booleanFields = this.booleanFields[object];
983
+ if (booleanFields && booleanFields.length > 0) {
984
+ for (const field of booleanFields) {
985
+ if (data[field] !== undefined && data[field] !== null) {
986
+ data[field] = Boolean(data[field]);
987
+ }
988
+ }
989
+ }
990
+ }
991
+
992
+ return data;
993
+ }
994
+
995
+ // ── Introspection internals ─────────────────────────────────────────────────
996
+
997
+ private async introspectColumns(tableName: string): Promise<IntrospectedColumn[]> {
998
+ const columnInfo = await this.knex(tableName).columnInfo();
999
+ const columns: IntrospectedColumn[] = [];
1000
+
1001
+ for (const [colName, info] of Object.entries<any>(columnInfo)) {
1002
+ let type = 'string';
1003
+ let maxLength: number | undefined;
1004
+
1005
+ if (this.isSqlite) {
1006
+ type = info.type?.toLowerCase() || 'string';
1007
+ } else {
1008
+ type = info.type || 'string';
1009
+ }
1010
+
1011
+ if (info.maxLength) {
1012
+ maxLength = info.maxLength;
1013
+ }
1014
+
1015
+ columns.push({
1016
+ name: colName,
1017
+ type,
1018
+ nullable: info.nullable !== false,
1019
+ defaultValue: info.defaultValue,
1020
+ isPrimary: false,
1021
+ isUnique: false,
1022
+ maxLength,
1023
+ });
1024
+ }
1025
+
1026
+ return columns;
1027
+ }
1028
+
1029
+ private async introspectForeignKeys(tableName: string): Promise<IntrospectedForeignKey[]> {
1030
+ const foreignKeys: IntrospectedForeignKey[] = [];
1031
+
1032
+ try {
1033
+ if (this.isPostgres) {
1034
+ const result = await this.knex.raw(
1035
+ `
1036
+ SELECT
1037
+ kcu.column_name,
1038
+ ccu.table_name AS referenced_table,
1039
+ ccu.column_name AS referenced_column,
1040
+ tc.constraint_name
1041
+ FROM information_schema.table_constraints AS tc
1042
+ JOIN information_schema.key_column_usage AS kcu
1043
+ ON tc.constraint_name = kcu.constraint_name
1044
+ AND tc.table_schema = kcu.table_schema
1045
+ JOIN information_schema.constraint_column_usage AS ccu
1046
+ ON ccu.constraint_name = tc.constraint_name
1047
+ AND ccu.table_schema = tc.table_schema
1048
+ WHERE tc.constraint_type = 'FOREIGN KEY'
1049
+ AND tc.table_name = ?
1050
+ `,
1051
+ [tableName],
1052
+ );
1053
+
1054
+ for (const row of result.rows) {
1055
+ foreignKeys.push({
1056
+ columnName: row.column_name,
1057
+ referencedTable: row.referenced_table,
1058
+ referencedColumn: row.referenced_column,
1059
+ constraintName: row.constraint_name,
1060
+ });
1061
+ }
1062
+ } else if (this.isMysql) {
1063
+ const result = await this.knex.raw(
1064
+ `
1065
+ SELECT
1066
+ COLUMN_NAME as column_name,
1067
+ REFERENCED_TABLE_NAME as referenced_table,
1068
+ REFERENCED_COLUMN_NAME as referenced_column,
1069
+ CONSTRAINT_NAME as constraint_name
1070
+ FROM information_schema.KEY_COLUMN_USAGE
1071
+ WHERE TABLE_SCHEMA = DATABASE()
1072
+ AND TABLE_NAME = ?
1073
+ AND REFERENCED_TABLE_NAME IS NOT NULL
1074
+ `,
1075
+ [tableName],
1076
+ );
1077
+
1078
+ for (const row of result[0]) {
1079
+ foreignKeys.push({
1080
+ columnName: row.column_name,
1081
+ referencedTable: row.referenced_table,
1082
+ referencedColumn: row.referenced_column,
1083
+ constraintName: row.constraint_name,
1084
+ });
1085
+ }
1086
+ } else if (this.isSqlite) {
1087
+ const tableExistsResult = await this.knex.raw(
1088
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
1089
+ [tableName],
1090
+ );
1091
+
1092
+ if (!Array.isArray(tableExistsResult) || tableExistsResult.length === 0) {
1093
+ return foreignKeys;
1094
+ }
1095
+
1096
+ const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, '');
1097
+ const result = await this.knex.raw(`PRAGMA foreign_key_list(${safeTableName})`);
1098
+
1099
+ for (const row of result) {
1100
+ foreignKeys.push({
1101
+ columnName: row.from,
1102
+ referencedTable: row.table,
1103
+ referencedColumn: row.to,
1104
+ constraintName: `fk_${tableName}_${row.from}`,
1105
+ });
1106
+ }
1107
+ }
1108
+ } catch {
1109
+ // silently ignore introspection errors
1110
+ }
1111
+
1112
+ return foreignKeys;
1113
+ }
1114
+
1115
+ private async introspectPrimaryKeys(tableName: string): Promise<string[]> {
1116
+ const primaryKeys: string[] = [];
1117
+
1118
+ try {
1119
+ if (this.isPostgres) {
1120
+ const result = await this.knex.raw(
1121
+ `
1122
+ SELECT a.attname as column_name
1123
+ FROM pg_index i
1124
+ JOIN pg_attribute a ON a.attrelid = i.indrelid
1125
+ AND a.attnum = ANY(i.indkey)
1126
+ WHERE i.indrelid = ?::regclass
1127
+ AND i.indisprimary
1128
+ `,
1129
+ [tableName],
1130
+ );
1131
+
1132
+ for (const row of result.rows) {
1133
+ primaryKeys.push(row.column_name);
1134
+ }
1135
+ } else if (this.isMysql) {
1136
+ const result = await this.knex.raw(
1137
+ `
1138
+ SELECT COLUMN_NAME as column_name
1139
+ FROM information_schema.KEY_COLUMN_USAGE
1140
+ WHERE TABLE_SCHEMA = DATABASE()
1141
+ AND TABLE_NAME = ?
1142
+ AND CONSTRAINT_NAME = 'PRIMARY'
1143
+ `,
1144
+ [tableName],
1145
+ );
1146
+
1147
+ for (const row of result[0]) {
1148
+ primaryKeys.push(row.column_name);
1149
+ }
1150
+ } else if (this.isSqlite) {
1151
+ const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, '');
1152
+
1153
+ const tablesResult = await this.knex.raw("SELECT name FROM sqlite_master WHERE type = 'table'");
1154
+ const tableNames = Array.isArray(tablesResult) ? tablesResult.map((row: any) => row.name) : [];
1155
+
1156
+ if (!tableNames.includes(safeTableName)) {
1157
+ return primaryKeys;
1158
+ }
1159
+
1160
+ const result = await this.knex.raw(`PRAGMA table_info(${safeTableName})`);
1161
+
1162
+ for (const row of result) {
1163
+ if (row.pk === 1) {
1164
+ primaryKeys.push(row.name);
1165
+ }
1166
+ }
1167
+ }
1168
+ } catch {
1169
+ // silently ignore
1170
+ }
1171
+
1172
+ return primaryKeys;
1173
+ }
1174
+
1175
+ private async introspectUniqueConstraints(tableName: string): Promise<string[]> {
1176
+ const uniqueColumns: string[] = [];
1177
+
1178
+ try {
1179
+ if (this.isPostgres) {
1180
+ const result = await this.knex.raw(
1181
+ `
1182
+ SELECT c.column_name
1183
+ FROM information_schema.table_constraints tc
1184
+ JOIN information_schema.constraint_column_usage AS ccu
1185
+ ON tc.constraint_schema = ccu.constraint_schema
1186
+ AND tc.constraint_name = ccu.constraint_name
1187
+ WHERE tc.constraint_type = 'UNIQUE'
1188
+ AND tc.table_name = ?
1189
+ `,
1190
+ [tableName],
1191
+ );
1192
+
1193
+ for (const row of result.rows) {
1194
+ uniqueColumns.push(row.column_name);
1195
+ }
1196
+ } else if (this.isMysql) {
1197
+ const result = await this.knex.raw(
1198
+ `
1199
+ SELECT COLUMN_NAME
1200
+ FROM information_schema.TABLE_CONSTRAINTS tc
1201
+ JOIN information_schema.KEY_COLUMN_USAGE kcu
1202
+ USING (CONSTRAINT_NAME, TABLE_SCHEMA, TABLE_NAME)
1203
+ WHERE CONSTRAINT_TYPE = 'UNIQUE'
1204
+ AND TABLE_SCHEMA = DATABASE()
1205
+ AND TABLE_NAME = ?
1206
+ `,
1207
+ [tableName],
1208
+ );
1209
+
1210
+ for (const row of result[0]) {
1211
+ uniqueColumns.push(row.COLUMN_NAME);
1212
+ }
1213
+ } else if (this.isSqlite) {
1214
+ const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, '');
1215
+
1216
+ const tablesResult = await this.knex.raw("SELECT name FROM sqlite_master WHERE type = 'table'");
1217
+ const tableNames = Array.isArray(tablesResult) ? tablesResult.map((row: any) => row.name) : [];
1218
+
1219
+ if (!tableNames.includes(safeTableName)) {
1220
+ return uniqueColumns;
1221
+ }
1222
+
1223
+ const indexes = await this.knex.raw(`PRAGMA index_list(${safeTableName})`);
1224
+
1225
+ for (const idx of indexes) {
1226
+ if (idx.unique === 1) {
1227
+ const info = await this.knex.raw(`PRAGMA index_info(${idx.name})`);
1228
+ if (info.length === 1) {
1229
+ uniqueColumns.push(info[0].name);
1230
+ }
1231
+ }
1232
+ }
1233
+ }
1234
+ } catch {
1235
+ // silently ignore
1236
+ }
1237
+
1238
+ return uniqueColumns;
1239
+ }
1240
+ }