tina4-nodejs 3.0.0-rc.2
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/BENCHMARK_REPORT.md +96 -0
- package/CARBONAH.md +140 -0
- package/CLAUDE.md +599 -0
- package/COMPARISON.md +194 -0
- package/README.md +595 -0
- package/package.json +59 -0
- package/packages/cli/src/bin.ts +110 -0
- package/packages/cli/src/commands/init.ts +194 -0
- package/packages/cli/src/commands/migrate.ts +96 -0
- package/packages/cli/src/commands/migrateCreate.ts +59 -0
- package/packages/cli/src/commands/routes.ts +61 -0
- package/packages/cli/src/commands/serve.ts +58 -0
- package/packages/cli/src/commands/test.ts +83 -0
- package/packages/core/gallery/auth/meta.json +1 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
- package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
- package/packages/core/gallery/database/meta.json +1 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
- package/packages/core/gallery/error-overlay/meta.json +1 -0
- package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
- package/packages/core/gallery/orm/meta.json +1 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
- package/packages/core/gallery/queue/meta.json +1 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
- package/packages/core/gallery/rest-api/meta.json +1 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
- package/packages/core/gallery/templates/meta.json +1 -0
- package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
- package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
- package/packages/core/public/css/tina4.css +2463 -0
- package/packages/core/public/css/tina4.min.css +1 -0
- package/packages/core/public/favicon.ico +0 -0
- package/packages/core/public/images/logo.svg +5 -0
- package/packages/core/public/images/tina4-logo-icon.webp +0 -0
- package/packages/core/public/js/frond.min.js +420 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
- package/packages/core/public/js/tina4.min.js +93 -0
- package/packages/core/public/swagger/index.html +90 -0
- package/packages/core/public/swagger/oauth2-redirect.html +63 -0
- package/packages/core/src/ai.ts +359 -0
- package/packages/core/src/api.ts +248 -0
- package/packages/core/src/auth.ts +287 -0
- package/packages/core/src/cache.ts +121 -0
- package/packages/core/src/constants.ts +48 -0
- package/packages/core/src/container.ts +90 -0
- package/packages/core/src/devAdmin.ts +2024 -0
- package/packages/core/src/devMailbox.ts +316 -0
- package/packages/core/src/dotenv.ts +172 -0
- package/packages/core/src/errorOverlay.test.ts +122 -0
- package/packages/core/src/errorOverlay.ts +278 -0
- package/packages/core/src/events.ts +112 -0
- package/packages/core/src/fakeData.ts +309 -0
- package/packages/core/src/graphql.ts +812 -0
- package/packages/core/src/health.ts +31 -0
- package/packages/core/src/htmlElement.ts +172 -0
- package/packages/core/src/i18n.ts +136 -0
- package/packages/core/src/index.ts +88 -0
- package/packages/core/src/logger.ts +226 -0
- package/packages/core/src/messenger.ts +822 -0
- package/packages/core/src/middleware.ts +138 -0
- package/packages/core/src/queue.ts +481 -0
- package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
- package/packages/core/src/rateLimiter.ts +107 -0
- package/packages/core/src/request.ts +189 -0
- package/packages/core/src/response.ts +146 -0
- package/packages/core/src/routeDiscovery.ts +87 -0
- package/packages/core/src/router.ts +398 -0
- package/packages/core/src/scss.ts +366 -0
- package/packages/core/src/server.ts +610 -0
- package/packages/core/src/service.ts +380 -0
- package/packages/core/src/session.ts +480 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
- package/packages/core/src/static.ts +58 -0
- package/packages/core/src/testing.ts +233 -0
- package/packages/core/src/types.ts +98 -0
- package/packages/core/src/watcher.ts +37 -0
- package/packages/core/src/websocket.ts +408 -0
- package/packages/core/src/wsdl.ts +546 -0
- package/packages/core/templates/errors/302.twig +14 -0
- package/packages/core/templates/errors/401.twig +9 -0
- package/packages/core/templates/errors/403.twig +29 -0
- package/packages/core/templates/errors/404.twig +29 -0
- package/packages/core/templates/errors/500.twig +38 -0
- package/packages/core/templates/errors/502.twig +9 -0
- package/packages/core/templates/errors/503.twig +12 -0
- package/packages/core/templates/errors/base.twig +37 -0
- package/packages/frond/src/engine.ts +1475 -0
- package/packages/frond/src/index.ts +2 -0
- package/packages/orm/src/adapters/firebird.ts +455 -0
- package/packages/orm/src/adapters/mssql.ts +440 -0
- package/packages/orm/src/adapters/mysql.ts +355 -0
- package/packages/orm/src/adapters/postgres.ts +362 -0
- package/packages/orm/src/adapters/sqlite.ts +270 -0
- package/packages/orm/src/autoCrud.ts +231 -0
- package/packages/orm/src/baseModel.ts +536 -0
- package/packages/orm/src/database.ts +321 -0
- package/packages/orm/src/fakeData.ts +118 -0
- package/packages/orm/src/index.ts +49 -0
- package/packages/orm/src/migration.ts +392 -0
- package/packages/orm/src/model.ts +56 -0
- package/packages/orm/src/query.ts +113 -0
- package/packages/orm/src/seeder.ts +120 -0
- package/packages/orm/src/sqlTranslation.ts +272 -0
- package/packages/orm/src/types.ts +110 -0
- package/packages/orm/src/validation.ts +93 -0
- package/packages/swagger/src/generator.ts +189 -0
- package/packages/swagger/src/index.ts +2 -0
- package/packages/swagger/src/ui.ts +48 -0
- package/skills/tina4-developer.skill +0 -0
- package/skills/tina4-js.skill +0 -0
- package/skills/tina4-maintainer.skill +0 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import { getAdapter, getNamedAdapter } from "./database.js";
|
|
2
|
+
import { validate as validateFields } from "./validation.js";
|
|
3
|
+
import type { DatabaseAdapter, FieldDefinition, RelationshipDefinition } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* BaseModel provides instance methods for ORM models.
|
|
7
|
+
* Models extend this class and define static properties.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* class User extends BaseModel {
|
|
11
|
+
* static tableName = "users";
|
|
12
|
+
* static fields = { id: { type: "integer", primaryKey: true, autoIncrement: true }, ... };
|
|
13
|
+
* static softDelete = true;
|
|
14
|
+
* static tableFilter = "active = 1";
|
|
15
|
+
* static hasOne = [{ model: "Profile", foreignKey: "user_id" }];
|
|
16
|
+
* static hasMany = [{ model: "Post", foreignKey: "author_id" }];
|
|
17
|
+
* static _db = "secondary";
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
export class BaseModel {
|
|
21
|
+
static tableName: string;
|
|
22
|
+
static fields: Record<string, FieldDefinition>;
|
|
23
|
+
static softDelete?: boolean;
|
|
24
|
+
static tableFilter?: string;
|
|
25
|
+
static hasOne?: RelationshipDefinition[];
|
|
26
|
+
static hasMany?: RelationshipDefinition[];
|
|
27
|
+
static _db?: string;
|
|
28
|
+
|
|
29
|
+
/** Instance data */
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
|
|
32
|
+
constructor(data?: Record<string, unknown>) {
|
|
33
|
+
if (data) {
|
|
34
|
+
for (const [key, value] of Object.entries(data)) {
|
|
35
|
+
this[key] = value;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the database adapter for this model.
|
|
42
|
+
*/
|
|
43
|
+
protected static getDb(): DatabaseAdapter {
|
|
44
|
+
if (this._db) {
|
|
45
|
+
return getNamedAdapter(this._db);
|
|
46
|
+
}
|
|
47
|
+
return getAdapter();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get the primary key field name.
|
|
52
|
+
*/
|
|
53
|
+
protected static getPkField(): string {
|
|
54
|
+
return Object.entries(this.fields).find(([, def]) => def.primaryKey)?.[0] ?? "id";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Find a record by primary key.
|
|
59
|
+
*/
|
|
60
|
+
static findById<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown): T | null {
|
|
61
|
+
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
62
|
+
const db = ModelClass.getDb();
|
|
63
|
+
const pk = ModelClass.getPkField();
|
|
64
|
+
let sql = `SELECT * FROM "${ModelClass.tableName}" WHERE "${pk}" = ?`;
|
|
65
|
+
|
|
66
|
+
if (ModelClass.softDelete) {
|
|
67
|
+
sql += ` AND is_deleted = 0`;
|
|
68
|
+
}
|
|
69
|
+
if (ModelClass.tableFilter) {
|
|
70
|
+
sql += ` AND ${ModelClass.tableFilter}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const rows = db.query(sql, [id]);
|
|
74
|
+
if (rows.length === 0) return null;
|
|
75
|
+
return new ModelClass(rows[0] as Record<string, unknown>) as T;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Find all records, optionally with a where clause.
|
|
80
|
+
*/
|
|
81
|
+
static findAll<T extends BaseModel>(
|
|
82
|
+
this: new (data?: Record<string, unknown>) => T,
|
|
83
|
+
where?: string,
|
|
84
|
+
params?: unknown[],
|
|
85
|
+
): T[] {
|
|
86
|
+
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
87
|
+
const db = ModelClass.getDb();
|
|
88
|
+
|
|
89
|
+
const conditions: string[] = [];
|
|
90
|
+
if (ModelClass.softDelete) {
|
|
91
|
+
conditions.push("is_deleted = 0");
|
|
92
|
+
}
|
|
93
|
+
if (ModelClass.tableFilter) {
|
|
94
|
+
conditions.push(ModelClass.tableFilter);
|
|
95
|
+
}
|
|
96
|
+
if (where) {
|
|
97
|
+
conditions.push(where);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const whereClause = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
|
|
101
|
+
const sql = `SELECT * FROM "${ModelClass.tableName}"${whereClause}`;
|
|
102
|
+
|
|
103
|
+
const rows = db.query(sql, params);
|
|
104
|
+
return rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Save this instance (insert or update).
|
|
109
|
+
*/
|
|
110
|
+
save(): void {
|
|
111
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
112
|
+
const db = ModelClass.getDb();
|
|
113
|
+
const pk = ModelClass.getPkField();
|
|
114
|
+
const pkValue = this[pk];
|
|
115
|
+
|
|
116
|
+
if (pkValue !== undefined && pkValue !== null) {
|
|
117
|
+
// Update
|
|
118
|
+
const updateFields = Object.entries(ModelClass.fields).filter(
|
|
119
|
+
([name, def]) => !def.primaryKey && this[name] !== undefined,
|
|
120
|
+
);
|
|
121
|
+
if (updateFields.length === 0) return;
|
|
122
|
+
|
|
123
|
+
const setClause = updateFields.map(([k]) => `"${k}" = ?`).join(", ");
|
|
124
|
+
const values = [...updateFields.map(([k]) => this[k]), pkValue];
|
|
125
|
+
|
|
126
|
+
db.execute(`UPDATE "${ModelClass.tableName}" SET ${setClause} WHERE "${pk}" = ?`, values);
|
|
127
|
+
} else {
|
|
128
|
+
// Insert
|
|
129
|
+
const insertFields = Object.entries(ModelClass.fields).filter(
|
|
130
|
+
([name, def]) => !(def.primaryKey && def.autoIncrement) && this[name] !== undefined,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const columns = insertFields.map(([k]) => `"${k}"`).join(", ");
|
|
134
|
+
const placeholders = insertFields.map(() => "?").join(", ");
|
|
135
|
+
const values = insertFields.map(([k]) => this[k]);
|
|
136
|
+
|
|
137
|
+
const result = db.execute(
|
|
138
|
+
`INSERT INTO "${ModelClass.tableName}" (${columns}) VALUES (${placeholders})`,
|
|
139
|
+
values,
|
|
140
|
+
) as { lastInsertRowid?: number };
|
|
141
|
+
|
|
142
|
+
if (result.lastInsertRowid) {
|
|
143
|
+
this[pk] = result.lastInsertRowid;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Delete this instance. Uses soft delete if configured.
|
|
150
|
+
*/
|
|
151
|
+
delete(): void {
|
|
152
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
153
|
+
const db = ModelClass.getDb();
|
|
154
|
+
const pk = ModelClass.getPkField();
|
|
155
|
+
const pkValue = this[pk];
|
|
156
|
+
|
|
157
|
+
if (pkValue === undefined || pkValue === null) {
|
|
158
|
+
throw new Error("Cannot delete a model without a primary key value");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (ModelClass.softDelete) {
|
|
162
|
+
db.execute(
|
|
163
|
+
`UPDATE "${ModelClass.tableName}" SET is_deleted = 1 WHERE "${pk}" = ?`,
|
|
164
|
+
[pkValue],
|
|
165
|
+
);
|
|
166
|
+
this.is_deleted = 1;
|
|
167
|
+
} else {
|
|
168
|
+
db.execute(
|
|
169
|
+
`DELETE FROM "${ModelClass.tableName}" WHERE "${pk}" = ?`,
|
|
170
|
+
[pkValue],
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Convert to plain object (dictionary).
|
|
177
|
+
*/
|
|
178
|
+
toDict(): Record<string, unknown> {
|
|
179
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
180
|
+
const result: Record<string, unknown> = {};
|
|
181
|
+
for (const key of Object.keys(ModelClass.fields)) {
|
|
182
|
+
if (this[key] !== undefined) {
|
|
183
|
+
result[key] = this[key];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Include relationship data
|
|
187
|
+
if (ModelClass.hasOne) {
|
|
188
|
+
for (const rel of ModelClass.hasOne) {
|
|
189
|
+
const relKey = rel.model.toLowerCase();
|
|
190
|
+
if (this[relKey] !== undefined) {
|
|
191
|
+
result[relKey] = this[relKey];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (ModelClass.hasMany) {
|
|
196
|
+
for (const rel of ModelClass.hasMany) {
|
|
197
|
+
const relKey = rel.model.toLowerCase() + "s";
|
|
198
|
+
if (this[relKey] !== undefined) {
|
|
199
|
+
result[relKey] = this[relKey];
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Include soft delete field
|
|
204
|
+
if (ModelClass.softDelete && this.is_deleted !== undefined) {
|
|
205
|
+
result.is_deleted = this.is_deleted;
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Convert to a plain object (alias for toDict).
|
|
212
|
+
*/
|
|
213
|
+
toObject(): Record<string, unknown> {
|
|
214
|
+
return this.toDict();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Convert to an array of values.
|
|
219
|
+
*/
|
|
220
|
+
toArray(): unknown[] {
|
|
221
|
+
return Object.values(this.toDict());
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Convert to a list (alias for toArray).
|
|
226
|
+
*/
|
|
227
|
+
toList(): unknown[] {
|
|
228
|
+
return this.toArray();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Convert to JSON string.
|
|
233
|
+
*/
|
|
234
|
+
toJson(): string {
|
|
235
|
+
return JSON.stringify(this.toDict());
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Validate this instance's values against the model's field definitions.
|
|
240
|
+
* Returns an array of error strings (empty array means valid).
|
|
241
|
+
*/
|
|
242
|
+
validate(): string[] {
|
|
243
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
244
|
+
const data: Record<string, unknown> = {};
|
|
245
|
+
for (const name of Object.keys(ModelClass.fields)) {
|
|
246
|
+
data[name] = this[name];
|
|
247
|
+
}
|
|
248
|
+
const errors = validateFields(data, ModelClass.fields);
|
|
249
|
+
return errors.map((e) => `${e.field} ${e.message}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Generate and execute CREATE TABLE DDL from the model's field definitions.
|
|
254
|
+
* Uses the adapter's createTable method if available, otherwise builds SQL directly.
|
|
255
|
+
*/
|
|
256
|
+
static createTable(): void {
|
|
257
|
+
const db = this.getDb();
|
|
258
|
+
if (db.tableExists(this.tableName)) return;
|
|
259
|
+
|
|
260
|
+
if (typeof db.createTable === "function") {
|
|
261
|
+
db.createTable(this.tableName, this.fields);
|
|
262
|
+
} else {
|
|
263
|
+
// Fallback: build SQL manually
|
|
264
|
+
const typeMap: Record<string, string> = {
|
|
265
|
+
integer: "INTEGER",
|
|
266
|
+
string: "TEXT",
|
|
267
|
+
text: "TEXT",
|
|
268
|
+
number: "REAL",
|
|
269
|
+
numeric: "REAL",
|
|
270
|
+
boolean: "INTEGER",
|
|
271
|
+
datetime: "TEXT",
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const colDefs: string[] = [];
|
|
275
|
+
for (const [colName, def] of Object.entries(this.fields)) {
|
|
276
|
+
const sqlType = typeMap[def.type] || "TEXT";
|
|
277
|
+
const parts = [`"${colName}" ${sqlType}`];
|
|
278
|
+
if (def.primaryKey) parts.push("PRIMARY KEY");
|
|
279
|
+
if (def.autoIncrement) parts.push("AUTOINCREMENT");
|
|
280
|
+
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
281
|
+
if (def.default !== undefined) {
|
|
282
|
+
const dv = typeof def.default === "string" ? `'${def.default}'` : String(def.default);
|
|
283
|
+
parts.push(`DEFAULT ${dv}`);
|
|
284
|
+
}
|
|
285
|
+
colDefs.push(parts.join(" "));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const sql = `CREATE TABLE IF NOT EXISTS "${this.tableName}" (${colDefs.join(", ")})`;
|
|
289
|
+
db.execute(sql);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Find a record by primary key or throw an error if not found.
|
|
295
|
+
*/
|
|
296
|
+
static findOrFail<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown): T {
|
|
297
|
+
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
298
|
+
const result = ModelClass.findById(id) as T | null;
|
|
299
|
+
if (result === null) {
|
|
300
|
+
throw new Error(`${ModelClass.tableName}: record with id ${id} not found`);
|
|
301
|
+
}
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Execute a raw SQL SELECT and return results as model instances.
|
|
307
|
+
*/
|
|
308
|
+
static select<T extends BaseModel>(
|
|
309
|
+
this: new (data?: Record<string, unknown>) => T,
|
|
310
|
+
sql: string,
|
|
311
|
+
params?: unknown[],
|
|
312
|
+
): T[] {
|
|
313
|
+
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
314
|
+
const db = ModelClass.getDb();
|
|
315
|
+
const rows = db.query(sql, params);
|
|
316
|
+
return rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Permanently delete this instance, bypassing soft delete.
|
|
321
|
+
*/
|
|
322
|
+
forceDelete(): void {
|
|
323
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
324
|
+
const db = ModelClass.getDb();
|
|
325
|
+
const pk = ModelClass.getPkField();
|
|
326
|
+
const pkValue = this[pk];
|
|
327
|
+
|
|
328
|
+
if (pkValue === undefined || pkValue === null) {
|
|
329
|
+
throw new Error("Cannot delete a model without a primary key value");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
db.execute(
|
|
333
|
+
`DELETE FROM "${ModelClass.tableName}" WHERE "${pk}" = ?`,
|
|
334
|
+
[pkValue],
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Restore a soft-deleted record.
|
|
340
|
+
*/
|
|
341
|
+
restore(): void {
|
|
342
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
343
|
+
if (!ModelClass.softDelete) {
|
|
344
|
+
throw new Error("restore() is only available on models with softDelete enabled");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const db = ModelClass.getDb();
|
|
348
|
+
const pk = ModelClass.getPkField();
|
|
349
|
+
const pkValue = this[pk];
|
|
350
|
+
|
|
351
|
+
if (pkValue === undefined || pkValue === null) {
|
|
352
|
+
throw new Error("Cannot restore a model without a primary key value");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
db.execute(
|
|
356
|
+
`UPDATE "${ModelClass.tableName}" SET is_deleted = 0 WHERE "${pk}" = ?`,
|
|
357
|
+
[pkValue],
|
|
358
|
+
);
|
|
359
|
+
this.is_deleted = 0;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Find records including soft-deleted ones.
|
|
364
|
+
*/
|
|
365
|
+
static withTrashed<T extends BaseModel>(
|
|
366
|
+
this: new (data?: Record<string, unknown>) => T,
|
|
367
|
+
conditions?: string,
|
|
368
|
+
params?: unknown[],
|
|
369
|
+
limit?: number,
|
|
370
|
+
skip?: number,
|
|
371
|
+
): T[] {
|
|
372
|
+
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
373
|
+
const db = ModelClass.getDb();
|
|
374
|
+
|
|
375
|
+
const parts: string[] = [];
|
|
376
|
+
if (ModelClass.tableFilter) {
|
|
377
|
+
parts.push(ModelClass.tableFilter);
|
|
378
|
+
}
|
|
379
|
+
if (conditions) {
|
|
380
|
+
parts.push(conditions);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let sql = `SELECT * FROM "${ModelClass.tableName}"`;
|
|
384
|
+
if (parts.length > 0) {
|
|
385
|
+
sql += ` WHERE ${parts.join(" AND ")}`;
|
|
386
|
+
}
|
|
387
|
+
if (limit !== undefined) {
|
|
388
|
+
sql += ` LIMIT ${limit}`;
|
|
389
|
+
}
|
|
390
|
+
if (skip !== undefined) {
|
|
391
|
+
sql += ` OFFSET ${skip}`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const rows = db.query(sql, params);
|
|
395
|
+
return rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Count records matching conditions (respects soft delete and table filter).
|
|
400
|
+
*/
|
|
401
|
+
static count(conditions?: string, params?: unknown[]): number {
|
|
402
|
+
const db = this.getDb();
|
|
403
|
+
const parts: string[] = [];
|
|
404
|
+
if (this.softDelete) {
|
|
405
|
+
parts.push("is_deleted = 0");
|
|
406
|
+
}
|
|
407
|
+
if (this.tableFilter) {
|
|
408
|
+
parts.push(this.tableFilter);
|
|
409
|
+
}
|
|
410
|
+
if (conditions) {
|
|
411
|
+
parts.push(conditions);
|
|
412
|
+
}
|
|
413
|
+
const whereClause = parts.length > 0 ? ` WHERE ${parts.join(" AND ")}` : "";
|
|
414
|
+
const sql = `SELECT COUNT(*) as cnt FROM "${this.tableName}"${whereClause}`;
|
|
415
|
+
const rows = db.query(sql, params);
|
|
416
|
+
return rows.length > 0 ? (rows[0] as any).cnt : 0;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Apply a named scope (reusable query filter).
|
|
421
|
+
*/
|
|
422
|
+
static scope<T extends BaseModel>(
|
|
423
|
+
this: new (data?: Record<string, unknown>) => T,
|
|
424
|
+
name: string,
|
|
425
|
+
filterSql: string,
|
|
426
|
+
params?: unknown[],
|
|
427
|
+
): T[] {
|
|
428
|
+
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
429
|
+
const db = ModelClass.getDb();
|
|
430
|
+
|
|
431
|
+
const conditions: string[] = [];
|
|
432
|
+
if (ModelClass.softDelete) {
|
|
433
|
+
conditions.push("is_deleted = 0");
|
|
434
|
+
}
|
|
435
|
+
if (ModelClass.tableFilter) {
|
|
436
|
+
conditions.push(ModelClass.tableFilter);
|
|
437
|
+
}
|
|
438
|
+
conditions.push(filterSql);
|
|
439
|
+
|
|
440
|
+
const sql = `SELECT * FROM "${ModelClass.tableName}" WHERE ${conditions.join(" AND ")}`;
|
|
441
|
+
const rows = db.query(sql, params);
|
|
442
|
+
return rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Load a has-one related model instance.
|
|
447
|
+
*/
|
|
448
|
+
hasOne<T extends BaseModel, R extends BaseModel>(
|
|
449
|
+
this: T,
|
|
450
|
+
relatedClass: typeof BaseModel & (new (data?: Record<string, unknown>) => R),
|
|
451
|
+
foreignKey: string,
|
|
452
|
+
): R | null {
|
|
453
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
454
|
+
const pk = ModelClass.getPkField();
|
|
455
|
+
const pkValue = this[pk];
|
|
456
|
+
|
|
457
|
+
if (pkValue === undefined || pkValue === null) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const db = relatedClass.getDb();
|
|
462
|
+
let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${foreignKey}" = ?`;
|
|
463
|
+
if (relatedClass.softDelete) {
|
|
464
|
+
sql += ` AND is_deleted = 0`;
|
|
465
|
+
}
|
|
466
|
+
sql += ` LIMIT 1`;
|
|
467
|
+
|
|
468
|
+
const rows = db.query(sql, [pkValue]);
|
|
469
|
+
if (rows.length === 0) return null;
|
|
470
|
+
|
|
471
|
+
const related = new relatedClass(rows[0] as Record<string, unknown>) as R;
|
|
472
|
+
const relKey = relatedClass.tableName.toLowerCase();
|
|
473
|
+
this[relKey] = related;
|
|
474
|
+
return related;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Load has-many related model instances.
|
|
479
|
+
*/
|
|
480
|
+
hasMany<T extends BaseModel, R extends BaseModel>(
|
|
481
|
+
this: T,
|
|
482
|
+
relatedClass: typeof BaseModel & (new (data?: Record<string, unknown>) => R),
|
|
483
|
+
foreignKey: string,
|
|
484
|
+
): R[] {
|
|
485
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
486
|
+
const pk = ModelClass.getPkField();
|
|
487
|
+
const pkValue = this[pk];
|
|
488
|
+
|
|
489
|
+
if (pkValue === undefined || pkValue === null) {
|
|
490
|
+
return [];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const db = relatedClass.getDb();
|
|
494
|
+
let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${foreignKey}" = ?`;
|
|
495
|
+
if (relatedClass.softDelete) {
|
|
496
|
+
sql += ` AND is_deleted = 0`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const rows = db.query(sql, [pkValue]);
|
|
500
|
+
const related = rows.map((row) => new relatedClass(row as Record<string, unknown>) as R);
|
|
501
|
+
const relKey = relatedClass.tableName.toLowerCase();
|
|
502
|
+
this[relKey] = related;
|
|
503
|
+
return related;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Load the parent model this instance belongs to.
|
|
508
|
+
*/
|
|
509
|
+
belongsTo<T extends BaseModel, R extends BaseModel>(
|
|
510
|
+
this: T,
|
|
511
|
+
relatedClass: typeof BaseModel & (new (data?: Record<string, unknown>) => R),
|
|
512
|
+
foreignKey: string,
|
|
513
|
+
): R | null {
|
|
514
|
+
const fkValue = this[foreignKey];
|
|
515
|
+
|
|
516
|
+
if (fkValue === undefined || fkValue === null) {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const db = relatedClass.getDb();
|
|
521
|
+
const relatedPk = relatedClass.getPkField();
|
|
522
|
+
let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${relatedPk}" = ?`;
|
|
523
|
+
if (relatedClass.softDelete) {
|
|
524
|
+
sql += ` AND is_deleted = 0`;
|
|
525
|
+
}
|
|
526
|
+
sql += ` LIMIT 1`;
|
|
527
|
+
|
|
528
|
+
const rows = db.query(sql, [fkValue]);
|
|
529
|
+
if (rows.length === 0) return null;
|
|
530
|
+
|
|
531
|
+
const related = new relatedClass(rows[0] as Record<string, unknown>) as R;
|
|
532
|
+
const relKey = relatedClass.tableName.toLowerCase();
|
|
533
|
+
this[relKey] = related;
|
|
534
|
+
return related;
|
|
535
|
+
}
|
|
536
|
+
}
|