@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 CHANGED
@@ -269,7 +269,9 @@ t.json({
269
269
 
270
270
  #### Relationships
271
271
  ```typescript
272
- t.foreignKey('TableName', 'column') // Foreign key reference
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
- $count: 500,
752
+ // Auto-calculated: 200 * 3 = 600 orders
673
753
  id: t.bigserial().primaryKey(),
674
- customer_id: t.foreignKey('Customer', 'id').notNull(),
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
- $count: 1500,
765
+ // Auto-calculated: 600 * 2.5 = 1,500 items
682
766
  id: t.bigserial().primaryKey(),
683
- order_id: t.foreignKey('Order', 'id').notNull(),
684
- product_id: t.foreignKey('Product', 'id').notNull(),
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
- $count: 300,
797
+ // Auto-calculated: 50 * 6.5 = 325 articles
709
798
  id: t.bigserial().primaryKey(),
710
- author_id: t.foreignKey('Author', 'id'),
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
- article_id: t.foreignKey('Article', 'id'),
729
- tag_id: t.foreignKey('Tag', 'id')
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doviui/dev-db",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "TypeScript-first mock database generator for rapid application development",
5
5
  "main": "index.ts",
6
6
  "module": "index.ts",
@@ -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) {
@@ -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);