@doviui/dev-db 0.2.1 → 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
@@ -15,6 +15,7 @@ dev-db eliminates the friction of setting up databases during development. Defin
15
15
  ## Key Features
16
16
 
17
17
  - **Type-Safe Schema Definition** - Fluent TypeScript API with full IntelliSense support
18
+ - **TypeScript Types Generation** - Auto-generate type definitions from schemas for type-safe data consumption
18
19
  - **Automatic Relationship Resolution** - Foreign keys handled intelligently with topological sorting
19
20
  - **Production-Quality Mock Data** - Powered by Faker.js for realistic, diverse datasets
20
21
  - **Built-In Validation** - Detect circular dependencies, missing tables, and constraint conflicts before generation
@@ -145,12 +146,73 @@ function getPostsByUserId(userId: number) {
145
146
  app.get('/users/:id', (req, res) => {
146
147
  const user = getUserById(parseInt(req.params.id))
147
148
  if (!user) return res.status(404).json({ error: 'User not found' })
148
-
149
+
149
150
  const userPosts = getPostsByUserId(user.id)
150
151
  res.json({ ...user, posts: userPosts })
151
152
  })
152
153
  ```
153
154
 
155
+ ### Type-Safe Data Consumption
156
+
157
+ Generate TypeScript type definitions from your schemas for full type safety when consuming the mock data:
158
+
159
+ ```bash
160
+ # Generate types alongside JSON data
161
+ bunx @doviui/dev-db schema.ts --types
162
+ ```
163
+
164
+ This creates a `types.ts` file with interfaces matching your schema:
165
+
166
+ ```typescript
167
+ // mock-data/types.ts (auto-generated)
168
+ export interface User {
169
+ id: number;
170
+ username: string;
171
+ email: string;
172
+ age: number | null;
173
+ created_at?: string;
174
+ }
175
+
176
+ export interface Post {
177
+ id: number;
178
+ user_id: number; // Correctly typed based on User.id
179
+ title: string;
180
+ content: string | null;
181
+ created_at?: string;
182
+ }
183
+ ```
184
+
185
+ Import and use the types in your application:
186
+
187
+ ```typescript
188
+ // server.ts
189
+ import users from './mock-data/User.json'
190
+ import posts from './mock-data/Post.json'
191
+ import type { User, Post } from './mock-data/types'
192
+
193
+ // Fully type-safe!
194
+ function getUserById(id: number): User | undefined {
195
+ return users.find(u => u.id === id)
196
+ }
197
+
198
+ function getPostsByUserId(userId: number): Post[] {
199
+ return posts.filter(p => p.user_id === userId)
200
+ }
201
+ ```
202
+
203
+ **Programmatic API:**
204
+
205
+ ```typescript
206
+ import { TypesGenerator } from '@doviui/dev-db'
207
+
208
+ const typesGenerator = new TypesGenerator(schema, {
209
+ outputDir: './mock-data',
210
+ fileName: 'types.ts' // Optional, defaults to 'types.ts'
211
+ })
212
+
213
+ await typesGenerator.generate()
214
+ ```
215
+
154
216
  ## API Reference
155
217
 
156
218
  ### Data Types
@@ -189,13 +251,27 @@ t.timestamptz() // Date and time with timezone
189
251
  ```typescript
190
252
  t.boolean() // True/false
191
253
  t.uuid() // UUID v4
192
- t.json() // JSON object
193
- t.jsonb() // JSON binary
254
+
255
+ // JSON types with optional structured schemas
256
+ t.json() // JSON object (unstructured)
257
+ t.jsonb() // JSON binary (unstructured)
258
+
259
+ // Structured JSON with type-safe schema
260
+ t.json({
261
+ id: t.integer(),
262
+ name: t.varchar(100),
263
+ preferences: t.json({
264
+ theme: t.varchar(20),
265
+ notifications: t.boolean()
266
+ })
267
+ })
194
268
  ```
195
269
 
196
270
  #### Relationships
197
271
  ```typescript
198
- 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)
199
275
  ```
200
276
 
201
277
  ### Field Modifiers
@@ -227,6 +303,81 @@ Chain modifiers to configure field behavior and constraints:
227
303
 
228
304
  ## Advanced Usage
229
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
+
230
381
  ### Multi-Table Schemas
231
382
 
232
383
  You can organize schemas in two ways:
@@ -332,6 +483,95 @@ Schema validation failed:
332
483
  Post.user_id: Foreign key references non-existent table 'User'
333
484
  ```
334
485
 
486
+ ### Structured JSON Fields
487
+
488
+ Define type-safe JSON schemas for complex nested data structures. This generates proper TypeScript types instead of `any`, and creates realistic structured data:
489
+
490
+ ```typescript
491
+ import { t } from '@doviui/dev-db'
492
+
493
+ export default {
494
+ User: {
495
+ $count: 100,
496
+ id: t.bigserial().primaryKey(),
497
+ email: t.varchar(255).unique().notNull(),
498
+
499
+ // Structured JSON field with nested schema
500
+ profile: t.json({
501
+ firstName: t.varchar(50).generate('person.firstName'),
502
+ lastName: t.varchar(50).generate('person.lastName'),
503
+ age: t.integer().min(18).max(90),
504
+
505
+ // Deeply nested structures
506
+ preferences: t.json({
507
+ theme: t.varchar(20).enum(['light', 'dark', 'auto']).default('auto'),
508
+ notifications: t.json({
509
+ email: t.boolean().default(true),
510
+ push: t.boolean().default(false),
511
+ frequency: t.varchar(20).enum(['realtime', 'daily', 'weekly'])
512
+ })
513
+ })
514
+ }),
515
+
516
+ // Simple unstructured JSON (fallback)
517
+ metadata: t.jsonb()
518
+ }
519
+ }
520
+ ```
521
+
522
+ **Generated TypeScript types:**
523
+
524
+ ```typescript
525
+ // mock-data/types.ts
526
+ export interface User {
527
+ id: number;
528
+ email: string;
529
+ profile: {
530
+ firstName: string;
531
+ lastName: string;
532
+ age: number;
533
+ preferences: {
534
+ theme?: string; // Optional because it has a default
535
+ notifications: {
536
+ email?: boolean;
537
+ push?: boolean;
538
+ frequency: string;
539
+ };
540
+ };
541
+ };
542
+ metadata: any; // Unstructured JSON falls back to 'any'
543
+ }
544
+ ```
545
+
546
+ **Generated JSON data:**
547
+
548
+ ```json
549
+ {
550
+ "id": 1,
551
+ "email": "john@example.com",
552
+ "profile": {
553
+ "firstName": "John",
554
+ "lastName": "Doe",
555
+ "age": 34,
556
+ "preferences": {
557
+ "theme": "dark",
558
+ "notifications": {
559
+ "email": true,
560
+ "push": false,
561
+ "frequency": "daily"
562
+ }
563
+ }
564
+ },
565
+ "metadata": { "data": "sample" }
566
+ }
567
+ ```
568
+
569
+ **Benefits:**
570
+ - ✅ Full TypeScript type safety for nested JSON structures
571
+ - ✅ All field modifiers work (`.nullable()`, `.default()`, `.enum()`, `.generate()`, etc.)
572
+ - ✅ Supports unlimited nesting depth
573
+ - ✅ Works with both `t.json()` and `t.jsonb()`
574
+
335
575
  ### Custom Data Generators
336
576
 
337
577
  Leverage any [Faker.js](https://fakerjs.dev/) method for realistic data generation:
@@ -370,6 +610,7 @@ Arguments:
370
610
  Options:
371
611
  -o, --output <dir> Output directory for generated JSON files (default: ./mock-data)
372
612
  -s, --seed <number> Random seed for reproducible data generation
613
+ -t, --types Generate TypeScript type definitions alongside JSON
373
614
  -h, --help Show help message
374
615
  ```
375
616
 
@@ -388,8 +629,11 @@ bunx @doviui/dev-db ./schemas -o ./data
388
629
  # Reproducible generation with seed
389
630
  bunx @doviui/dev-db schema.ts -s 42
390
631
 
632
+ # Generate TypeScript types
633
+ bunx @doviui/dev-db schema.ts --types
634
+
391
635
  # All options combined
392
- bunx @doviui/dev-db ./schemas --output ./database --seed 12345
636
+ bunx @doviui/dev-db ./schemas --output ./database --seed 12345 --types
393
637
  ```
394
638
 
395
639
  **Schema File Format:**
@@ -488,9 +732,12 @@ export default {
488
732
  first_name: t.varchar(50).generate('person.firstName'),
489
733
  last_name: t.varchar(50).generate('person.lastName'),
490
734
  phone: t.varchar(20).generate('phone.number'),
491
- 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 })
492
739
  },
493
-
740
+
494
741
  Product: {
495
742
  $count: 100,
496
743
  id: t.bigserial().primaryKey(),
@@ -500,21 +747,27 @@ export default {
500
747
  stock: t.integer().min(0).max(1000),
501
748
  category: t.varchar(50).enum(['electronics', 'clothing', 'home', 'books'])
502
749
  },
503
-
750
+
504
751
  Order: {
505
- $count: 500,
752
+ // Auto-calculated: 200 * 3 = 600 orders
506
753
  id: t.bigserial().primaryKey(),
507
- customer_id: t.foreignKey('Customer', 'id').notNull(),
754
+ customerId: t.varchar(36),
755
+ customer: t.belongsTo('Customer', 'customerId'),
508
756
  status: t.varchar(20).enum(['pending', 'processing', 'shipped', 'delivered']),
509
757
  total: t.decimal(10, 2).min(10).max(10000),
510
- 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 })
511
762
  },
512
-
763
+
513
764
  OrderItem: {
514
- $count: 1500,
765
+ // Auto-calculated: 600 * 2.5 = 1,500 items
515
766
  id: t.bigserial().primaryKey(),
516
- order_id: t.foreignKey('Order', 'id').notNull(),
517
- 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'),
518
771
  quantity: t.integer().min(1).max(10),
519
772
  price: t.decimal(10, 2)
520
773
  }
@@ -534,13 +787,17 @@ export default {
534
787
  username: t.varchar(50).unique().generate('internet.userName'),
535
788
  email: t.varchar(255).unique().generate('internet.email'),
536
789
  bio: t.text().generate('lorem.paragraph'),
537
- 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 })
538
794
  },
539
-
795
+
540
796
  Article: {
541
- $count: 300,
797
+ // Auto-calculated: 50 * 6.5 = 325 articles
542
798
  id: t.bigserial().primaryKey(),
543
- author_id: t.foreignKey('Author', 'id'),
799
+ authorId: t.varchar(36),
800
+ author: t.belongsTo('Author', 'authorId'),
544
801
  title: t.varchar(200).generate('lorem.sentence'),
545
802
  slug: t.varchar(200).unique().generate('lorem.slug'),
546
803
  content: t.text().generate('lorem.paragraphs', 5),
@@ -549,17 +806,19 @@ export default {
549
806
  published_at: t.timestamptz().nullable(),
550
807
  created_at: t.timestamptz().default('now')
551
808
  },
552
-
809
+
553
810
  Tag: {
554
811
  $count: 30,
555
812
  id: t.serial().primaryKey(),
556
813
  name: t.varchar(50).unique().generate('lorem.word')
557
814
  },
558
-
815
+
559
816
  ArticleTag: {
560
817
  $count: 800,
561
- article_id: t.foreignKey('Article', 'id'),
562
- 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')
563
822
  }
564
823
  }
565
824
  ```
package/cli.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { MockDataGenerator } from './src/generator';
8
8
  import { SchemaValidator } from './src/validator';
9
+ import { TypesGenerator } from './src/types-generator';
9
10
  import type { Schema } from './src/types';
10
11
  import { readdirSync, statSync } from 'fs';
11
12
  import { join, extname, resolve } from 'path';
@@ -22,6 +23,7 @@ ARGUMENTS:
22
23
  OPTIONS:
23
24
  -o, --output <dir> Output directory for generated JSON files (default: ./mock-data)
24
25
  -s, --seed <number> Random seed for reproducible data generation
26
+ -t, --types Generate TypeScript type definitions alongside JSON
25
27
  -h, --help Show this help message
26
28
 
27
29
  EXAMPLES:
@@ -29,6 +31,7 @@ EXAMPLES:
29
31
  dev-db ./schemas
30
32
  dev-db schema.ts -o ./data -s 42
31
33
  dev-db ./schemas --output ./database --seed 12345
34
+ dev-db schema.ts --types
32
35
 
33
36
  SCHEMA FILE FORMAT:
34
37
  Schema files must export a default object or named 'schema' export:
@@ -53,12 +56,14 @@ interface CLIOptions {
53
56
  schemaPath?: string;
54
57
  outputDir: string;
55
58
  seed?: number;
59
+ generateTypes: boolean;
56
60
  help: boolean;
57
61
  }
58
62
 
59
63
  function parseArgs(args: string[]): CLIOptions {
60
64
  const options: CLIOptions = {
61
65
  outputDir: './mock-data',
66
+ generateTypes: false,
62
67
  help: false,
63
68
  };
64
69
 
@@ -74,6 +79,8 @@ function parseArgs(args: string[]): CLIOptions {
74
79
  } else if (arg === '-s' || arg === '--seed') {
75
80
  const nextArg = args[++i];
76
81
  if (nextArg !== undefined) options.seed = parseInt(nextArg, 10);
82
+ } else if (arg === '-t' || arg === '--types') {
83
+ options.generateTypes = true;
77
84
  } else if (!arg.startsWith('-')) {
78
85
  options.schemaPath = arg;
79
86
  }
@@ -189,6 +196,17 @@ async function main() {
189
196
  await generator.generate();
190
197
 
191
198
  console.log(`Generated mock data in: ${absoluteOutputDir}`);
199
+
200
+ // Generate TypeScript types if requested
201
+ if (options.generateTypes) {
202
+ const typesGenerator = new TypesGenerator(schema, {
203
+ outputDir: absoluteOutputDir,
204
+ });
205
+
206
+ await typesGenerator.generate();
207
+
208
+ console.log(`Generated TypeScript types in: ${absoluteOutputDir}/types.ts`);
209
+ }
192
210
  } catch (error) {
193
211
  console.error('Error:', error instanceof Error ? error.message : error);
194
212
  process.exit(1);
package/index.ts CHANGED
@@ -34,6 +34,8 @@
34
34
  export { t } from './src/schema-builder';
35
35
  export { MockDataGenerator } from './src/generator';
36
36
  export { SchemaValidator } from './src/validator';
37
+ export { TypesGenerator } from './src/types-generator';
37
38
  export type { Schema, TableConfig, FieldConfig, Generator } from './src/types';
38
39
  export type { ValidationError } from './src/validator';
39
40
  export type { GeneratorOptions } from './src/generator';
41
+ export type { TypesGeneratorOptions } from './src/types-generator';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doviui/dev-db",
3
- "version": "0.2.1",
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",
@@ -1,5 +1,5 @@
1
1
  import type { Faker } from '@faker-js/faker';
2
- import type { FieldConfig, FieldBuilderLike } from './types';
2
+ import type { FieldConfig, FieldBuilderLike, JsonSchema } from './types';
3
3
 
4
4
  export type Generator = string | ((faker: Faker) => any);
5
5
 
@@ -160,6 +160,29 @@ export class FieldBuilder implements FieldBuilderLike {
160
160
  return this;
161
161
  }
162
162
 
163
+ /**
164
+ * Sets a nested schema for JSON/JSONB fields.
165
+ * Defines the structure and types of properties within the JSON object.
166
+ *
167
+ * @param schema - The JSON schema defining nested fields
168
+ * @returns The builder instance for chaining
169
+ *
170
+ * @example
171
+ * ```typescript
172
+ * t.json({
173
+ * userId: t.integer(),
174
+ * preferences: t.json({
175
+ * theme: t.varchar(20),
176
+ * notifications: t.boolean()
177
+ * })
178
+ * })
179
+ * ```
180
+ */
181
+ withJsonSchema(schema: JsonSchema): this {
182
+ this.config.jsonSchema = schema;
183
+ return this;
184
+ }
185
+
163
186
  /**
164
187
  * Converts the builder to a plain FieldConfig object.
165
188
  *
@@ -186,3 +209,44 @@ export class ForeignKeyBuilder extends FieldBuilder {
186
209
  this.config.foreignKey = { table, column };
187
210
  }
188
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;
@@ -206,12 +268,13 @@ export class MockDataGenerator {
206
268
  return index + 1;
207
269
  }
208
270
 
209
- // Handle nullable fields (but not if they have enum, min/max constraints, or custom generators)
271
+ // Handle nullable fields (but not if they have enum, min/max constraints, custom generators, or JSON schemas)
210
272
  if (
211
273
  fieldConfig.nullable &&
212
274
  !fieldConfig.notNull &&
213
275
  !fieldConfig.enum &&
214
276
  !fieldConfig.generator &&
277
+ !fieldConfig.jsonSchema &&
215
278
  fieldConfig.min === undefined &&
216
279
  fieldConfig.max === undefined &&
217
280
  Math.random() < 0.1
@@ -249,6 +312,25 @@ export class MockDataGenerator {
249
312
  return value;
250
313
  }
251
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
+
252
334
  private generateValueByType(fieldConfig: FieldConfig): any {
253
335
  // Use custom generator if provided
254
336
  if (fieldConfig.generator) {
@@ -309,6 +391,11 @@ export class MockDataGenerator {
309
391
 
310
392
  case 'json':
311
393
  case 'jsonb':
394
+ // If there's a nested schema, generate structured data
395
+ if (fieldConfig.jsonSchema) {
396
+ return this.generateJsonFromSchema(fieldConfig.jsonSchema);
397
+ }
398
+ // Otherwise, generate a simple object with sample data
312
399
  return { data: faker.lorem.word() };
313
400
 
314
401
  default:
@@ -334,6 +421,21 @@ export class MockDataGenerator {
334
421
  return current;
335
422
  }
336
423
 
424
+ private generateJsonFromSchema(jsonSchema: any): any {
425
+ const result: any = {};
426
+
427
+ for (const [fieldName, field] of Object.entries(jsonSchema)) {
428
+ const fieldConfig = this.toFieldConfig(field);
429
+ if (!fieldConfig) continue;
430
+
431
+ // Generate value based on field config
432
+ // Note: We don't track unique values for nested JSON fields
433
+ result[fieldName] = this.generateValueByType(fieldConfig);
434
+ }
435
+
436
+ return result;
437
+ }
438
+
337
439
  private async writeDataToFiles(): Promise<void> {
338
440
  const outputDir = this.options.outputDir ?? './mock-data';
339
441
 
@@ -1,4 +1,5 @@
1
- import { FieldBuilder, ForeignKeyBuilder } from './field-builder';
1
+ import { FieldBuilder, ForeignKeyBuilder, BelongsToBuilder, HasManyBuilder } from './field-builder';
2
+ import type { JsonSchema } from './types';
2
3
 
3
4
  /**
4
5
  * Type builder API for defining database schemas with a fluent interface.
@@ -183,19 +184,55 @@ export const t = {
183
184
 
184
185
  /**
185
186
  * Creates a JSON field for storing structured data.
187
+ * @param schema - Optional nested schema defining the JSON structure
186
188
  * @returns FieldBuilder instance for method chaining
189
+ * @example
190
+ * ```typescript
191
+ * // Simple JSON field
192
+ * t.json()
193
+ *
194
+ * // Structured JSON field with schema
195
+ * t.json({
196
+ * id: t.integer(),
197
+ * preferences: t.json({
198
+ * dark: t.boolean()
199
+ * })
200
+ * })
201
+ * ```
187
202
  */
188
- json(): FieldBuilder {
189
- return new FieldBuilder('json');
203
+ json(schema?: JsonSchema): FieldBuilder {
204
+ const builder = new FieldBuilder('json');
205
+ if (schema) {
206
+ builder.withJsonSchema(schema);
207
+ }
208
+ return builder;
190
209
  },
191
210
 
192
211
  /**
193
212
  * Creates a binary JSON field (JSONB).
194
213
  * More efficient for querying than regular JSON.
214
+ * @param schema - Optional nested schema defining the JSON structure
195
215
  * @returns FieldBuilder instance for method chaining
216
+ * @example
217
+ * ```typescript
218
+ * // Simple JSONB field
219
+ * t.jsonb()
220
+ *
221
+ * // Structured JSONB field with schema
222
+ * t.jsonb({
223
+ * userId: t.integer(),
224
+ * metadata: t.json({
225
+ * tags: t.varchar(255)
226
+ * })
227
+ * })
228
+ * ```
196
229
  */
197
- jsonb(): FieldBuilder {
198
- return new FieldBuilder('jsonb');
230
+ jsonb(schema?: JsonSchema): FieldBuilder {
231
+ const builder = new FieldBuilder('jsonb');
232
+ if (schema) {
233
+ builder.withJsonSchema(schema);
234
+ }
235
+ return builder;
199
236
  },
200
237
 
201
238
  // Relationships
@@ -210,4 +247,42 @@ export const t = {
210
247
  foreignKey(table: string, column: string): ForeignKeyBuilder {
211
248
  return new ForeignKeyBuilder(table, column);
212
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
+ },
213
288
  };
@@ -0,0 +1,250 @@
1
+ import type { Schema, FieldConfig, TableConfig } from './types';
2
+
3
+ /**
4
+ * Options for configuring the TypeScript types generator.
5
+ */
6
+ export interface TypesGeneratorOptions {
7
+ /** Directory where the types file will be saved (default: './mock-data') */
8
+ outputDir?: string;
9
+
10
+ /** Name of the generated types file (default: 'types.ts') */
11
+ fileName?: string;
12
+ }
13
+
14
+ /**
15
+ * Generates TypeScript type definitions from dev-db schemas.
16
+ *
17
+ * Features:
18
+ * - Converts field types to TypeScript types
19
+ * - Handles nullable and optional fields
20
+ * - Generates interfaces for each table
21
+ * - Creates a single types file for easy importing
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * const generator = new TypesGenerator(schema, {
26
+ * outputDir: './mock-data',
27
+ * fileName: 'types.ts'
28
+ * });
29
+ *
30
+ * await generator.generate();
31
+ * // Creates ./mock-data/types.ts with all interfaces
32
+ * ```
33
+ */
34
+ export class TypesGenerator {
35
+ private schema: Schema;
36
+ private options: TypesGeneratorOptions;
37
+
38
+ /**
39
+ * Creates a new TypeScript types generator.
40
+ *
41
+ * @param schema - The schema definition
42
+ * @param options - Generator options
43
+ */
44
+ constructor(schema: Schema, options: TypesGeneratorOptions = {}) {
45
+ this.schema = schema;
46
+ this.options = {
47
+ outputDir: options.outputDir || './mock-data',
48
+ fileName: options.fileName || 'types.ts',
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Generates TypeScript type definitions for all tables in the schema.
54
+ *
55
+ * Creates a single .ts file with interface definitions that match
56
+ * the structure of the generated JSON data.
57
+ *
58
+ * @returns Promise resolving when the types file is written
59
+ *
60
+ * @example
61
+ * ```typescript
62
+ * await generator.generate();
63
+ * // Creates ./mock-data/types.ts
64
+ * ```
65
+ */
66
+ async generate(): Promise<void> {
67
+ const interfaces: string[] = [];
68
+
69
+ // Generate interface for each table
70
+ for (const [tableName, tableConfig] of Object.entries(this.schema)) {
71
+ const interfaceCode = this.generateInterface(tableName, tableConfig);
72
+ interfaces.push(interfaceCode);
73
+ }
74
+
75
+ // Combine all interfaces into a single file
76
+ const fileContent = interfaces.join('\n\n');
77
+
78
+ // Write to file
79
+ const outputPath = `${this.options.outputDir}/${this.options.fileName}`;
80
+ await Bun.write(outputPath, fileContent);
81
+ }
82
+
83
+ private generateInterface(tableName: string, tableConfig: TableConfig): string {
84
+ const fields: string[] = [];
85
+
86
+ // Process each field
87
+ for (const [fieldName, field] of Object.entries(tableConfig)) {
88
+ // Skip the $count property
89
+ if (fieldName === '$count') continue;
90
+
91
+ const fieldConfig = this.toFieldConfig(field);
92
+ if (!fieldConfig) continue;
93
+
94
+ const fieldDeclaration = this.generateFieldDeclaration(fieldName, fieldConfig);
95
+ fields.push(` ${fieldDeclaration};`);
96
+ }
97
+
98
+ // Generate interface
99
+ return `export interface ${tableName} {\n${fields.join('\n')}\n}`;
100
+ }
101
+
102
+ private generateFieldDeclaration(fieldName: string, fieldConfig: FieldConfig): string {
103
+ const tsType = this.mapFieldTypeToTypeScript(fieldConfig);
104
+ const isOptional = this.isFieldOptional(fieldConfig);
105
+ const isNullable = this.isFieldNullable(fieldConfig);
106
+
107
+ let declaration = fieldName;
108
+
109
+ // Add optional marker if needed
110
+ if (isOptional) {
111
+ declaration += '?';
112
+ }
113
+
114
+ declaration += ': ';
115
+
116
+ // Add type
117
+ declaration += tsType;
118
+
119
+ // Add null union if nullable (but only if not already optional with a default)
120
+ if (isNullable && !(isOptional && fieldConfig.default !== undefined)) {
121
+ declaration += ' | null';
122
+ }
123
+
124
+ return declaration;
125
+ }
126
+
127
+ private mapFieldTypeToTypeScript(fieldConfig: FieldConfig): string {
128
+ // Handle foreign keys by looking up the referenced column type
129
+ if (fieldConfig.foreignKey) {
130
+ const refTable = fieldConfig.foreignKey.table;
131
+ const refColumn = fieldConfig.foreignKey.column;
132
+
133
+ const refTableConfig = this.schema[refTable];
134
+ if (refTableConfig) {
135
+ const refFieldConfig = this.toFieldConfig(refTableConfig[refColumn]);
136
+ if (refFieldConfig) {
137
+ return this.mapBasicTypeToTypeScript(refFieldConfig.type);
138
+ }
139
+ }
140
+
141
+ // Fallback if reference not found
142
+ return 'any';
143
+ }
144
+
145
+ // Handle JSON/JSONB with nested schema
146
+ if ((fieldConfig.type === 'json' || fieldConfig.type === 'jsonb') && fieldConfig.jsonSchema) {
147
+ return this.generateJsonSchemaType(fieldConfig.jsonSchema);
148
+ }
149
+
150
+ return this.mapBasicTypeToTypeScript(fieldConfig.type);
151
+ }
152
+
153
+ private generateJsonSchemaType(jsonSchema: any): string {
154
+ const fields: string[] = [];
155
+
156
+ for (const [fieldName, field] of Object.entries(jsonSchema)) {
157
+ const fieldConfig = this.toFieldConfig(field);
158
+ if (!fieldConfig) continue;
159
+
160
+ const tsType = this.mapFieldTypeToTypeScript(fieldConfig);
161
+ const isNullable = this.isFieldNullable(fieldConfig);
162
+ const isOptional = this.isFieldOptional(fieldConfig);
163
+
164
+ let declaration = `${fieldName}`;
165
+
166
+ if (isOptional) {
167
+ declaration += '?';
168
+ }
169
+
170
+ declaration += `: ${tsType}`;
171
+
172
+ if (isNullable && !(isOptional && fieldConfig.default !== undefined)) {
173
+ declaration += ' | null';
174
+ }
175
+
176
+ fields.push(` ${declaration}`);
177
+ }
178
+
179
+ return `{\n${fields.join(';\n')}${fields.length > 0 ? ';' : ''}\n }`;
180
+ }
181
+
182
+ private mapBasicTypeToTypeScript(type: string): string {
183
+ switch (type) {
184
+ case 'bigint':
185
+ case 'bigserial':
186
+ case 'integer':
187
+ case 'smallint':
188
+ case 'serial':
189
+ case 'decimal':
190
+ case 'numeric':
191
+ case 'real':
192
+ case 'double':
193
+ return 'number';
194
+
195
+ case 'varchar':
196
+ case 'char':
197
+ case 'text':
198
+ case 'uuid':
199
+ return 'string';
200
+
201
+ case 'boolean':
202
+ return 'boolean';
203
+
204
+ case 'date':
205
+ case 'time':
206
+ case 'timestamp':
207
+ case 'timestamptz':
208
+ return 'string'; // ISO format strings
209
+
210
+ case 'json':
211
+ case 'jsonb':
212
+ return 'any'; // Could be `object` or a generic type parameter in the future
213
+
214
+ default:
215
+ return 'any';
216
+ }
217
+ }
218
+
219
+ private isFieldOptional(fieldConfig: FieldConfig): boolean {
220
+ // A field is optional if it has a default value
221
+ // This means it doesn't need to be provided when creating the object
222
+ return fieldConfig.default !== undefined;
223
+ }
224
+
225
+ private isFieldNullable(fieldConfig: FieldConfig): boolean {
226
+ // A field can be null if:
227
+ // - Explicitly marked as nullable AND not marked as notNull
228
+ if (fieldConfig.notNull) return false;
229
+ if (fieldConfig.nullable === true) return true;
230
+
231
+ // Fields with defaults are not nullable unless explicitly set
232
+ if (fieldConfig.default !== undefined) return false;
233
+
234
+ // Primary keys are not nullable
235
+ if (fieldConfig.primaryKey) return false;
236
+
237
+ return false; // Default to not nullable unless explicitly set
238
+ }
239
+
240
+ private toFieldConfig(field: any): FieldConfig | null {
241
+ if (!field) return null;
242
+ if (typeof field === 'object' && 'toConfig' in field) {
243
+ return field.toConfig();
244
+ }
245
+ if (typeof field === 'object' && 'type' in field) {
246
+ return field;
247
+ }
248
+ return null;
249
+ }
250
+ }
package/src/types.ts CHANGED
@@ -57,8 +57,24 @@ 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;
68
+
69
+ /** Nested JSON schema for json/jsonb types */
70
+ jsonSchema?: JsonSchema;
71
+ }
72
+
73
+ /**
74
+ * Schema definition for JSON fields, mapping property names to field configurations.
75
+ */
76
+ export interface JsonSchema {
77
+ [fieldName: string]: FieldConfig | FieldBuilderLike;
62
78
  }
63
79
 
64
80
  /**
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);