@objectql/driver-knex 1.1.0 → 1.2.1
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/CHANGELOG.md +18 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +287 -46
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/index.ts +276 -41
- package/test/index.test.ts +17 -6
- package/test/schema.test.ts +253 -0
- package/tsconfig.json +1 -1
- package/tsconfig.tsbuildinfo +1 -1
package/src/index.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { Driver } from '@objectql/
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
package/test/index.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { KnexDriver } from '../src';
|
|
2
|
-
import { UnifiedQuery } from '@objectql/
|
|
2
|
+
import { UnifiedQuery } from '@objectql/types';
|
|
3
3
|
|
|
4
4
|
describe('KnexDriver (SQLite Integration)', () => {
|
|
5
5
|
let driver: KnexDriver;
|
|
@@ -17,16 +17,16 @@ describe('KnexDriver (SQLite Integration)', () => {
|
|
|
17
17
|
const k = (driver as any).knex;
|
|
18
18
|
|
|
19
19
|
await k.schema.createTable('users', (t: any) => {
|
|
20
|
-
t.
|
|
20
|
+
t.string('id').primary();
|
|
21
21
|
t.string('name');
|
|
22
22
|
t.integer('age');
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
await k('users').insert([
|
|
26
|
-
{ name: 'Alice', age: 25 },
|
|
27
|
-
{ name: 'Bob', age: 17 },
|
|
28
|
-
{ name: 'Charlie', age: 30 },
|
|
29
|
-
{ name: 'Dave', age: 17 }
|
|
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
30
|
]);
|
|
31
31
|
});
|
|
32
32
|
|
|
@@ -105,5 +105,16 @@ describe('KnexDriver (SQLite Integration)', () => {
|
|
|
105
105
|
const count = await driver.count('users', [['age', '=', 17]]);
|
|
106
106
|
expect(count).toBe(2);
|
|
107
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
|
+
});
|
|
108
119
|
});
|
|
109
120
|
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { KnexDriver } from '../src';
|
|
2
|
+
|
|
3
|
+
describe('KnexDriver Schema Sync (SQLite)', () => {
|
|
4
|
+
let driver: KnexDriver;
|
|
5
|
+
let knexInstance: any;
|
|
6
|
+
|
|
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
|
+
knexInstance = (driver as any).knex;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await knexInstance.destroy();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should create table if not exists', async () => {
|
|
24
|
+
const objects = [{
|
|
25
|
+
name: 'test_obj',
|
|
26
|
+
fields: {
|
|
27
|
+
name: { type: 'string' },
|
|
28
|
+
age: { type: 'integer' }
|
|
29
|
+
}
|
|
30
|
+
}];
|
|
31
|
+
|
|
32
|
+
await driver.init(objects);
|
|
33
|
+
|
|
34
|
+
const exists = await knexInstance.schema.hasTable('test_obj');
|
|
35
|
+
expect(exists).toBe(true);
|
|
36
|
+
|
|
37
|
+
const columns = await knexInstance('test_obj').columnInfo();
|
|
38
|
+
expect(columns).toHaveProperty('id');
|
|
39
|
+
expect(columns).toHaveProperty('created_at');
|
|
40
|
+
expect(columns).toHaveProperty('updated_at');
|
|
41
|
+
expect(columns).toHaveProperty('name');
|
|
42
|
+
expect(columns).toHaveProperty('age');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should add new columns if table exists', async () => {
|
|
46
|
+
// 1. Setup existing table with subset of columns
|
|
47
|
+
await knexInstance.schema.createTable('test_obj', (t: any) => {
|
|
48
|
+
t.string('id').primary();
|
|
49
|
+
t.string('name');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// 2. Insert some data
|
|
53
|
+
await knexInstance('test_obj').insert({ id: '1', name: 'Old Data' });
|
|
54
|
+
|
|
55
|
+
// 3. Init with new fields
|
|
56
|
+
const objects = [{
|
|
57
|
+
name: 'test_obj',
|
|
58
|
+
fields: {
|
|
59
|
+
name: { type: 'string' },
|
|
60
|
+
age: { type: 'integer' }, // New field
|
|
61
|
+
active: { type: 'boolean' } // New field
|
|
62
|
+
}
|
|
63
|
+
}];
|
|
64
|
+
|
|
65
|
+
await driver.init(objects);
|
|
66
|
+
|
|
67
|
+
// 4. Verify columns
|
|
68
|
+
const columns = await knexInstance('test_obj').columnInfo();
|
|
69
|
+
expect(columns).toHaveProperty('age');
|
|
70
|
+
expect(columns).toHaveProperty('active');
|
|
71
|
+
|
|
72
|
+
// 5. Verify data is intact
|
|
73
|
+
const row = await knexInstance('test_obj').where('id', '1').first();
|
|
74
|
+
expect(row.name).toBe('Old Data');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should not delete existing columns', async () => {
|
|
78
|
+
// 1. Setup table with extra column
|
|
79
|
+
await knexInstance.schema.createTable('test_obj', (t: any) => {
|
|
80
|
+
t.string('id').primary();
|
|
81
|
+
t.string('name');
|
|
82
|
+
t.string('extra_column'); // Should stay
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// 2. Init with only 'name'
|
|
86
|
+
const objects = [{
|
|
87
|
+
name: 'test_obj',
|
|
88
|
+
fields: {
|
|
89
|
+
name: { type: 'string' }
|
|
90
|
+
}
|
|
91
|
+
}];
|
|
92
|
+
|
|
93
|
+
await driver.init(objects);
|
|
94
|
+
|
|
95
|
+
const columns = await knexInstance('test_obj').columnInfo();
|
|
96
|
+
expect(columns).toHaveProperty('name');
|
|
97
|
+
expect(columns).toHaveProperty('extra_column'); // Preservation check
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should not fail if table creation is repeated', async () => {
|
|
101
|
+
const objects = [{
|
|
102
|
+
name: 'test_obj',
|
|
103
|
+
fields: {
|
|
104
|
+
name: { type: 'string' }
|
|
105
|
+
}
|
|
106
|
+
}];
|
|
107
|
+
|
|
108
|
+
// First init
|
|
109
|
+
await driver.init(objects);
|
|
110
|
+
|
|
111
|
+
// Second init (should be idempotent-ish, or just skip creation)
|
|
112
|
+
await driver.init(objects);
|
|
113
|
+
|
|
114
|
+
const exists = await knexInstance.schema.hasTable('test_obj');
|
|
115
|
+
expect(exists).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should create json column for multiple=true fields', async () => {
|
|
119
|
+
const objects = [{
|
|
120
|
+
name: 'multi_test',
|
|
121
|
+
fields: {
|
|
122
|
+
tags: { type: 'select', multiple: true } as any,
|
|
123
|
+
users: { type: 'lookup', reference_to: 'user', multiple: true } as any
|
|
124
|
+
}
|
|
125
|
+
}];
|
|
126
|
+
|
|
127
|
+
await driver.init(objects);
|
|
128
|
+
|
|
129
|
+
const columns = await knexInstance('multi_test').columnInfo();
|
|
130
|
+
// Types in SQLite might be generic, but verifying we can insert/read array is best.
|
|
131
|
+
|
|
132
|
+
// Try inserting array data
|
|
133
|
+
await driver.create('multi_test', {
|
|
134
|
+
tags: ['a', 'b'],
|
|
135
|
+
users: ['u1', 'u2']
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const results = await driver.find('multi_test', {});
|
|
139
|
+
const row = results[0];
|
|
140
|
+
|
|
141
|
+
// Driver should automatically parse JSON columns for SQLite
|
|
142
|
+
expect(row.tags).toEqual(['a', 'b']);
|
|
143
|
+
expect(row.users).toEqual(['u1', 'u2']);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should create percent column', async () => {
|
|
147
|
+
const objects = [{
|
|
148
|
+
name: 'percent_test',
|
|
149
|
+
fields: {
|
|
150
|
+
completion: { type: 'percent' } as any
|
|
151
|
+
}
|
|
152
|
+
}];
|
|
153
|
+
|
|
154
|
+
await driver.init(objects);
|
|
155
|
+
|
|
156
|
+
const columns = await knexInstance('percent_test').columnInfo();
|
|
157
|
+
expect(columns).toHaveProperty('completion');
|
|
158
|
+
|
|
159
|
+
// Insert a percentage
|
|
160
|
+
await driver.create('percent_test', { completion: 0.85 });
|
|
161
|
+
const res = await driver.find('percent_test', {});
|
|
162
|
+
expect(res[0].completion).toBe(0.85);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should handle special fields (formula, summary, auto_number)', async () => {
|
|
166
|
+
const objects = [{
|
|
167
|
+
name: 'special_fields_test',
|
|
168
|
+
fields: {
|
|
169
|
+
// Formula should NOT create a column
|
|
170
|
+
total: { type: 'formula', expression: 'price * qty', data_type: 'number' } as any,
|
|
171
|
+
// Summary should create a numeric column
|
|
172
|
+
child_count: { type: 'summary', summary_object: 'child_obj', summary_type: 'count' } as any,
|
|
173
|
+
// Auto Number should create a string column
|
|
174
|
+
invoice_no: { type: 'auto_number', auto_number_format: 'INV-{0000}' } as any
|
|
175
|
+
}
|
|
176
|
+
}];
|
|
177
|
+
|
|
178
|
+
await driver.init(objects);
|
|
179
|
+
|
|
180
|
+
const columns = await knexInstance('special_fields_test').columnInfo();
|
|
181
|
+
|
|
182
|
+
expect(columns).not.toHaveProperty('total');
|
|
183
|
+
expect(columns).toHaveProperty('child_count');
|
|
184
|
+
expect(columns).toHaveProperty('invoice_no');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should create database constraints (unique, required)', async () => {
|
|
188
|
+
const objects = [{
|
|
189
|
+
name: 'constraint_test',
|
|
190
|
+
fields: {
|
|
191
|
+
unique_field: { type: 'string', unique: true } as any,
|
|
192
|
+
required_field: { type: 'string', required: true } as any
|
|
193
|
+
}
|
|
194
|
+
}];
|
|
195
|
+
|
|
196
|
+
await driver.init(objects);
|
|
197
|
+
|
|
198
|
+
// Verify Unique using negative test?
|
|
199
|
+
// SQLite enforces unique.
|
|
200
|
+
await driver.create('constraint_test', { unique_field: 'u1', required_field: 'r1' });
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
await driver.create('constraint_test', { unique_field: 'u1', required_field: 'r2' });
|
|
204
|
+
fail('Should throw error for unique violation');
|
|
205
|
+
} catch (e: any) {
|
|
206
|
+
expect(e.message).toMatch(/UNIQUE constraint failed|duplicate key value/);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
await driver.create('constraint_test', { unique_field: 'u2' });
|
|
211
|
+
fail('Should throw error for not null violation');
|
|
212
|
+
} catch (e: any) {
|
|
213
|
+
expect(e.message).toMatch(/NOT NULL constraint failed|null value in column/);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should handle new field types (email, file, location)', async () => {
|
|
218
|
+
const objects = [{
|
|
219
|
+
name: 'new_types_test',
|
|
220
|
+
fields: {
|
|
221
|
+
email: { type: 'email' } as any,
|
|
222
|
+
profile_pic: { type: 'image' } as any,
|
|
223
|
+
resume: { type: 'file' } as any,
|
|
224
|
+
office_loc: { type: 'location' } as any,
|
|
225
|
+
work_hours: { type: 'time' } as any
|
|
226
|
+
}
|
|
227
|
+
}];
|
|
228
|
+
|
|
229
|
+
await driver.init(objects);
|
|
230
|
+
const cols = await knexInstance('new_types_test').columnInfo();
|
|
231
|
+
|
|
232
|
+
expect(cols).toHaveProperty('email');
|
|
233
|
+
// File/Image/Location/Avatar are stored as JSON in KnexDriver mapping
|
|
234
|
+
// But columnInfo might report 'text' for sqlite or 'json' for postgres.
|
|
235
|
+
// We just ensure they were created.
|
|
236
|
+
expect(cols).toHaveProperty('profile_pic');
|
|
237
|
+
expect(cols).toHaveProperty('resume');
|
|
238
|
+
|
|
239
|
+
// Test Insert for complex types
|
|
240
|
+
await driver.create('new_types_test', {
|
|
241
|
+
email: 'test@example.com',
|
|
242
|
+
profile_pic: { url: 'http://img.com/1.png' },
|
|
243
|
+
office_loc: { lat: 10, lng: 20 },
|
|
244
|
+
work_hours: '09:00:00'
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const res = await driver.find('new_types_test', {});
|
|
248
|
+
const row = res[0];
|
|
249
|
+
|
|
250
|
+
expect(row.email).toBe('test@example.com');
|
|
251
|
+
expect(row.office_loc).toEqual({ lat: 10, lng: 20 }); // Auto-parsed by driver logic
|
|
252
|
+
});
|
|
253
|
+
});
|