tina4-nodejs 3.10.48 → 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/init.ts +25 -2
- package/packages/cli/src/commands/serve.ts +25 -16
- package/packages/core/src/devAdmin.ts +1 -1
- package/packages/core/src/request.ts +1 -1
- package/packages/core/src/router.ts +5 -0
- package/packages/core/src/server.ts +66 -11
- package/packages/core/src/session.ts +14 -6
- 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 +16 -7
- package/packages/orm/src/baseModel.ts +32 -5
- package/packages/orm/src/database.ts +73 -2
- package/packages/orm/src/databaseResult.ts +34 -5
- package/packages/orm/src/index.ts +4 -0
- package/packages/orm/src/query.ts +8 -1
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 ODBC Adapter — uses the `odbc` package (optional peer dependency).
|
|
3
|
+
*
|
|
4
|
+
* Install: npm install odbc
|
|
5
|
+
* URL format: odbc:///DSN=MyDSN
|
|
6
|
+
* odbc:///DRIVER={driver};SERVER=host;DATABASE=db
|
|
7
|
+
*
|
|
8
|
+
* The connection string after stripping the "odbc:///" prefix is passed
|
|
9
|
+
* directly to odbc.connect(), so any valid ODBC connection string works.
|
|
10
|
+
*/
|
|
11
|
+
import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "../types.js";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
|
|
14
|
+
let odbcModule: any = null;
|
|
15
|
+
|
|
16
|
+
function requireOdbc(): any {
|
|
17
|
+
if (odbcModule) return odbcModule;
|
|
18
|
+
try {
|
|
19
|
+
const req = createRequire(import.meta.url);
|
|
20
|
+
odbcModule = req("odbc");
|
|
21
|
+
return odbcModule;
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"The 'odbc' package is required for ODBC connections. Install it with: npm install odbc",
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OdbcConfig {
|
|
30
|
+
/** Full ODBC connection string, e.g. "DSN=MyDSN" or "DRIVER={SQL Server};SERVER=host;DATABASE=db" */
|
|
31
|
+
connectionString: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class OdbcAdapter implements DatabaseAdapter {
|
|
35
|
+
private connection: any = null;
|
|
36
|
+
private _lastInsertId: number | bigint | null = null;
|
|
37
|
+
private _inTransaction: boolean = false;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Accepts either an OdbcConfig object or a raw connection string.
|
|
41
|
+
* When created via Database.create("odbc:///DSN=MyDSN"), the "odbc:///"
|
|
42
|
+
* prefix is stripped by parseDatabaseUrl and the remainder is passed here.
|
|
43
|
+
*/
|
|
44
|
+
constructor(private config: OdbcConfig | string) {}
|
|
45
|
+
|
|
46
|
+
/** Extract the raw ODBC connection string from config. */
|
|
47
|
+
private getConnectionString(): string {
|
|
48
|
+
if (typeof this.config === "string") return this.config;
|
|
49
|
+
return this.config.connectionString;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Connect to the ODBC data source. Must be called before using the adapter. */
|
|
53
|
+
async connect(): Promise<void> {
|
|
54
|
+
const odbc = requireOdbc();
|
|
55
|
+
const connStr = this.getConnectionString();
|
|
56
|
+
// odbc package may expose connect as default export or named export
|
|
57
|
+
const connectFn = odbc.connect ?? odbc.default?.connect;
|
|
58
|
+
if (!connectFn) {
|
|
59
|
+
throw new Error("odbc module does not export a connect() function. Check your odbc package version.");
|
|
60
|
+
}
|
|
61
|
+
this.connection = await connectFn(connStr);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private ensureConnected(): void {
|
|
65
|
+
if (!this.connection) {
|
|
66
|
+
throw new Error("ODBC adapter not connected. Call connect() first.");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// -------------------------------------------------------------------------
|
|
71
|
+
// Synchronous interface stubs — ODBC is async; use the *Async variants
|
|
72
|
+
// -------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
execute(sql: string, params?: unknown[]): unknown {
|
|
75
|
+
throw new Error("Use executeAsync() for ODBC — async adapter requires async methods.");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
executeMany(sql: string, paramsList: unknown[][]): { totalAffected: number; lastInsertId?: number | bigint } {
|
|
79
|
+
throw new Error("Use executeManyAsync() for ODBC — async adapter requires async methods.");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[] {
|
|
83
|
+
throw new Error("Use queryAsync() for ODBC — async adapter requires async methods.");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fetch<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, skip?: number): T[] {
|
|
87
|
+
throw new Error("Use fetchAsync() for ODBC — async adapter requires async methods.");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[]): T | null {
|
|
91
|
+
throw new Error("Use fetchOneAsync() for ODBC — async adapter requires async methods.");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
insert(table: string, data: Record<string, unknown>): DatabaseResult {
|
|
95
|
+
throw new Error("Use insertAsync() for ODBC — async adapter requires async methods.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): DatabaseResult {
|
|
99
|
+
throw new Error("Use updateAsync() for ODBC — async adapter requires async methods.");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
delete(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[]): DatabaseResult {
|
|
103
|
+
throw new Error("Use deleteAsync() for ODBC — async adapter requires async methods.");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
startTransaction(): void {
|
|
107
|
+
throw new Error("Use startTransactionAsync() for ODBC — async adapter requires async methods.");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
commit(): void {
|
|
111
|
+
throw new Error("Use commitAsync() for ODBC — async adapter requires async methods.");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
rollback(): void {
|
|
115
|
+
throw new Error("Use rollbackAsync() for ODBC — async adapter requires async methods.");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
tables(): string[] {
|
|
119
|
+
throw new Error("Use tablesAsync() for ODBC — async adapter requires async methods.");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
columns(table: string): ColumnInfo[] {
|
|
123
|
+
throw new Error("Use columnsAsync() for ODBC — async adapter requires async methods.");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
tableExists(name: string): boolean {
|
|
127
|
+
throw new Error("Use tableExistsAsync() for ODBC — async adapter requires async methods.");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
createTable(name: string, columns: Record<string, FieldDefinition>): void {
|
|
131
|
+
throw new Error("Use createTableAsync() for ODBC — async adapter requires async methods.");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
getTableColumns(name: string): Array<{ name: string; type: string }> {
|
|
135
|
+
throw new Error("Use getTableColumnsAsync() for ODBC — async adapter requires async methods.");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
addColumn(table: string, colName: string, def: FieldDefinition): void {
|
|
139
|
+
throw new Error("Use addColumnAsync() for ODBC — async adapter requires async methods.");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// -------------------------------------------------------------------------
|
|
143
|
+
// Async methods — primary API for ODBC
|
|
144
|
+
// -------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/** Execute a write statement (INSERT, UPDATE, DELETE, DDL). */
|
|
147
|
+
async executeAsync(sql: string, params?: unknown[]): Promise<unknown> {
|
|
148
|
+
this.ensureConnected();
|
|
149
|
+
const result = await this.connection.query(sql, params ?? []);
|
|
150
|
+
// Try to capture last insert id from result metadata if present
|
|
151
|
+
if (result && typeof result === "object" && "lastInsertId" in result) {
|
|
152
|
+
this._lastInsertId = (result as any).lastInsertId;
|
|
153
|
+
}
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Execute a statement with multiple parameter sets inside a single transaction. */
|
|
158
|
+
async executeManyAsync(sql: string, paramsList: unknown[][]): Promise<{ totalAffected: number; lastInsertId?: number | bigint }> {
|
|
159
|
+
this.ensureConnected();
|
|
160
|
+
let totalAffected = 0;
|
|
161
|
+
let lastId: number | bigint | undefined;
|
|
162
|
+
|
|
163
|
+
await this.startTransactionAsync();
|
|
164
|
+
try {
|
|
165
|
+
for (const params of paramsList) {
|
|
166
|
+
await this.connection.query(sql, params);
|
|
167
|
+
totalAffected++;
|
|
168
|
+
}
|
|
169
|
+
await this.commitAsync();
|
|
170
|
+
} catch (e) {
|
|
171
|
+
await this.rollbackAsync();
|
|
172
|
+
throw e;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (lastId !== undefined) this._lastInsertId = lastId;
|
|
176
|
+
return { totalAffected, lastInsertId: lastId };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Run a SELECT and return all matching rows. */
|
|
180
|
+
async queryAsync<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
|
|
181
|
+
this.ensureConnected();
|
|
182
|
+
const result = await this.connection.query(sql, params ?? []);
|
|
183
|
+
// odbc returns an array-like result object; spread into a plain array
|
|
184
|
+
return Array.from(result) as T[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Run a SELECT with optional LIMIT/OFFSET pagination. */
|
|
188
|
+
async fetchAsync<T = Record<string, unknown>>(
|
|
189
|
+
sql: string,
|
|
190
|
+
params?: unknown[],
|
|
191
|
+
limit?: number,
|
|
192
|
+
skip?: number,
|
|
193
|
+
): Promise<T[]> {
|
|
194
|
+
let effectiveSql = sql;
|
|
195
|
+
if (limit !== undefined) {
|
|
196
|
+
const sqlUpper = sql.toUpperCase().split("--")[0];
|
|
197
|
+
if (!sqlUpper.includes("LIMIT")) {
|
|
198
|
+
effectiveSql += ` LIMIT ${limit}`;
|
|
199
|
+
if (skip !== undefined && skip > 0) {
|
|
200
|
+
effectiveSql += ` OFFSET ${skip}`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return this.queryAsync<T>(effectiveSql, params);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Run a SELECT and return the first row or null. */
|
|
208
|
+
async fetchOneAsync<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T | null> {
|
|
209
|
+
const rows = await this.queryAsync<T>(sql, params);
|
|
210
|
+
return rows[0] ?? null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Insert a single row into a table. */
|
|
214
|
+
async insertAsync(table: string, data: Record<string, unknown>): Promise<DatabaseResult> {
|
|
215
|
+
this.ensureConnected();
|
|
216
|
+
const keys = Object.keys(data);
|
|
217
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
218
|
+
const sql = `INSERT INTO "${table}" ("${keys.join('", "')}") VALUES (${placeholders})`;
|
|
219
|
+
const values = Object.values(data);
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
await this.connection.query(sql, values);
|
|
223
|
+
return { success: true, rowsAffected: 1, lastInsertId: this._lastInsertId ?? undefined };
|
|
224
|
+
} catch (e) {
|
|
225
|
+
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Update rows in a table matching filter. */
|
|
230
|
+
async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): Promise<DatabaseResult> {
|
|
231
|
+
this.ensureConnected();
|
|
232
|
+
const setClauses = Object.keys(data).map((k) => `"${k}" = ?`).join(", ");
|
|
233
|
+
const whereClauses = Object.keys(filter).map((k) => `"${k}" = ?`).join(" AND ");
|
|
234
|
+
const sql = `UPDATE "${table}" SET ${setClauses} WHERE ${whereClauses}`;
|
|
235
|
+
const values = [...Object.values(data), ...Object.values(filter)];
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
await this.connection.query(sql, values);
|
|
239
|
+
return { success: true, rowsAffected: 1 };
|
|
240
|
+
} catch (e) {
|
|
241
|
+
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Delete rows from a table. */
|
|
246
|
+
async deleteAsync(
|
|
247
|
+
table: string,
|
|
248
|
+
filter: Record<string, unknown> | string | Record<string, unknown>[],
|
|
249
|
+
): Promise<DatabaseResult> {
|
|
250
|
+
this.ensureConnected();
|
|
251
|
+
|
|
252
|
+
if (Array.isArray(filter)) {
|
|
253
|
+
let totalAffected = 0;
|
|
254
|
+
for (const row of filter) {
|
|
255
|
+
const result = await this.deleteAsync(table, row);
|
|
256
|
+
totalAffected += result.rowsAffected;
|
|
257
|
+
}
|
|
258
|
+
return { success: true, rowsAffected: totalAffected };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (typeof filter === "string") {
|
|
262
|
+
const sql = filter
|
|
263
|
+
? `DELETE FROM "${table}" WHERE ${filter}`
|
|
264
|
+
: `DELETE FROM "${table}"`;
|
|
265
|
+
try {
|
|
266
|
+
await this.connection.query(sql, []);
|
|
267
|
+
return { success: true, rowsAffected: 1 };
|
|
268
|
+
} catch (e) {
|
|
269
|
+
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const whereClauses = Object.keys(filter).map((k) => `"${k}" = ?`).join(" AND ");
|
|
274
|
+
const sql = `DELETE FROM "${table}" WHERE ${whereClauses}`;
|
|
275
|
+
const values = Object.values(filter);
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
await this.connection.query(sql, values);
|
|
279
|
+
return { success: true, rowsAffected: 1 };
|
|
280
|
+
} catch (e) {
|
|
281
|
+
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Begin a transaction. */
|
|
286
|
+
async startTransactionAsync(): Promise<void> {
|
|
287
|
+
if (this._inTransaction) return;
|
|
288
|
+
this.ensureConnected();
|
|
289
|
+
// odbc connections have beginTransaction() method
|
|
290
|
+
await this.connection.beginTransaction();
|
|
291
|
+
this._inTransaction = true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Commit the current transaction. */
|
|
295
|
+
async commitAsync(): Promise<void> {
|
|
296
|
+
if (!this._inTransaction) return;
|
|
297
|
+
this.ensureConnected();
|
|
298
|
+
await this.connection.commit();
|
|
299
|
+
this._inTransaction = false;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Rollback the current transaction. */
|
|
303
|
+
async rollbackAsync(): Promise<void> {
|
|
304
|
+
if (!this._inTransaction) return;
|
|
305
|
+
this.ensureConnected();
|
|
306
|
+
try {
|
|
307
|
+
await this.connection.rollback();
|
|
308
|
+
} catch {
|
|
309
|
+
// Rollback may fail if transaction already ended
|
|
310
|
+
}
|
|
311
|
+
this._inTransaction = false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** List all user tables using ODBC catalog functions. */
|
|
315
|
+
async tablesAsync(): Promise<string[]> {
|
|
316
|
+
this.ensureConnected();
|
|
317
|
+
// odbc.Connection.tables(catalog, schema, table, type) returns catalog rows
|
|
318
|
+
const rows: any[] = await this.connection.tables(null, null, null, "TABLE");
|
|
319
|
+
return rows.map((r: any) => r.TABLE_NAME ?? r.table_name ?? r.name).filter(Boolean);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Get column metadata for a table using ODBC catalog functions. */
|
|
323
|
+
async columnsAsync(table: string): Promise<ColumnInfo[]> {
|
|
324
|
+
this.ensureConnected();
|
|
325
|
+
// odbc.Connection.columns(catalog, schema, table, column)
|
|
326
|
+
const rows: any[] = await this.connection.columns(null, null, table, null);
|
|
327
|
+
return rows.map((r: any) => ({
|
|
328
|
+
name: r.COLUMN_NAME ?? r.column_name,
|
|
329
|
+
type: r.TYPE_NAME ?? r.type_name ?? r.DATA_TYPE ?? "",
|
|
330
|
+
nullable: (r.NULLABLE ?? r.nullable) === 1,
|
|
331
|
+
default: r.COLUMN_DEF ?? r.column_def ?? null,
|
|
332
|
+
primaryKey: false, // ODBC catalog doesn't easily expose PK; requires separate primaryKeys() call
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Check whether a table exists. */
|
|
337
|
+
async tableExistsAsync(name: string): Promise<boolean> {
|
|
338
|
+
this.ensureConnected();
|
|
339
|
+
const rows: any[] = await this.connection.tables(null, null, name, "TABLE");
|
|
340
|
+
return rows.length > 0;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Create a table from a FieldDefinition map. Uses generic SQL — works with most ODBC sources. */
|
|
344
|
+
async createTableAsync(name: string, columns: Record<string, FieldDefinition>): Promise<void> {
|
|
345
|
+
const colDefs: string[] = [];
|
|
346
|
+
for (const [colName, def] of Object.entries(columns)) {
|
|
347
|
+
const sqlType = fieldTypeToOdbc(def.type);
|
|
348
|
+
const parts = [`"${colName}" ${sqlType}`];
|
|
349
|
+
if (def.primaryKey) parts.push("PRIMARY KEY");
|
|
350
|
+
if (def.autoIncrement) parts.push("GENERATED ALWAYS AS IDENTITY"); // ANSI SQL
|
|
351
|
+
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
352
|
+
if (def.default !== undefined && def.default !== "now") {
|
|
353
|
+
parts.push(`DEFAULT ${sqlDefault(def.default)}`);
|
|
354
|
+
}
|
|
355
|
+
if (def.default === "now") parts.push("DEFAULT CURRENT_TIMESTAMP");
|
|
356
|
+
colDefs.push(parts.join(" "));
|
|
357
|
+
}
|
|
358
|
+
await this.connection.query(`CREATE TABLE IF NOT EXISTS "${name}" (${colDefs.join(", ")})`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Get raw column name+type list for a table. */
|
|
362
|
+
async getTableColumnsAsync(name: string): Promise<Array<{ name: string; type: string }>> {
|
|
363
|
+
const cols = await this.columnsAsync(name);
|
|
364
|
+
return cols.map((c) => ({ name: c.name, type: c.type }));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Add a column to an existing table. */
|
|
368
|
+
async addColumnAsync(table: string, colName: string, def: FieldDefinition): Promise<void> {
|
|
369
|
+
const sqlType = fieldTypeToOdbc(def.type);
|
|
370
|
+
let sql = `ALTER TABLE "${table}" ADD COLUMN "${colName}" ${sqlType}`;
|
|
371
|
+
if (def.default !== undefined && def.default !== "now") {
|
|
372
|
+
sql += ` DEFAULT ${sqlDefault(def.default)}`;
|
|
373
|
+
} else if (def.default === "now") {
|
|
374
|
+
sql += " DEFAULT CURRENT_TIMESTAMP";
|
|
375
|
+
}
|
|
376
|
+
await this.connection.query(sql);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
lastInsertId(): number | bigint | null {
|
|
380
|
+
return this._lastInsertId;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
close(): void {
|
|
384
|
+
if (this.connection) {
|
|
385
|
+
// odbc close is async but we keep the sync interface — fire and forget
|
|
386
|
+
this.connection.close().catch(() => {});
|
|
387
|
+
this.connection = null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// Helpers
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
function fieldTypeToOdbc(type: string): string {
|
|
397
|
+
switch (type) {
|
|
398
|
+
case "integer": return "INTEGER";
|
|
399
|
+
case "number":
|
|
400
|
+
case "numeric": return "DOUBLE PRECISION";
|
|
401
|
+
case "boolean": return "SMALLINT";
|
|
402
|
+
case "datetime": return "TIMESTAMP";
|
|
403
|
+
case "text": return "CLOB";
|
|
404
|
+
case "string":
|
|
405
|
+
default: return "VARCHAR(255)";
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function sqlDefault(value: unknown): string {
|
|
410
|
+
if (typeof value === "string") return `'${value}'`;
|
|
411
|
+
if (typeof value === "boolean") return value ? "1" : "0";
|
|
412
|
+
return String(value);
|
|
413
|
+
}
|
|
@@ -146,9 +146,29 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
private _inTransaction = false;
|
|
150
|
+
|
|
151
|
+
startTransaction(): void {
|
|
152
|
+
if (this._inTransaction) return;
|
|
153
|
+
this.db.exec("BEGIN TRANSACTION");
|
|
154
|
+
this._inTransaction = true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
commit(): void {
|
|
158
|
+
if (!this._inTransaction) return;
|
|
159
|
+
this.db.exec("COMMIT");
|
|
160
|
+
this._inTransaction = false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
rollback(): void {
|
|
164
|
+
if (!this._inTransaction) return;
|
|
165
|
+
try {
|
|
166
|
+
this.db.exec("ROLLBACK");
|
|
167
|
+
} catch {
|
|
168
|
+
// Rollback may fail if transaction already ended
|
|
169
|
+
}
|
|
170
|
+
this._inTransaction = false;
|
|
171
|
+
}
|
|
152
172
|
|
|
153
173
|
tables(): string[] {
|
|
154
174
|
const rows = this.query<{ name: string }>(
|
|
@@ -45,17 +45,26 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
45
45
|
const items = adapter.query(sql, params);
|
|
46
46
|
const [{ total }] = adapter.query<{ total: number }>(countSql, countParams);
|
|
47
47
|
|
|
48
|
-
const limit = options.limit ??
|
|
48
|
+
const limit = options.limit ?? 100;
|
|
49
49
|
const page = options.page ?? 1;
|
|
50
|
+
const offset = (page - 1) * limit;
|
|
51
|
+
const totalPages = Math.ceil(total / limit);
|
|
50
52
|
|
|
51
53
|
res.json({
|
|
54
|
+
// Primary keys
|
|
55
|
+
records: items,
|
|
52
56
|
data: items,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
count: total,
|
|
58
|
+
total,
|
|
59
|
+
limit,
|
|
60
|
+
offset,
|
|
61
|
+
page,
|
|
62
|
+
per_page: limit,
|
|
63
|
+
perPage: limit,
|
|
64
|
+
totalPages,
|
|
65
|
+
total_pages: totalPages,
|
|
66
|
+
// Legacy nested meta (kept for any clients that use it)
|
|
67
|
+
meta: { total, page, limit, totalPages },
|
|
59
68
|
});
|
|
60
69
|
},
|
|
61
70
|
});
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { getAdapter, getNamedAdapter } from "./database.js";
|
|
1
|
+
import { getAdapter, getNamedAdapter, setAdapter, parseDatabaseUrl } from "./database.js";
|
|
2
2
|
import { validate as validateFields } from "./validation.js";
|
|
3
3
|
import { QueryBuilder } from "./queryBuilder.js";
|
|
4
|
+
import { SQLiteAdapter } from "./adapters/sqlite.js";
|
|
4
5
|
import type { DatabaseAdapter, FieldDefinition, RelationshipDefinition } from "./types.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Convert a snake_case name to camelCase.
|
|
9
|
+
* Lowercases the input first so UPPERCASE DB column names (Firebird/Oracle) map correctly.
|
|
8
10
|
*/
|
|
9
11
|
export function snakeToCamel(name: string): string {
|
|
10
|
-
return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
12
|
+
return name.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -90,8 +92,8 @@ export class BaseModel {
|
|
|
90
92
|
}
|
|
91
93
|
const reverseMapping = ModelClass.getReverseMapping();
|
|
92
94
|
for (const [key, value] of Object.entries(data)) {
|
|
93
|
-
//
|
|
94
|
-
const jsProp = reverseMapping[key] ?? key;
|
|
95
|
+
// Lowercase the DB column key so UPPERCASE columns (Firebird/Oracle) match the mapping
|
|
96
|
+
const jsProp = reverseMapping[key] ?? reverseMapping[key.toLowerCase()] ?? key;
|
|
95
97
|
this[jsProp] = value;
|
|
96
98
|
}
|
|
97
99
|
}
|
|
@@ -147,12 +149,37 @@ export class BaseModel {
|
|
|
147
149
|
|
|
148
150
|
/**
|
|
149
151
|
* Get the database adapter for this model.
|
|
152
|
+
* If no adapter is registered, attempts auto-discovery from DATABASE_URL.
|
|
153
|
+
* SQLite URLs are initialised synchronously. Other engines require initDatabase()
|
|
154
|
+
* to be called before first use.
|
|
150
155
|
*/
|
|
151
156
|
protected static getDb(): DatabaseAdapter {
|
|
152
157
|
if (this._db) {
|
|
153
158
|
return getNamedAdapter(this._db);
|
|
154
159
|
}
|
|
155
|
-
|
|
160
|
+
try {
|
|
161
|
+
return getAdapter();
|
|
162
|
+
} catch {
|
|
163
|
+
// No adapter registered — try DATABASE_URL auto-discovery
|
|
164
|
+
const url = process.env.DATABASE_URL;
|
|
165
|
+
if (url) {
|
|
166
|
+
const parsed = parseDatabaseUrl(url);
|
|
167
|
+
if (parsed.type === "sqlite") {
|
|
168
|
+
// SQLite adapter is synchronous — create it inline and register as default
|
|
169
|
+
const dbPath = parsed.path ?? "./data/tina4.db";
|
|
170
|
+
const adapter = new SQLiteAdapter(dbPath);
|
|
171
|
+
setAdapter(adapter);
|
|
172
|
+
return adapter;
|
|
173
|
+
}
|
|
174
|
+
throw new Error(
|
|
175
|
+
`DATABASE_URL is set to a non-SQLite engine ("${parsed.type}"). ` +
|
|
176
|
+
`Call await initDatabase() at startup before using ORM models.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
throw new Error(
|
|
180
|
+
"No database adapter configured. Call initDatabase() or set DATABASE_URL in .env.",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
156
183
|
}
|
|
157
184
|
|
|
158
185
|
/**
|
|
@@ -45,7 +45,7 @@ export function closeDatabase(): void {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export interface DatabaseConfig {
|
|
48
|
-
type?: "sqlite" | "postgres" | "mysql" | "mssql" | "sqlserver" | "firebird";
|
|
48
|
+
type?: "sqlite" | "postgres" | "mysql" | "mssql" | "sqlserver" | "firebird" | "mongodb" | "odbc";
|
|
49
49
|
path?: string;
|
|
50
50
|
url?: string;
|
|
51
51
|
host?: string;
|
|
@@ -54,19 +54,23 @@ export interface DatabaseConfig {
|
|
|
54
54
|
username?: string;
|
|
55
55
|
password?: string;
|
|
56
56
|
database?: string;
|
|
57
|
+
/** ODBC-specific: full connection string, e.g. "DSN=MyDSN" or "DRIVER={SQL Server};SERVER=host;DATABASE=db" */
|
|
58
|
+
connectionString?: string;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
/**
|
|
60
62
|
* Parsed result from a DATABASE_URL connection string.
|
|
61
63
|
*/
|
|
62
64
|
export interface ParsedDatabaseUrl {
|
|
63
|
-
type: "sqlite" | "postgres" | "mysql" | "mssql" | "firebird";
|
|
65
|
+
type: "sqlite" | "postgres" | "mysql" | "mssql" | "firebird" | "mongodb" | "odbc";
|
|
64
66
|
path?: string;
|
|
65
67
|
host?: string;
|
|
66
68
|
port?: number;
|
|
67
69
|
user?: string;
|
|
68
70
|
password?: string;
|
|
69
71
|
database?: string;
|
|
72
|
+
/** ODBC-specific: raw connection string passed to odbc.connect() */
|
|
73
|
+
connectionString?: string;
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
/**
|
|
@@ -120,6 +124,28 @@ export function parseDatabaseUrl(url: string, username?: string, password?: stri
|
|
|
120
124
|
port: match[4] ? parseInt(match[4], 10) : undefined,
|
|
121
125
|
database: "/" + match[5],
|
|
122
126
|
};
|
|
127
|
+
} else if (url.startsWith("odbc:///")) {
|
|
128
|
+
// odbc:///DSN=MyDSN or odbc:///DRIVER={driver};SERVER=host;DATABASE=db
|
|
129
|
+
// Strip the "odbc:///" prefix and pass the rest directly as the connection string
|
|
130
|
+
const connectionString = url.slice("odbc:///".length);
|
|
131
|
+
result = { type: "odbc", connectionString };
|
|
132
|
+
} else if (url.startsWith("mongodb://") || url.startsWith("mongodb+srv://")) {
|
|
133
|
+
// Pass through as-is; MongodbAdapter handles the full connection string
|
|
134
|
+
let parsed: URL;
|
|
135
|
+
try {
|
|
136
|
+
parsed = new URL(url);
|
|
137
|
+
} catch {
|
|
138
|
+
throw new Error(`Invalid MongoDB URL: ${url}`);
|
|
139
|
+
}
|
|
140
|
+
const database = parsed.pathname.replace(/^\//, "") || "tina4";
|
|
141
|
+
result = {
|
|
142
|
+
type: "mongodb",
|
|
143
|
+
host: parsed.hostname || undefined,
|
|
144
|
+
port: parsed.port ? parseInt(parsed.port, 10) : undefined,
|
|
145
|
+
user: parsed.username ? decodeURIComponent(parsed.username) : undefined,
|
|
146
|
+
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
|
|
147
|
+
database,
|
|
148
|
+
};
|
|
123
149
|
} else {
|
|
124
150
|
// Normalize postgres:// to postgresql:// for URL parsing
|
|
125
151
|
const normalizedUrl = url.startsWith("postgres://")
|
|
@@ -531,6 +557,17 @@ export class Database {
|
|
|
531
557
|
getNextId(table: string, pkColumn = "id", generatorName?: string): number {
|
|
532
558
|
const adapter = this.getNextAdapter();
|
|
533
559
|
|
|
560
|
+
// MongoDB — getNextId() is not supported synchronously.
|
|
561
|
+
// MongoDB uses ObjectId for primary keys by default.
|
|
562
|
+
// For integer sequences, use getNextIdAsync() instead.
|
|
563
|
+
if (this.dbType === "mongodb") {
|
|
564
|
+
throw new Error(
|
|
565
|
+
"getNextId() is not supported for MongoDB (async adapter). " +
|
|
566
|
+
"MongoDB uses ObjectId for _id by default. " +
|
|
567
|
+
"For integer sequences, use getNextIdAsync() or let MongoDB generate _id automatically.",
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
534
571
|
// Firebird — use generators (atomic)
|
|
535
572
|
if (this.dbType === "firebird") {
|
|
536
573
|
const genName = generatorName ?? `GEN_${table.toUpperCase()}_ID`;
|
|
@@ -641,6 +678,18 @@ async function createAdapterFromUrl(url: string, username?: string, password?: s
|
|
|
641
678
|
await adapter.connect();
|
|
642
679
|
return adapter;
|
|
643
680
|
}
|
|
681
|
+
case "mongodb": {
|
|
682
|
+
const { MongodbAdapter } = await import("./adapters/mongodb.js");
|
|
683
|
+
const adapter = new MongodbAdapter(url);
|
|
684
|
+
await adapter.connect();
|
|
685
|
+
return adapter;
|
|
686
|
+
}
|
|
687
|
+
case "odbc": {
|
|
688
|
+
const { OdbcAdapter } = await import("./adapters/odbc.js");
|
|
689
|
+
const adapter = new OdbcAdapter({ connectionString: parsed.connectionString ?? "" });
|
|
690
|
+
await adapter.connect();
|
|
691
|
+
return adapter;
|
|
692
|
+
}
|
|
644
693
|
}
|
|
645
694
|
}
|
|
646
695
|
|
|
@@ -730,6 +779,28 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
|
|
|
730
779
|
setAdapter(adapter);
|
|
731
780
|
return new Database(adapter);
|
|
732
781
|
}
|
|
782
|
+
case "mongodb": {
|
|
783
|
+
const { MongodbAdapter } = await import("./adapters/mongodb.js");
|
|
784
|
+
const creds = resolvedUser && resolvedPassword
|
|
785
|
+
? `${encodeURIComponent(resolvedUser)}:${encodeURIComponent(resolvedPassword)}@`
|
|
786
|
+
: "";
|
|
787
|
+
const host = config?.host ?? "localhost";
|
|
788
|
+
const port = config?.port ?? 27017;
|
|
789
|
+
const database = config?.database ?? "tina4";
|
|
790
|
+
const connectionString = `mongodb://${creds}${host}:${port}/${database}`;
|
|
791
|
+
const adapter = new MongodbAdapter(connectionString);
|
|
792
|
+
await adapter.connect();
|
|
793
|
+
setAdapter(adapter);
|
|
794
|
+
return new Database(adapter);
|
|
795
|
+
}
|
|
796
|
+
case "odbc": {
|
|
797
|
+
const { OdbcAdapter } = await import("./adapters/odbc.js");
|
|
798
|
+
const connStr = config?.connectionString ?? config?.url?.replace(/^odbc:\/\/\//, "") ?? "";
|
|
799
|
+
const adapter = new OdbcAdapter({ connectionString: connStr });
|
|
800
|
+
await adapter.connect();
|
|
801
|
+
setAdapter(adapter);
|
|
802
|
+
return new Database(adapter);
|
|
803
|
+
}
|
|
733
804
|
default:
|
|
734
805
|
throw new Error(`Unknown database type: ${type}`);
|
|
735
806
|
}
|