@doviui/dev-db 0.1.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.
@@ -0,0 +1,188 @@
1
+ import type { Faker } from '@faker-js/faker';
2
+ import type { FieldConfig, FieldBuilderLike } from './types';
3
+
4
+ export type Generator = string | ((faker: Faker) => any);
5
+
6
+ /**
7
+ * Builder class for defining database field configurations with a fluent API.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * const field = new FieldBuilder('varchar', 255)
12
+ * .unique()
13
+ * .notNull()
14
+ * .generate('internet.email');
15
+ * ```
16
+ */
17
+ export class FieldBuilder implements FieldBuilderLike {
18
+ protected config: FieldConfig;
19
+
20
+ /**
21
+ * Creates a new FieldBuilder instance.
22
+ *
23
+ * @param type - The SQL-like type of the field
24
+ * @param length - Optional length for string types
25
+ * @param precision - Optional precision for numeric types
26
+ * @param scale - Optional scale for numeric types
27
+ */
28
+ constructor(type: string, length?: number, precision?: number, scale?: number) {
29
+ this.config = {
30
+ type,
31
+ length,
32
+ precision,
33
+ scale,
34
+ nullable: true,
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Marks this field as a primary key.
40
+ * Automatically sets notNull to true and nullable to false.
41
+ *
42
+ * @returns The builder instance for chaining
43
+ */
44
+ primaryKey(): this {
45
+ this.config.primaryKey = true;
46
+ this.config.notNull = true;
47
+ this.config.nullable = false;
48
+ return this;
49
+ }
50
+
51
+ /**
52
+ * Enforces uniqueness constraint on this field.
53
+ * All generated values will be unique.
54
+ *
55
+ * @returns The builder instance for chaining
56
+ */
57
+ unique(): this {
58
+ this.config.unique = true;
59
+ return this;
60
+ }
61
+
62
+ /**
63
+ * Marks this field as NOT NULL.
64
+ * Generated values will never be null.
65
+ *
66
+ * @returns The builder instance for chaining
67
+ */
68
+ notNull(): this {
69
+ this.config.notNull = true;
70
+ this.config.nullable = false;
71
+ return this;
72
+ }
73
+
74
+ /**
75
+ * Marks this field as nullable.
76
+ * Generated values may occasionally be null (10% chance by default).
77
+ *
78
+ * @returns The builder instance for chaining
79
+ */
80
+ nullable(): this {
81
+ this.config.nullable = true;
82
+ this.config.notNull = false;
83
+ return this;
84
+ }
85
+
86
+ /**
87
+ * Sets a default value for this field.
88
+ *
89
+ * @param value - The default value. Use 'now' for current timestamp.
90
+ * @returns The builder instance for chaining
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * t.boolean().default(true)
95
+ * t.timestamptz().default('now')
96
+ * ```
97
+ */
98
+ default(value: any): this {
99
+ this.config.default = value;
100
+ return this;
101
+ }
102
+
103
+ /**
104
+ * Sets the minimum value for numeric fields.
105
+ *
106
+ * @param value - Minimum allowed value
107
+ * @returns The builder instance for chaining
108
+ */
109
+ min(value: number): this {
110
+ this.config.min = value;
111
+ return this;
112
+ }
113
+
114
+ /**
115
+ * Sets the maximum value for numeric fields.
116
+ *
117
+ * @param value - Maximum allowed value
118
+ * @returns The builder instance for chaining
119
+ */
120
+ max(value: number): this {
121
+ this.config.max = value;
122
+ return this;
123
+ }
124
+
125
+ /**
126
+ * Restricts field values to a specific set of allowed values.
127
+ *
128
+ * @param values - Array of allowed values
129
+ * @returns The builder instance for chaining
130
+ *
131
+ * @example
132
+ * ```typescript
133
+ * t.varchar(20).enum(['draft', 'published', 'archived'])
134
+ * ```
135
+ */
136
+ enum(values: any[]): this {
137
+ this.config.enum = values;
138
+ return this;
139
+ }
140
+
141
+ /**
142
+ * Sets a custom generator for field values.
143
+ *
144
+ * @param generator - Either a Faker.js method path or a custom function
145
+ * @returns The builder instance for chaining
146
+ *
147
+ * @example
148
+ * ```typescript
149
+ * // Using Faker.js method
150
+ * t.varchar(100).generate('internet.email')
151
+ *
152
+ * // Using custom function
153
+ * t.varchar(20).generate((faker) =>
154
+ * faker.helpers.arrayElement(['red', 'blue', 'green'])
155
+ * )
156
+ * ```
157
+ */
158
+ generate(generator: Generator): this {
159
+ this.config.generator = generator;
160
+ return this;
161
+ }
162
+
163
+ /**
164
+ * Converts the builder to a plain FieldConfig object.
165
+ *
166
+ * @returns The field configuration
167
+ */
168
+ toConfig(): FieldConfig {
169
+ return this.config;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Builder class for foreign key fields.
175
+ * Extends FieldBuilder with foreign key-specific configuration.
176
+ */
177
+ export class ForeignKeyBuilder extends FieldBuilder {
178
+ /**
179
+ * Creates a new foreign key field builder.
180
+ *
181
+ * @param table - The referenced table name
182
+ * @param column - The referenced column name
183
+ */
184
+ constructor(table: string, column: string) {
185
+ super('foreignKey');
186
+ this.config.foreignKey = { table, column };
187
+ }
188
+ }
@@ -0,0 +1,360 @@
1
+ import { faker } from '@faker-js/faker';
2
+ import type { Schema, FieldConfig, TableConfig } from './types';
3
+
4
+ /**
5
+ * Options for configuring the mock data generator.
6
+ */
7
+ export interface GeneratorOptions {
8
+ /** Directory where generated JSON files will be saved (default: './mock-data') */
9
+ outputDir?: string;
10
+
11
+ /** Random seed for reproducible data generation */
12
+ seed?: number;
13
+ }
14
+
15
+ /**
16
+ * Generated data indexed by table name.
17
+ */
18
+ interface GeneratedData {
19
+ [tableName: string]: any[];
20
+ }
21
+
22
+ /**
23
+ * Generates realistic mock data from schema definitions.
24
+ *
25
+ * Features:
26
+ * - Topological sorting to handle foreign key dependencies
27
+ * - Unique value generation with retry logic
28
+ * - Custom generators via Faker.js or custom functions
29
+ * - Enum support
30
+ * - Min/max constraints for numeric types
31
+ * - Automatic foreign key resolution
32
+ *
33
+ * @example
34
+ * ```typescript
35
+ * const generator = new MockDataGenerator(schema, {
36
+ * outputDir: './mock-data',
37
+ * seed: 42 // Optional: for reproducible data
38
+ * });
39
+ *
40
+ * const data = await generator.generate();
41
+ * console.log(`Generated ${data.User.length} users`);
42
+ * ```
43
+ */
44
+ export class MockDataGenerator {
45
+ private schema: Schema;
46
+ private options: GeneratorOptions;
47
+ private generatedData: GeneratedData = {};
48
+
49
+ /**
50
+ * Creates a new mock data generator.
51
+ *
52
+ * @param schema - The schema definition
53
+ * @param options - Generator options
54
+ */
55
+ constructor(schema: Schema, options: GeneratorOptions = {}) {
56
+ this.schema = schema;
57
+ this.options = {
58
+ outputDir: options.outputDir || './mock-data',
59
+ seed: options.seed,
60
+ };
61
+
62
+ if (this.options.seed !== undefined) {
63
+ faker.seed(this.options.seed);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Generates mock data for all tables in the schema.
69
+ *
70
+ * Tables are processed in dependency order (based on foreign keys).
71
+ * Generated data is written to JSON files in the output directory.
72
+ *
73
+ * @returns Promise resolving to the generated data indexed by table name
74
+ *
75
+ * @example
76
+ * ```typescript
77
+ * const data = await generator.generate();
78
+ * // Files created: ./mock-data/User.json, ./mock-data/Post.json, etc.
79
+ * ```
80
+ */
81
+ async generate(): Promise<GeneratedData> {
82
+ // Topologically sort tables based on foreign key dependencies
83
+ const sortedTables = this.topologicalSort();
84
+
85
+ // Generate data for each table in order
86
+ for (const tableName of sortedTables) {
87
+ const tableConfig = this.schema[tableName];
88
+ if (!tableConfig) continue;
89
+
90
+ const count = tableConfig.$count || 10;
91
+
92
+ this.generatedData[tableName] = this.generateTableData(tableName, tableConfig, count);
93
+ }
94
+
95
+ // Write data to files
96
+ await this.writeDataToFiles();
97
+
98
+ return this.generatedData;
99
+ }
100
+
101
+ private topologicalSort(): string[] {
102
+ const tables = Object.keys(this.schema);
103
+ const visited = new Set<string>();
104
+ const result: string[] = [];
105
+
106
+ const visit = (tableName: string) => {
107
+ if (visited.has(tableName)) return;
108
+ visited.add(tableName);
109
+
110
+ const tableConfig = this.schema[tableName];
111
+ if (!tableConfig) return;
112
+
113
+ const fields = Object.entries(tableConfig).filter(([key]) => key !== '$count');
114
+
115
+ // Visit dependencies first
116
+ for (const [_, field] of fields) {
117
+ const fieldConfig = this.toFieldConfig(field);
118
+ if (fieldConfig?.foreignKey) {
119
+ const refTable = fieldConfig.foreignKey.table;
120
+ if (refTable !== tableName) { // Avoid self-references
121
+ visit(refTable);
122
+ }
123
+ }
124
+ }
125
+
126
+ result.push(tableName);
127
+ };
128
+
129
+ for (const tableName of tables) {
130
+ visit(tableName);
131
+ }
132
+
133
+ return result;
134
+ }
135
+
136
+ private generateTableData(tableName: string, tableConfig: TableConfig, count: number): any[] {
137
+ const records: any[] = [];
138
+ const uniqueValues = new Map<string, Set<any>>();
139
+
140
+ for (let i = 0; i < count; i++) {
141
+ const record: any = {};
142
+ const fields = Object.entries(tableConfig).filter(([key]) => key !== '$count');
143
+
144
+ for (const [fieldName, field] of fields) {
145
+ const fieldConfig = this.toFieldConfig(field);
146
+ if (fieldConfig) {
147
+ record[fieldName] = this.generateFieldValue(
148
+ fieldName,
149
+ fieldConfig,
150
+ i,
151
+ uniqueValues,
152
+ tableName
153
+ );
154
+ }
155
+ }
156
+
157
+ records.push(record);
158
+ }
159
+
160
+ return records;
161
+ }
162
+
163
+ private toFieldConfig(field: any): FieldConfig | null {
164
+ if (!field) return null;
165
+ if (typeof field === 'object' && 'toConfig' in field) {
166
+ return field.toConfig();
167
+ }
168
+ if (typeof field === 'object' && 'type' in field) {
169
+ return field;
170
+ }
171
+ return null;
172
+ }
173
+
174
+ private generateFieldValue(
175
+ fieldName: string,
176
+ fieldConfig: FieldConfig,
177
+ index: number,
178
+ uniqueValues: Map<string, Set<any>>,
179
+ tableName: string
180
+ ): any {
181
+ // Handle default values
182
+ if (fieldConfig.default !== undefined) {
183
+ if (fieldConfig.default === 'now') {
184
+ return new Date().toISOString();
185
+ }
186
+ return fieldConfig.default;
187
+ }
188
+
189
+ // Handle foreign keys
190
+ if (fieldConfig.foreignKey) {
191
+ const refTable = fieldConfig.foreignKey.table;
192
+ const refColumn = fieldConfig.foreignKey.column;
193
+ const refData = this.generatedData[refTable];
194
+
195
+ if (!refData || refData.length === 0) {
196
+ if (fieldConfig.nullable) return null;
197
+ throw new Error(`Cannot generate foreign key for ${tableName}.${fieldName}: no data in ${refTable}`);
198
+ }
199
+
200
+ const randomRecord = refData[Math.floor(Math.random() * refData.length)];
201
+ return randomRecord[refColumn];
202
+ }
203
+
204
+ // Handle serial/bigserial auto-increment
205
+ if (fieldConfig.type === 'serial' || fieldConfig.type === 'bigserial') {
206
+ return index + 1;
207
+ }
208
+
209
+ // Handle nullable fields (but not if they have enum, min/max constraints, or custom generators)
210
+ if (
211
+ fieldConfig.nullable &&
212
+ !fieldConfig.notNull &&
213
+ !fieldConfig.enum &&
214
+ !fieldConfig.generator &&
215
+ fieldConfig.min === undefined &&
216
+ fieldConfig.max === undefined &&
217
+ Math.random() < 0.1
218
+ ) {
219
+ return null;
220
+ }
221
+
222
+ // Generate value with uniqueness check
223
+ let value: any;
224
+ let attempts = 0;
225
+ const maxAttempts = 1000;
226
+
227
+ do {
228
+ value = this.generateValueByType(fieldConfig);
229
+ attempts++;
230
+
231
+ if (attempts >= maxAttempts) {
232
+ throw new Error(
233
+ `Could not generate unique value for ${tableName}.${fieldName} after ${maxAttempts} attempts`
234
+ );
235
+ }
236
+ } while (
237
+ fieldConfig.unique &&
238
+ uniqueValues.get(fieldName)?.has(JSON.stringify(value))
239
+ );
240
+
241
+ // Track unique values
242
+ if (fieldConfig.unique) {
243
+ if (!uniqueValues.has(fieldName)) {
244
+ uniqueValues.set(fieldName, new Set());
245
+ }
246
+ uniqueValues.get(fieldName)!.add(JSON.stringify(value));
247
+ }
248
+
249
+ return value;
250
+ }
251
+
252
+ private generateValueByType(fieldConfig: FieldConfig): any {
253
+ // Use custom generator if provided
254
+ if (fieldConfig.generator) {
255
+ if (typeof fieldConfig.generator === 'string') {
256
+ return this.callFakerMethod(fieldConfig.generator);
257
+ } else {
258
+ return fieldConfig.generator(faker);
259
+ }
260
+ }
261
+
262
+ // Use enum if provided
263
+ if (fieldConfig.enum && fieldConfig.enum.length > 0) {
264
+ return fieldConfig.enum[Math.floor(Math.random() * fieldConfig.enum.length)];
265
+ }
266
+
267
+ // Generate based on type
268
+ switch (fieldConfig.type) {
269
+ case 'uuid':
270
+ return faker.string.uuid();
271
+
272
+ case 'boolean':
273
+ return faker.datatype.boolean();
274
+
275
+ case 'integer':
276
+ case 'bigint':
277
+ case 'smallint':
278
+ return faker.number.int({
279
+ min: fieldConfig.min ?? 1,
280
+ max: fieldConfig.max ?? 100000,
281
+ });
282
+
283
+ case 'decimal':
284
+ case 'numeric':
285
+ case 'real':
286
+ case 'double':
287
+ return faker.number.float({
288
+ min: fieldConfig.min ?? 0,
289
+ max: fieldConfig.max ?? 10000,
290
+ fractionDigits: fieldConfig.scale ?? 2,
291
+ });
292
+
293
+ case 'varchar':
294
+ case 'char':
295
+ return faker.lorem.word().substring(0, fieldConfig.length || 255);
296
+
297
+ case 'text':
298
+ return faker.lorem.paragraph();
299
+
300
+ case 'date':
301
+ return faker.date.past().toISOString().split('T')[0];
302
+
303
+ case 'time':
304
+ return faker.date.recent().toISOString().split('T')[1]?.split('.')[0] ?? '00:00:00';
305
+
306
+ case 'timestamp':
307
+ case 'timestamptz':
308
+ return faker.date.recent().toISOString();
309
+
310
+ case 'json':
311
+ case 'jsonb':
312
+ return { data: faker.lorem.word() };
313
+
314
+ default:
315
+ return faker.lorem.word();
316
+ }
317
+ }
318
+
319
+ private callFakerMethod(method: string): any {
320
+ const parts = method.split('.');
321
+ let current: any = faker;
322
+
323
+ for (const part of parts) {
324
+ current = current[part];
325
+ if (current === undefined) {
326
+ throw new Error(`Faker method not found: ${method}`);
327
+ }
328
+ }
329
+
330
+ if (typeof current === 'function') {
331
+ return current();
332
+ }
333
+
334
+ return current;
335
+ }
336
+
337
+ private async writeDataToFiles(): Promise<void> {
338
+ const outputDir = this.options.outputDir ?? './mock-data';
339
+
340
+ // Create output directory if it doesn't exist
341
+ await Bun.write(`${outputDir}/.gitkeep`, '');
342
+
343
+ // Write each table to a JSON file
344
+ for (const [tableName, data] of Object.entries(this.generatedData)) {
345
+ const filePath = `${outputDir}/${tableName}.json`;
346
+ await Bun.write(filePath, JSON.stringify(data, null, 2));
347
+ console.log(` Generated ${data.length} records for ${tableName} -> ${filePath}`);
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Gets the generated data without triggering generation.
353
+ * Must be called after generate().
354
+ *
355
+ * @returns The generated data indexed by table name
356
+ */
357
+ getGeneratedData(): GeneratedData {
358
+ return this.generatedData;
359
+ }
360
+ }