@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.
- package/LICENSE +21 -0
- package/README.md +548 -0
- package/dist/core/base-repository.d.ts +26 -0
- package/dist/core/base-repository.js +82 -0
- package/dist/core/config.d.ts +62 -0
- package/dist/core/config.js +116 -0
- package/dist/core/errors.d.ts +30 -0
- package/dist/core/errors.js +49 -0
- package/dist/core/factory.d.ts +41 -0
- package/dist/core/factory.js +142 -0
- package/dist/core/normalizer.d.ts +9 -0
- package/dist/core/normalizer.js +19 -0
- package/dist/core/registry.d.ts +43 -0
- package/dist/core/registry.js +78 -0
- package/dist/core/types.d.ts +228 -0
- package/dist/core/types.js +5 -0
- package/dist/dialects/abstract-sql.dialect.d.ts +113 -0
- package/dist/dialects/abstract-sql.dialect.js +1071 -0
- package/dist/dialects/cockroachdb.dialect.d.ts +2 -0
- package/dist/dialects/cockroachdb.dialect.js +23 -0
- package/dist/dialects/db2.dialect.d.ts +2 -0
- package/dist/dialects/db2.dialect.js +190 -0
- package/dist/dialects/hana.dialect.d.ts +2 -0
- package/dist/dialects/hana.dialect.js +199 -0
- package/dist/dialects/hsqldb.dialect.d.ts +2 -0
- package/dist/dialects/hsqldb.dialect.js +114 -0
- package/dist/dialects/mariadb.dialect.d.ts +2 -0
- package/dist/dialects/mariadb.dialect.js +87 -0
- package/dist/dialects/mongo.dialect.d.ts +2 -0
- package/dist/dialects/mongo.dialect.js +480 -0
- package/dist/dialects/mssql.dialect.d.ts +27 -0
- package/dist/dialects/mssql.dialect.js +127 -0
- package/dist/dialects/mysql.dialect.d.ts +24 -0
- package/dist/dialects/mysql.dialect.js +101 -0
- package/dist/dialects/oracle.dialect.d.ts +2 -0
- package/dist/dialects/oracle.dialect.js +206 -0
- package/dist/dialects/postgres.dialect.d.ts +26 -0
- package/dist/dialects/postgres.dialect.js +105 -0
- package/dist/dialects/spanner.dialect.d.ts +2 -0
- package/dist/dialects/spanner.dialect.js +259 -0
- package/dist/dialects/sqlite.dialect.d.ts +2 -0
- package/dist/dialects/sqlite.dialect.js +1027 -0
- package/dist/dialects/sybase.dialect.d.ts +2 -0
- package/dist/dialects/sybase.dialect.js +119 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +26 -0
- package/docs/api-reference.md +1009 -0
- package/docs/dialects.md +673 -0
- package/docs/tutorial.md +846 -0
- 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
|
+
}
|