From bb882d3b1dc0f25c69e25fb3f2fe72c35c75ea95 Mon Sep 17 00:00:00 2001 From: Loris Sigrist <43482866+LorisSigrist@users.noreply.github.com> Date: Tue, 9 May 2023 14:38:03 +0200 Subject: [PATCH] Better customisation (#15) * Added options for any and unknown * Added options for extra properties on objects * Housekeeping * Added API section to docs * Added note on `.shape` property --- .changeset/eleven-donuts-impress.md | 5 + .changeset/sweet-crews-hunt.md | 5 + README.md | 174 ++++++++++++++++++++++++++-- src/lib/generate.ts | 14 +-- src/lib/generators/any.ts | 120 +++++++++---------- src/lib/generators/array.ts | 21 +++- src/lib/generators/object.ts | 31 ++--- src/lib/zocker.ts | 59 +++++++++- 8 files changed, 328 insertions(+), 101 deletions(-) create mode 100644 .changeset/eleven-donuts-impress.md create mode 100644 .changeset/sweet-crews-hunt.md diff --git a/.changeset/eleven-donuts-impress.md b/.changeset/eleven-donuts-impress.md new file mode 100644 index 0000000..35160b7 --- /dev/null +++ b/.changeset/eleven-donuts-impress.md @@ -0,0 +1,5 @@ +--- +"zocker": minor +--- + +Added configuration for extra properties on objects diff --git a/.changeset/sweet-crews-hunt.md b/.changeset/sweet-crews-hunt.md new file mode 100644 index 0000000..835b2dc --- /dev/null +++ b/.changeset/sweet-crews-hunt.md @@ -0,0 +1,5 @@ +--- +"zocker": minor +--- + +Added customization options for `any` and `unknown` data-types diff --git a/README.md b/README.md index 5d72c6b..c04a566 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,18 @@ Zocker is a library that automatically generates reasonable mock data from your npm install --save-dev zocker ``` +## Features & Limitations + +`zocker` is still in development, but it already is the most feature-complete library of its kind. It's easier to list the limitations than the features. All these limitations can be worked around by customizing the generation process (see below). + +1. `z.preprocess` and `z.refine` are not supported out of the box (and probably never will be) +2. `toUpperCase`, `toLowerCase` and `trim` only work if they are the last operation on a string +3. `z.function` is not supported +4. `z.Intersection` is not supported (yet) +5. `z.transform` is only supported if it's the last operation on a schema +6. `z.string` supports at most one format (e.g regex, cuid, ip) at a time +7. The customization for the built-in generators is still limited, but expanding rapidly (suggestions welcome) + ## Usage Like `zod`, we use a fluent API to make customization easy. Get started by wrapping your schema in `zocker`, and then call `generate()` on it to generate some data. @@ -43,18 +55,6 @@ const mockData = zocker(person_schema).generate(); */ ``` -### Features & Limitations - -`zocker` is still in early development, but it already is the most feature-complete library of its kind. It's easier to list the limitations than the features. All these limitations can be worked around by customizing the generation process (see below). - -1. `z.preprocess` and `z.refine` are not supported out of the box (and probably never will be) -2. `toUpperCase`, `toLowerCase` and `trim` only work if they are the last operation in the chain -3. `z.function` is not supported -4. `z.Intersection` is not supported -5. `z.transform` is only supported if it's the last operation in the chain -6. `z.string` supports at most one format (e.g regex, cuid, ip) at a time -7. The customization for the built-in generators is still limited (suggestions welcome) - ### Supply your own value If you have a value that you would like to control explicitly, you can supply your own. @@ -78,6 +78,19 @@ It will be used whenever a sub-schema is encoutnered, that matches the one you p This is the main way to work around unsupported types. +One convenient way to get a sub_schema by reference is through the `shape` property on the schema. + +```typescript +const schema = z.object({ + name: z.string(), + age: z.number() +}); + +const data = zocker(schema).supply(schema.shape.name, "Jonathan").generate(); +``` + +This way you don't need to break out the sub-schema into a separate variable. + ### Customizing the generation process #### Providing Options to Built-Ins @@ -202,6 +215,143 @@ const regex_schema = z.string().regex(/^[a-z0-9]{5,10}$/); const data = zocker(regex_schema); ``` +## API +### `.supply` +Allows you to supply a specific value for a specific schema. This is useful for testing edge-cases. + +```typescript +const data = zocker(my_schema).supply(my_sub_schema, 0).generate(); +``` + +The supplied value will be used if during the generation process a schema is encoutered, that matches the supplied `sub_schema` by *reference*. + +You can also supply a function that returns a value. The function must follow the `Generator` type. + +### `.override` +Allows you to override the generator for an entire category of schemas. This is useful if you want to override the generation of `z.ZodNumber` for example. + +```typescript +const data = zocker(my_schema).override(z.ZodNumber, 0).generate(); +``` + +The supplied value will be used if during the generation process a schema is encoutered, that is an *instance* of the supplied `schema`. + +You can also supply a function that returns a value. The function must follow the `Generator` type. + + +### `.setDepthLimit` +Allows you to set the maximum depth of cyclic data. Defaults to 5. + +### `.setSeed` +Allows you to set the seed for the random number generator. This ensures that the generation process is repeatable. If you don't set a seed, a random one will be chosen. + +### `.generate` +Executes the generation process. Returns the generated data that matches the schema provided to `zocker`. + + +### `.set` +Options for the built-in `z.ZodSet` generator. + +```typescript +{ + max: 10, + min: 0 +} +``` + +### `.array` +Options for the built-in `z.ZodArray` generator. + +```typescript +{ + max: 10, + min: 0 +} +``` + +### `.map` +Options for the built-in `z.ZodMap` generator. + +```typescript +{ + max: 10, + min: 0 +} +``` + + +### `.record` +Options for the built-in `z.ZodRecord` generator. + +```typescript +{ + max: 10, + min: 0 +} +``` + +### `.object` +Options for the built-in `z.ZodObject` generator. + +```typescript +{ + generate_extra_keys: true //extra keys will be generated if allowed by the schema +} +``` + +### `.any` / `.unknown` +Options for the built-in `z.ZodAny` and `z.ZodUnknown` generators. + +```typescript +{ + strategy: "true-any" | "json-compatible" | "fast" +} +``` + +### `.optional` +Options for the built-in `z.ZodOptional` generator. + +```typescript +{ + undefined_chance: 0.3 +} +``` + +### `.nullable` +Options for the built-in `z.ZodNullable` generator. + +```typescript +{ + null_chance: 0.3 +} +``` + +### `.default` +Options for the built-in `z.ZodDefault` generator. + +```typescript +{ + default_chance: 0.3 +} +``` + +### `.number` +Options for the built-in `z.ZodNumber` generator. + +```typescript +{ + extreme_value_chance: 0.3 +} +``` + + +## `type Generator` +A generator is a function that takes a schema and a generation-context, and returns a value that matches the schema. + +```typescript +type Generator = (schema: Z, ctx: GenerationContext) => z.infer; +``` + ## The Future I intend to continue expanding the number of built-in generators, and make the generation process more customizable. If you have any ideas, please open an issue or a pull request - I'd love to hear your thoughts. diff --git a/src/lib/generate.ts b/src/lib/generate.ts index e13df51..acbed68 100644 --- a/src/lib/generate.ts +++ b/src/lib/generate.ts @@ -15,6 +15,9 @@ import { DefaultOptions } from "./generators/default.js"; import { MapOptions } from "./generators/map.js"; import { RecordOptions } from "./generators/record.js"; import { SetOptions } from "./generators/set.js"; +import { AnyOptions } from "./generators/any.js"; +import { ArrayOptions } from "./generators/array.js"; +import { ObjectOptions } from "./generators/object.js"; /** * Contains all the necessary configuration to generate a value for a given schema. @@ -32,20 +35,17 @@ export type GenerationContext = { seed: number; - /** Options for the z.ZodNumber generator */ number_options: NumberGeneratorOptions; - /** Options for the z.ZodOptional generator */ optional_options: OptionalOptions; - /** Options for the z.ZodNullable generator */ nullable_options: NullableOptions; - /** Options for the z.ZodDefault generator */ default_options: DefaultOptions; - /** Options for the z.ZodMap generator */ map_options: MapOptions; - /** Options for the z.ZodRecord generator */ record_options: RecordOptions; - /** Options for the z.ZodSet generator */ set_options: SetOptions; + any_options: AnyOptions; + unknown_options: AnyOptions; + array_options: ArrayOptions; + object_options: ObjectOptions; }; export type Generator = ( diff --git a/src/lib/generators/any.ts b/src/lib/generators/any.ts index b16c697..c6dc6cd 100644 --- a/src/lib/generators/any.ts +++ b/src/lib/generators/any.ts @@ -3,74 +3,74 @@ import { generate, Generator } from "../generate.js"; import { pick } from "../utils/random.js"; import { InstanceofGeneratorDefinition } from "../zocker.js"; -export const AnyGenerator: InstanceofGeneratorDefinition = { - schema: z.ZodAny as any, - generator: Any("true-any"), - match: "instanceof" +export type AnyOptions = { + strategy: "true-any" | "json-compatible" | "fast"; }; -export const UnknownGenerator: InstanceofGeneratorDefinition = { - schema: z.ZodUnknown as any, - generator: Any(), - match: "instanceof" -}; +const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); +const jsonSchema: z.ZodSchema = z.lazy(() => + z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]) +); -/** - * Create a Generator for the `z.any()` and `z.unknown()` schemas. - * @param strategy - How to generate the value. "true-any" will generate any possible value, "json-compatible" will generate any JSON-compatible value, and "fast" will just return undefined, but is vastly faster. - * @returns - */ -function Any( - strategy: "true-any" | "json-compatible" | "fast" = "true-any" -): Generator { - if (strategy === "fast") { - return () => undefined; - } +//It's important to have the schemas outside the generator, so that they have reference equality accross invocations. +//This allows us to not worry about infinite recursion, as the cyclic generation logic will protect us. +const any = z.any(); +const potential_schemas = [ + z.undefined(), + z.null(), + z.boolean(), + z.number(), + z.string(), + z.bigint(), + z.date(), + z.symbol(), + z.unknown(), + z.nan(), + z.record(any), //`z.object` is just a subset of this - no need for a separate case. + z.array(any), //Tuples are just a subset of this - no need for a separate case. + z.map(any, any), + z.set(any), + z.promise(any) +].map((schema) => schema.optional()); - if (strategy === "json-compatible") { - const literalSchema = z.union([ - z.string(), - z.number(), - z.boolean(), - z.null() - ]); - const jsonSchema: z.ZodSchema = z.lazy(() => - z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]) - ); +const generate_any: Generator = (schema, ctx) => { + if (ctx.any_options.strategy === "fast") { + return undefined; + } - return (_schema, generation_context) => { - const generated = generate(jsonSchema, generation_context); - return generated; - }; + if (ctx.any_options.strategy === "json-compatible") { + const generated = generate(jsonSchema, ctx); + return generated; } - const any = z.any(); + const schema_to_use = pick(potential_schemas); + const generated = generate(schema_to_use, ctx); + return generated; +}; - //It's important to have the schemas outside the generator, so that they have reference equality accross invocations. - //This allows us to not worry about infinite recursion, as the cyclic generation logic will protect us. - const potential_schemas = [ - z.undefined(), - z.null(), - z.boolean(), - z.number(), - z.string(), - z.bigint(), - z.date(), - z.symbol(), - z.unknown(), - z.nan(), - z.record(any), //`z.object` is just a subset of this - no need for a separate case. - z.array(any), //Tuples are just a subset of this - no need for a separate case. - z.map(any, any), - z.set(any), - z.promise(any) - ].map((schema) => schema.optional()); +const generate_unknown: Generator = (schema, ctx) => { + if (ctx.unknown_options.strategy === "fast") { + return undefined; + } - const generate_any: Generator = (_schema, generation_context) => { - const schema_to_use = pick(potential_schemas); - const generated = generate(schema_to_use, generation_context); + if (ctx.unknown_options.strategy === "json-compatible") { + const generated = generate(jsonSchema, ctx); return generated; - }; + } + + const schema_to_use = pick(potential_schemas); + const generated = generate(schema_to_use, ctx); + return generated; +}; + +export const AnyGenerator: InstanceofGeneratorDefinition = { + schema: z.ZodAny as any, + generator: generate_any, + match: "instanceof" +}; - return generate_any; -} +export const UnknownGenerator: InstanceofGeneratorDefinition = { + schema: z.ZodUnknown as any, + generator: generate_unknown, + match: "instanceof" +}; diff --git a/src/lib/generators/array.ts b/src/lib/generators/array.ts index a381088..db7c52c 100644 --- a/src/lib/generators/array.ts +++ b/src/lib/generators/array.ts @@ -1,18 +1,31 @@ import { faker } from "@faker-js/faker"; import { Generator, generate } from "../generate.js"; import { z } from "zod"; -import { RecursionLimitReachedException } from "../exceptions.js"; -import { InstanceofGeneratorDefinition } from "lib/zocker.js"; +import { + InvalidSchemaException, + RecursionLimitReachedException +} from "../exceptions.js"; +import { InstanceofGeneratorDefinition } from "../zocker.js"; + +export type ArrayOptions = { + /** The minimum number of elements, unless specified otherwise */ + min: number; + /** The maximum number of elements, unless specified otherwise */ + max: number; +}; const generate_array: Generator> = (array_schema, ctx) => { const exact_length = array_schema._def.exactLength?.value ?? null; const min = array_schema._def.minLength ? array_schema._def.minLength.value - : 0; + : ctx.array_options.min; const max = array_schema._def.maxLength ? array_schema._def.maxLength.value - : min + 10; + : ctx.array_options.max; + + if (min > max) + throw new InvalidSchemaException("min length is greater than max length"); const length = exact_length !== null ? exact_length : faker.datatype.number({ min, max }); diff --git a/src/lib/generators/object.ts b/src/lib/generators/object.ts index 2c4a2b2..f05d8f0 100644 --- a/src/lib/generators/object.ts +++ b/src/lib/generators/object.ts @@ -4,9 +4,14 @@ import { InstanceofGeneratorDefinition } from "../zocker.js"; import { z } from "zod"; import { faker } from "@faker-js/faker"; +export type ObjectOptions = { + /** If extra keys should be generated on schemas that allow it. Defaults to true */ + generate_extra_keys: boolean; +}; + const generate_object = ( object_schema: z.ZodObject, - generation_context: GenerationContext> + ctx: GenerationContext> ): z.infer> => { type Shape = z.infer; type Value = Shape[keyof Shape]; @@ -18,19 +23,19 @@ const generate_object = ( const key = entry[0] as Key; const property_schema = entry[1] as Value; - const prev_semantic_context = generation_context.semantic_context; + const prev_semantic_context = ctx.semantic_context; const semantic_flag = get_semantic_flag(String(key)); try { - generation_context.path.push(key); - generation_context.semantic_context = semantic_flag; + ctx.path.push(key); + ctx.semantic_context = semantic_flag; //@ts-ignore - const generated_value = generate(property_schema, generation_context); + const generated_value = generate(property_schema, ctx); mock_entries.push([key, generated_value]); } finally { - generation_context.path.pop(); - generation_context.semantic_context = prev_semantic_context; + ctx.path.pop(); + ctx.semantic_context = prev_semantic_context; } }); @@ -39,21 +44,21 @@ const generate_object = ( const is_passthrough = object_schema._def.unknownKeys === "passthrough"; if (is_passthrough && !catchall_schema) catchall_schema = z.any(); - if (catchall_schema) { + if (catchall_schema && ctx.object_options.generate_extra_keys) { const key_schema = z.union([z.string(), z.number(), z.symbol()]); const num_additional_keys = faker.datatype.number({ min: 0, max: 10 }); try { for (let i = 0; i < num_additional_keys; i++) { - const prev_semantic_context = generation_context.semantic_context; + const prev_semantic_context = ctx.semantic_context; let key: Key; try { - generation_context.semantic_context = "key"; - key = generate(key_schema, generation_context); + ctx.semantic_context = "key"; + key = generate(key_schema, ctx); } finally { - generation_context.semantic_context = prev_semantic_context; + ctx.semantic_context = prev_semantic_context; } - const value = generate(catchall_schema, generation_context); + const value = generate(catchall_schema, ctx); //Prepend to mock_entries, //so that the catchall keys would be overwritten by the original keys in case of a collision diff --git a/src/lib/zocker.ts b/src/lib/zocker.ts index b588ce9..d69e378 100644 --- a/src/lib/zocker.ts +++ b/src/lib/zocker.ts @@ -9,17 +9,22 @@ import { DefaultOptions } from "./generators/default.js"; import { MapOptions } from "./generators/map.js"; import { RecordOptions } from "./generators/record.js"; import { SetOptions } from "./generators/set.js"; +import { AnyOptions } from "./generators/any.js"; +import { ArrayOptions } from "./generators/array.js"; +import { ObjectOptions } from "./generators/object.js"; export type InstanceofGeneratorDefinition = { schema: Z; generator: Generator; - match: "instanceof"; + /** @deprecated No longer used*/ + match?: "instanceof"; }; export type ReferenceGeneratorDefinition = { schema: Z; generator: Generator; - match: "reference"; + /** @deprecated No longer used*/ + match?: "reference"; }; export function zocker(schema: Z) { @@ -65,6 +70,23 @@ class Zocker { min: 0 }; + private any_options: AnyOptions = { + strategy: "true-any" + }; + + private unknown_options: AnyOptions = { + strategy: "true-any" + }; + + private array_options: ArrayOptions = { + min: 0, + max: 10 + }; + + private object_options: ObjectOptions = { + generate_extra_keys: true + }; + constructor(public schema: Z) {} /** @@ -95,7 +117,6 @@ class Zocker { return next; } - /** * Override one of the built-in generators using your own. * It will be used whenever an encoutntered Schema matches the one specified by **instance** @@ -103,7 +124,7 @@ class Zocker { * @param schema - Which schema to override. E.g: `z.ZodNumber`. * @param generator - A value, or a function that generates a value that matches the schema */ - override( + override( schema: Z, generator: Generator | z.infer ) { @@ -177,6 +198,30 @@ class Zocker { return next; } + any(options: Partial) { + const next = this.clone(); + next.any_options = { ...next.any_options, ...options }; + return next; + } + + unknown(options: Partial) { + const next = this.clone(); + next.unknown_options = { ...next.unknown_options, ...options }; + return next; + } + + array(options: Partial) { + const next = this.clone(); + next.array_options = { ...next.array_options, ...options }; + return next; + } + + object(options: Partial) { + const next = this.clone(); + next.object_options = { ...next.object_options, ...options }; + return next; + } + generate(): z.infer { const ctx: GenerationContext = { reference_generators: this.reference_generators, @@ -193,7 +238,11 @@ class Zocker { default_options: this.default_options, map_options: this.map_options, record_options: this.record_options, - set_options: this.set_options + set_options: this.set_options, + any_options: this.any_options, + unknown_options: this.unknown_options, + array_options: this.array_options, + object_options: this.object_options }; faker.seed(ctx.seed);