UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

638 lines (569 loc) 19 kB
import { faker } from "@faker-js/faker"; import type { StaticDecode, TArray, TBoolean, TInteger, TNumber, TObject, TRecord, TSchema, TString, } from "alepha"; import { z } from "alepha"; export interface FakeOptions { /** * Faker locale to use for generating fake data. * @default "en" */ locale?: string; /** * Seed for deterministic fake data generation. */ seed?: number; /** * Probability (0-1) that an optional field will be undefined. * @default 0.3 */ optionalProbability?: number; /** * Probability (0-1) that a nullable field will be null. * @default 0.2 */ nullableProbability?: number; /** * Default number of items to generate for arrays when maxItems is not specified. * @default 5 */ defaultArrayLength?: number; /** * Maximum number of items to generate for arrays. * Set to Infinity to respect schema's maxItems without cap. * @default 20 */ maxArrayLength?: number; /** * Default number of entries to generate for record types. * @default { min: 2, max: 5 } */ defaultRecordEntries?: { min: number; max: number }; } /** * Generate fake data from TypeBox schemas using faker.js. * * @example * ```ts * const fake = new FakeProvider(); * const userSchema = z.object({ * id: z.uuid(), * name: z.text(), * email: z.email(), * }); * const fakeUser = fake.generate(userSchema); * * // Configure options * fake.configure({ seed: 99999, optionalProbability: 0.5 }); * ``` */ export class FakeProvider { protected readonly faker = faker; protected readonly guard = z.schema; protected options: Required<FakeOptions> = { locale: "en", seed: 12345, optionalProbability: 0.3, nullableProbability: 0.2, defaultArrayLength: 5, maxArrayLength: 20, defaultRecordEntries: { min: 2, max: 5 }, }; constructor() { this.faker.seed(this.options.seed); } /** * Configure generation options. * If a seed is provided, the faker instance is reseeded. */ public configure(options: Partial<FakeOptions>): this { const oldSeed = this.options.seed; this.options = { ...this.options, ...options, // Merge defaultRecordEntries if provided defaultRecordEntries: options.defaultRecordEntries ? { ...this.options.defaultRecordEntries, ...options.defaultRecordEntries, } : this.options.defaultRecordEntries, }; // Reseed faker if seed is provided (allows resetting to same seed) if (options.seed !== undefined) { this.faker.seed(options.seed); } return this; } /** * Generate fake data matching the given TypeBox schema. * * For object schemas, property names are used to generate contextually * appropriate values. The following field name patterns are recognized * (case-insensitive, underscores and hyphens are ignored): * * **String fields:** * - `email` → email address * - `firstName`, `first` → first name * - `lastName`, `last` → last name * - `fullName`, `name` → full name * - `username` → username * - `phone`, `mobile` → phone number * - `address` → street address * - `city` → city name * - `country` → country name * - `state`, `province` → state/province * - `zip`, `postal` → postal code * - `company`, `organization` → company name * - `job`, `title`, `position` → job title * - `url`, `website` → URL * - `avatar`, `image`, `photo` → avatar URL * - `color`, `colour` → color name * - `bio`, `about`, `description` → bio text * * **Number/Integer fields:** * - `age` → 18-99 * - `year` → past year * - `month` → 1-12 * - `day` → 1-31 * - `price`, `amount`, `cost` → monetary value * * @example * ```ts * const fake = new FakeProvider(); * const schema = z.object({ * user_name: z.string(), // generates username * firstName: z.string(), // generates first name * "e-mail": z.string(), // generates email * }); * const result = fake.generate(schema); * ``` */ public generate<T extends TSchema>(schema: T): StaticDecode<T> { return this.generateValue(schema) as StaticDecode<T>; } /** * Generate multiple fake data items. */ public generateMany<T extends TSchema>( schema: T, count: number, ): StaticDecode<T>[] { return Array.from({ length: count }, () => this.generate(schema)); } protected generateValue(schema: TSchema): unknown { const s = schema as any; // Handle optional — peel one layer and recurse if (this.guard.isOptional(schema)) { if ( this.faker.datatype.boolean({ probability: this.options.optionalProbability, }) ) { return undefined; } return this.generateValue(s.unwrap()); } // Handle nullable (zod ZodNullable — distinct from a union-with-null) if (this.guard.isNullable(schema)) { if ( this.faker.datatype.boolean({ probability: this.options.nullableProbability, }) ) { return null; } return this.generateValue(s.unwrap()); } // Handle default — generate a value for the wrapped type if (z.schema.isDefault(s)) { return this.generateValue(s.unwrap() as TSchema); } // Handle enum if (this.guard.isEnum(schema)) { const values = z.schema.enumValues(schema); return values[this.faker.number.int({ min: 0, max: values.length - 1 })]; } // Handle union (a union containing null behaves like nullable) if (this.guard.isUnion(schema)) { const members = (s.anyOf ?? s.options ?? []) as TSchema[]; const hasNull = members.some((m) => this.guard.isNull(m)); if (hasNull) { if ( this.faker.datatype.boolean({ probability: this.options.nullableProbability, }) ) { return null; } const nonNull = members.filter((m) => !this.guard.isNull(m)); return this.generateValue( nonNull[ this.faker.number.int({ min: 0, max: nonNull.length - 1 }) ] as TSchema, ); } return this.generateValue( members[ this.faker.number.int({ min: 0, max: members.length - 1 }) ] as TSchema, ); } // Handle null if (this.guard.isNull(schema)) { return null; } // Handle undefined / void if (this.guard.isUndefined(schema) || this.guard.isVoid(schema)) { return undefined; } // Handle literal if (this.guard.isLiteral(schema)) { return s.value; } // Handle bigint (a validated string) — must precede the plain-string check if (this.guard.isBigInt(schema)) { return this.generateBigInt(schema as TString); } // Handle string if (this.guard.isString(schema)) { return this.generateString(schema as TString); } // Handle integer — must precede the plain-number check if (this.guard.isInteger(schema)) { return this.generateInteger(schema as TInteger); } // Handle number if (this.guard.isNumber(schema)) { return this.generateNumber(schema as TNumber); } // Handle boolean if (this.guard.isBoolean(schema)) { return this.generateBoolean(schema as TBoolean); } // Handle array if (this.guard.isArray(schema)) { return this.generateArray(schema as TArray); } // Handle object if (this.guard.isObject(schema)) { return this.generateObject(schema as TObject); } // Handle record if (this.guard.isRecord(schema)) { return this.generateRecord(schema as TRecord); } // Handle tuple if (this.guard.isTuple(schema)) { const items = (s.items ?? []) as TSchema[]; return items.map((item) => this.generateValue(item)); } // Handle any / unknown if (this.guard.isAny(schema) || this.guard.isUnsafe(schema)) { const types = ["string", "number", "boolean"]; const kind = types[this.faker.number.int({ min: 0, max: types.length - 1 })]; if (kind === "number") return this.faker.number.float(); if (kind === "boolean") return this.faker.datatype.boolean(); return this.faker.lorem.word(); } // Fallback return this.faker.lorem.word(); } protected generateString(schema: TString): string { const schemaAny = schema as any; const format = z.schema.format(schema); const metaObj = (schemaAny.meta?.() ?? {}) as Record<string, any>; const rawPattern = metaObj.pattern; const pattern = typeof rawPattern === "string" ? rawPattern : (rawPattern?.source ?? undefined); const minLength = schemaAny.minLength ?? undefined; const maxLength = schemaAny.maxLength ?? undefined; // Handle specific formats if (format) { switch (format) { case "uuid": return this.faker.string.uuid(); case "email": return this.faker.internet.email(); case "url": return this.faker.internet.url(); case "date-time": return this.faker.date.recent().toISOString(); case "date": return this.faker.date.recent().toISOString().split("T")[0]; case "time": return this.faker.date.recent().toISOString().split("T")[1]; case "bigint": return this.faker.number .bigInt({ min: -9007199254740991n, max: 9007199254740991n }) .toString(); case "binary": return btoa(this.faker.string.alphanumeric(20)); } } // Handle E.164 phone pattern if (pattern?.includes("\\+[1-9]\\d{1,14}")) { // Generate a proper E.164 format: + followed by 1-15 digits const countryCode = this.faker.number.int({ min: 1, max: 999 }); const phoneNumber = this.faker.string.numeric({ length: this.faker.number.int({ min: 7, max: 11 }), }); return `+${countryCode}${phoneNumber}`; } // Handle BCP 47 language tag pattern if (pattern?.includes("[a-z]{2,3}")) { const locales = ["en", "en-US", "fr", "fr-CA", "es", "de", "it", "ja"]; return locales[ this.faker.number.int({ min: 0, max: locales.length - 1 }) ]; } // Handle snake_case pattern if (pattern === "^[A-Z_-]+$") { return this.faker.lorem .word() .toUpperCase() .replace(/[^A-Z]/g, "_"); } // Generate text based on length constraints let text: string; if (maxLength !== undefined) { if (maxLength <= 10) { text = this.faker.lorem.word(); } else if (maxLength <= 64) { text = this.faker.lorem.words(2); } else if (maxLength <= 255) { text = this.faker.lorem.sentence(); } else if (maxLength <= 1024) { text = this.faker.lorem.paragraph(1); } else { text = this.faker.lorem.paragraphs(3); } } else { text = this.faker.lorem.sentence(); } // Ensure min/max length constraints if (minLength !== undefined && text.length < minLength) { text = text.padEnd(minLength, " "); } if (maxLength !== undefined && text.length > maxLength) { text = text.slice(0, maxLength); } return text; } protected generateNumber(schema: TNumber): number { const schemaAny = schema as any; // zod exposes numeric bounds as `.minValue` / `.maxValue` (number | null). const min = schemaAny.minValue ?? -1000000; const max = schemaAny.maxValue ?? 1000000; // `.multipleOf` is a builder method on zod schemas, not a value — only use // it if it surfaces as an actual number. const multipleOf = typeof schemaAny.multipleOf === "number" ? schemaAny.multipleOf : undefined; let value = this.faker.number.float({ min, max }); if (multipleOf != null) { value = Math.round(value / multipleOf) * multipleOf; } return value; } protected generateInteger(schema: TInteger): number { const schemaAny = schema as any; const min = schemaAny.minValue ?? -2147483647; const max = schemaAny.maxValue ?? 2147483647; return this.faker.number.int({ min, max }); } protected generateBigInt(schema: TString): string { return this.faker.number .bigInt({ min: -9007199254740991n, max: 9007199254740991n }) .toString(); } protected generateBoolean(_schema: TBoolean): boolean { return this.faker.datatype.boolean(); } protected generateArray(schema: TArray): unknown[] { // Native zod stores `.min()`/`.max()` length bounds in `_zod.def.checks`, // not in `.meta()`. Read those (falling back to legacy meta minItems/maxItems). const def = (schema as any)._zod?.def; const checks = (def?.checks ?? []) as any[]; const readBound = (check: string, key: string): number | undefined => { for (const c of checks) { const d = c?._zod?.def; if (d?.check === check) return d[key]; } return undefined; }; const metaObj = ((schema as any).meta?.() ?? {}) as Record<string, any>; const minItems = readBound("min_length", "minimum") ?? metaObj.minItems ?? 0; const maxItems = Math.min( readBound("max_length", "maximum") ?? metaObj.maxItems ?? this.options.defaultArrayLength, this.options.maxArrayLength, ); const length = this.faker.number.int({ min: minItems, max: Math.max(minItems, maxItems), }); const element = (schema as any).element ?? def?.element; return Array.from({ length }, () => this.generateValue(element)); } protected generateObject(schema: TObject): Record<string, unknown> { const result: Record<string, unknown> = {}; for (const [key, propSchema] of Object.entries(schema.properties)) { result[key] = this.generateValueWithContext(propSchema as TSchema, key); } return result; } /** * Generate a value with context from the property key name. * This helps generate more realistic fake data based on field names. */ protected generateValueWithContext( schema: TSchema, keyName?: string, ): unknown { // If no key name context, use regular generation if (!keyName) { return this.generateValue(schema); } // Normalize key name: lowercase and remove underscores/hyphens // This allows matching "user_name", "user-name", "userName" etc. const normalizedKey = keyName.toLowerCase().replace(/[_-]/g, ""); // Check if this is a string type that could benefit from context if (this.guard.isString(schema)) { const stringSchema = schema as TString; // Skip if it has a specific format already if ( z.schema.format(stringSchema) && z.schema.format(stringSchema) !== "string" ) { return this.generateValue(schema); } // Skip if it has enum values if ((stringSchema as any).enum) { return this.generateValue(schema); } // Generate based on common field name patterns if (normalizedKey.includes("email")) { return this.faker.internet.email(); } if (normalizedKey.includes("firstname") || normalizedKey === "first") { return this.faker.person.firstName(); } if (normalizedKey.includes("lastname") || normalizedKey === "last") { return this.faker.person.lastName(); } if (normalizedKey.includes("fullname") || normalizedKey === "name") { return this.faker.person.fullName(); } if (normalizedKey.includes("phone") || normalizedKey.includes("mobile")) { return this.faker.phone.number(); } if (normalizedKey.includes("address")) { return this.faker.location.streetAddress(); } if (normalizedKey.includes("city")) { return this.faker.location.city(); } if (normalizedKey.includes("country")) { return this.faker.location.country(); } if ( normalizedKey.includes("state") || normalizedKey.includes("province") ) { return this.faker.location.state(); } if (normalizedKey.includes("zip") || normalizedKey.includes("postal")) { return this.faker.location.zipCode(); } if ( normalizedKey.includes("company") || normalizedKey.includes("organization") ) { return this.faker.company.name(); } if ( normalizedKey.includes("job") || normalizedKey.includes("title") || normalizedKey.includes("position") ) { return this.faker.person.jobTitle(); } if (normalizedKey.includes("username")) { return this.faker.internet.username(); } if (normalizedKey.includes("url") || normalizedKey.includes("website")) { return this.faker.internet.url(); } if ( normalizedKey.includes("avatar") || normalizedKey.includes("image") || normalizedKey.includes("photo") ) { return this.faker.image.avatar(); } if (normalizedKey.includes("color") || normalizedKey.includes("colour")) { return this.faker.color.human(); } if ( normalizedKey.includes("bio") || normalizedKey.includes("about") || normalizedKey.includes("description") ) { return this.faker.person.bio(); } } // For number/integer fields, check for common patterns if (this.guard.isInteger(schema) || this.guard.isNumber(schema)) { if (normalizedKey.includes("age")) { return this.faker.number.int({ min: 18, max: 99 }); } if (normalizedKey.includes("year")) { return this.faker.date.past().getFullYear(); } if (normalizedKey.includes("month")) { return this.faker.number.int({ min: 1, max: 12 }); } if (normalizedKey.includes("day")) { return this.faker.number.int({ min: 1, max: 31 }); } if ( normalizedKey.includes("price") || normalizedKey.includes("amount") || normalizedKey.includes("cost") ) { return this.faker.number.float({ min: 1, max: 1000, multipleOf: 0.01 }); } } // Fallback to regular generation return this.generateValue(schema); } protected generateRecord(schema: TRecord): Record<string, unknown> { const record = schema as any; // zod stores the value schema on `.valueType` (def.valueType internally). const valueSchema = record.valueType ?? record._zod?.def?.valueType ?? z.any(); // Generate 2-5 random key-value pairs const count = this.faker.number.int(this.options.defaultRecordEntries); const result: Record<string, unknown> = {}; for (let i = 0; i < count; i++) { const key = this.faker.lorem.word(); result[key] = this.generateValue(valueSchema); } return result; } }