@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 +282 -23
- package/cli.ts +18 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/field-builder.ts +65 -1
- package/src/generator.ts +103 -1
- package/src/schema-builder.ts +80 -5
- package/src/types-generator.ts +250 -0
- package/src/types.ts +16 -0
- package/src/validator.ts +82 -0
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
|
-
|
|
193
|
-
|
|
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')
|
|
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
|
-
|
|
752
|
+
// Auto-calculated: 200 * 3 = 600 orders
|
|
506
753
|
id: t.bigserial().primaryKey(),
|
|
507
|
-
|
|
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
|
-
|
|
765
|
+
// Auto-calculated: 600 * 2.5 = 1,500 items
|
|
515
766
|
id: t.bigserial().primaryKey(),
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
797
|
+
// Auto-calculated: 50 * 6.5 = 325 articles
|
|
542
798
|
id: t.bigserial().primaryKey(),
|
|
543
|
-
|
|
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
|
-
|
|
562
|
-
|
|
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
package/src/field-builder.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/schema-builder.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|