@objectql/driver-knex 1.0.0 → 1.2.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/src/index.ts CHANGED
@@ -1,10 +1,13 @@
1
- import { Driver } from '@objectql/core';
1
+ import { Driver } from '@objectql/types';
2
2
  import knex, { Knex } from 'knex';
3
3
 
4
4
  export class KnexDriver implements Driver {
5
5
  private knex: Knex;
6
+ private config: any;
7
+ private jsonFields: Record<string, string[]> = {};
6
8
 
7
9
  constructor(config: any) {
10
+ this.config = config;
8
11
  this.knex = knex(config);
9
12
  }
10
13
 
@@ -19,8 +22,6 @@ export class KnexDriver implements Driver {
19
22
  private applyFilters(builder: Knex.QueryBuilder, filters: any) {
20
23
  if (!filters || filters.length === 0) return;
21
24
 
22
- // Simple linear parser handling [cond, 'or', cond, 'and', cond]
23
- // Default join is AND.
24
25
  let nextJoin = 'and';
25
26
 
26
27
  for (const item of filters) {
@@ -31,46 +32,57 @@ export class KnexDriver implements Driver {
31
32
  }
32
33
 
33
34
  if (Array.isArray(item)) {
34
- const [field, op, value] = item;
35
-
36
- // Handle specific operators that map to different knex methods
37
- const apply = (b: any) => {
38
- // b is the builder to apply on (could be root or a where clause)
39
- // But here we call directly on builder using 'where' or 'orWhere'
40
-
41
- // Method selection
42
- let method = nextJoin === 'or' ? 'orWhere' : 'where';
43
- let methodIn = nextJoin === 'or' ? 'orWhereIn' : 'whereIn';
44
- let methodNotIn = nextJoin === 'or' ? 'orWhereNotIn' : 'whereNotIn';
45
-
46
- switch (op) {
47
- case '=': b[method](field, value); break;
48
- case '!=': b[method](field, '<>', value); break;
49
- case 'in': b[methodIn](field, value); break;
50
- case 'nin': b[methodNotIn](field, value); break;
51
- case 'contains': b[method](field, 'like', `%${value}%`); break; // Simple LIKE
52
- default: b[method](field, op, value);
53
- }
54
- };
35
+ // Heuristic to detect if it is a criterion [field, op, value] or a nested group
36
+ const [fieldRaw, op, value] = item;
37
+ const isCriterion = typeof fieldRaw === 'string' && typeof op === 'string';
55
38
 
56
- apply(builder);
39
+ if (isCriterion) {
40
+ const field = this.mapSortField(fieldRaw);
41
+ // Handle specific operators that map to different knex methods
42
+ const apply = (b: any) => {
43
+ let method = nextJoin === 'or' ? 'orWhere' : 'where';
44
+ let methodIn = nextJoin === 'or' ? 'orWhereIn' : 'whereIn';
45
+ let methodNotIn = nextJoin === 'or' ? 'orWhereNotIn' : 'whereNotIn';
46
+
47
+ // Fix for 'contains' mapping
48
+ if (op === 'contains') {
49
+ b[method](field, 'like', `%${value}%`);
50
+ return;
51
+ }
52
+
53
+ switch (op) {
54
+ case '=': b[method](field, value); break;
55
+ case '!=': b[method](field, '<>', value); break;
56
+ case 'in': b[methodIn](field, value); break;
57
+ case 'nin': b[methodNotIn](field, value); break;
58
+ default: b[method](field, op, value);
59
+ }
60
+ };
61
+ apply(builder);
62
+ } else {
63
+ // Recursive Group
64
+ const method = nextJoin === 'or' ? 'orWhere' : 'where';
65
+ builder[method]((qb) => {
66
+ this.applyFilters(qb, item);
67
+ });
68
+ }
57
69
 
58
- // Reset join to 'and' for subsequent terms unless strictly specified?
59
- // In SQL `A or B and C` means `A or (B and C)`.
60
- // If we chain `.where(A).orWhere(B).where(C)` in Knex:
61
- // It produces `... WHERE A OR B AND C`.
62
- // So linear application matches SQL precedence usually if implicit validation is ok.
63
- // But explicit AND after OR is necessary in our array format.
64
70
  nextJoin = 'and';
65
71
  }
66
72
  }
67
73
  }
68
74
 
75
+ private mapSortField(field: string): string {
76
+ if (field === 'createdAt') return 'created_at';
77
+ if (field === 'updatedAt') return 'updated_at';
78
+ return field;
79
+ }
80
+
69
81
  async find(objectName: string, query: any, options?: any): Promise<any[]> {
70
82
  const builder = this.getBuilder(objectName, options);
71
83
 
72
84
  if (query.fields) {
73
- builder.select(query.fields);
85
+ builder.select(query.fields.map((f: string) => this.mapSortField(f)));
74
86
  } else {
75
87
  builder.select('*');
76
88
  }
@@ -81,19 +93,26 @@ export class KnexDriver implements Driver {
81
93
 
82
94
  if (query.sort) {
83
95
  for (const [field, dir] of query.sort) {
84
- builder.orderBy(field, dir);
96
+ builder.orderBy(this.mapSortField(field), dir);
85
97
  }
86
98
  }
87
99
 
88
100
  if (query.skip) builder.offset(query.skip);
89
101
  if (query.limit) builder.limit(query.limit);
90
102
 
91
- return await builder;
103
+ const results = await builder;
104
+ if (this.config.client === 'sqlite3') {
105
+ for (const row of results) {
106
+ this.formatOutput(objectName, row);
107
+ }
108
+ }
109
+ return results;
92
110
  }
93
111
 
94
112
  async findOne(objectName: string, id: string | number, query?: any, options?: any) {
95
113
  if (id) {
96
- return await this.getBuilder(objectName, options).where('id', id).first();
114
+ const res = await this.getBuilder(objectName, options).where('id', id).first();
115
+ return this.formatOutput(objectName, res);
97
116
  }
98
117
  if (query) {
99
118
  const results = await this.find(objectName, { ...query, limit: 1 }, options);
@@ -103,19 +122,32 @@ export class KnexDriver implements Driver {
103
122
  }
104
123
 
105
124
  async create(objectName: string, data: any, options?: any) {
125
+ // Handle _id -> id mapping for compatibility
126
+ const { _id, ...rest } = data;
127
+ const toInsert = { ...rest };
128
+ // If _id exists and id doesn't, map _id to id
129
+ if (_id !== undefined && toInsert.id === undefined) {
130
+ toInsert.id = _id;
131
+ } else if (toInsert.id !== undefined) {
132
+ // normal case
133
+ }
134
+
106
135
  // Knex insert returns Result array (e.g. ids)
107
136
  // We want the created document.
108
- // Some DBs support .returning('*'), others don't easily.
109
- // Assuming Postgres/SQLite/Modern MySQL for now support returning.
110
137
  const builder = this.getBuilder(objectName, options);
111
- const result = await builder.insert(data).returning('*'); // This might fail on old MySQL
112
- return result[0];
138
+
139
+ const formatted = this.formatInput(objectName, toInsert);
140
+
141
+ // We should insert 'toInsert' instead of 'data'
142
+ const result = await builder.insert(formatted).returning('*');
143
+ return this.formatOutput(objectName, result[0]);
113
144
  }
114
145
 
115
146
  async update(objectName: string, id: string | number, data: any, options?: any) {
116
147
  const builder = this.getBuilder(objectName, options);
117
- await builder.where('id', id).update(data);
118
- return { id, ...data }; // Return patched data? Or fetch fresh?
148
+ const formatted = this.formatInput(objectName, data);
149
+ await builder.where('id', id).update(formatted);
150
+ return { id, ...data };
119
151
  }
120
152
 
121
153
  async delete(objectName: string, id: string | number, options?: any) {
@@ -167,5 +199,208 @@ export class KnexDriver implements Driver {
167
199
  if(filters) this.applyFilters(builder, filters);
168
200
  return await builder.delete();
169
201
  }
202
+
203
+ async init(objects: any[]): Promise<void> {
204
+ await this.ensureDatabaseExists();
205
+
206
+ for (const obj of objects) {
207
+ const tableName = obj.name;
208
+
209
+ // Cache JSON fields
210
+ const jsonCols: string[] = [];
211
+ if (obj.fields) {
212
+ for (const [name, field] of Object.entries<any>(obj.fields)) {
213
+ const type = field.type || 'string';
214
+ if (this.isJsonField(type, field)) {
215
+ jsonCols.push(name);
216
+ }
217
+ }
218
+ }
219
+ this.jsonFields[tableName] = jsonCols;
220
+
221
+ let exists = await this.knex.schema.hasTable(tableName);
222
+
223
+ if (exists) {
224
+ const columnInfo = await this.knex(tableName).columnInfo();
225
+ const existingColumns = Object.keys(columnInfo);
226
+
227
+ // Check for _id vs id conflict (Legacy _id from mongo-style init)
228
+ if (existingColumns.includes('_id') && !existingColumns.includes('id')) {
229
+ console.log(`[KnexDriver] Detected legacy '_id' in '${tableName}'. Recreating table for 'id' compatibility...`);
230
+ await this.knex.schema.dropTable(tableName);
231
+ exists = false;
232
+ }
233
+ }
234
+
235
+ if (!exists) {
236
+ await this.knex.schema.createTable(tableName, (table) => {
237
+ // Use standard 'id' for SQL databases
238
+ table.string('id').primary();
239
+ table.timestamp('created_at').defaultTo(this.knex.fn.now());
240
+ table.timestamp('updated_at').defaultTo(this.knex.fn.now());
241
+ if (obj.fields) {
242
+ for (const [name, field] of Object.entries(obj.fields)) {
243
+ this.createColumn(table, name, field);
244
+ }
245
+ }
246
+ });
247
+ console.log(`[KnexDriver] Created table '${tableName}'`);
248
+ } else {
249
+ const columnInfo = await this.knex(tableName).columnInfo();
250
+ const existingColumns = Object.keys(columnInfo);
251
+
252
+ await this.knex.schema.alterTable(tableName, (table) => {
253
+ if (obj.fields) {
254
+ for (const [name, field] of Object.entries(obj.fields)) {
255
+ if (!existingColumns.includes(name)) {
256
+ this.createColumn(table, name, field);
257
+ console.log(`[KnexDriver] Added column '${name}' to '${tableName}'`);
258
+ }
259
+ }
260
+ }
261
+ });
262
+ }
263
+ }
264
+ }
265
+
266
+ private createColumn(table: Knex.CreateTableBuilder, name: string, field: any) {
267
+ if (field.multiple) {
268
+ table.json(name);
269
+ return;
270
+ }
271
+
272
+ const type = field.type || 'string';
273
+ let col;
274
+ switch(type) {
275
+ case 'string':
276
+ case 'email':
277
+ case 'url':
278
+ case 'phone':
279
+ case 'password':
280
+ col = table.string(name); break;
281
+ case 'text':
282
+ case 'textarea':
283
+ case 'html':
284
+ case 'markdown':
285
+ col = table.text(name); break;
286
+ case 'integer':
287
+ case 'int': col = table.integer(name); break;
288
+ case 'float':
289
+ case 'number':
290
+ case 'currency':
291
+ case 'percent': col = table.float(name); break;
292
+ case 'boolean': col = table.boolean(name); break;
293
+ case 'date': col = table.date(name); break;
294
+ case 'datetime': col = table.timestamp(name); break;
295
+ case 'time': col = table.time(name); break;
296
+ case 'json':
297
+ case 'object':
298
+ case 'array':
299
+ case 'image':
300
+ case 'file':
301
+ case 'avatar':
302
+ case 'location': col = table.json(name); break;
303
+ case 'summary': col = table.float(name); break; // Stored calculation result
304
+ case 'auto_number': col = table.string(name); break; // Generated string
305
+ case 'formula': return; // Virtual field, do not create column
306
+ default: col = table.string(name);
307
+ }
308
+
309
+ if (field.unique) {
310
+ col.unique();
311
+ }
312
+ if (field.required) {
313
+ col.notNullable();
314
+ }
315
+ }
316
+
317
+ private async ensureDatabaseExists() {
318
+ if (this.config.client !== 'pg' && this.config.client !== 'postgresql') return;
319
+
320
+ try {
321
+ await this.knex.raw('SELECT 1');
322
+ } catch (e: any) {
323
+ if (e.code === '3D000') { // Database does not exist
324
+ await this.createDatabase();
325
+ } else {
326
+ throw e;
327
+ }
328
+ }
329
+ }
330
+
331
+ private async createDatabase() {
332
+ const config = this.config;
333
+ const connection = config.connection;
334
+ let dbName = '';
335
+ let adminConfig = { ...config };
336
+
337
+ if (typeof connection === 'string') {
338
+ const url = new URL(connection);
339
+ dbName = url.pathname.slice(1);
340
+ url.pathname = '/postgres';
341
+ adminConfig.connection = url.toString();
342
+ } else {
343
+ dbName = connection.database;
344
+ adminConfig.connection = { ...connection, database: 'postgres' };
345
+ }
346
+
347
+ console.log(`[KnexDriver] Database '${dbName}' does not exist. Creating...`);
348
+
349
+ const adminKnex = knex(adminConfig);
350
+ try {
351
+ await adminKnex.raw(`CREATE DATABASE "${dbName}"`);
352
+ console.log(`[KnexDriver] Database '${dbName}' created successfully.`);
353
+ } catch (e: any) {
354
+ console.error(`[KnexDriver] Failed to create database '${dbName}':`, e.message);
355
+ if (e.code === '42501') {
356
+ console.error(`[KnexDriver] Hint: The user '${adminConfig.connection.user || 'current user'}' does not have CREATEDB privileges.`);
357
+ console.error(`[KnexDriver] Please run: createdb ${dbName}`);
358
+ }
359
+ throw e;
360
+ } finally {
361
+ await adminKnex.destroy();
362
+ }
363
+ }
364
+
365
+ private isJsonField(type: string, field: any) {
366
+ return ['json', 'object', 'array', 'image', 'file', 'avatar', 'location'].includes(type) || field.multiple;
367
+ }
368
+
369
+ private formatInput(objectName: string, data: any) {
370
+ // Only needed for SQLite usually, PG handles JSON
371
+ const isSqlite = this.config.client === 'sqlite3';
372
+ if (!isSqlite) return data;
373
+
374
+ const fields = this.jsonFields[objectName];
375
+ if (!fields || fields.length === 0) return data;
376
+
377
+ const copy = { ...data };
378
+ for (const field of fields) {
379
+ if (copy[field] !== undefined && typeof copy[field] === 'object' && copy[field] !== null) {
380
+ copy[field] = JSON.stringify(copy[field]);
381
+ }
382
+ }
383
+ return copy;
384
+ }
385
+
386
+ private formatOutput(objectName: string, data: any) {
387
+ const isSqlite = this.config.client === 'sqlite3';
388
+ if (!isSqlite) return data;
389
+
390
+ const fields = this.jsonFields[objectName];
391
+ if (!fields || fields.length === 0) return data;
392
+
393
+ // data is a single row object
394
+ for (const field of fields) {
395
+ if (data[field] !== undefined && typeof data[field] === 'string') {
396
+ try {
397
+ data[field] = JSON.parse(data[field]);
398
+ } catch (e) {
399
+ // ignore parse error, keep as string
400
+ }
401
+ }
402
+ }
403
+ return data;
404
+ }
170
405
  }
171
406
 
@@ -1,30 +1,38 @@
1
1
  import { KnexDriver } from '../src';
2
- import knex from 'knex';
2
+ import { UnifiedQuery } from '@objectql/types';
3
3
 
4
- const mockChain = (methods: string[]) => {
5
- const mock: any = {};
6
- methods.forEach(m => {
7
- mock[m] = jest.fn().mockReturnThis();
8
- });
9
- mock.then = jest.fn((resolve) => resolve([]));
10
- mock.catch = jest.fn();
11
- return mock;
12
- };
13
-
14
- const mockBuilder = mockChain(['select', 'where', 'orWhere', 'orderBy', 'offset', 'limit', 'insert', 'update', 'delete', 'transacting', 'count']);
15
- const mockFirst = jest.fn();
16
- mockBuilder.first = mockFirst;
4
+ describe('KnexDriver (SQLite Integration)', () => {
5
+ let driver: KnexDriver;
17
6
 
18
- jest.mock('knex', () => {
19
- return jest.fn(() => (tableName: string) => mockBuilder);
20
- });
7
+ beforeEach(async () => {
8
+ // Init ephemeral in-memory database
9
+ driver = new KnexDriver({
10
+ client: 'sqlite3',
11
+ connection: {
12
+ filename: ':memory:'
13
+ },
14
+ useNullAsDefault: true
15
+ });
16
+
17
+ const k = (driver as any).knex;
18
+
19
+ await k.schema.createTable('users', (t: any) => {
20
+ t.string('id').primary();
21
+ t.string('name');
22
+ t.integer('age');
23
+ });
21
24
 
22
- describe('KnexDriver', () => {
23
- let driver: KnexDriver;
25
+ await k('users').insert([
26
+ { id: '1', name: 'Alice', age: 25 },
27
+ { id: '2', name: 'Bob', age: 17 },
28
+ { id: '3', name: 'Charlie', age: 30 },
29
+ { id: '4', name: 'Dave', age: 17 }
30
+ ]);
31
+ });
24
32
 
25
- beforeEach(() => {
26
- driver = new KnexDriver({ client: 'sqlite3' });
27
- jest.clearAllMocks();
33
+ afterEach(async () => {
34
+ const k = (driver as any).knex;
35
+ await k.destroy();
28
36
  });
29
37
 
30
38
  it('should be instantiable', () => {
@@ -32,39 +40,81 @@ describe('KnexDriver', () => {
32
40
  expect(driver).toBeInstanceOf(KnexDriver);
33
41
  });
34
42
 
35
- it('should find objects', async () => {
36
- const query = {
43
+ it('should find objects with filters', async () => {
44
+ const query: UnifiedQuery = {
37
45
  fields: ['name', 'age'],
38
46
  filters: [['age', '>', 18]],
39
- sort: [['name', 'asc']],
40
- skip: 10,
41
- limit: 5
47
+ sort: [['name', 'asc']]
42
48
  };
43
- await driver.find('users', query);
49
+ const results = await driver.find('users', query);
44
50
 
45
- expect(mockBuilder.select).toHaveBeenCalledWith(['name', 'age']);
46
- expect(mockBuilder.where).toHaveBeenCalledWith('age', '>', 18);
47
- expect(mockBuilder.orderBy).toHaveBeenCalledWith('name', 'asc');
48
- expect(mockBuilder.offset).toHaveBeenCalledWith(10);
49
- expect(mockBuilder.limit).toHaveBeenCalledWith(5);
51
+ expect(results.length).toBe(2);
52
+ expect(results.map((r: any) => r.name)).toEqual(['Alice', 'Charlie']);
50
53
  });
51
54
 
52
- it('should apply OR filters correctly', async () => {
53
- const query = {
54
- filters: [['age', '>', 18], 'or', ['role', '=', 'admin']]
55
+ it('should apply simple AND/OR logic', async () => {
56
+ // age = 17 OR age > 29
57
+ const query: UnifiedQuery = {
58
+ filters: [
59
+ ['age', '=', 17],
60
+ 'or',
61
+ ['age', '>', 29]
62
+ ]
55
63
  };
56
- await driver.find('users', query);
57
- expect(mockBuilder.where).toHaveBeenCalledWith('age', '>', 18);
58
- expect(mockBuilder.orWhere).toHaveBeenCalledWith('role', 'admin');
64
+ const results = await driver.find('users', query);
65
+ const names = results.map((r: any) => r.name).sort();
66
+ expect(names).toEqual(['Bob', 'Charlie', 'Dave']);
59
67
  });
60
68
 
61
69
  it('should find one object by id', async () => {
62
- mockFirst.mockResolvedValueOnce({ id: 1, name: 'Alice' });
63
- const result = await driver.findOne('users', 1);
64
- expect(mockBuilder.where).toHaveBeenCalledWith('id', 1);
65
- expect(mockFirst).toHaveBeenCalled();
66
- expect(result).toEqual({ id: 1, name: 'Alice' });
70
+ // First get an ID
71
+ const [alice] = await driver.find('users', { filters: [['name', '=', 'Alice']] });
72
+ expect(alice).toBeDefined();
73
+
74
+ const fetched = await driver.findOne('users', alice.id);
75
+ expect(fetched).toBeDefined();
76
+ expect(fetched.name).toBe('Alice');
77
+ });
78
+
79
+ it('should create an object', async () => {
80
+ const newItem = { name: 'Eve', age: 22 };
81
+ await driver.create('users', newItem);
82
+
83
+ const [eve] = await driver.find('users', { filters: [['name', '=', 'Eve']] });
84
+ expect(eve).toBeDefined();
85
+ expect(eve.age).toBe(22);
67
86
  });
68
87
 
69
- // Add more tests for insert, update, delete when implemented in src
88
+ it('should update an object', async () => {
89
+ const [bob] = await driver.find('users', { filters: [['name', '=', 'Bob']] });
90
+ await driver.update('users', bob.id, { age: 18 });
91
+
92
+ const updated = await driver.findOne('users', bob.id);
93
+ expect(updated.age).toBe(18);
94
+ });
95
+
96
+ it('should delete an object', async () => {
97
+ const [charlie] = await driver.find('users', { filters: [['name', '=', 'Charlie']] });
98
+ await driver.delete('users', charlie.id);
99
+
100
+ const deleted = await driver.findOne('users', charlie.id);
101
+ expect(deleted).toBeUndefined();
102
+ });
103
+
104
+ it('should count objects', async () => {
105
+ const count = await driver.count('users', [['age', '=', 17]]);
106
+ expect(count).toBe(2);
107
+ });
108
+
109
+ it('should map _id to id if provided', async () => {
110
+ const newItemWithId = { _id: 'custom-id', name: 'Frank', age: 40 };
111
+ const created = await driver.create('users', newItemWithId);
112
+
113
+ expect(created.id).toBe('custom-id');
114
+ // Check if we can retrieve it by id
115
+ const fetched = await driver.findOne('users', 'custom-id');
116
+ expect(fetched).toBeDefined();
117
+ expect(fetched.name).toBe('Frank');
118
+ });
70
119
  });
120
+