tina4-nodejs 3.10.50 → 3.10.55
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/package.json +1 -1
- package/packages/cli/src/bin.ts +3 -1
- package/packages/cli/src/commands/serve.ts +23 -14
- package/packages/core/src/devAdmin.ts +1 -1
- package/packages/core/src/server.ts +41 -2
- package/packages/orm/src/adapters/mongodb.ts +679 -0
- package/packages/orm/src/adapters/odbc.ts +413 -0
- package/packages/orm/src/adapters/sqlite.ts +23 -3
- package/packages/orm/src/autoCrud.ts +15 -6
- package/packages/orm/src/baseModel.ts +32 -5
- package/packages/orm/src/database.ts +73 -2
- package/packages/orm/src/databaseResult.ts +26 -6
- package/packages/orm/src/index.ts +4 -0
- package/packages/orm/src/query.ts +7 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 MongoDB Adapter — uses the `mongodb` package (optional peer dependency).
|
|
3
|
+
*
|
|
4
|
+
* Install: npm install mongodb
|
|
5
|
+
* URL format: mongodb://host:port/dbname or mongodb+srv://user:pass@host/dbname
|
|
6
|
+
*/
|
|
7
|
+
import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "../types.js";
|
|
8
|
+
|
|
9
|
+
export interface MongoConfig {
|
|
10
|
+
host?: string;
|
|
11
|
+
port?: number;
|
|
12
|
+
user?: string;
|
|
13
|
+
password?: string;
|
|
14
|
+
database?: string;
|
|
15
|
+
connectionString?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse a simple SQL SELECT/INSERT/UPDATE/DELETE into a MongoDB operation descriptor.
|
|
20
|
+
* Handles the most common patterns that Tina4 ORM generates internally.
|
|
21
|
+
*/
|
|
22
|
+
interface MongoOperation {
|
|
23
|
+
type: "find" | "insertOne" | "insertMany" | "updateMany" | "deleteMany" | "aggregate" | "listCollections" | "raw";
|
|
24
|
+
collection?: string;
|
|
25
|
+
filter?: Record<string, unknown>;
|
|
26
|
+
document?: Record<string, unknown>;
|
|
27
|
+
documents?: Record<string, unknown>[];
|
|
28
|
+
update?: Record<string, unknown>;
|
|
29
|
+
projection?: Record<string, unknown>;
|
|
30
|
+
limit?: number;
|
|
31
|
+
skip?: number;
|
|
32
|
+
sort?: Record<string, 1 | -1>;
|
|
33
|
+
pipeline?: Record<string, unknown>[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Convert SQL WHERE clause tokens into a MongoDB filter object. */
|
|
37
|
+
function parseWhereClause(where: string, params: unknown[], paramOffset = 0): { filter: Record<string, unknown>; consumed: number } {
|
|
38
|
+
const filter: Record<string, unknown> = {};
|
|
39
|
+
// Simple key = ? / key = 'value' patterns separated by AND
|
|
40
|
+
const parts = where.split(/\s+AND\s+/i);
|
|
41
|
+
let paramIndex = paramOffset;
|
|
42
|
+
|
|
43
|
+
for (const part of parts) {
|
|
44
|
+
const trimmed = part.trim();
|
|
45
|
+
|
|
46
|
+
// key = ?
|
|
47
|
+
const eqParam = trimmed.match(/^["']?(\w+)["']?\s*=\s*\?$/i);
|
|
48
|
+
if (eqParam) {
|
|
49
|
+
filter[eqParam[1]] = params[paramIndex++];
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// key = 'literal' or key = 123
|
|
54
|
+
const eqLiteral = trimmed.match(/^["']?(\w+)["']?\s*=\s*(?:'([^']*)'|(\d+(?:\.\d+)?))$/i);
|
|
55
|
+
if (eqLiteral) {
|
|
56
|
+
filter[eqLiteral[1]] = eqLiteral[2] !== undefined ? eqLiteral[2] : Number(eqLiteral[3]);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// key != ? or key <> ?
|
|
61
|
+
const neParam = trimmed.match(/^["']?(\w+)["']?\s*(?:!=|<>)\s*\?$/i);
|
|
62
|
+
if (neParam) {
|
|
63
|
+
filter[neParam[1]] = { $ne: params[paramIndex++] };
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// key > ?
|
|
68
|
+
const gtParam = trimmed.match(/^["']?(\w+)["']?\s*>\s*\?$/i);
|
|
69
|
+
if (gtParam) {
|
|
70
|
+
filter[gtParam[1]] = { $gt: params[paramIndex++] };
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// key >= ?
|
|
75
|
+
const gteParam = trimmed.match(/^["']?(\w+)["']?\s*>=\s*\?$/i);
|
|
76
|
+
if (gteParam) {
|
|
77
|
+
filter[gteParam[1]] = { $gte: params[paramIndex++] };
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// key < ?
|
|
82
|
+
const ltParam = trimmed.match(/^["']?(\w+)["']?\s*<\s*\?$/i);
|
|
83
|
+
if (ltParam) {
|
|
84
|
+
filter[ltParam[1]] = { $lt: params[paramIndex++] };
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// key <= ?
|
|
89
|
+
const lteParam = trimmed.match(/^["']?(\w+)["']?\s*<=\s*\?$/i);
|
|
90
|
+
if (lteParam) {
|
|
91
|
+
filter[lteParam[1]] = { $lte: params[paramIndex++] };
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// key LIKE ? (translate % wildcards to regex)
|
|
96
|
+
const likeParam = trimmed.match(/^["']?(\w+)["']?\s+(?:I?LIKE)\s+\?$/i);
|
|
97
|
+
if (likeParam) {
|
|
98
|
+
const val = String(params[paramIndex++]);
|
|
99
|
+
const regex = val.replace(/%/g, ".*").replace(/_/g, ".");
|
|
100
|
+
filter[likeParam[1]] = { $regex: regex, $options: "i" };
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// key IN (?, ?, ...) — count the ?s
|
|
105
|
+
const inMatch = trimmed.match(/^["']?(\w+)["']?\s+IN\s*\(([^)]+)\)$/i);
|
|
106
|
+
if (inMatch) {
|
|
107
|
+
const placeholders = (inMatch[2].match(/\?/g) || []).length;
|
|
108
|
+
const values = params.slice(paramIndex, paramIndex + placeholders);
|
|
109
|
+
paramIndex += placeholders;
|
|
110
|
+
filter[inMatch[1]] = { $in: values };
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// IS NULL / IS NOT NULL
|
|
115
|
+
const nullMatch = trimmed.match(/^["']?(\w+)["']?\s+IS\s+(NOT\s+)?NULL$/i);
|
|
116
|
+
if (nullMatch) {
|
|
117
|
+
filter[nullMatch[1]] = nullMatch[2] ? { $ne: null } : null;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { filter, consumed: paramIndex - paramOffset };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Parse a SQL string into a MongoOperation. Returns null if parsing is not supported. */
|
|
126
|
+
function parseSql(sql: string, params: unknown[] = []): MongoOperation | null {
|
|
127
|
+
const s = sql.trim();
|
|
128
|
+
|
|
129
|
+
// ---- SELECT ----
|
|
130
|
+
const selectMatch = s.match(
|
|
131
|
+
/^SELECT\s+(.*?)\s+FROM\s+["']?(\w+)["']?(?:\s+WHERE\s+(.*?))?(?:\s+ORDER\s+BY\s+(.*?))?(?:\s+LIMIT\s+(\d+))?(?:\s+OFFSET\s+(\d+))?$/is,
|
|
132
|
+
);
|
|
133
|
+
if (selectMatch) {
|
|
134
|
+
const [, cols, collection, whereClause, orderBy, limitStr, skipStr] = selectMatch;
|
|
135
|
+
|
|
136
|
+
// Projection
|
|
137
|
+
let projection: Record<string, unknown> | undefined;
|
|
138
|
+
if (cols && cols.trim() !== "*") {
|
|
139
|
+
projection = {};
|
|
140
|
+
for (const col of cols.split(",")) {
|
|
141
|
+
const name = col.trim().replace(/^["']|["']$/g, "");
|
|
142
|
+
if (name && name !== "*") projection[name] = 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Filter
|
|
147
|
+
let filter: Record<string, unknown> = {};
|
|
148
|
+
if (whereClause) {
|
|
149
|
+
const parsed = parseWhereClause(whereClause.trim(), params);
|
|
150
|
+
filter = parsed.filter;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Sort
|
|
154
|
+
let sort: Record<string, 1 | -1> | undefined;
|
|
155
|
+
if (orderBy) {
|
|
156
|
+
sort = {};
|
|
157
|
+
for (const part of orderBy.split(",")) {
|
|
158
|
+
const t = part.trim();
|
|
159
|
+
const desc = /DESC$/i.test(t);
|
|
160
|
+
const col = t.replace(/\s+(ASC|DESC)$/i, "").replace(/^["']|["']$/g, "").trim();
|
|
161
|
+
sort[col] = desc ? -1 : 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
type: "find",
|
|
167
|
+
collection,
|
|
168
|
+
filter,
|
|
169
|
+
projection: projection && Object.keys(projection).length > 0 ? projection : undefined,
|
|
170
|
+
limit: limitStr ? parseInt(limitStr, 10) : undefined,
|
|
171
|
+
skip: skipStr ? parseInt(skipStr, 10) : undefined,
|
|
172
|
+
sort,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- INSERT ----
|
|
177
|
+
const insertMatch = s.match(
|
|
178
|
+
/^INSERT\s+INTO\s+["']?(\w+)["']?\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)$/is,
|
|
179
|
+
);
|
|
180
|
+
if (insertMatch) {
|
|
181
|
+
const [, collection, colsStr, valsStr] = insertMatch;
|
|
182
|
+
const cols = colsStr.split(",").map((c) => c.trim().replace(/^["']|["']$/g, ""));
|
|
183
|
+
const valPlaceholders = valsStr.split(",").map((v) => v.trim());
|
|
184
|
+
let paramIndex = 0;
|
|
185
|
+
const doc: Record<string, unknown> = {};
|
|
186
|
+
for (let i = 0; i < cols.length; i++) {
|
|
187
|
+
if (valPlaceholders[i] === "?") {
|
|
188
|
+
doc[cols[i]] = params[paramIndex++];
|
|
189
|
+
} else if (/^'.*'$/.test(valPlaceholders[i])) {
|
|
190
|
+
doc[cols[i]] = valPlaceholders[i].slice(1, -1);
|
|
191
|
+
} else if (/^-?\d+(\.\d+)?$/.test(valPlaceholders[i])) {
|
|
192
|
+
doc[cols[i]] = Number(valPlaceholders[i]);
|
|
193
|
+
} else {
|
|
194
|
+
doc[cols[i]] = valPlaceholders[i];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { type: "insertOne", collection, document: doc };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- UPDATE ----
|
|
201
|
+
const updateMatch = s.match(
|
|
202
|
+
/^UPDATE\s+["']?(\w+)["']?\s+SET\s+(.*?)\s+WHERE\s+(.+)$/is,
|
|
203
|
+
);
|
|
204
|
+
if (updateMatch) {
|
|
205
|
+
const [, collection, setClause, whereClause] = updateMatch;
|
|
206
|
+
|
|
207
|
+
// Parse SET clause
|
|
208
|
+
const setDoc: Record<string, unknown> = {};
|
|
209
|
+
let setParamIndex = 0;
|
|
210
|
+
for (const setPart of setClause.split(",")) {
|
|
211
|
+
const m = setPart.trim().match(/^["']?(\w+)["']?\s*=\s*\?$/);
|
|
212
|
+
if (m) {
|
|
213
|
+
setDoc[m[1]] = params[setParamIndex++];
|
|
214
|
+
} else {
|
|
215
|
+
const mLit = setPart.trim().match(/^["']?(\w+)["']?\s*=\s*(?:'([^']*)'|(-?\d+(?:\.\d+)?))$/);
|
|
216
|
+
if (mLit) {
|
|
217
|
+
setDoc[mLit[1]] = mLit[2] !== undefined ? mLit[2] : Number(mLit[3]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Parse WHERE clause (params start after SET params)
|
|
223
|
+
const { filter } = parseWhereClause(whereClause.trim(), params, setParamIndex);
|
|
224
|
+
|
|
225
|
+
return { type: "updateMany", collection, filter, update: { $set: setDoc } };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- DELETE ----
|
|
229
|
+
const deleteMatch = s.match(
|
|
230
|
+
/^DELETE\s+FROM\s+["']?(\w+)["']?(?:\s+WHERE\s+(.+))?$/is,
|
|
231
|
+
);
|
|
232
|
+
if (deleteMatch) {
|
|
233
|
+
const [, collection, whereClause] = deleteMatch;
|
|
234
|
+
const filter = whereClause
|
|
235
|
+
? parseWhereClause(whereClause.trim(), params).filter
|
|
236
|
+
: {};
|
|
237
|
+
return { type: "deleteMany", collection, filter };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---- CREATE TABLE (treated as createCollection) ----
|
|
241
|
+
const createTableMatch = s.match(/^CREATE\s+(?:TABLE|COLLECTION)\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i);
|
|
242
|
+
if (createTableMatch) {
|
|
243
|
+
// We handle this in createTable(); signal it as a "raw" skip
|
|
244
|
+
return { type: "raw", collection: createTableMatch[1] };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- SELECT COUNT(*) ----
|
|
248
|
+
const countMatch = s.match(/^SELECT\s+COUNT\(\*\)\s+AS\s+(\w+)\s+FROM\s+["']?(\w+)["']?(?:\s+WHERE\s+(.+))?$/is);
|
|
249
|
+
if (countMatch) {
|
|
250
|
+
const [, alias, collection, whereClause] = countMatch;
|
|
251
|
+
const filter = whereClause
|
|
252
|
+
? parseWhereClause(whereClause.trim(), params).filter
|
|
253
|
+
: {};
|
|
254
|
+
return {
|
|
255
|
+
type: "aggregate",
|
|
256
|
+
collection,
|
|
257
|
+
pipeline: [
|
|
258
|
+
{ $match: filter },
|
|
259
|
+
{ $count: alias },
|
|
260
|
+
],
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export class MongodbAdapter implements DatabaseAdapter {
|
|
268
|
+
private client: any = null;
|
|
269
|
+
private db: any = null;
|
|
270
|
+
private session: any = null;
|
|
271
|
+
private _lastInsertId: number | bigint | null = null;
|
|
272
|
+
private _inTransaction = false;
|
|
273
|
+
private _connectionString: string;
|
|
274
|
+
private _dbName: string;
|
|
275
|
+
|
|
276
|
+
constructor(private config: MongoConfig | string) {
|
|
277
|
+
if (typeof config === "string") {
|
|
278
|
+
this._connectionString = config;
|
|
279
|
+
// Extract database name from the URL path
|
|
280
|
+
try {
|
|
281
|
+
const url = new URL(config);
|
|
282
|
+
this._dbName = url.pathname.replace(/^\//, "") || "tina4";
|
|
283
|
+
} catch {
|
|
284
|
+
this._dbName = "tina4";
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
const host = config.host ?? "localhost";
|
|
288
|
+
const port = config.port ?? 27017;
|
|
289
|
+
const creds = config.user && config.password
|
|
290
|
+
? `${encodeURIComponent(config.user)}:${encodeURIComponent(config.password)}@`
|
|
291
|
+
: "";
|
|
292
|
+
this._connectionString = `mongodb://${creds}${host}:${port}/${config.database ?? "tina4"}`;
|
|
293
|
+
this._dbName = config.database ?? "tina4";
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Connect to MongoDB. Must be called before using the adapter. */
|
|
298
|
+
async connect(): Promise<void> {
|
|
299
|
+
let MongoClient: any;
|
|
300
|
+
try {
|
|
301
|
+
MongoClient = (await import("mongodb")).MongoClient;
|
|
302
|
+
} catch {
|
|
303
|
+
throw new Error(
|
|
304
|
+
"The 'mongodb' package is required for MongoDB connections. " +
|
|
305
|
+
"Install: npm install mongodb",
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
this.client = new MongoClient(this._connectionString);
|
|
310
|
+
await this.client.connect();
|
|
311
|
+
this.db = this.client.db(this._dbName);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private ensureConnected(): void {
|
|
315
|
+
if (!this.db) {
|
|
316
|
+
throw new Error("MongoDB adapter not connected. Call connect() first.");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Execute a SQL-like statement translated to a MongoDB operation. */
|
|
321
|
+
execute(sql: string, params?: unknown[]): unknown {
|
|
322
|
+
throw new Error("Use executeAsync() for MongoDB — async adapter requires async methods.");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async executeAsync(sql: string, params?: unknown[]): Promise<unknown> {
|
|
326
|
+
this.ensureConnected();
|
|
327
|
+
const op = parseSql(sql, params ?? []);
|
|
328
|
+
|
|
329
|
+
if (!op || op.type === "raw") {
|
|
330
|
+
// Unsupported SQL — log and skip (DDL, etc.)
|
|
331
|
+
return { acknowledged: true };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const col = this.db.collection(op.collection!);
|
|
335
|
+
|
|
336
|
+
switch (op.type) {
|
|
337
|
+
case "insertOne": {
|
|
338
|
+
const result = await col.insertOne(op.document!, { session: this.session });
|
|
339
|
+
this._lastInsertId = null; // MongoDB uses ObjectId
|
|
340
|
+
return result;
|
|
341
|
+
}
|
|
342
|
+
case "insertMany": {
|
|
343
|
+
const result = await col.insertMany(op.documents!, { session: this.session });
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
case "updateMany": {
|
|
347
|
+
const result = await col.updateMany(op.filter ?? {}, op.update!, { session: this.session });
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
350
|
+
case "deleteMany": {
|
|
351
|
+
const result = await col.deleteMany(op.filter ?? {}, { session: this.session });
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
case "find": {
|
|
355
|
+
let cursor = col.find(op.filter ?? {}, { session: this.session });
|
|
356
|
+
if (op.projection) cursor = cursor.project(op.projection);
|
|
357
|
+
if (op.sort) cursor = cursor.sort(op.sort);
|
|
358
|
+
if (op.skip) cursor = cursor.skip(op.skip);
|
|
359
|
+
if (op.limit) cursor = cursor.limit(op.limit);
|
|
360
|
+
return cursor.toArray();
|
|
361
|
+
}
|
|
362
|
+
case "aggregate": {
|
|
363
|
+
return col.aggregate(op.pipeline ?? [], { session: this.session }).toArray();
|
|
364
|
+
}
|
|
365
|
+
default:
|
|
366
|
+
return { acknowledged: true };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
executeMany(sql: string, paramsList: unknown[][]): { totalAffected: number; lastInsertId?: number | bigint } {
|
|
371
|
+
throw new Error("Use executeManyAsync() for MongoDB — async adapter requires async methods.");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async executeManyAsync(sql: string, paramsList: unknown[][]): Promise<{ totalAffected: number; lastInsertId?: number | bigint }> {
|
|
375
|
+
let totalAffected = 0;
|
|
376
|
+
for (const params of paramsList) {
|
|
377
|
+
await this.executeAsync(sql, params);
|
|
378
|
+
totalAffected++;
|
|
379
|
+
}
|
|
380
|
+
return { totalAffected };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[] {
|
|
384
|
+
throw new Error("Use queryAsync() for MongoDB — async adapter requires async methods.");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async queryAsync<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
|
|
388
|
+
this.ensureConnected();
|
|
389
|
+
const op = parseSql(sql, params ?? []);
|
|
390
|
+
|
|
391
|
+
if (!op) return [];
|
|
392
|
+
|
|
393
|
+
const col = this.db.collection(op.collection!);
|
|
394
|
+
|
|
395
|
+
switch (op.type) {
|
|
396
|
+
case "find": {
|
|
397
|
+
let cursor = col.find(op.filter ?? {}, { session: this.session });
|
|
398
|
+
if (op.projection) cursor = cursor.project(op.projection);
|
|
399
|
+
if (op.sort) cursor = cursor.sort(op.sort);
|
|
400
|
+
if (op.skip) cursor = cursor.skip(op.skip);
|
|
401
|
+
if (op.limit) cursor = cursor.limit(op.limit);
|
|
402
|
+
return cursor.toArray() as Promise<T[]>;
|
|
403
|
+
}
|
|
404
|
+
case "aggregate": {
|
|
405
|
+
return col.aggregate(op.pipeline ?? [], { session: this.session }).toArray() as Promise<T[]>;
|
|
406
|
+
}
|
|
407
|
+
default:
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
fetch<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, skip?: number): T[] {
|
|
413
|
+
throw new Error("Use fetchAsync() for MongoDB — async adapter requires async methods.");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async fetchAsync<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, skip?: number): Promise<T[]> {
|
|
417
|
+
this.ensureConnected();
|
|
418
|
+
const op = parseSql(sql, params ?? []);
|
|
419
|
+
|
|
420
|
+
if (!op || op.type !== "find") return this.queryAsync<T>(sql, params);
|
|
421
|
+
|
|
422
|
+
const col = this.db.collection(op.collection!);
|
|
423
|
+
let cursor = col.find(op.filter ?? {}, { session: this.session });
|
|
424
|
+
if (op.projection) cursor = cursor.project(op.projection);
|
|
425
|
+
if (op.sort) cursor = cursor.sort(op.sort);
|
|
426
|
+
|
|
427
|
+
const effectiveSkip = skip ?? op.skip ?? 0;
|
|
428
|
+
const effectiveLimit = limit ?? op.limit;
|
|
429
|
+
if (effectiveSkip > 0) cursor = cursor.skip(effectiveSkip);
|
|
430
|
+
if (effectiveLimit !== undefined) cursor = cursor.limit(effectiveLimit);
|
|
431
|
+
|
|
432
|
+
return cursor.toArray() as Promise<T[]>;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[]): T | null {
|
|
436
|
+
throw new Error("Use fetchOneAsync() for MongoDB — async adapter requires async methods.");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async fetchOneAsync<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T | null> {
|
|
440
|
+
const rows = await this.fetchAsync<T>(sql, params, 1);
|
|
441
|
+
return rows[0] ?? null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): DatabaseResult {
|
|
445
|
+
throw new Error("Use insertAsync() for MongoDB — async adapter requires async methods.");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async insertAsync(table: string, data: Record<string, unknown> | Record<string, unknown>[]): Promise<DatabaseResult> {
|
|
449
|
+
this.ensureConnected();
|
|
450
|
+
const col = this.db.collection(table);
|
|
451
|
+
try {
|
|
452
|
+
if (Array.isArray(data)) {
|
|
453
|
+
if (data.length === 0) return { success: true, rowsAffected: 0 };
|
|
454
|
+
const result = await col.insertMany(data, { session: this.session });
|
|
455
|
+
return { success: true, rowsAffected: result.insertedCount };
|
|
456
|
+
}
|
|
457
|
+
const result = await col.insertOne(data, { session: this.session });
|
|
458
|
+
return { success: true, rowsAffected: 1, lastInsertId: undefined };
|
|
459
|
+
} catch (e) {
|
|
460
|
+
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): DatabaseResult {
|
|
465
|
+
throw new Error("Use updateAsync() for MongoDB — async adapter requires async methods.");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): Promise<DatabaseResult> {
|
|
469
|
+
this.ensureConnected();
|
|
470
|
+
const col = this.db.collection(table);
|
|
471
|
+
try {
|
|
472
|
+
const result = await col.updateMany(filter, { $set: data }, { session: this.session });
|
|
473
|
+
return { success: true, rowsAffected: result.modifiedCount };
|
|
474
|
+
} catch (e) {
|
|
475
|
+
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
delete(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[]): DatabaseResult {
|
|
480
|
+
throw new Error("Use deleteAsync() for MongoDB — async adapter requires async methods.");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async deleteAsync(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[]): Promise<DatabaseResult> {
|
|
484
|
+
this.ensureConnected();
|
|
485
|
+
const col = this.db.collection(table);
|
|
486
|
+
try {
|
|
487
|
+
if (Array.isArray(filter)) {
|
|
488
|
+
let total = 0;
|
|
489
|
+
for (const f of filter) {
|
|
490
|
+
const r = await col.deleteMany(f, { session: this.session });
|
|
491
|
+
total += r.deletedCount;
|
|
492
|
+
}
|
|
493
|
+
return { success: true, rowsAffected: total };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// String WHERE clause — not directly translatable; delete nothing safely
|
|
497
|
+
if (typeof filter === "string") {
|
|
498
|
+
if (!filter.trim()) {
|
|
499
|
+
// Empty filter = delete all documents
|
|
500
|
+
const r = await col.deleteMany({}, { session: this.session });
|
|
501
|
+
return { success: true, rowsAffected: r.deletedCount };
|
|
502
|
+
}
|
|
503
|
+
// Attempt parse via dummy SELECT wrapping
|
|
504
|
+
const { filter: parsedFilter } = parseWhereClause(filter, []);
|
|
505
|
+
const r = await col.deleteMany(parsedFilter, { session: this.session });
|
|
506
|
+
return { success: true, rowsAffected: r.deletedCount };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const result = await col.deleteMany(filter as Record<string, unknown>, { session: this.session });
|
|
510
|
+
return { success: true, rowsAffected: result.deletedCount };
|
|
511
|
+
} catch (e) {
|
|
512
|
+
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
startTransaction(): void {
|
|
517
|
+
throw new Error("Use startTransactionAsync() for MongoDB — async adapter requires async methods.");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async startTransactionAsync(): Promise<void> {
|
|
521
|
+
this.ensureConnected();
|
|
522
|
+
if (this._inTransaction) return;
|
|
523
|
+
this.session = this.client.startSession();
|
|
524
|
+
this.session.startTransaction();
|
|
525
|
+
this._inTransaction = true;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
commit(): void {
|
|
529
|
+
throw new Error("Use commitAsync() for MongoDB — async adapter requires async methods.");
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async commitAsync(): Promise<void> {
|
|
533
|
+
if (!this._inTransaction || !this.session) return;
|
|
534
|
+
await this.session.commitTransaction();
|
|
535
|
+
await this.session.endSession();
|
|
536
|
+
this.session = null;
|
|
537
|
+
this._inTransaction = false;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
rollback(): void {
|
|
541
|
+
throw new Error("Use rollbackAsync() for MongoDB — async adapter requires async methods.");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async rollbackAsync(): Promise<void> {
|
|
545
|
+
if (!this._inTransaction || !this.session) return;
|
|
546
|
+
try {
|
|
547
|
+
await this.session.abortTransaction();
|
|
548
|
+
} catch {
|
|
549
|
+
// Ignore rollback failures
|
|
550
|
+
}
|
|
551
|
+
await this.session.endSession();
|
|
552
|
+
this.session = null;
|
|
553
|
+
this._inTransaction = false;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
tables(): string[] {
|
|
557
|
+
throw new Error("Use tablesAsync() for MongoDB — async adapter requires async methods.");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async tablesAsync(): Promise<string[]> {
|
|
561
|
+
this.ensureConnected();
|
|
562
|
+
const collections = await this.db.listCollections().toArray();
|
|
563
|
+
return collections.map((c: any) => c.name as string);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
columns(table: string): ColumnInfo[] {
|
|
567
|
+
throw new Error("Use columnsAsync() for MongoDB — async adapter requires async methods.");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Infer column schema by sampling a document from the collection.
|
|
572
|
+
* MongoDB is schema-less; this returns field names and inferred JS types.
|
|
573
|
+
*/
|
|
574
|
+
async columnsAsync(table: string): Promise<ColumnInfo[]> {
|
|
575
|
+
this.ensureConnected();
|
|
576
|
+
const doc = await this.db.collection(table).findOne({});
|
|
577
|
+
if (!doc) return [];
|
|
578
|
+
return Object.entries(doc).map(([key, value]) => ({
|
|
579
|
+
name: key,
|
|
580
|
+
type: typeof value === "number"
|
|
581
|
+
? "number"
|
|
582
|
+
: typeof value === "boolean"
|
|
583
|
+
? "boolean"
|
|
584
|
+
: value instanceof Date
|
|
585
|
+
? "datetime"
|
|
586
|
+
: "string",
|
|
587
|
+
nullable: true,
|
|
588
|
+
default: undefined,
|
|
589
|
+
primaryKey: key === "_id",
|
|
590
|
+
}));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
lastInsertId(): number | bigint | null {
|
|
594
|
+
return this._lastInsertId;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
close(): void {
|
|
598
|
+
if (this.client) {
|
|
599
|
+
// MongoDB client.close() is async but we match the sync interface
|
|
600
|
+
this.client.close().catch(() => {});
|
|
601
|
+
this.client = null;
|
|
602
|
+
this.db = null;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
tableExists(name: string): boolean {
|
|
607
|
+
throw new Error("Use tableExistsAsync() for MongoDB — async adapter requires async methods.");
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async tableExistsAsync(name: string): Promise<boolean> {
|
|
611
|
+
const tables = await this.tablesAsync();
|
|
612
|
+
return tables.includes(name);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
createTable(name: string, columns: Record<string, FieldDefinition>): void {
|
|
616
|
+
throw new Error("Use createTableAsync() for MongoDB — async adapter requires async methods.");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Create a MongoDB collection with optional JSON schema validation derived
|
|
621
|
+
* from the Tina4 field definitions.
|
|
622
|
+
*/
|
|
623
|
+
async createTableAsync(name: string, columns: Record<string, FieldDefinition>): Promise<void> {
|
|
624
|
+
this.ensureConnected();
|
|
625
|
+
|
|
626
|
+
// Only create if not already present
|
|
627
|
+
const exists = await this.tableExistsAsync(name);
|
|
628
|
+
if (exists) return;
|
|
629
|
+
|
|
630
|
+
// Build a JSON Schema validator
|
|
631
|
+
const required: string[] = [];
|
|
632
|
+
const properties: Record<string, unknown> = {};
|
|
633
|
+
|
|
634
|
+
for (const [colName, def] of Object.entries(columns)) {
|
|
635
|
+
if (def.required && !def.primaryKey) {
|
|
636
|
+
required.push(colName);
|
|
637
|
+
}
|
|
638
|
+
properties[colName] = fieldTypeToJsonSchema(def);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const validator = {
|
|
642
|
+
$jsonSchema: {
|
|
643
|
+
bsonType: "object",
|
|
644
|
+
properties,
|
|
645
|
+
...(required.length > 0 ? { required } : {}),
|
|
646
|
+
},
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
await this.db.createCollection(name, { validator });
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Get column info as a plain array (legacy migration support). */
|
|
653
|
+
async getTableColumnsAsync(table: string): Promise<Array<{ name: string; type: string }>> {
|
|
654
|
+
const cols = await this.columnsAsync(table);
|
|
655
|
+
return cols.map((c) => ({ name: c.name, type: c.type }));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function fieldTypeToJsonSchema(def: FieldDefinition): Record<string, unknown> {
|
|
660
|
+
switch (def.type) {
|
|
661
|
+
case "integer":
|
|
662
|
+
return { bsonType: "int" };
|
|
663
|
+
case "number":
|
|
664
|
+
case "numeric":
|
|
665
|
+
return { bsonType: "double" };
|
|
666
|
+
case "boolean":
|
|
667
|
+
return { bsonType: "bool" };
|
|
668
|
+
case "datetime":
|
|
669
|
+
return { bsonType: "date" };
|
|
670
|
+
case "text":
|
|
671
|
+
return { bsonType: "string" };
|
|
672
|
+
case "string":
|
|
673
|
+
return def.maxLength
|
|
674
|
+
? { bsonType: "string", maxLength: def.maxLength }
|
|
675
|
+
: { bsonType: "string" };
|
|
676
|
+
default:
|
|
677
|
+
return { bsonType: "string" };
|
|
678
|
+
}
|
|
679
|
+
}
|