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/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
- constructor(table: string) { this.table = table; }
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[]> { return query<T>(this.buildSelect(), this.bindings); }
163
- async first(): Promise<T | null> { this.limitVal = 1; const r = await this.get(); return r[0] || null; }
164
- async count(): Promise<number> { this.selectCols = ['COUNT(*) as count']; const r = await this.get(); return (r[0] as any)?.count || 0; }
165
- async sum(col: any): Promise<number> { this.selectCols = [`SUM(${String(col)}) as sum`]; const r = await this.get(); return (r[0] as any)?.sum || 0; }
166
- async avg(col: any): Promise<number> { this.selectCols = [`AVG(${String(col)}) as avg`]; const r = await this.get(); return (r[0] as any)?.avg || 0; }
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<T extends Record<string, any> = any> {
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
- static table<T>(): QueryBuilder<T> {
205
- return new QueryBuilderImpl<T>(this.tableName);
389
+ constructor(data?: any) {
390
+ if (data) Object.assign(this, data);
206
391
  }
207
392
 
208
- static async find<T>(id: number | string): Promise<T | null> {
209
- return new QueryBuilderImpl<T>(this.tableName).where(this.primaryKey, '=', id).first();
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
- static async all<T>(): Promise<T[]> {
213
- return new QueryBuilderImpl<T>(this.tableName).get();
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
- static async create<T>(data: Partial<T>): Promise<T> {
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 new QueryBuilderImpl<T>(this.tableName).insert(data);
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<T>(this.tableName).where(this.primaryKey, '=', id).update(data);
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
- static query<T>(): QueryBuilder<T> {
237
- return new QueryBuilderImpl<T>(this.tableName);
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, QueryBuilderImpl };
542
+ export { query, execute };
242
543
  export default Model;
@@ -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
  // ============================================
@@ -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
- export function validateAsync(data: Record<string, unknown>, schema: ValidationSchema): Promise<ValidationResult> {
109
- return Promise.resolve(validate(data, schema));
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