@mostajs/orm 1.0.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +548 -0
  3. package/dist/core/base-repository.d.ts +26 -0
  4. package/dist/core/base-repository.js +82 -0
  5. package/dist/core/config.d.ts +62 -0
  6. package/dist/core/config.js +116 -0
  7. package/dist/core/errors.d.ts +30 -0
  8. package/dist/core/errors.js +49 -0
  9. package/dist/core/factory.d.ts +41 -0
  10. package/dist/core/factory.js +142 -0
  11. package/dist/core/normalizer.d.ts +9 -0
  12. package/dist/core/normalizer.js +19 -0
  13. package/dist/core/registry.d.ts +43 -0
  14. package/dist/core/registry.js +78 -0
  15. package/dist/core/types.d.ts +228 -0
  16. package/dist/core/types.js +5 -0
  17. package/dist/dialects/abstract-sql.dialect.d.ts +113 -0
  18. package/dist/dialects/abstract-sql.dialect.js +1071 -0
  19. package/dist/dialects/cockroachdb.dialect.d.ts +2 -0
  20. package/dist/dialects/cockroachdb.dialect.js +23 -0
  21. package/dist/dialects/db2.dialect.d.ts +2 -0
  22. package/dist/dialects/db2.dialect.js +190 -0
  23. package/dist/dialects/hana.dialect.d.ts +2 -0
  24. package/dist/dialects/hana.dialect.js +199 -0
  25. package/dist/dialects/hsqldb.dialect.d.ts +2 -0
  26. package/dist/dialects/hsqldb.dialect.js +114 -0
  27. package/dist/dialects/mariadb.dialect.d.ts +2 -0
  28. package/dist/dialects/mariadb.dialect.js +87 -0
  29. package/dist/dialects/mongo.dialect.d.ts +2 -0
  30. package/dist/dialects/mongo.dialect.js +480 -0
  31. package/dist/dialects/mssql.dialect.d.ts +27 -0
  32. package/dist/dialects/mssql.dialect.js +127 -0
  33. package/dist/dialects/mysql.dialect.d.ts +24 -0
  34. package/dist/dialects/mysql.dialect.js +101 -0
  35. package/dist/dialects/oracle.dialect.d.ts +2 -0
  36. package/dist/dialects/oracle.dialect.js +206 -0
  37. package/dist/dialects/postgres.dialect.d.ts +26 -0
  38. package/dist/dialects/postgres.dialect.js +105 -0
  39. package/dist/dialects/spanner.dialect.d.ts +2 -0
  40. package/dist/dialects/spanner.dialect.js +259 -0
  41. package/dist/dialects/sqlite.dialect.d.ts +2 -0
  42. package/dist/dialects/sqlite.dialect.js +1027 -0
  43. package/dist/dialects/sybase.dialect.d.ts +2 -0
  44. package/dist/dialects/sybase.dialect.js +119 -0
  45. package/dist/index.d.ts +8 -0
  46. package/dist/index.js +26 -0
  47. package/docs/api-reference.md +1009 -0
  48. package/docs/dialects.md +673 -0
  49. package/docs/tutorial.md +846 -0
  50. package/package.json +91 -0
@@ -0,0 +1,1027 @@
1
+ // SQLite Dialect — implements IDialect with better-sqlite3
2
+ // Equivalent to org.hibernate.dialect.SQLiteDialect
3
+ // Author: Dr Hamid MADANI drmdh@msn.com
4
+ import Database from 'better-sqlite3';
5
+ import { randomUUID } from 'crypto';
6
+ import { readFileSync } from 'fs';
7
+ import { resolve, dirname } from 'path';
8
+ import { mkdirSync, existsSync } from 'fs';
9
+ // ============================================================
10
+ // SQL Logging — inspired by hibernate.show_sql / hibernate.format_sql
11
+ // ============================================================
12
+ let showSql = false;
13
+ let formatSql = false;
14
+ function logQuery(operation, table, details) {
15
+ if (!showSql)
16
+ return;
17
+ const prefix = `[DAL:SQLite] ${operation} ${table}`;
18
+ if (formatSql && details) {
19
+ console.log(prefix);
20
+ console.log(JSON.stringify(details, null, 2));
21
+ }
22
+ else if (details) {
23
+ console.log(`${prefix} ${JSON.stringify(details)}`);
24
+ }
25
+ else {
26
+ console.log(prefix);
27
+ }
28
+ }
29
+ // ============================================================
30
+ // Type Mapping — DAL FieldType → SQLite column type
31
+ // ============================================================
32
+ function fieldToSqlType(field) {
33
+ switch (field.type) {
34
+ case 'string': return 'TEXT';
35
+ case 'number': return 'REAL';
36
+ case 'boolean': return 'INTEGER';
37
+ case 'date': return 'TEXT';
38
+ case 'json': return 'TEXT';
39
+ case 'array': return 'TEXT'; // JSON-encoded array
40
+ default: return 'TEXT';
41
+ }
42
+ }
43
+ // ============================================================
44
+ // Value Serialization — JS values → SQLite-compatible values
45
+ // ============================================================
46
+ function serializeValue(value, field) {
47
+ if (value === undefined || value === null)
48
+ return null;
49
+ if (field?.type === 'boolean' || typeof value === 'boolean') {
50
+ return value ? 1 : 0;
51
+ }
52
+ if (field?.type === 'date' || value instanceof Date) {
53
+ if (value === 'now')
54
+ return new Date().toISOString();
55
+ if (value instanceof Date)
56
+ return value.toISOString();
57
+ if (typeof value === 'string')
58
+ return value;
59
+ return null;
60
+ }
61
+ if (field?.type === 'json' || field?.type === 'array') {
62
+ return typeof value === 'string' ? value : JSON.stringify(value);
63
+ }
64
+ if (Array.isArray(value)) {
65
+ return JSON.stringify(value);
66
+ }
67
+ if (typeof value === 'object' && value !== null) {
68
+ return JSON.stringify(value);
69
+ }
70
+ return value;
71
+ }
72
+ function deserializeRow(row, schema) {
73
+ if (!row)
74
+ return row;
75
+ const result = {};
76
+ for (const [key, val] of Object.entries(row)) {
77
+ if (key === 'id') {
78
+ result.id = val;
79
+ continue;
80
+ }
81
+ const fieldDef = schema.fields[key];
82
+ const relDef = schema.relations[key];
83
+ if (fieldDef) {
84
+ result[key] = deserializeField(val, fieldDef);
85
+ }
86
+ else if (relDef) {
87
+ // many-to-many: no column in entity table, handled by junction table
88
+ if (relDef.type === 'many-to-many') {
89
+ result[key] = [];
90
+ continue;
91
+ }
92
+ // Relation column — stored as TEXT (ID or JSON array of IDs)
93
+ if (relDef.type === 'one-to-many') {
94
+ result[key] = parseJsonSafe(val, []);
95
+ }
96
+ else {
97
+ result[key] = val;
98
+ }
99
+ }
100
+ else if (key === 'createdAt' || key === 'updatedAt') {
101
+ result[key] = val;
102
+ }
103
+ else {
104
+ result[key] = val;
105
+ }
106
+ }
107
+ // Ensure many-to-many relations default to [] even when no column exists in SQL row
108
+ for (const [relName, relDef] of Object.entries(schema.relations)) {
109
+ if (relDef.type === 'many-to-many' && !(relName in result)) {
110
+ result[relName] = [];
111
+ }
112
+ }
113
+ return result;
114
+ }
115
+ function deserializeField(val, field) {
116
+ if (val === null || val === undefined)
117
+ return val;
118
+ switch (field.type) {
119
+ case 'boolean':
120
+ return val === 1 || val === true;
121
+ case 'date':
122
+ return val;
123
+ case 'json':
124
+ return parseJsonSafe(val, val);
125
+ case 'array':
126
+ return parseJsonSafe(val, []);
127
+ case 'number':
128
+ return val;
129
+ default:
130
+ return val;
131
+ }
132
+ }
133
+ function parseJsonSafe(val, fallback) {
134
+ if (val === null || val === undefined)
135
+ return fallback;
136
+ if (typeof val !== 'string')
137
+ return val;
138
+ try {
139
+ return JSON.parse(val);
140
+ }
141
+ catch {
142
+ return fallback;
143
+ }
144
+ }
145
+ function translateFilter(filter, schema) {
146
+ const conditions = [];
147
+ const params = [];
148
+ for (const [key, value] of Object.entries(filter)) {
149
+ if (key === '$or' && Array.isArray(value)) {
150
+ const orClauses = value.map(f => translateFilter(f, schema));
151
+ if (orClauses.length > 0) {
152
+ const orSql = orClauses.map(c => `(${c.sql})`).join(' OR ');
153
+ conditions.push(`(${orSql})`);
154
+ for (const c of orClauses)
155
+ params.push(...c.params);
156
+ }
157
+ continue;
158
+ }
159
+ if (key === '$and' && Array.isArray(value)) {
160
+ const andClauses = value.map(f => translateFilter(f, schema));
161
+ if (andClauses.length > 0) {
162
+ const andSql = andClauses.map(c => `(${c.sql})`).join(' AND ');
163
+ conditions.push(`(${andSql})`);
164
+ for (const c of andClauses)
165
+ params.push(...c.params);
166
+ }
167
+ continue;
168
+ }
169
+ // Quoted column name to handle reserved words
170
+ const col = quoteCol(key);
171
+ if (value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
172
+ // FilterOperator
173
+ const op = value;
174
+ if ('$eq' in op) {
175
+ if (op.$eq === null) {
176
+ conditions.push(`${col} IS NULL`);
177
+ }
178
+ else {
179
+ conditions.push(`${col} = ?`);
180
+ params.push(serializeForFilter(op.$eq, key, schema));
181
+ }
182
+ }
183
+ if ('$ne' in op) {
184
+ if (op.$ne === null) {
185
+ conditions.push(`${col} IS NOT NULL`);
186
+ }
187
+ else {
188
+ conditions.push(`${col} != ?`);
189
+ params.push(serializeForFilter(op.$ne, key, schema));
190
+ }
191
+ }
192
+ if ('$gt' in op) {
193
+ conditions.push(`${col} > ?`);
194
+ params.push(serializeForFilter(op.$gt, key, schema));
195
+ }
196
+ if ('$gte' in op) {
197
+ conditions.push(`${col} >= ?`);
198
+ params.push(serializeForFilter(op.$gte, key, schema));
199
+ }
200
+ if ('$lt' in op) {
201
+ conditions.push(`${col} < ?`);
202
+ params.push(serializeForFilter(op.$lt, key, schema));
203
+ }
204
+ if ('$lte' in op) {
205
+ conditions.push(`${col} <= ?`);
206
+ params.push(serializeForFilter(op.$lte, key, schema));
207
+ }
208
+ if ('$in' in op && Array.isArray(op.$in)) {
209
+ const placeholders = op.$in.map(() => '?').join(', ');
210
+ conditions.push(`${col} IN (${placeholders})`);
211
+ for (const v of op.$in)
212
+ params.push(serializeForFilter(v, key, schema));
213
+ }
214
+ if ('$nin' in op && Array.isArray(op.$nin)) {
215
+ const placeholders = op.$nin.map(() => '?').join(', ');
216
+ conditions.push(`${col} NOT IN (${placeholders})`);
217
+ for (const v of op.$nin)
218
+ params.push(serializeForFilter(v, key, schema));
219
+ }
220
+ if ('$regex' in op) {
221
+ // SQLite LIKE approximation: convert basic regex patterns
222
+ const pattern = regexToLike(op.$regex);
223
+ conditions.push(`${col} LIKE ?`);
224
+ params.push(pattern);
225
+ }
226
+ if ('$exists' in op) {
227
+ if (op.$exists) {
228
+ conditions.push(`${col} IS NOT NULL`);
229
+ }
230
+ else {
231
+ conditions.push(`${col} IS NULL`);
232
+ }
233
+ }
234
+ }
235
+ else {
236
+ // Direct equality
237
+ if (value === null) {
238
+ conditions.push(`${col} IS NULL`);
239
+ }
240
+ else {
241
+ conditions.push(`${col} = ?`);
242
+ params.push(serializeForFilter(value, key, schema));
243
+ }
244
+ }
245
+ }
246
+ return {
247
+ sql: conditions.length > 0 ? conditions.join(' AND ') : '1=1',
248
+ params,
249
+ };
250
+ }
251
+ function serializeForFilter(value, fieldName, schema) {
252
+ const field = schema.fields[fieldName];
253
+ if (field)
254
+ return serializeValue(value, field);
255
+ // For relation fields or unknown, pass through
256
+ if (typeof value === 'boolean')
257
+ return value ? 1 : 0;
258
+ if (value instanceof Date)
259
+ return value.toISOString();
260
+ return value;
261
+ }
262
+ /**
263
+ * Convert basic regex patterns to SQLite LIKE patterns.
264
+ * Handles common cases: ^prefix, suffix$, .*contains.*
265
+ */
266
+ function regexToLike(regex) {
267
+ let pattern = regex;
268
+ // Remove regex anchors and translate
269
+ const hasStart = pattern.startsWith('^');
270
+ const hasEnd = pattern.endsWith('$');
271
+ pattern = pattern.replace(/^\^/, '').replace(/\$$/, '');
272
+ // Replace .* with %
273
+ pattern = pattern.replace(/\.\*/g, '%');
274
+ // Replace . with _
275
+ pattern = pattern.replace(/\./g, '_');
276
+ // Escape SQLite LIKE special chars that aren't our wildcards
277
+ // (We already converted . and .* so remaining regex chars are literal)
278
+ if (!hasStart)
279
+ pattern = `%${pattern}`;
280
+ if (!hasEnd)
281
+ pattern = `${pattern}%`;
282
+ return pattern;
283
+ }
284
+ function quoteCol(name) {
285
+ // Quote column names to handle reserved words like "order"
286
+ return `"${name}"`;
287
+ }
288
+ // ============================================================
289
+ // Query Building Helpers
290
+ // ============================================================
291
+ function buildSelectColumns(schema, options) {
292
+ if (options?.select && options.select.length > 0) {
293
+ const cols = ['id', ...options.select.filter(f => f !== 'id')];
294
+ return cols.map(quoteCol).join(', ');
295
+ }
296
+ if (options?.exclude && options.exclude.length > 0) {
297
+ const allCols = getAllColumns(schema);
298
+ const filtered = allCols.filter(c => !options.exclude.includes(c));
299
+ return filtered.map(quoteCol).join(', ');
300
+ }
301
+ return '*';
302
+ }
303
+ function getAllColumns(schema) {
304
+ const cols = ['id'];
305
+ cols.push(...Object.keys(schema.fields));
306
+ // Skip many-to-many relations (no column in entity table)
307
+ for (const [name, rel] of Object.entries(schema.relations)) {
308
+ if (rel.type !== 'many-to-many') {
309
+ cols.push(name);
310
+ }
311
+ }
312
+ if (schema.timestamps) {
313
+ cols.push('createdAt', 'updatedAt');
314
+ }
315
+ return cols;
316
+ }
317
+ function buildOrderBy(options) {
318
+ if (!options?.sort)
319
+ return '';
320
+ const clauses = Object.entries(options.sort)
321
+ .map(([field, dir]) => `${quoteCol(field)} ${dir === -1 ? 'DESC' : 'ASC'}`);
322
+ return clauses.length > 0 ? ` ORDER BY ${clauses.join(', ')}` : '';
323
+ }
324
+ function buildLimitOffset(options) {
325
+ let sql = '';
326
+ if (options?.limit)
327
+ sql += ` LIMIT ${options.limit}`;
328
+ if (options?.skip)
329
+ sql += ` OFFSET ${options.skip}`;
330
+ return sql;
331
+ }
332
+ // ============================================================
333
+ // Data Preparation — EntitySchema + data → columns/values
334
+ // ============================================================
335
+ function prepareInsertData(schema, data) {
336
+ const columns = ['id'];
337
+ const placeholders = ['?'];
338
+ const id = data.id || randomUUID();
339
+ const values = [id];
340
+ // Fields
341
+ for (const [name, field] of Object.entries(schema.fields)) {
342
+ if (name in data) {
343
+ columns.push(name);
344
+ placeholders.push('?');
345
+ values.push(serializeValue(data[name], field));
346
+ }
347
+ else if (field.default !== undefined) {
348
+ columns.push(name);
349
+ placeholders.push('?');
350
+ const def = field.default === 'now' ? new Date().toISOString() : field.default;
351
+ values.push(serializeValue(def, field));
352
+ }
353
+ }
354
+ // Relations
355
+ for (const [name, rel] of Object.entries(schema.relations)) {
356
+ if (rel.type === 'many-to-many') {
357
+ // Handled by junction table, skip column insert
358
+ continue;
359
+ }
360
+ if (name in data) {
361
+ columns.push(name);
362
+ placeholders.push('?');
363
+ if (rel.type === 'one-to-many') {
364
+ // Array of IDs → JSON
365
+ values.push(JSON.stringify(data[name] ?? []));
366
+ }
367
+ else {
368
+ // Empty string → null for FK columns (avoids FOREIGN KEY constraint failures)
369
+ values.push(data[name] || null);
370
+ }
371
+ }
372
+ else if (rel.type === 'one-to-many') {
373
+ columns.push(name);
374
+ placeholders.push('?');
375
+ values.push('[]');
376
+ }
377
+ }
378
+ // Timestamps
379
+ if (schema.timestamps) {
380
+ const now = new Date().toISOString();
381
+ if (!columns.includes('createdAt')) {
382
+ columns.push('createdAt');
383
+ placeholders.push('?');
384
+ values.push(now);
385
+ }
386
+ if (!columns.includes('updatedAt')) {
387
+ columns.push('updatedAt');
388
+ placeholders.push('?');
389
+ values.push(now);
390
+ }
391
+ }
392
+ return { columns, placeholders, values };
393
+ }
394
+ function prepareUpdateData(schema, data) {
395
+ const setClauses = [];
396
+ const values = [];
397
+ for (const [key, val] of Object.entries(data)) {
398
+ if (key === 'id' || key === '_id')
399
+ continue;
400
+ const field = schema.fields[key];
401
+ const rel = schema.relations[key];
402
+ if (field) {
403
+ setClauses.push(`${quoteCol(key)} = ?`);
404
+ values.push(serializeValue(val, field));
405
+ }
406
+ else if (rel) {
407
+ if (rel.type === 'many-to-many') {
408
+ // Handled by junction table, skip column update
409
+ continue;
410
+ }
411
+ setClauses.push(`${quoteCol(key)} = ?`);
412
+ if (rel.type === 'one-to-many') {
413
+ values.push(JSON.stringify(val ?? []));
414
+ }
415
+ else {
416
+ // Empty string → null for FK columns (avoids FOREIGN KEY constraint failures)
417
+ values.push(val || null);
418
+ }
419
+ }
420
+ else if (key === 'createdAt' || key === 'updatedAt') {
421
+ setClauses.push(`${quoteCol(key)} = ?`);
422
+ values.push(val instanceof Date ? val.toISOString() : val);
423
+ }
424
+ }
425
+ // Auto-update updatedAt
426
+ if (schema.timestamps && !setClauses.some(c => c.startsWith('"updatedAt"'))) {
427
+ setClauses.push(`"updatedAt" = ?`);
428
+ values.push(new Date().toISOString());
429
+ }
430
+ return { setClauses, values };
431
+ }
432
+ // ============================================================
433
+ // DDL Generation — EntitySchema → CREATE TABLE
434
+ // ============================================================
435
+ function generateCreateTable(schema) {
436
+ const cols = [' "id" TEXT PRIMARY KEY'];
437
+ // Fields
438
+ for (const [name, field] of Object.entries(schema.fields)) {
439
+ let colDef = ` ${quoteCol(name)} ${fieldToSqlType(field)}`;
440
+ if (field.required)
441
+ colDef += ' NOT NULL';
442
+ if (field.unique)
443
+ colDef += ' UNIQUE';
444
+ if (field.default !== undefined && field.default !== 'now' && field.default !== null) {
445
+ const defVal = serializeValue(field.default, field);
446
+ if (typeof defVal === 'string')
447
+ colDef += ` DEFAULT '${defVal.replace(/'/g, "''")}'`;
448
+ else if (typeof defVal === 'number')
449
+ colDef += ` DEFAULT ${defVal}`;
450
+ }
451
+ cols.push(colDef);
452
+ }
453
+ // Relations
454
+ for (const [name, rel] of Object.entries(schema.relations)) {
455
+ if (rel.type === 'many-to-many') {
456
+ // Handled by junction table, no column in entity table
457
+ continue;
458
+ }
459
+ if (rel.type === 'one-to-many') {
460
+ cols.push(` ${quoteCol(name)} TEXT DEFAULT '[]'`);
461
+ }
462
+ else {
463
+ let colDef = ` ${quoteCol(name)} TEXT`;
464
+ if (rel.required)
465
+ colDef += ' NOT NULL';
466
+ cols.push(colDef);
467
+ }
468
+ }
469
+ // Timestamps
470
+ if (schema.timestamps) {
471
+ cols.push(' "createdAt" TEXT');
472
+ cols.push(' "updatedAt" TEXT');
473
+ }
474
+ return `CREATE TABLE IF NOT EXISTS "${schema.collection}" (\n${cols.join(',\n')}\n)`;
475
+ }
476
+ function generateIndexes(schema) {
477
+ const statements = [];
478
+ for (let i = 0; i < schema.indexes.length; i++) {
479
+ const idx = schema.indexes[i];
480
+ const fields = Object.entries(idx.fields);
481
+ // Skip text indexes (handled differently in search)
482
+ if (fields.some(([, dir]) => dir === 'text'))
483
+ continue;
484
+ const idxName = `idx_${schema.collection}_${i}`;
485
+ const colDefs = fields.map(([f, dir]) => `${quoteCol(f)} ${dir === 'desc' ? 'DESC' : 'ASC'}`);
486
+ const unique = idx.unique ? 'UNIQUE ' : '';
487
+ statements.push(`CREATE ${unique}INDEX IF NOT EXISTS "${idxName}" ON "${schema.collection}" (${colDefs.join(', ')})`);
488
+ }
489
+ return statements;
490
+ }
491
+ // ============================================================
492
+ // SQLiteDialect — implements IDialect
493
+ // ============================================================
494
+ class SQLiteDialect {
495
+ dialectType = 'sqlite';
496
+ db = null;
497
+ config = null;
498
+ schemas = [];
499
+ async connect(config) {
500
+ this.config = config;
501
+ showSql = config.showSql ?? false;
502
+ formatSql = config.formatSql ?? false;
503
+ // Ensure parent directory exists for file-based DBs
504
+ if (config.uri !== ':memory:') {
505
+ const dbPath = resolve(config.uri);
506
+ const dbDir = dirname(dbPath);
507
+ if (!existsSync(dbDir)) {
508
+ mkdirSync(dbDir, { recursive: true });
509
+ }
510
+ this.db = new Database(dbPath);
511
+ }
512
+ else {
513
+ this.db = new Database(':memory:');
514
+ }
515
+ // WAL mode for better concurrency
516
+ this.db.pragma('journal_mode = WAL');
517
+ this.db.pragma('foreign_keys = ON');
518
+ logQuery('CONNECT', config.uri);
519
+ // hibernate.hbm2ddl.auto=create
520
+ if (config.schemaStrategy === 'create') {
521
+ logQuery('SCHEMA', 'create — dropping existing tables');
522
+ // Get all table names and drop them
523
+ const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
524
+ this.db.pragma('foreign_keys = OFF');
525
+ for (const t of tables) {
526
+ this.db.exec(`DROP TABLE IF EXISTS "${t.name}"`);
527
+ }
528
+ this.db.pragma('foreign_keys = ON');
529
+ }
530
+ }
531
+ async disconnect() {
532
+ if (!this.db)
533
+ return;
534
+ // hibernate.hbm2ddl.auto=create-drop
535
+ if (this.config?.schemaStrategy === 'create-drop') {
536
+ logQuery('SCHEMA', 'create-drop — dropping all tables on shutdown');
537
+ const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
538
+ this.db.pragma('foreign_keys = OFF');
539
+ for (const t of tables) {
540
+ this.db.exec(`DROP TABLE IF EXISTS "${t.name}"`);
541
+ }
542
+ }
543
+ this.db.close();
544
+ this.db = null;
545
+ this.schemas = [];
546
+ logQuery('DISCONNECT', '');
547
+ }
548
+ async testConnection() {
549
+ try {
550
+ if (!this.db)
551
+ return false;
552
+ this.db.prepare('SELECT 1').get();
553
+ return true;
554
+ }
555
+ catch {
556
+ return false;
557
+ }
558
+ }
559
+ // --- Schema management (hibernate.hbm2ddl.auto) ---
560
+ async initSchema(schemas) {
561
+ if (!this.db)
562
+ throw new Error('SQLite not connected');
563
+ this.schemas = schemas;
564
+ const strategy = this.config?.schemaStrategy ?? 'none';
565
+ logQuery('INIT_SCHEMA', `strategy=${strategy}`, { entities: schemas.map(s => s.name) });
566
+ if (strategy === 'none')
567
+ return;
568
+ if (strategy === 'validate') {
569
+ // Check all tables exist
570
+ for (const schema of schemas) {
571
+ const row = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(schema.collection);
572
+ if (!row) {
573
+ throw new Error(`Schema validation failed: table "${schema.collection}" does not exist ` +
574
+ `(entity: ${schema.name}). Set schemaStrategy to "update" or "create".`);
575
+ }
576
+ }
577
+ return;
578
+ }
579
+ // strategy: 'update' or 'create' — create tables + indexes
580
+ // Try to run the migration file first
581
+ const migrationPath = resolve(dirname(new URL(import.meta.url).pathname), '../migrations/sqlite/001-initial.sql');
582
+ if (existsSync(migrationPath)) {
583
+ logQuery('MIGRATION', migrationPath);
584
+ const sql = readFileSync(migrationPath, 'utf-8');
585
+ this.db.exec(sql);
586
+ }
587
+ else {
588
+ // Fall back to dynamic DDL generation from schemas
589
+ for (const schema of schemas) {
590
+ const createSql = generateCreateTable(schema);
591
+ logQuery('DDL', schema.collection, createSql);
592
+ this.db.exec(createSql);
593
+ const indexStatements = generateIndexes(schema);
594
+ for (const stmt of indexStatements) {
595
+ this.db.exec(stmt);
596
+ }
597
+ }
598
+ }
599
+ // Create junction tables for many-to-many relations
600
+ for (const schema of schemas) {
601
+ for (const [, rel] of Object.entries(schema.relations)) {
602
+ if (rel.type === 'many-to-many' && rel.through) {
603
+ const targetSchema = schemas.find(s => s.name === rel.target);
604
+ if (!targetSchema)
605
+ continue;
606
+ const sourceKey = `${schema.name.toLowerCase()}Id`;
607
+ const targetKey = `${rel.target.toLowerCase()}Id`;
608
+ const ddl = `CREATE TABLE IF NOT EXISTS "${rel.through}" (
609
+ "${sourceKey}" TEXT NOT NULL,
610
+ "${targetKey}" TEXT NOT NULL,
611
+ PRIMARY KEY ("${sourceKey}", "${targetKey}"),
612
+ FOREIGN KEY ("${sourceKey}") REFERENCES "${schema.collection}"("id") ON DELETE CASCADE,
613
+ FOREIGN KEY ("${targetKey}") REFERENCES "${targetSchema.collection}"("id") ON DELETE CASCADE
614
+ )`;
615
+ logQuery('DDL_JUNCTION', rel.through, ddl);
616
+ this.db.exec(ddl);
617
+ }
618
+ }
619
+ }
620
+ }
621
+ // --- CRUD ---
622
+ async find(schema, filter, options) {
623
+ const db = this.getDb();
624
+ const where = translateFilter(filter, schema);
625
+ const cols = buildSelectColumns(schema, options);
626
+ const orderBy = buildOrderBy(options);
627
+ const limitOffset = buildLimitOffset(options);
628
+ const sql = `SELECT ${cols} FROM "${schema.collection}" WHERE ${where.sql}${orderBy}${limitOffset}`;
629
+ logQuery('FIND', schema.collection, { sql, params: where.params });
630
+ const rows = db.prepare(sql).all(...where.params);
631
+ return rows.map(row => deserializeRow(row, schema));
632
+ }
633
+ async findOne(schema, filter, options) {
634
+ const db = this.getDb();
635
+ const where = translateFilter(filter, schema);
636
+ const cols = buildSelectColumns(schema, options);
637
+ const orderBy = buildOrderBy(options);
638
+ const sql = `SELECT ${cols} FROM "${schema.collection}" WHERE ${where.sql}${orderBy} LIMIT 1`;
639
+ logQuery('FIND_ONE', schema.collection, { sql, params: where.params });
640
+ const row = db.prepare(sql).get(...where.params);
641
+ return row ? deserializeRow(row, schema) : null;
642
+ }
643
+ async findById(schema, id, options) {
644
+ const db = this.getDb();
645
+ const cols = buildSelectColumns(schema, options);
646
+ const sql = `SELECT ${cols} FROM "${schema.collection}" WHERE "id" = ?`;
647
+ logQuery('FIND_BY_ID', schema.collection, { id });
648
+ const row = db.prepare(sql).get(id);
649
+ return row ? deserializeRow(row, schema) : null;
650
+ }
651
+ async create(schema, data) {
652
+ const db = this.getDb();
653
+ const { columns, placeholders, values } = prepareInsertData(schema, data);
654
+ const sql = `INSERT INTO "${schema.collection}" (${columns.map(quoteCol).join(', ')}) VALUES (${placeholders.join(', ')})`;
655
+ logQuery('CREATE', schema.collection, { sql, values });
656
+ db.prepare(sql).run(...values);
657
+ // Insert junction table rows for many-to-many relations
658
+ const entityId = values[0];
659
+ for (const [relName, rel] of Object.entries(schema.relations)) {
660
+ if (rel.type === 'many-to-many' && rel.through && Array.isArray(data[relName])) {
661
+ const sourceKey = `${schema.name.toLowerCase()}Id`;
662
+ const targetKey = `${rel.target.toLowerCase()}Id`;
663
+ const stmt = db.prepare(`INSERT OR IGNORE INTO "${rel.through}" ("${sourceKey}", "${targetKey}") VALUES (?, ?)`);
664
+ for (const targetId of data[relName]) {
665
+ stmt.run(entityId, targetId);
666
+ }
667
+ }
668
+ }
669
+ // Return the created row
670
+ return this.findById(schema, entityId);
671
+ }
672
+ async update(schema, id, data) {
673
+ const db = this.getDb();
674
+ // Check existence first
675
+ const existing = await this.findById(schema, id);
676
+ if (!existing)
677
+ return null;
678
+ const { setClauses, values } = prepareUpdateData(schema, data);
679
+ if (setClauses.length > 0) {
680
+ const sql = `UPDATE "${schema.collection}" SET ${setClauses.join(', ')} WHERE "id" = ?`;
681
+ values.push(id);
682
+ logQuery('UPDATE', schema.collection, { sql, values });
683
+ db.prepare(sql).run(...values);
684
+ }
685
+ // Replace junction table rows for many-to-many relations
686
+ for (const [relName, rel] of Object.entries(schema.relations)) {
687
+ if (rel.type === 'many-to-many' && rel.through && relName in data) {
688
+ const sourceKey = `${schema.name.toLowerCase()}Id`;
689
+ const targetKey = `${rel.target.toLowerCase()}Id`;
690
+ // Delete existing junction rows
691
+ db.prepare(`DELETE FROM "${rel.through}" WHERE "${sourceKey}" = ?`).run(id);
692
+ // Insert new junction rows
693
+ if (Array.isArray(data[relName])) {
694
+ const stmt = db.prepare(`INSERT OR IGNORE INTO "${rel.through}" ("${sourceKey}", "${targetKey}") VALUES (?, ?)`);
695
+ for (const targetId of data[relName]) {
696
+ stmt.run(id, targetId);
697
+ }
698
+ }
699
+ }
700
+ }
701
+ return this.findById(schema, id);
702
+ }
703
+ async updateMany(schema, filter, data) {
704
+ const db = this.getDb();
705
+ const where = translateFilter(filter, schema);
706
+ const { setClauses, values } = prepareUpdateData(schema, data);
707
+ if (setClauses.length === 0)
708
+ return 0;
709
+ const sql = `UPDATE "${schema.collection}" SET ${setClauses.join(', ')} WHERE ${where.sql}`;
710
+ const allValues = [...values, ...where.params];
711
+ logQuery('UPDATE_MANY', schema.collection, { sql, params: allValues });
712
+ const result = db.prepare(sql).run(...allValues);
713
+ return result.changes;
714
+ }
715
+ async delete(schema, id) {
716
+ const db = this.getDb();
717
+ const sql = `DELETE FROM "${schema.collection}" WHERE "id" = ?`;
718
+ logQuery('DELETE', schema.collection, { id });
719
+ const result = db.prepare(sql).run(id);
720
+ return result.changes > 0;
721
+ }
722
+ async deleteMany(schema, filter) {
723
+ const db = this.getDb();
724
+ const where = translateFilter(filter, schema);
725
+ const sql = `DELETE FROM "${schema.collection}" WHERE ${where.sql}`;
726
+ logQuery('DELETE_MANY', schema.collection, { sql, params: where.params });
727
+ const result = db.prepare(sql).run(...where.params);
728
+ return result.changes;
729
+ }
730
+ // --- Queries ---
731
+ async count(schema, filter) {
732
+ const db = this.getDb();
733
+ const where = translateFilter(filter, schema);
734
+ const sql = `SELECT COUNT(*) as cnt FROM "${schema.collection}" WHERE ${where.sql}`;
735
+ logQuery('COUNT', schema.collection, { sql, params: where.params });
736
+ const row = db.prepare(sql).get(...where.params);
737
+ return row.cnt;
738
+ }
739
+ async distinct(schema, field, filter) {
740
+ const db = this.getDb();
741
+ const where = translateFilter(filter, schema);
742
+ const sql = `SELECT DISTINCT ${quoteCol(field)} FROM "${schema.collection}" WHERE ${where.sql}`;
743
+ logQuery('DISTINCT', schema.collection, { sql, params: where.params });
744
+ const rows = db.prepare(sql).all(...where.params);
745
+ return rows.map(r => {
746
+ const val = r[field];
747
+ const fieldDef = schema.fields[field];
748
+ if (fieldDef)
749
+ return deserializeField(val, fieldDef);
750
+ return val;
751
+ });
752
+ }
753
+ async aggregate(schema, stages) {
754
+ const db = this.getDb();
755
+ // Build SQL from aggregate stages
756
+ // Strategy: translate $match → WHERE, $group → GROUP BY, $sort → ORDER BY, $limit → LIMIT
757
+ let whereClause = '1=1';
758
+ let whereParams = [];
759
+ let groupBy = null;
760
+ let selectCols = [];
761
+ let orderBy = '';
762
+ let limit = '';
763
+ for (const stage of stages) {
764
+ if ('$match' in stage) {
765
+ const w = translateFilter(stage.$match, schema);
766
+ whereClause = w.sql;
767
+ whereParams = w.params;
768
+ }
769
+ else if ('$group' in stage) {
770
+ const group = stage;
771
+ const groupDef = group.$group;
772
+ selectCols = [];
773
+ for (const [key, val] of Object.entries(groupDef)) {
774
+ if (key === '_by') {
775
+ if (val) {
776
+ groupBy = quoteCol(val);
777
+ selectCols.push(`${groupBy} as "_id"`);
778
+ }
779
+ else {
780
+ selectCols.push(`NULL as "_id"`);
781
+ }
782
+ }
783
+ else if (val && typeof val === 'object') {
784
+ const acc = val;
785
+ if ('$sum' in acc) {
786
+ if (typeof acc.$sum === 'string') {
787
+ selectCols.push(`SUM(${quoteCol(acc.$sum.replace(/^\$/, ''))}) as ${quoteCol(key)}`);
788
+ }
789
+ else {
790
+ selectCols.push(`SUM(${acc.$sum}) as ${quoteCol(key)}`);
791
+ }
792
+ }
793
+ if ('$count' in acc) {
794
+ selectCols.push(`COUNT(*) as ${quoteCol(key)}`);
795
+ }
796
+ if ('$avg' in acc && typeof acc.$avg === 'string') {
797
+ selectCols.push(`AVG(${quoteCol(acc.$avg.replace(/^\$/, ''))}) as ${quoteCol(key)}`);
798
+ }
799
+ if ('$min' in acc && typeof acc.$min === 'string') {
800
+ selectCols.push(`MIN(${quoteCol(acc.$min.replace(/^\$/, ''))}) as ${quoteCol(key)}`);
801
+ }
802
+ if ('$max' in acc && typeof acc.$max === 'string') {
803
+ selectCols.push(`MAX(${quoteCol(acc.$max.replace(/^\$/, ''))}) as ${quoteCol(key)}`);
804
+ }
805
+ }
806
+ }
807
+ }
808
+ else if ('$sort' in stage) {
809
+ const sortClauses = Object.entries(stage.$sort)
810
+ .map(([f, dir]) => `${quoteCol(f)} ${dir === -1 ? 'DESC' : 'ASC'}`);
811
+ orderBy = ` ORDER BY ${sortClauses.join(', ')}`;
812
+ }
813
+ else if ('$limit' in stage) {
814
+ limit = ` LIMIT ${stage.$limit}`;
815
+ }
816
+ }
817
+ if (selectCols.length === 0) {
818
+ selectCols = ['*'];
819
+ }
820
+ let sql = `SELECT ${selectCols.join(', ')} FROM "${schema.collection}" WHERE ${whereClause}`;
821
+ if (groupBy)
822
+ sql += ` GROUP BY ${groupBy}`;
823
+ sql += orderBy + limit;
824
+ logQuery('AGGREGATE', schema.collection, { sql, params: whereParams });
825
+ const rows = db.prepare(sql).all(...whereParams);
826
+ return rows;
827
+ }
828
+ // --- Relations (N+1 strategy — SELECT principal + 1 query par relation) ---
829
+ async findWithRelations(schema, filter, relations, options) {
830
+ // 1. Main query
831
+ const rows = await this.find(schema, filter, options);
832
+ if (rows.length === 0)
833
+ return [];
834
+ // 2. Populate each relation
835
+ return Promise.all(rows.map(row => this.populateRelations(row, schema, relations)));
836
+ }
837
+ async findByIdWithRelations(schema, id, relations, options) {
838
+ const row = await this.findById(schema, id, options);
839
+ if (!row)
840
+ return null;
841
+ return this.populateRelations(row, schema, relations);
842
+ }
843
+ async populateRelations(row, schema, relations) {
844
+ const result = { ...row };
845
+ for (const relName of relations) {
846
+ const relDef = schema.relations[relName];
847
+ if (!relDef)
848
+ continue;
849
+ // Find the target schema in our stored schemas
850
+ const targetSchema = this.schemas.find(s => s.name === relDef.target);
851
+ if (!targetSchema)
852
+ continue;
853
+ const selectOpts = relDef.select
854
+ ? { select: relDef.select }
855
+ : undefined;
856
+ if (relDef.type === 'many-to-many' && relDef.through) {
857
+ // SELECT from junction table then fetch each related entity
858
+ const db = this.getDb();
859
+ const sourceKey = `${schema.name.toLowerCase()}Id`;
860
+ const targetKey = `${relDef.target.toLowerCase()}Id`;
861
+ const junctionRows = db.prepare(`SELECT "${targetKey}" FROM "${relDef.through}" WHERE "${sourceKey}" = ?`).all(result.id);
862
+ const populated = [];
863
+ for (const jr of junctionRows) {
864
+ const related = await this.findById(targetSchema, jr[targetKey], selectOpts);
865
+ if (related)
866
+ populated.push(related);
867
+ }
868
+ result[relName] = populated;
869
+ }
870
+ else if (relDef.type === 'one-to-many') {
871
+ // The field stores a JSON array of IDs
872
+ const ids = result[relName];
873
+ if (Array.isArray(ids) && ids.length > 0) {
874
+ const populated = [];
875
+ for (const refId of ids) {
876
+ const related = await this.findById(targetSchema, String(refId), selectOpts);
877
+ if (related)
878
+ populated.push(related);
879
+ }
880
+ result[relName] = populated;
881
+ }
882
+ else {
883
+ result[relName] = [];
884
+ }
885
+ }
886
+ else {
887
+ // many-to-one or one-to-one — the field stores a single ID
888
+ const refId = result[relName];
889
+ if (refId) {
890
+ const related = await this.findById(targetSchema, String(refId), selectOpts);
891
+ result[relName] = related ?? refId;
892
+ }
893
+ }
894
+ }
895
+ return result;
896
+ }
897
+ // --- Upsert (equivalent Hibernate saveOrUpdate) ---
898
+ async upsert(schema, filter, data) {
899
+ const existing = await this.findOne(schema, filter);
900
+ if (existing) {
901
+ const updated = await this.update(schema, existing.id, data);
902
+ return updated;
903
+ }
904
+ else {
905
+ return this.create(schema, data);
906
+ }
907
+ }
908
+ // --- Atomic operations ---
909
+ async increment(schema, id, field, amount) {
910
+ const db = this.getDb();
911
+ // Upsert: insert if not exists, increment if exists
912
+ const existing = await this.findById(schema, id);
913
+ if (existing) {
914
+ const sql = `UPDATE "${schema.collection}" SET ${quoteCol(field)} = COALESCE(${quoteCol(field)}, 0) + ?${schema.timestamps ? ', "updatedAt" = ?' : ''} WHERE "id" = ?`;
915
+ const params = [amount];
916
+ if (schema.timestamps)
917
+ params.push(new Date().toISOString());
918
+ params.push(id);
919
+ logQuery('INCREMENT', schema.collection, { id, field, amount });
920
+ db.prepare(sql).run(...params);
921
+ }
922
+ else {
923
+ // Create with this ID and the incremented field
924
+ const data = { id, [field]: amount };
925
+ await this.create(schema, data);
926
+ }
927
+ return (await this.findById(schema, id));
928
+ }
929
+ // --- Array operations (equivalent Hibernate @ElementCollection management) ---
930
+ async addToSet(schema, id, field, value) {
931
+ const row = await this.findById(schema, id);
932
+ if (!row)
933
+ return null;
934
+ // Many-to-many: INSERT into junction table
935
+ const relDef = schema.relations[field];
936
+ if (relDef?.type === 'many-to-many' && relDef.through) {
937
+ const db = this.getDb();
938
+ const sourceKey = `${schema.name.toLowerCase()}Id`;
939
+ const targetKey = `${relDef.target.toLowerCase()}Id`;
940
+ logQuery('ADD_TO_SET_M2M', relDef.through, { id, field, value });
941
+ db.prepare(`INSERT OR IGNORE INTO "${relDef.through}" ("${sourceKey}", "${targetKey}") VALUES (?, ?)`).run(id, value);
942
+ return this.findById(schema, id);
943
+ }
944
+ // Get current array value
945
+ let arr = [];
946
+ const currentVal = row[field];
947
+ if (Array.isArray(currentVal)) {
948
+ arr = [...currentVal];
949
+ }
950
+ // Add only if not present (set semantics)
951
+ const serialized = JSON.stringify(value);
952
+ const exists = arr.some(item => JSON.stringify(item) === serialized);
953
+ if (!exists) {
954
+ arr.push(value);
955
+ const db = this.getDb();
956
+ const sql = `UPDATE "${schema.collection}" SET ${quoteCol(field)} = ?${schema.timestamps ? ', "updatedAt" = ?' : ''} WHERE "id" = ?`;
957
+ const params = [JSON.stringify(arr)];
958
+ if (schema.timestamps)
959
+ params.push(new Date().toISOString());
960
+ params.push(id);
961
+ logQuery('ADD_TO_SET', schema.collection, { id, field, value });
962
+ db.prepare(sql).run(...params);
963
+ }
964
+ return this.findById(schema, id);
965
+ }
966
+ async pull(schema, id, field, value) {
967
+ const row = await this.findById(schema, id);
968
+ if (!row)
969
+ return null;
970
+ // Many-to-many: DELETE from junction table
971
+ const relDef = schema.relations[field];
972
+ if (relDef?.type === 'many-to-many' && relDef.through) {
973
+ const db = this.getDb();
974
+ const sourceKey = `${schema.name.toLowerCase()}Id`;
975
+ const targetKey = `${relDef.target.toLowerCase()}Id`;
976
+ logQuery('PULL_M2M', relDef.through, { id, field, value });
977
+ db.prepare(`DELETE FROM "${relDef.through}" WHERE "${sourceKey}" = ? AND "${targetKey}" = ?`).run(id, value);
978
+ return this.findById(schema, id);
979
+ }
980
+ // Get current array and remove matching element
981
+ let arr = [];
982
+ const currentVal = row[field];
983
+ if (Array.isArray(currentVal)) {
984
+ arr = [...currentVal];
985
+ }
986
+ const serialized = JSON.stringify(value);
987
+ const filtered = arr.filter(item => JSON.stringify(item) !== serialized);
988
+ if (filtered.length !== arr.length) {
989
+ const db = this.getDb();
990
+ const sql = `UPDATE "${schema.collection}" SET ${quoteCol(field)} = ?${schema.timestamps ? ', "updatedAt" = ?' : ''} WHERE "id" = ?`;
991
+ const params = [JSON.stringify(filtered)];
992
+ if (schema.timestamps)
993
+ params.push(new Date().toISOString());
994
+ params.push(id);
995
+ logQuery('PULL', schema.collection, { id, field, value });
996
+ db.prepare(sql).run(...params);
997
+ }
998
+ return this.findById(schema, id);
999
+ }
1000
+ // --- Text search ---
1001
+ async search(schema, query, fields, options) {
1002
+ const db = this.getDb();
1003
+ // Build OR conditions with LIKE for each field (case-insensitive)
1004
+ const conditions = fields.map(f => `${quoteCol(f)} LIKE ?`);
1005
+ const pattern = `%${query}%`;
1006
+ const params = fields.map(() => pattern);
1007
+ const cols = buildSelectColumns(schema, options);
1008
+ const orderBy = buildOrderBy(options);
1009
+ const limitOffset = buildLimitOffset(options);
1010
+ const sql = `SELECT ${cols} FROM "${schema.collection}" WHERE (${conditions.join(' OR ')})${orderBy}${limitOffset}`;
1011
+ logQuery('SEARCH', schema.collection, { sql, query, fields });
1012
+ const rows = db.prepare(sql).all(...params);
1013
+ return rows.map(row => deserializeRow(row, schema));
1014
+ }
1015
+ // --- Private helpers ---
1016
+ getDb() {
1017
+ if (!this.db)
1018
+ throw new Error('SQLite not connected. Call connect() first.');
1019
+ return this.db;
1020
+ }
1021
+ }
1022
+ // ============================================================
1023
+ // Factory export
1024
+ // ============================================================
1025
+ export function createDialect() {
1026
+ return new SQLiteDialect();
1027
+ }