@doviui/dev-db 0.3.0 → 0.4.0
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 +111 -19
- package/package.json +1 -1
- package/src/field-builder.ts +41 -0
- package/src/generator.ts +81 -0
- package/src/schema-builder.ts +39 -1
- package/src/types.ts +6 -0
- package/src/validator.ts +82 -0
package/README.md
CHANGED
|
@@ -269,7 +269,9 @@ t.json({
|
|
|
269
269
|
|
|
270
270
|
#### Relationships
|
|
271
271
|
```typescript
|
|
272
|
-
t.foreignKey('TableName', 'column')
|
|
272
|
+
t.foreignKey('TableName', 'column') // Foreign key reference
|
|
273
|
+
t.belongsTo('TableName', 'foreignKeyField') // Many-to-one relationship
|
|
274
|
+
t.hasMany('TableName', 'foreignKeyField', { min: 1, max: 5 }) // One-to-many relationship (virtual)
|
|
273
275
|
```
|
|
274
276
|
|
|
275
277
|
### Field Modifiers
|
|
@@ -301,6 +303,81 @@ Chain modifiers to configure field behavior and constraints:
|
|
|
301
303
|
|
|
302
304
|
## Advanced Usage
|
|
303
305
|
|
|
306
|
+
### hasMany and belongsTo Relationships
|
|
307
|
+
|
|
308
|
+
For clearer relationship semantics and automatic record count calculation, use `hasMany()` and `belongsTo()` helpers:
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
import { t } from '@doviui/dev-db'
|
|
312
|
+
|
|
313
|
+
export default {
|
|
314
|
+
User: {
|
|
315
|
+
$count: 100,
|
|
316
|
+
id: t.bigserial().primaryKey(),
|
|
317
|
+
username: t.varchar(50).unique().generate('internet.userName'),
|
|
318
|
+
|
|
319
|
+
// Define one-to-many: each user has 2-5 posts
|
|
320
|
+
posts: t.hasMany('Post', 'userId', { min: 2, max: 5 })
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
Post: {
|
|
324
|
+
// $count is automatically calculated: 100 users * avg(2,5) posts = 350 posts
|
|
325
|
+
id: t.bigserial().primaryKey(),
|
|
326
|
+
userId: t.integer(), // The actual foreign key field
|
|
327
|
+
|
|
328
|
+
// Define many-to-one for clearer intent
|
|
329
|
+
author: t.belongsTo('User', 'userId'),
|
|
330
|
+
|
|
331
|
+
title: t.varchar(200).generate('lorem.sentence'),
|
|
332
|
+
content: t.text().generate('lorem.paragraphs')
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
**Key Benefits:**
|
|
338
|
+
|
|
339
|
+
- **Auto-calculated counts**: `hasMany` automatically calculates child table record counts based on parent count and min/max constraints
|
|
340
|
+
- **Clearer intent**: `belongsTo` makes many-to-one relationships more explicit than raw `foreignKey`
|
|
341
|
+
- **Virtual fields**: Both `hasMany` and `belongsTo` fields don't appear in generated output—they're metadata for generation logic
|
|
342
|
+
|
|
343
|
+
**How it works:**
|
|
344
|
+
|
|
345
|
+
1. `hasMany('Post', 'userId', { min: 2, max: 5 })` tells the generator: "Each User should have between 2-5 Posts"
|
|
346
|
+
2. The generator calculates: `100 users * average(2, 5) = 100 * 3.5 = 350 posts`
|
|
347
|
+
3. `belongsTo('User', 'userId')` instructs the generator to populate the `userId` field with valid User IDs
|
|
348
|
+
4. Neither `posts` nor `author` appear in the generated JSON—only the actual data fields
|
|
349
|
+
|
|
350
|
+
**Complex Example:**
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
export default {
|
|
354
|
+
Author: {
|
|
355
|
+
$count: 50,
|
|
356
|
+
id: t.uuid().primaryKey(),
|
|
357
|
+
name: t.varchar(100).generate('person.fullName'),
|
|
358
|
+
articles: t.hasMany('Article', 'authorId', { min: 3, max: 10 })
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
Article: {
|
|
362
|
+
// Auto-calculated: 50 * 6.5 = 325 articles
|
|
363
|
+
id: t.bigserial().primaryKey(),
|
|
364
|
+
authorId: t.varchar(36), // UUID foreign key
|
|
365
|
+
author: t.belongsTo('Author', 'authorId'),
|
|
366
|
+
title: t.varchar(200),
|
|
367
|
+
|
|
368
|
+
comments: t.hasMany('Comment', 'articleId', { min: 0, max: 20 })
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
Comment: {
|
|
372
|
+
// Auto-calculated: 325 * 10 = 3,250 comments
|
|
373
|
+
id: t.bigserial().primaryKey(),
|
|
374
|
+
articleId: t.integer(),
|
|
375
|
+
article: t.belongsTo('Article', 'articleId'),
|
|
376
|
+
content: t.text()
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
304
381
|
### Multi-Table Schemas
|
|
305
382
|
|
|
306
383
|
You can organize schemas in two ways:
|
|
@@ -655,9 +732,12 @@ export default {
|
|
|
655
732
|
first_name: t.varchar(50).generate('person.firstName'),
|
|
656
733
|
last_name: t.varchar(50).generate('person.lastName'),
|
|
657
734
|
phone: t.varchar(20).generate('phone.number'),
|
|
658
|
-
created_at: t.timestamptz().default('now')
|
|
735
|
+
created_at: t.timestamptz().default('now'),
|
|
736
|
+
|
|
737
|
+
// Each customer has 1-5 orders
|
|
738
|
+
orders: t.hasMany('Order', 'customerId', { min: 1, max: 5 })
|
|
659
739
|
},
|
|
660
|
-
|
|
740
|
+
|
|
661
741
|
Product: {
|
|
662
742
|
$count: 100,
|
|
663
743
|
id: t.bigserial().primaryKey(),
|
|
@@ -667,21 +747,27 @@ export default {
|
|
|
667
747
|
stock: t.integer().min(0).max(1000),
|
|
668
748
|
category: t.varchar(50).enum(['electronics', 'clothing', 'home', 'books'])
|
|
669
749
|
},
|
|
670
|
-
|
|
750
|
+
|
|
671
751
|
Order: {
|
|
672
|
-
|
|
752
|
+
// Auto-calculated: 200 * 3 = 600 orders
|
|
673
753
|
id: t.bigserial().primaryKey(),
|
|
674
|
-
|
|
754
|
+
customerId: t.varchar(36),
|
|
755
|
+
customer: t.belongsTo('Customer', 'customerId'),
|
|
675
756
|
status: t.varchar(20).enum(['pending', 'processing', 'shipped', 'delivered']),
|
|
676
757
|
total: t.decimal(10, 2).min(10).max(10000),
|
|
677
|
-
created_at: t.timestamptz().default('now')
|
|
758
|
+
created_at: t.timestamptz().default('now'),
|
|
759
|
+
|
|
760
|
+
// Each order has 1-4 items
|
|
761
|
+
items: t.hasMany('OrderItem', 'orderId', { min: 1, max: 4 })
|
|
678
762
|
},
|
|
679
|
-
|
|
763
|
+
|
|
680
764
|
OrderItem: {
|
|
681
|
-
|
|
765
|
+
// Auto-calculated: 600 * 2.5 = 1,500 items
|
|
682
766
|
id: t.bigserial().primaryKey(),
|
|
683
|
-
|
|
684
|
-
|
|
767
|
+
orderId: t.integer(),
|
|
768
|
+
order: t.belongsTo('Order', 'orderId'),
|
|
769
|
+
productId: t.integer(),
|
|
770
|
+
product: t.belongsTo('Product', 'productId'),
|
|
685
771
|
quantity: t.integer().min(1).max(10),
|
|
686
772
|
price: t.decimal(10, 2)
|
|
687
773
|
}
|
|
@@ -701,13 +787,17 @@ export default {
|
|
|
701
787
|
username: t.varchar(50).unique().generate('internet.userName'),
|
|
702
788
|
email: t.varchar(255).unique().generate('internet.email'),
|
|
703
789
|
bio: t.text().generate('lorem.paragraph'),
|
|
704
|
-
avatar_url: t.varchar(500).generate('image.avatar')
|
|
790
|
+
avatar_url: t.varchar(500).generate('image.avatar'),
|
|
791
|
+
|
|
792
|
+
// Each author has 3-10 articles
|
|
793
|
+
articles: t.hasMany('Article', 'authorId', { min: 3, max: 10 })
|
|
705
794
|
},
|
|
706
|
-
|
|
795
|
+
|
|
707
796
|
Article: {
|
|
708
|
-
|
|
797
|
+
// Auto-calculated: 50 * 6.5 = 325 articles
|
|
709
798
|
id: t.bigserial().primaryKey(),
|
|
710
|
-
|
|
799
|
+
authorId: t.varchar(36),
|
|
800
|
+
author: t.belongsTo('Author', 'authorId'),
|
|
711
801
|
title: t.varchar(200).generate('lorem.sentence'),
|
|
712
802
|
slug: t.varchar(200).unique().generate('lorem.slug'),
|
|
713
803
|
content: t.text().generate('lorem.paragraphs', 5),
|
|
@@ -716,17 +806,19 @@ export default {
|
|
|
716
806
|
published_at: t.timestamptz().nullable(),
|
|
717
807
|
created_at: t.timestamptz().default('now')
|
|
718
808
|
},
|
|
719
|
-
|
|
809
|
+
|
|
720
810
|
Tag: {
|
|
721
811
|
$count: 30,
|
|
722
812
|
id: t.serial().primaryKey(),
|
|
723
813
|
name: t.varchar(50).unique().generate('lorem.word')
|
|
724
814
|
},
|
|
725
|
-
|
|
815
|
+
|
|
726
816
|
ArticleTag: {
|
|
727
817
|
$count: 800,
|
|
728
|
-
|
|
729
|
-
|
|
818
|
+
articleId: t.integer(),
|
|
819
|
+
article: t.belongsTo('Article', 'articleId'),
|
|
820
|
+
tagId: t.integer(),
|
|
821
|
+
tag: t.belongsTo('Tag', 'tagId')
|
|
730
822
|
}
|
|
731
823
|
}
|
|
732
824
|
```
|
package/package.json
CHANGED
package/src/field-builder.ts
CHANGED
|
@@ -209,3 +209,44 @@ export class ForeignKeyBuilder extends FieldBuilder {
|
|
|
209
209
|
this.config.foreignKey = { table, column };
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Builder class for belongsTo relationships.
|
|
215
|
+
* Alias for ForeignKeyBuilder with clearer intent for many-to-one relationships.
|
|
216
|
+
*/
|
|
217
|
+
export class BelongsToBuilder extends FieldBuilder {
|
|
218
|
+
/**
|
|
219
|
+
* Creates a new belongsTo relationship builder.
|
|
220
|
+
*
|
|
221
|
+
* @param table - The referenced table name
|
|
222
|
+
* @param foreignKey - The foreign key field name in the current table
|
|
223
|
+
*/
|
|
224
|
+
constructor(table: string, foreignKey: string) {
|
|
225
|
+
super('belongsTo');
|
|
226
|
+
this.config.belongsTo = { table, foreignKey };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Builder class for hasMany relationships.
|
|
232
|
+
* Defines one-to-many relationships. This is a virtual field that doesn't appear in output
|
|
233
|
+
* but controls how related records are generated.
|
|
234
|
+
*/
|
|
235
|
+
export class HasManyBuilder extends FieldBuilder {
|
|
236
|
+
/**
|
|
237
|
+
* Creates a new hasMany relationship builder.
|
|
238
|
+
*
|
|
239
|
+
* @param table - The related table name
|
|
240
|
+
* @param foreignKey - The foreign key field name in the related table
|
|
241
|
+
* @param options - Min/max constraints for how many related records to generate
|
|
242
|
+
*/
|
|
243
|
+
constructor(table: string, foreignKey: string, options?: { min?: number; max?: number }) {
|
|
244
|
+
super('hasMany');
|
|
245
|
+
this.config.hasMany = {
|
|
246
|
+
table,
|
|
247
|
+
foreignKey,
|
|
248
|
+
min: options?.min,
|
|
249
|
+
max: options?.max
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
package/src/generator.ts
CHANGED
|
@@ -79,6 +79,9 @@ export class MockDataGenerator {
|
|
|
79
79
|
* ```
|
|
80
80
|
*/
|
|
81
81
|
async generate(): Promise<GeneratedData> {
|
|
82
|
+
// Calculate record counts based on hasMany relationships
|
|
83
|
+
this.calculateRecordCounts();
|
|
84
|
+
|
|
82
85
|
// Topologically sort tables based on foreign key dependencies
|
|
83
86
|
const sortedTables = this.topologicalSort();
|
|
84
87
|
|
|
@@ -98,6 +101,34 @@ export class MockDataGenerator {
|
|
|
98
101
|
return this.generatedData;
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Calculates record counts for tables based on hasMany relationships.
|
|
106
|
+
* If a parent table has a hasMany relationship, it overrides the child table's $count.
|
|
107
|
+
*/
|
|
108
|
+
private calculateRecordCounts(): void {
|
|
109
|
+
for (const [tableName, tableConfig] of Object.entries(this.schema)) {
|
|
110
|
+
const fields = Object.entries(tableConfig).filter(([key]) => key !== '$count');
|
|
111
|
+
|
|
112
|
+
for (const [_, field] of fields) {
|
|
113
|
+
const fieldConfig = this.toFieldConfig(field);
|
|
114
|
+
|
|
115
|
+
if (fieldConfig?.hasMany) {
|
|
116
|
+
const { table: relatedTable, min = 1, max = 5 } = fieldConfig.hasMany;
|
|
117
|
+
const parentCount = tableConfig.$count || 10;
|
|
118
|
+
|
|
119
|
+
// Calculate child count: average of min/max per parent * parent count
|
|
120
|
+
const avgPerParent = (min + max) / 2;
|
|
121
|
+
const calculatedCount = Math.ceil(parentCount * avgPerParent);
|
|
122
|
+
|
|
123
|
+
// Override the related table's $count
|
|
124
|
+
if (this.schema[relatedTable]) {
|
|
125
|
+
this.schema[relatedTable].$count = calculatedCount;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
101
132
|
private topologicalSort(): string[] {
|
|
102
133
|
const tables = Object.keys(this.schema);
|
|
103
134
|
const visited = new Set<string>();
|
|
@@ -115,12 +146,22 @@ export class MockDataGenerator {
|
|
|
115
146
|
// Visit dependencies first
|
|
116
147
|
for (const [_, field] of fields) {
|
|
117
148
|
const fieldConfig = this.toFieldConfig(field);
|
|
149
|
+
|
|
150
|
+
// Handle foreignKey dependencies
|
|
118
151
|
if (fieldConfig?.foreignKey) {
|
|
119
152
|
const refTable = fieldConfig.foreignKey.table;
|
|
120
153
|
if (refTable !== tableName) { // Avoid self-references
|
|
121
154
|
visit(refTable);
|
|
122
155
|
}
|
|
123
156
|
}
|
|
157
|
+
|
|
158
|
+
// Handle belongsTo dependencies
|
|
159
|
+
if (fieldConfig?.belongsTo) {
|
|
160
|
+
const refTable = fieldConfig.belongsTo.table;
|
|
161
|
+
if (refTable !== tableName) { // Avoid self-references
|
|
162
|
+
visit(refTable);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
124
165
|
}
|
|
125
166
|
|
|
126
167
|
result.push(tableName);
|
|
@@ -144,6 +185,11 @@ export class MockDataGenerator {
|
|
|
144
185
|
for (const [fieldName, field] of fields) {
|
|
145
186
|
const fieldConfig = this.toFieldConfig(field);
|
|
146
187
|
if (fieldConfig) {
|
|
188
|
+
// Skip hasMany and belongsTo fields (they're virtual and don't appear in output)
|
|
189
|
+
if (fieldConfig.hasMany || fieldConfig.belongsTo) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
147
193
|
record[fieldName] = this.generateFieldValue(
|
|
148
194
|
fieldName,
|
|
149
195
|
fieldConfig,
|
|
@@ -186,6 +232,22 @@ export class MockDataGenerator {
|
|
|
186
232
|
return fieldConfig.default;
|
|
187
233
|
}
|
|
188
234
|
|
|
235
|
+
// Check if this field is a foreign key in a belongsTo relationship
|
|
236
|
+
const belongsToRelationship = this.findBelongsToForField(tableName, fieldName);
|
|
237
|
+
if (belongsToRelationship) {
|
|
238
|
+
const refTable = belongsToRelationship.table;
|
|
239
|
+
const refData = this.generatedData[refTable];
|
|
240
|
+
|
|
241
|
+
if (!refData || refData.length === 0) {
|
|
242
|
+
if (fieldConfig.nullable) return null;
|
|
243
|
+
throw new Error(`Cannot generate belongsTo for ${tableName}.${fieldName}: no data in ${refTable}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const randomRecord = refData[Math.floor(Math.random() * refData.length)];
|
|
247
|
+
// Get the primary key value from the referenced table
|
|
248
|
+
return randomRecord.id || randomRecord[Object.keys(randomRecord)[0]];
|
|
249
|
+
}
|
|
250
|
+
|
|
189
251
|
// Handle foreign keys
|
|
190
252
|
if (fieldConfig.foreignKey) {
|
|
191
253
|
const refTable = fieldConfig.foreignKey.table;
|
|
@@ -250,6 +312,25 @@ export class MockDataGenerator {
|
|
|
250
312
|
return value;
|
|
251
313
|
}
|
|
252
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Finds a belongsTo relationship that references the given field name.
|
|
317
|
+
*/
|
|
318
|
+
private findBelongsToForField(tableName: string, fieldName: string): { table: string; foreignKey: string } | null {
|
|
319
|
+
const tableConfig = this.schema[tableName];
|
|
320
|
+
if (!tableConfig) return null;
|
|
321
|
+
|
|
322
|
+
const fields = Object.entries(tableConfig).filter(([key]) => key !== '$count');
|
|
323
|
+
|
|
324
|
+
for (const [_, field] of fields) {
|
|
325
|
+
const fieldConfig = this.toFieldConfig(field);
|
|
326
|
+
if (fieldConfig?.belongsTo && fieldConfig.belongsTo.foreignKey === fieldName) {
|
|
327
|
+
return fieldConfig.belongsTo;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
253
334
|
private generateValueByType(fieldConfig: FieldConfig): any {
|
|
254
335
|
// Use custom generator if provided
|
|
255
336
|
if (fieldConfig.generator) {
|
package/src/schema-builder.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FieldBuilder, ForeignKeyBuilder } from './field-builder';
|
|
1
|
+
import { FieldBuilder, ForeignKeyBuilder, BelongsToBuilder, HasManyBuilder } from './field-builder';
|
|
2
2
|
import type { JsonSchema } from './types';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -247,4 +247,42 @@ export const t = {
|
|
|
247
247
|
foreignKey(table: string, column: string): ForeignKeyBuilder {
|
|
248
248
|
return new ForeignKeyBuilder(table, column);
|
|
249
249
|
},
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Creates a belongsTo relationship (many-to-one).
|
|
253
|
+
* This is an alias for foreignKey with clearer intent.
|
|
254
|
+
* @param table - The referenced table name
|
|
255
|
+
* @param foreignKey - The foreign key field name in the current table
|
|
256
|
+
* @returns BelongsToBuilder instance for method chaining
|
|
257
|
+
* @example
|
|
258
|
+
* ```typescript
|
|
259
|
+
* Post: {
|
|
260
|
+
* userId: t.belongsTo('User', 'userId')
|
|
261
|
+
* }
|
|
262
|
+
* ```
|
|
263
|
+
*/
|
|
264
|
+
belongsTo(table: string, foreignKey: string): BelongsToBuilder {
|
|
265
|
+
return new BelongsToBuilder(table, foreignKey);
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Creates a hasMany relationship (one-to-many).
|
|
270
|
+
* This is a virtual field that controls generation of related records.
|
|
271
|
+
* It does not appear in the generated output.
|
|
272
|
+
* @param table - The related table name
|
|
273
|
+
* @param foreignKey - The foreign key field name in the related table
|
|
274
|
+
* @param options - Min/max constraints for number of related records
|
|
275
|
+
* @returns HasManyBuilder instance for method chaining
|
|
276
|
+
* @example
|
|
277
|
+
* ```typescript
|
|
278
|
+
* User: {
|
|
279
|
+
* $count: 100,
|
|
280
|
+
* id: t.bigserial().primaryKey(),
|
|
281
|
+
* posts: t.hasMany('Post', 'userId', { min: 2, max: 10 })
|
|
282
|
+
* }
|
|
283
|
+
* ```
|
|
284
|
+
*/
|
|
285
|
+
hasMany(table: string, foreignKey: string, options?: { min?: number; max?: number }): HasManyBuilder {
|
|
286
|
+
return new HasManyBuilder(table, foreignKey, options);
|
|
287
|
+
},
|
|
250
288
|
};
|
package/src/types.ts
CHANGED
|
@@ -57,6 +57,12 @@ export interface FieldConfig {
|
|
|
57
57
|
/** Foreign key reference to another table */
|
|
58
58
|
foreignKey?: { table: string; column: string };
|
|
59
59
|
|
|
60
|
+
/** One-to-many relationship definition (virtual field, not in output) */
|
|
61
|
+
hasMany?: { table: string; foreignKey: string; min?: number; max?: number };
|
|
62
|
+
|
|
63
|
+
/** Many-to-one relationship definition (alias for foreignKey with clearer intent) */
|
|
64
|
+
belongsTo?: { table: string; foreignKey: string };
|
|
65
|
+
|
|
60
66
|
/** Custom generator function or Faker.js method path */
|
|
61
67
|
generator?: Generator;
|
|
62
68
|
|
package/src/validator.ts
CHANGED
|
@@ -156,6 +156,69 @@ export class SchemaValidator {
|
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// Validate belongsTo relationships
|
|
160
|
+
if (fieldConfig.belongsTo) {
|
|
161
|
+
const { table: refTable, foreignKey: refForeignKey } = fieldConfig.belongsTo;
|
|
162
|
+
|
|
163
|
+
if (!schema[refTable]) {
|
|
164
|
+
this.errors.push({
|
|
165
|
+
table: tableName,
|
|
166
|
+
field: fieldName,
|
|
167
|
+
message: `belongsTo references non-existent table '${refTable}'`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Check if the foreign key field exists in the current table
|
|
172
|
+
const currentTableFields = Object.keys(schema[tableName]).filter(key => key !== '$count');
|
|
173
|
+
if (!currentTableFields.includes(refForeignKey)) {
|
|
174
|
+
this.errors.push({
|
|
175
|
+
table: tableName,
|
|
176
|
+
field: fieldName,
|
|
177
|
+
message: `belongsTo references non-existent foreign key field '${refForeignKey}' in current table`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Validate hasMany relationships
|
|
183
|
+
if (fieldConfig.hasMany) {
|
|
184
|
+
const { table: relatedTable, foreignKey: foreignKeyField, min, max } = fieldConfig.hasMany;
|
|
185
|
+
|
|
186
|
+
if (!schema[relatedTable]) {
|
|
187
|
+
this.errors.push({
|
|
188
|
+
table: tableName,
|
|
189
|
+
field: fieldName,
|
|
190
|
+
message: `hasMany references non-existent table '${relatedTable}'`,
|
|
191
|
+
});
|
|
192
|
+
} else {
|
|
193
|
+
// Check if the foreign key field exists in the related table
|
|
194
|
+
const relatedTableFields = Object.keys(schema[relatedTable]).filter(key => key !== '$count');
|
|
195
|
+
if (!relatedTableFields.includes(foreignKeyField)) {
|
|
196
|
+
this.errors.push({
|
|
197
|
+
table: tableName,
|
|
198
|
+
field: fieldName,
|
|
199
|
+
message: `hasMany references non-existent foreign key field '${foreignKeyField}' in table '${relatedTable}'`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Validate min/max constraints for hasMany
|
|
205
|
+
if (min !== undefined && max !== undefined && min > max) {
|
|
206
|
+
this.errors.push({
|
|
207
|
+
table: tableName,
|
|
208
|
+
field: fieldName,
|
|
209
|
+
message: `hasMany min (${min}) cannot be greater than max (${max})`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (min !== undefined && min < 0) {
|
|
214
|
+
this.errors.push({
|
|
215
|
+
table: tableName,
|
|
216
|
+
field: fieldName,
|
|
217
|
+
message: `hasMany min (${min}) cannot be negative`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
159
222
|
// Validate min/max constraints
|
|
160
223
|
if (fieldConfig.min !== undefined && fieldConfig.max !== undefined) {
|
|
161
224
|
if (fieldConfig.min > fieldConfig.max) {
|
|
@@ -201,6 +264,8 @@ export class SchemaValidator {
|
|
|
201
264
|
|
|
202
265
|
for (const [_, fieldConfig] of fields) {
|
|
203
266
|
const config = this.toFieldConfig(fieldConfig);
|
|
267
|
+
|
|
268
|
+
// Check foreignKey dependencies
|
|
204
269
|
if (config?.foreignKey) {
|
|
205
270
|
const refTable = config.foreignKey.table;
|
|
206
271
|
|
|
@@ -216,6 +281,23 @@ export class SchemaValidator {
|
|
|
216
281
|
return true;
|
|
217
282
|
}
|
|
218
283
|
}
|
|
284
|
+
|
|
285
|
+
// Check belongsTo dependencies
|
|
286
|
+
if (config?.belongsTo) {
|
|
287
|
+
const refTable = config.belongsTo.table;
|
|
288
|
+
|
|
289
|
+
if (!visited.has(refTable)) {
|
|
290
|
+
if (hasCycle(refTable, [...path, refTable])) {
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
} else if (recursionStack.has(refTable)) {
|
|
294
|
+
this.errors.push({
|
|
295
|
+
table: tableName,
|
|
296
|
+
message: `Circular dependency detected: ${[...path, refTable].join(' -> ')}`,
|
|
297
|
+
});
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
219
301
|
}
|
|
220
302
|
|
|
221
303
|
recursionStack.delete(tableName);
|