canxjs 1.2.2 → 1.2.4
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/README.md +23 -1
- package/dist/auth/Auth.d.ts +19 -2
- package/dist/auth/Auth.d.ts.map +1 -1
- package/dist/auth/drivers/DatabaseSessionDriver.d.ts +13 -0
- package/dist/auth/drivers/DatabaseSessionDriver.d.ts.map +1 -0
- package/dist/database/Migration.d.ts +2 -1
- package/dist/database/Migration.d.ts.map +1 -1
- package/dist/database/migrations/20240114000000_create_sessions_table.d.ts +3 -0
- package/dist/database/migrations/20240114000000_create_sessions_table.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +395 -23
- package/dist/mvc/Model.d.ts +55 -10
- package/dist/mvc/Model.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/Validator.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/auth/Auth.ts +51 -3
- package/src/auth/drivers/DatabaseSessionDriver.ts +80 -0
- package/src/database/Migration.ts +37 -8
- package/src/database/migrations/20240114000000_create_sessions_table.ts +18 -0
- package/src/index.ts +1 -0
- package/src/mvc/Model.ts +325 -24
- package/src/types/index.ts +1 -0
- package/src/utils/Validator.ts +124 -2
package/src/mvc/Model.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { DatabaseConfig, DatabaseDriver, ModelField, ModelSchema, QueryBuil
|
|
|
7
7
|
// Database connection pool
|
|
8
8
|
let mysqlPool: any = null;
|
|
9
9
|
let pgPool: any = null;
|
|
10
|
+
let sqliteDb: any = null;
|
|
10
11
|
let currentDriver: DatabaseDriver = 'mysql';
|
|
11
12
|
let dbConfig: DatabaseConfig | null = null;
|
|
12
13
|
|
|
@@ -39,12 +40,21 @@ export async function initDatabase(config: DatabaseConfig): Promise<void> {
|
|
|
39
40
|
idleTimeoutMillis: config.pool?.idle || 30000,
|
|
40
41
|
});
|
|
41
42
|
console.log('[CanxJS] PostgreSQL connection pool created');
|
|
43
|
+
} else if (config.driver === 'sqlite') {
|
|
44
|
+
const { Database } = await import('bun:sqlite');
|
|
45
|
+
sqliteDb = new Database(config.database);
|
|
46
|
+
console.log('[CanxJS] SQLite database connected');
|
|
42
47
|
}
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
export async function closeDatabase(): Promise<void> {
|
|
46
51
|
if (mysqlPool) { await mysqlPool.end(); mysqlPool = null; }
|
|
47
52
|
if (pgPool) { await pgPool.end(); pgPool = null; }
|
|
53
|
+
if (sqliteDb) { sqliteDb.close(); sqliteDb = null; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getCurrentDriver(): DatabaseDriver {
|
|
57
|
+
return currentDriver;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
async function query<T = any>(sql: string, params: any[] = []): Promise<T[]> {
|
|
@@ -59,6 +69,12 @@ async function query<T = any>(sql: string, params: any[] = []): Promise<T[]> {
|
|
|
59
69
|
const pgSql = sql.replace(/\?/g, () => `$${++idx}`);
|
|
60
70
|
const result = await pgPool.query(pgSql, params);
|
|
61
71
|
return result.rows as T[];
|
|
72
|
+
} else if (currentDriver === 'sqlite' && sqliteDb) {
|
|
73
|
+
const query = sqliteDb.query(sql);
|
|
74
|
+
// bun:sqlite uses $1, $2 or named params, but handle simple ? for compatibility?
|
|
75
|
+
// Bun sqlite actually supports ? binding since recent versions or via .all(...params)
|
|
76
|
+
// Let's try direct binding.
|
|
77
|
+
return query.all(...params) as T[];
|
|
62
78
|
}
|
|
63
79
|
throw new Error('No database connection');
|
|
64
80
|
}
|
|
@@ -74,12 +90,28 @@ async function execute(sql: string, params: any[] = []): Promise<{ affectedRows:
|
|
|
74
90
|
const pgSql = sql.replace(/\?/g, () => `$${++idx}`);
|
|
75
91
|
const result = await pgPool.query(pgSql + ' RETURNING *', params);
|
|
76
92
|
return { affectedRows: result.rowCount || 0, insertId: result.rows[0]?.id || 0 };
|
|
93
|
+
} else if (currentDriver === 'sqlite' && sqliteDb) {
|
|
94
|
+
const query = sqliteDb.query(sql);
|
|
95
|
+
const result = query.run(...params);
|
|
96
|
+
return { affectedRows: result.changes, insertId: result.lastInsertRowid };
|
|
77
97
|
}
|
|
78
98
|
throw new Error('No database connection');
|
|
79
99
|
}
|
|
80
100
|
|
|
101
|
+
// Relation Metadata interface
|
|
102
|
+
interface RelationInfo {
|
|
103
|
+
type: 'hasOne' | 'hasMany' | 'belongsTo' | 'belongsToMany';
|
|
104
|
+
relatedClass: any;
|
|
105
|
+
foreignKey: string;
|
|
106
|
+
localKey?: string;
|
|
107
|
+
ownerKey?: string;
|
|
108
|
+
pivotTable?: string;
|
|
109
|
+
foreignPivotKey?: string;
|
|
110
|
+
relatedPivotKey?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
81
113
|
// Query Builder implementation
|
|
82
|
-
class QueryBuilderImpl<T> implements QueryBuilder<T> {
|
|
114
|
+
export class QueryBuilderImpl<T> implements QueryBuilder<T> {
|
|
83
115
|
private table: string;
|
|
84
116
|
private selectCols: string[] = ['*'];
|
|
85
117
|
private whereClauses: string[] = [];
|
|
@@ -90,8 +122,21 @@ class QueryBuilderImpl<T> implements QueryBuilder<T> {
|
|
|
90
122
|
private groupClauses: string[] = [];
|
|
91
123
|
private havingClauses: string[] = [];
|
|
92
124
|
private bindings: any[] = [];
|
|
93
|
-
|
|
94
|
-
|
|
125
|
+
|
|
126
|
+
// Model mapping
|
|
127
|
+
private modelClass?: any;
|
|
128
|
+
|
|
129
|
+
// Eager Loading
|
|
130
|
+
private withRelations: string[] = [];
|
|
131
|
+
// Internal relation info attached by relationship methods
|
|
132
|
+
public _relationInfo?: RelationInfo;
|
|
133
|
+
|
|
134
|
+
constructor(table: string, modelClass?: any) {
|
|
135
|
+
this.table = table;
|
|
136
|
+
this.modelClass = modelClass;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ... existing methods ...
|
|
95
140
|
|
|
96
141
|
select(...cols: any[]): this { this.selectCols = cols.length ? cols : ['*']; return this; }
|
|
97
142
|
|
|
@@ -100,8 +145,12 @@ class QueryBuilderImpl<T> implements QueryBuilder<T> {
|
|
|
100
145
|
this.bindings.push(val);
|
|
101
146
|
return this;
|
|
102
147
|
}
|
|
103
|
-
|
|
148
|
+
|
|
104
149
|
whereIn(col: any, vals: any[]): this {
|
|
150
|
+
if (vals.length === 0) {
|
|
151
|
+
this.whereClauses.push('1 = 0'); // False condition if empty
|
|
152
|
+
return this;
|
|
153
|
+
}
|
|
105
154
|
this.whereClauses.push(`${String(col)} IN (${vals.map(() => '?').join(',')})`);
|
|
106
155
|
this.bindings.push(...vals);
|
|
107
156
|
return this;
|
|
@@ -125,7 +174,7 @@ class QueryBuilderImpl<T> implements QueryBuilder<T> {
|
|
|
125
174
|
this.orderClauses.push(`${String(col)} ${dir.toUpperCase()}`);
|
|
126
175
|
return this;
|
|
127
176
|
}
|
|
128
|
-
|
|
177
|
+
|
|
129
178
|
limit(n: number): this { this.limitVal = n; return this; }
|
|
130
179
|
offset(n: number): this { this.offsetVal = n; return this; }
|
|
131
180
|
|
|
@@ -133,7 +182,7 @@ class QueryBuilderImpl<T> implements QueryBuilder<T> {
|
|
|
133
182
|
this.joinClauses.push(`INNER JOIN ${table} ON ${first} ${op} ${second}`);
|
|
134
183
|
return this;
|
|
135
184
|
}
|
|
136
|
-
|
|
185
|
+
|
|
137
186
|
leftJoin(table: string, first: string, op: string, second: string): this {
|
|
138
187
|
this.joinClauses.push(`LEFT JOIN ${table} ON ${first} ${op} ${second}`);
|
|
139
188
|
return this;
|
|
@@ -146,6 +195,12 @@ class QueryBuilderImpl<T> implements QueryBuilder<T> {
|
|
|
146
195
|
this.bindings.push(val);
|
|
147
196
|
return this;
|
|
148
197
|
}
|
|
198
|
+
|
|
199
|
+
// Eager Loading
|
|
200
|
+
with(...relations: string[]): this {
|
|
201
|
+
this.withRelations.push(...relations);
|
|
202
|
+
return this;
|
|
203
|
+
}
|
|
149
204
|
|
|
150
205
|
private buildSelect(): string {
|
|
151
206
|
let sql = `SELECT ${this.selectCols.join(', ')} FROM ${this.table}`;
|
|
@@ -159,11 +214,129 @@ class QueryBuilderImpl<T> implements QueryBuilder<T> {
|
|
|
159
214
|
return sql;
|
|
160
215
|
}
|
|
161
216
|
|
|
162
|
-
async get(): Promise<T[]> {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
217
|
+
async get(): Promise<T[]> {
|
|
218
|
+
const rows = await query<any>(this.buildSelect(), this.bindings);
|
|
219
|
+
let results: T[] = rows as T[];
|
|
220
|
+
|
|
221
|
+
if (this.modelClass) {
|
|
222
|
+
results = rows.map(r => new this.modelClass().fill(r));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Process Eager Loading
|
|
226
|
+
if (this.withRelations.length > 0 && this.modelClass && results.length > 0) {
|
|
227
|
+
await this.eagerLoad(results);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return results;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async eagerLoad(results: any[]) {
|
|
234
|
+
// We assume results are instances of modelClass
|
|
235
|
+
const instance = new this.modelClass();
|
|
236
|
+
|
|
237
|
+
for (const relationName of this.withRelations) {
|
|
238
|
+
if (typeof instance[relationName] !== 'function') {
|
|
239
|
+
console.warn(`[Model] Relation method '${relationName}' not found on ${this.modelClass.name}`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Get relation definition by calling the method
|
|
244
|
+
// The method returns a QueryBuilder with _relationInfo attached
|
|
245
|
+
const relationQuery = instance[relationName]();
|
|
246
|
+
const info = (relationQuery as any)._relationInfo as RelationInfo;
|
|
247
|
+
|
|
248
|
+
if (!info) {
|
|
249
|
+
console.warn(`[Model] Method '${relationName}' did not return a valid relation QueryBuilder`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (info.type === 'hasMany' || info.type === 'hasOne') {
|
|
254
|
+
const localKey = info.localKey || 'id';
|
|
255
|
+
const foreignKey = info.foreignKey;
|
|
256
|
+
|
|
257
|
+
const parentIds = results.map(r => r[localKey]).filter(id => id !== undefined && id !== null);
|
|
258
|
+
if (parentIds.length === 0) continue;
|
|
259
|
+
|
|
260
|
+
// Ensure unique IDs
|
|
261
|
+
const uniqueIds = [...new Set(parentIds)];
|
|
262
|
+
|
|
263
|
+
// Fetch related
|
|
264
|
+
const relatedResults = await info.relatedClass.query().whereIn(foreignKey, uniqueIds).get();
|
|
265
|
+
|
|
266
|
+
// Map back
|
|
267
|
+
for (const parent of results) {
|
|
268
|
+
const parentId = parent[localKey];
|
|
269
|
+
if (info.type === 'hasMany') {
|
|
270
|
+
parent.startRelation(relationName);
|
|
271
|
+
parent.relations[relationName] = relatedResults.filter((r: any) => r[foreignKey] == parentId);
|
|
272
|
+
} else {
|
|
273
|
+
// hasOne
|
|
274
|
+
parent.startRelation(relationName);
|
|
275
|
+
parent.relations[relationName] = relatedResults.find((r: any) => r[foreignKey] == parentId) || null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
} else if (info.type === 'belongsTo') {
|
|
280
|
+
const foreignKey = info.foreignKey; // e.g. user_id on Post
|
|
281
|
+
const ownerKey = info.ownerKey || 'id'; // e.g. id on User
|
|
282
|
+
|
|
283
|
+
const relatedIds = results.map(r => r[foreignKey]).filter(id => id !== undefined && id !== null);
|
|
284
|
+
if (relatedIds.length === 0) continue;
|
|
285
|
+
|
|
286
|
+
const uniqueIds = [...new Set(relatedIds)];
|
|
287
|
+
const relatedResults = await info.relatedClass.query().whereIn(ownerKey, uniqueIds).get();
|
|
288
|
+
|
|
289
|
+
for (const parent of results) {
|
|
290
|
+
const relatedId = parent[foreignKey];
|
|
291
|
+
parent.startRelation(relationName);
|
|
292
|
+
parent.relations[relationName] = relatedResults.find((r: any) => r[ownerKey] == relatedId) || null;
|
|
293
|
+
}
|
|
294
|
+
} else if (info.type === 'belongsToMany') {
|
|
295
|
+
// Pivot logic
|
|
296
|
+
const localKey = 'id';
|
|
297
|
+
const parentIds = results.map(r => r[localKey]).filter(id => id !== undefined && id !== null);
|
|
298
|
+
if (parentIds.length === 0) continue;
|
|
299
|
+
const uniqueIds = [...new Set(parentIds)];
|
|
300
|
+
|
|
301
|
+
// 1. Get pivot rows
|
|
302
|
+
// SELECT * FROM pivot WHERE foreignPivotKey IN (ids)
|
|
303
|
+
const pivotSql = `SELECT * FROM ${info.pivotTable} WHERE ${info.foreignPivotKey} IN (${uniqueIds.map(() => '?').join(',')})`;
|
|
304
|
+
const pivotRows = await query<any>(pivotSql, uniqueIds);
|
|
305
|
+
|
|
306
|
+
if (pivotRows.length === 0) {
|
|
307
|
+
for (const parent of results) {
|
|
308
|
+
parent.startRelation(relationName);
|
|
309
|
+
parent.relations[relationName] = [];
|
|
310
|
+
}
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 2. Get related rows
|
|
315
|
+
const relatedIds = pivotRows.map(r => r[info.relatedPivotKey!]);
|
|
316
|
+
const uniqueRelatedIds = [...new Set(relatedIds)];
|
|
317
|
+
const relatedResults = await info.relatedClass.query().whereIn('id', uniqueRelatedIds).get();
|
|
318
|
+
|
|
319
|
+
// 3. Map back
|
|
320
|
+
for (const parent of results) {
|
|
321
|
+
parent.startRelation(relationName);
|
|
322
|
+
const myPivotRows = pivotRows.filter(r => r[info.foreignPivotKey!] == parent[localKey]);
|
|
323
|
+
const myRelatedIds = myPivotRows.map(r => r[info.relatedPivotKey!]);
|
|
324
|
+
|
|
325
|
+
parent.relations[relationName] = relatedResults.filter((r: any) => myRelatedIds.includes(r.id));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async first(): Promise<T | null> {
|
|
332
|
+
this.limitVal = 1;
|
|
333
|
+
const rows = await this.get();
|
|
334
|
+
return rows[0] || null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async count(): Promise<number> { this.selectCols = ['COUNT(*) as count']; const r = await query<any>(this.buildSelect(), this.bindings); return (r[0] as any)?.count || 0; }
|
|
338
|
+
async sum(col: any): Promise<number> { this.selectCols = [`SUM(${String(col)}) as sum`]; const r = await query<any>(this.buildSelect(), this.bindings); return (r[0] as any)?.sum || 0; }
|
|
339
|
+
async avg(col: any): Promise<number> { this.selectCols = [`AVG(${String(col)}) as avg`]; const r = await query<any>(this.buildSelect(), this.bindings); return (r[0] as any)?.avg || 0; }
|
|
167
340
|
|
|
168
341
|
async insert(data: Partial<T> | Partial<T>[]): Promise<T> {
|
|
169
342
|
const items = Array.isArray(data) ? data : [data];
|
|
@@ -172,6 +345,12 @@ class QueryBuilderImpl<T> implements QueryBuilder<T> {
|
|
|
172
345
|
const placeholders = values.map(() => `(${keys.map(() => '?').join(',')})`).join(',');
|
|
173
346
|
const sql = `INSERT INTO ${this.table} (${keys.join(',')}) VALUES ${placeholders}`;
|
|
174
347
|
const result = await execute(sql, values.flat());
|
|
348
|
+
|
|
349
|
+
// If modelClass is set, return instance?
|
|
350
|
+
// insert() signature returns T.
|
|
351
|
+
if (this.modelClass) {
|
|
352
|
+
return new this.modelClass().fill({ ...(items[0] as any), id: result.insertId });
|
|
353
|
+
}
|
|
175
354
|
return { ...(items[0] as any), id: result.insertId } as T;
|
|
176
355
|
}
|
|
177
356
|
|
|
@@ -196,47 +375,169 @@ class QueryBuilderImpl<T> implements QueryBuilder<T> {
|
|
|
196
375
|
}
|
|
197
376
|
|
|
198
377
|
// Base Model class
|
|
199
|
-
export abstract class Model
|
|
378
|
+
export abstract class Model {
|
|
200
379
|
protected static tableName: string;
|
|
201
380
|
protected static primaryKey: string = 'id';
|
|
202
381
|
protected static timestamps: boolean = true;
|
|
382
|
+
|
|
383
|
+
// Instance properties
|
|
384
|
+
[key: string]: any;
|
|
385
|
+
|
|
386
|
+
// Relations storage
|
|
387
|
+
public relations: Record<string, any> = {};
|
|
203
388
|
|
|
204
|
-
|
|
205
|
-
|
|
389
|
+
constructor(data?: any) {
|
|
390
|
+
if (data) Object.assign(this, data);
|
|
206
391
|
}
|
|
207
392
|
|
|
208
|
-
|
|
209
|
-
|
|
393
|
+
// Static helper to get entries as Instances
|
|
394
|
+
static table<T extends Model>(this: new () => T): QueryBuilder<T> {
|
|
395
|
+
return new QueryBuilderImpl<T>((this as any).tableName, this);
|
|
210
396
|
}
|
|
211
397
|
|
|
212
|
-
|
|
213
|
-
|
|
398
|
+
// Find by ID - returns Instance
|
|
399
|
+
static async find<T extends Model>(this: { new (): T } & typeof Model, id: number | string): Promise<T | null> {
|
|
400
|
+
return this.table<T>().where(this.primaryKey, '=', id).first();
|
|
214
401
|
}
|
|
215
402
|
|
|
216
|
-
|
|
403
|
+
// Chainable query builder
|
|
404
|
+
static query<T extends Model>(this: { new (): T } & typeof Model): QueryBuilder<T> {
|
|
405
|
+
return new QueryBuilderImpl<T>(this.tableName, this);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
static async all<T extends Model>(this: { new (): T } & typeof Model): Promise<T[]> {
|
|
409
|
+
return this.query<T>().get();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Init relation storage
|
|
413
|
+
startRelation(name: string) {
|
|
414
|
+
if (!this.relations) this.relations = {};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Lazy load relations
|
|
418
|
+
async load(...relations: string[]): Promise<this> {
|
|
419
|
+
// Create a builder just to use its eagerLoad ability
|
|
420
|
+
const builder = new QueryBuilderImpl<any>((this.constructor as any).tableName, this.constructor);
|
|
421
|
+
builder.with(...relations);
|
|
422
|
+
await builder.eagerLoad([this]);
|
|
423
|
+
return this;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Start Eager Loading
|
|
427
|
+
static with<T extends Model>(this: { new (): T } & typeof Model, ...relations: string[]): QueryBuilder<T> {
|
|
428
|
+
return this.query<T>().with(...relations);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Helper to fill data (chainable)
|
|
432
|
+
fill(data: any): this {
|
|
433
|
+
Object.assign(this, data);
|
|
434
|
+
return this;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Create - returns Instance
|
|
438
|
+
static async create<T extends Model>(this: { new (): T } & typeof Model, data: Partial<T>): Promise<T> {
|
|
217
439
|
if (this.timestamps) {
|
|
218
440
|
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
219
441
|
(data as any).created_at = now;
|
|
220
442
|
(data as any).updated_at = now;
|
|
221
443
|
}
|
|
222
|
-
return
|
|
444
|
+
return this.query<T>().insert(data);
|
|
223
445
|
}
|
|
224
446
|
|
|
225
447
|
static async updateById<T>(id: number | string, data: Partial<T>): Promise<number> {
|
|
226
448
|
if (this.timestamps) {
|
|
227
449
|
(data as any).updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
228
450
|
}
|
|
229
|
-
return new QueryBuilderImpl<
|
|
451
|
+
return new QueryBuilderImpl<any>(this.tableName).where(this.primaryKey, '=', id).update(data);
|
|
230
452
|
}
|
|
231
453
|
|
|
232
454
|
static async deleteById(id: number | string): Promise<number> {
|
|
233
455
|
return new QueryBuilderImpl<any>(this.tableName).where(this.primaryKey, '=', id).delete();
|
|
234
456
|
}
|
|
457
|
+
|
|
458
|
+
// ============================================
|
|
459
|
+
// Relationships
|
|
460
|
+
// ============================================
|
|
461
|
+
|
|
462
|
+
// HasOne: User has one Profile
|
|
463
|
+
hasOne<R extends Model>(related: { new (): R } & typeof Model, foreignKey?: string, localKey: string = 'id'): Promise<R | null> {
|
|
464
|
+
const fk = foreignKey || `${this.constructor.name.toLowerCase()}_id`;
|
|
465
|
+
const pk = (this as any)[localKey];
|
|
466
|
+
|
|
467
|
+
// We need to return a Promise that behaves like the query result BUT also carries metadata
|
|
468
|
+
// This is tricky because we are returning Promise<R|null> here for direct usage,
|
|
469
|
+
// but eager loading needs the QueryBuilder.
|
|
470
|
+
// However, in our eagerLoad impl, we call this method and check `_relationInfo` on the returned object.
|
|
471
|
+
// If we return a Promise, we must attach _relationInfo to the Promise!
|
|
472
|
+
|
|
473
|
+
const qb = related.query<R>();
|
|
474
|
+
const info: RelationInfo = { type: 'hasOne', relatedClass: related, foreignKey: fk, localKey };
|
|
475
|
+
(qb as any)._relationInfo = info;
|
|
476
|
+
|
|
477
|
+
const promise = qb.where(fk, '=', pk).first();
|
|
478
|
+
(promise as any)._relationInfo = info; // Attach to promise effectively
|
|
479
|
+
return promise;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// HasMany: User has many Posts
|
|
483
|
+
hasMany<R extends Model>(related: { new (): R } & typeof Model, foreignKey?: string, localKey: string = 'id'): QueryBuilder<R> {
|
|
484
|
+
const fk = foreignKey || `${this.constructor.name.toLowerCase()}_id`;
|
|
485
|
+
const pk = (this as any)[localKey];
|
|
486
|
+
|
|
487
|
+
const qb = related.query<R>().where(fk, '=', pk);
|
|
488
|
+
const info: RelationInfo = { type: 'hasMany', relatedClass: related, foreignKey: fk, localKey };
|
|
489
|
+
(qb as any)._relationInfo = info;
|
|
490
|
+
return qb;
|
|
491
|
+
}
|
|
235
492
|
|
|
236
|
-
|
|
237
|
-
|
|
493
|
+
// BelongsTo: Post belongs to User
|
|
494
|
+
belongsTo<R extends Model>(related: { new (): R } & typeof Model, foreignKey?: string, ownerKey: string = 'id'): Promise<R | null> {
|
|
495
|
+
const fk = foreignKey || `${related.name.toLowerCase()}_id`;
|
|
496
|
+
const val = (this as any)[fk];
|
|
497
|
+
|
|
498
|
+
const qb = related.query<R>();
|
|
499
|
+
const info: RelationInfo = { type: 'belongsTo', relatedClass: related, foreignKey: fk, ownerKey };
|
|
500
|
+
(qb as any)._relationInfo = info;
|
|
501
|
+
|
|
502
|
+
const promise = qb.where(ownerKey, '=', val).first();
|
|
503
|
+
(promise as any)._relationInfo = info;
|
|
504
|
+
return promise;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// BelongsToMany: User belongsForMany Roles (through user_roles)
|
|
508
|
+
belongsToMany<R extends Model>(
|
|
509
|
+
related: { new (): R } & typeof Model,
|
|
510
|
+
pivotTable: string,
|
|
511
|
+
foreignPivotKey?: string,
|
|
512
|
+
relatedPivotKey?: string
|
|
513
|
+
): QueryBuilder<R> {
|
|
514
|
+
// User belongsToMany Role
|
|
515
|
+
// pivot: user_roles
|
|
516
|
+
// foreignPivotKey: user_id (this model)
|
|
517
|
+
// relatedPivotKey: role_id (related model)
|
|
518
|
+
|
|
519
|
+
const foreignKey = foreignPivotKey || `${this.constructor.name.toLowerCase()}_id`;
|
|
520
|
+
const relatedKey = relatedPivotKey || `${related.name.toLowerCase()}_id`;
|
|
521
|
+
const pk = (this as any).id;
|
|
522
|
+
|
|
523
|
+
const qb = related.query<R>();
|
|
524
|
+
const info: RelationInfo = {
|
|
525
|
+
type: 'belongsToMany',
|
|
526
|
+
relatedClass: related,
|
|
527
|
+
foreignKey: '', // Not used for belongsToMany but required by interface
|
|
528
|
+
pivotTable,
|
|
529
|
+
foreignPivotKey: foreignKey,
|
|
530
|
+
relatedPivotKey: relatedKey
|
|
531
|
+
};
|
|
532
|
+
(qb as any)._relationInfo = info;
|
|
533
|
+
|
|
534
|
+
// SELECT roles.* FROM roles INNER JOIN pivot ON pivot.role_id = roles.id WHERE pivot.user_id = ?
|
|
535
|
+
return qb
|
|
536
|
+
.select(`${related.tableName}.*`)
|
|
537
|
+
.join(pivotTable, `${pivotTable}.${relatedKey}`, '=', `${related.tableName}.id`)
|
|
538
|
+
.where(`${pivotTable}.${foreignKey}`, '=', pk);
|
|
238
539
|
}
|
|
239
540
|
}
|
|
240
541
|
|
|
241
|
-
export { query, execute
|
|
542
|
+
export { query, execute };
|
|
242
543
|
export default Model;
|
package/src/types/index.ts
CHANGED
|
@@ -241,6 +241,7 @@ export interface QueryBuilder<T> {
|
|
|
241
241
|
update: (data: Partial<T>) => Promise<number>;
|
|
242
242
|
delete: () => Promise<number>;
|
|
243
243
|
raw: (sql: string, bindings?: unknown[]) => Promise<unknown>;
|
|
244
|
+
with: (...relations: string[]) => QueryBuilder<T>;
|
|
244
245
|
}
|
|
245
246
|
|
|
246
247
|
// ============================================
|
package/src/utils/Validator.ts
CHANGED
|
@@ -77,6 +77,11 @@ export function validate(data: Record<string, unknown>, schema: ValidationSchema
|
|
|
77
77
|
if (name !== 'required' && (value === undefined || value === null || value === '')) {
|
|
78
78
|
continue;
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
// Skip async rules
|
|
82
|
+
if (['unique', 'exists'].includes(name)) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
80
85
|
|
|
81
86
|
let valid = false;
|
|
82
87
|
if (validators[name]) {
|
|
@@ -105,8 +110,125 @@ export function validate(data: Record<string, unknown>, schema: ValidationSchema
|
|
|
105
110
|
return { valid: errors.size === 0, errors, data: validData };
|
|
106
111
|
}
|
|
107
112
|
|
|
108
|
-
|
|
109
|
-
|
|
113
|
+
import { query } from '../mvc/Model';
|
|
114
|
+
|
|
115
|
+
type AsyncValidatorFn = (value: unknown, param?: string) => Promise<boolean>;
|
|
116
|
+
|
|
117
|
+
const asyncValidators: Record<string, AsyncValidatorFn> = {
|
|
118
|
+
unique: async (v, p) => {
|
|
119
|
+
if (!p) return false;
|
|
120
|
+
const [table, column, exceptId, idColumn = 'id'] = p.split(',').map(s => s.trim());
|
|
121
|
+
|
|
122
|
+
let sql = `SELECT COUNT(*) as count FROM ${table} WHERE ${column} = ?`;
|
|
123
|
+
const params: any[] = [v];
|
|
124
|
+
|
|
125
|
+
if (exceptId) {
|
|
126
|
+
sql += ` AND ${idColumn} != ?`;
|
|
127
|
+
params.push(exceptId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const rows = await query<{count: number}>(sql, params);
|
|
134
|
+
return (rows[0]?.count || 0) === 0;
|
|
135
|
+
} catch (e) {
|
|
136
|
+
console.error('[Validator] Database error in unique:', e);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
exists: async (v, p) => {
|
|
142
|
+
if (!p) return false;
|
|
143
|
+
const [table, column = 'id'] = p.split(',').map(s => s.trim());
|
|
144
|
+
|
|
145
|
+
const sql = `SELECT COUNT(*) as count FROM ${table} WHERE ${column} = ?`;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const rows = await query<{count: number}>(sql, [v]);
|
|
149
|
+
return (rows[0]?.count || 0) > 0;
|
|
150
|
+
} catch (e) {
|
|
151
|
+
console.error('[Validator] Database error in exists:', e);
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const defaultAsyncMessages: Record<string, string> = {
|
|
158
|
+
unique: 'Field {field} has already been taken',
|
|
159
|
+
exists: 'Selected {field} is invalid',
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export async function validateAsync(data: Record<string, unknown>, schema: ValidationSchema): Promise<ValidationResult> {
|
|
163
|
+
const errors = new Map<string, string[]>();
|
|
164
|
+
const validData: Record<string, unknown> = {};
|
|
165
|
+
|
|
166
|
+
// Run sync validation first
|
|
167
|
+
const syncResult = validate(data, schema);
|
|
168
|
+
if (!syncResult.valid) {
|
|
169
|
+
// If sync validation fails, we might still want to run async,
|
|
170
|
+
// but usually if format is wrong, checking DB is waste.
|
|
171
|
+
// However, mixing errors is good.
|
|
172
|
+
// Let's copy errors
|
|
173
|
+
syncResult.errors.forEach((msgs, field) => errors.set(field, msgs));
|
|
174
|
+
} else {
|
|
175
|
+
Object.assign(validData, syncResult.data);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Check async rules
|
|
179
|
+
for (const [field, rules] of Object.entries(schema)) {
|
|
180
|
+
const value = data[field];
|
|
181
|
+
|
|
182
|
+
// Get rules list
|
|
183
|
+
let ruleList: ValidationRule[];
|
|
184
|
+
let customMessages: Record<string, string> = {};
|
|
185
|
+
|
|
186
|
+
if (Array.isArray(rules)) {
|
|
187
|
+
ruleList = rules;
|
|
188
|
+
} else if (typeof rules === 'object') {
|
|
189
|
+
ruleList = rules.rules;
|
|
190
|
+
customMessages = rules.messages || {};
|
|
191
|
+
} else {
|
|
192
|
+
ruleList = [rules];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const rule of ruleList) {
|
|
196
|
+
const { name, param } = parseRule(rule);
|
|
197
|
+
|
|
198
|
+
// We only care about async validators here
|
|
199
|
+
// And we skip if value is empty/null (unless it's required, but sync validate handled required)
|
|
200
|
+
if (
|
|
201
|
+
!asyncValidators[name] ||
|
|
202
|
+
(value === undefined || value === null || value === '')
|
|
203
|
+
) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// If sync validation already failed for this field, usually we skip checking DB
|
|
208
|
+
if (errors.has(field)) continue;
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const valid = await asyncValidators[name](value, param);
|
|
212
|
+
|
|
213
|
+
if (!valid) {
|
|
214
|
+
const msg = customMessages[name] || defaultAsyncMessages[name] || defaultMessages[name] || `Validation failed for {field}`;
|
|
215
|
+
const finalMsg = msg.replace('{field}', field).replace('{param}', param || '');
|
|
216
|
+
|
|
217
|
+
const existing = errors.get(field) || [];
|
|
218
|
+
existing.push(finalMsg);
|
|
219
|
+
errors.set(field, existing);
|
|
220
|
+
}
|
|
221
|
+
} catch (err) {
|
|
222
|
+
console.error('[Validator] Unexpected error in validateAsync', err);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!errors.has(field) && value !== undefined) {
|
|
227
|
+
validData[field] = value;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { valid: errors.size === 0, errors, data: validData };
|
|
110
232
|
}
|
|
111
233
|
|
|
112
234
|
// Quick validators
|