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