diff --git a/.changeset/fair-moons-study.md b/.changeset/fair-moons-study.md new file mode 100644 index 00000000..0b6d89f7 --- /dev/null +++ b/.changeset/fair-moons-study.md @@ -0,0 +1,5 @@ +--- +"@traversable/json-schema": patch +--- + +init(json-schema): initializes `@traversable/json-schema` diff --git a/.changeset/lazy-chefs-cry.md b/.changeset/lazy-chefs-cry.md new file mode 100644 index 00000000..fcc897be --- /dev/null +++ b/.changeset/lazy-chefs-cry.md @@ -0,0 +1,14 @@ +--- +"@traversable/schema-to-json-schema": patch +"@traversable/json-schema-types": patch +"@traversable/json-schema-test": patch +"@traversable/typebox-test": patch +"@traversable/json-schema": patch +"@traversable/schema-seed": patch +"@traversable/registry": patch +"@traversable/zod-test": patch +"@traversable/schema": patch +"@traversable/zod": patch +--- + +feat(json-schema-types): adds fuzz-tested Json Schema validator via `JsonSchema.check` diff --git a/.changeset/major-melons-smile.md b/.changeset/major-melons-smile.md new file mode 100644 index 00000000..22572fd0 --- /dev/null +++ b/.changeset/major-melons-smile.md @@ -0,0 +1,5 @@ +--- +"@traversable/typebox-test": patch +--- + +init(typebox-test): initializes `@traversable/typebox-test` diff --git a/.changeset/mighty-seas-film.md b/.changeset/mighty-seas-film.md new file mode 100644 index 00000000..704cb104 --- /dev/null +++ b/.changeset/mighty-seas-film.md @@ -0,0 +1,5 @@ +--- +"@traversable/json-schema-types": patch +--- + +init(json-schema-types): initializes `@traversable/json-schema-types` diff --git a/.changeset/orange-trees-heal.md b/.changeset/orange-trees-heal.md new file mode 100644 index 00000000..14888d77 --- /dev/null +++ b/.changeset/orange-trees-heal.md @@ -0,0 +1,5 @@ +--- +"@traversable/json-schema-test": patch +--- + +init(json-schema-test): initializes `@traversable/json-schema-test` diff --git a/.changeset/shaggy-actors-drum.md b/.changeset/shaggy-actors-drum.md new file mode 100644 index 00000000..3a9c162e --- /dev/null +++ b/.changeset/shaggy-actors-drum.md @@ -0,0 +1,18 @@ +--- +"@traversable/schema-to-json-schema": patch +"@traversable/json-schema-types": patch +"@traversable/json-schema-test": patch +"@traversable/schema-compiler": patch +"@traversable/schema-errors": patch +"@traversable/typebox-test": patch +"@traversable/json-schema": patch +"@traversable/schema-seed": patch +"@traversable/registry": patch +"@traversable/zod-test": patch +"@traversable/typebox": patch +"@traversable/schema": patch +"@traversable/json": patch +"@traversable/zod": patch +--- + +feat(json-schema): adds fuzz-tested `JsonSchema.equals` diff --git a/.changeset/stale-rocks-allow.md b/.changeset/stale-rocks-allow.md new file mode 100644 index 00000000..b659e0a9 --- /dev/null +++ b/.changeset/stale-rocks-allow.md @@ -0,0 +1,5 @@ +--- +"@traversable/zod-test": patch +--- + +init(zod-test): initializes `@traversable/zod-test` diff --git a/.changeset/tall-bikes-brush.md b/.changeset/tall-bikes-brush.md new file mode 100644 index 00000000..aa29806a --- /dev/null +++ b/.changeset/tall-bikes-brush.md @@ -0,0 +1,5 @@ +--- +"@traversable/zod-types": patch +--- + +init(zod-types): initializes `@traversable/zod-types` diff --git a/README.md b/README.md index 4b948536..228b4502 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Let's look at a concrete example of how `@traversable/schema` can be used as a r For this example, we'll be using [`@traversable/zod`](https://github.com/traversable/schema/tree/main/packages/zod), since zod is the library most users are familiar with. -Let's write a function that takes an arbitrary zod schema as input an stringifies it. +Let's write a function that takes an arbitrary zod schema as input and stringifies it. > [!NOTE] > This functionality is already available off-the shelf via `zx.toString`. @@ -496,8 +496,11 @@ let toAPI = User.encode(fromAPI) ```mermaid flowchart TD + json-schema(json-schema) + json-schema-test(json-schema-test) registry(registry) zod(zod) + zod-types(zod-types) json(json) -.-> registry(registry) schema(schema) -.-> registry(registry) typebox-test(typebox-test) -.-> registry(registry) @@ -510,6 +513,8 @@ flowchart TD derive-validators(derive-validators) -.-> json(json) derive-validators(derive-validators) -.-> registry(registry) derive-validators(derive-validators) -.-> schema(schema) + json-schema-types(json-schema-types) -.-> json(json) + json-schema-types(json-schema-types) -.-> registry(registry) schema-compiler(schema-compiler) -.-> json(json) schema-compiler(schema-compiler) -.-> registry(registry) schema-compiler(schema-compiler) -.-> schema(schema) diff --git a/config/__generated__/package-list.ts b/config/__generated__/package-list.ts index d916a3da..a0628e74 100644 --- a/config/__generated__/package-list.ts +++ b/config/__generated__/package-list.ts @@ -3,6 +3,9 @@ export const PACKAGES = [ "packages/derive-equals", "packages/derive-validators", "packages/json", + "packages/json-schema", + "packages/json-schema-test", + "packages/json-schema-types", "packages/registry", "packages/schema", "packages/schema-compiler", @@ -14,6 +17,7 @@ export const PACKAGES = [ "packages/typebox", "packages/typebox-test", "packages/zod", - "packages/zod-test" + "packages/zod-test", + "packages/zod-types" ] as const export type PACKAGES = typeof PACKAGES \ No newline at end of file diff --git a/packages/json-schema-test/README.md b/packages/json-schema-test/README.md new file mode 100644 index 00000000..47fcad17 --- /dev/null +++ b/packages/json-schema-test/README.md @@ -0,0 +1,29 @@ +
+

แฏ“๐˜๐—ฟ๐—ฎ๐˜ƒ๐—ฒ๐—ฟ๐˜€๐—ฎ๐—ฏ๐—น๐—ฒ/๐—ท๐˜€๐—ผ๐—ป-๐˜€๐—ฐ๐—ต๐—ฒ๐—บ๐—ฎ-๐˜๐—ฒ๐˜€๐˜

+
+ +

+ Testing utility that generates arbitrary, pseudorandom Json Schema documents, powered by fast-check +

+ +
+ NPM Version +   + TypeScript +   + Static Badge +   + npm +   +
+ +
+ + Static Badge +   + Static Badge +   +
+
+
diff --git a/packages/json-schema-test/package.json b/packages/json-schema-test/package.json new file mode 100644 index 00000000..58d08bec --- /dev/null +++ b/packages/json-schema-test/package.json @@ -0,0 +1,44 @@ +{ + "name": "@traversable/json-schema-test", + "type": "module", + "version": "0.0.0", + "private": false, + "description": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/traversable/schema.git", + "directory": "packages/json-schema-test" + }, + "bugs": { + "url": "https://github.com/traversable/schema/issues", + "email": "ahrjarrett@gmail.com" + }, + "@traversable": { + "generateExports": { "include": ["**/*.ts"] }, + "generateIndex": { "include": ["**/*.ts"] } + }, + "publishConfig": { + "access": "public", + "directory": "dist", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "bench": "echo NOTHING TO BENCH", + "build": "pnpm build:esm && pnpm build:cjs && pnpm build:annotate", + "build:annotate": "babel build --plugins annotate-pure-calls --out-dir build --source-maps", + "build:esm": "tsc -b tsconfig.build.json", + "build:cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps", + "check": "tsc -b tsconfig.json", + "clean": "pnpm run \"/^clean:.*/\"", + "clean:build": "rm -rf .tsbuildinfo dist build", + "clean:deps": "rm -rf node_modules", + "test": "vitest" + }, + "dependencies": { + "@traversable/json": "workspace:^", + "@traversable/json-schema-types": "workspace:^", + "@traversable/registry": "workspace:^" + }, + "devDependencies": {} +} diff --git a/packages/json-schema-test/src/__generated__/__manifest__.ts b/packages/json-schema-test/src/__generated__/__manifest__.ts new file mode 100644 index 00000000..dc75c34e --- /dev/null +++ b/packages/json-schema-test/src/__generated__/__manifest__.ts @@ -0,0 +1,44 @@ +export default { + "name": "@traversable/json-schema-test", + "type": "module", + "version": "0.0.0", + "private": false, + "description": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/traversable/schema.git", + "directory": "packages/json-schema-test" + }, + "bugs": { + "url": "https://github.com/traversable/schema/issues", + "email": "ahrjarrett@gmail.com" + }, + "@traversable": { + "generateExports": { "include": ["**/*.ts"] }, + "generateIndex": { "include": ["**/*.ts"] } + }, + "publishConfig": { + "access": "public", + "directory": "dist", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "bench": "echo NOTHING TO BENCH", + "build": "pnpm build:esm && pnpm build:cjs && pnpm build:annotate", + "build:annotate": "babel build --plugins annotate-pure-calls --out-dir build --source-maps", + "build:esm": "tsc -b tsconfig.build.json", + "build:cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps", + "check": "tsc -b tsconfig.json", + "clean": "pnpm run \"/^clean:.*/\"", + "clean:build": "rm -rf .tsbuildinfo dist build", + "clean:deps": "rm -rf node_modules", + "test": "vitest" + }, + "dependencies": { + "@traversable/json": "workspace:^", + "@traversable/json-schema-types": "workspace:^", + "@traversable/registry": "workspace:^" + }, + "devDependencies": {} +} as const \ No newline at end of file diff --git a/packages/json-schema-test/src/exports.ts b/packages/json-schema-test/src/exports.ts new file mode 100644 index 00000000..e0882de4 --- /dev/null +++ b/packages/json-schema-test/src/exports.ts @@ -0,0 +1,19 @@ +export * from './version.js' + +export type Seed = import('./generator-seed.js').Seed +export * as Seed from './generator-seed.js' + +export { + Builder, + SchemaGenerator, + SeedGenerator, + SeedValidDataGenerator, + SeedInvalidDataGenerator, + seedToValidDataGenerator, + seedToInvalidDataGenerator, + seedToSchema, + seedToValidData, + seedToInvalidData, + seedsThatPreventGeneratingValidData, + seedsThatPreventGeneratingInvalidData, +} from './generator.js' diff --git a/packages/json-schema-test/src/generator-bounds.ts b/packages/json-schema-test/src/generator-bounds.ts new file mode 100644 index 00000000..76f58a88 --- /dev/null +++ b/packages/json-schema-test/src/generator-bounds.ts @@ -0,0 +1,189 @@ +import * as fc from 'fast-check' + +import type { newtype } from '@traversable/registry' +import { fn, Number_isFinite, Number_isNatural, Number_isSafeInteger } from '@traversable/registry' + +/** @internal */ +const nullable = (model: fc.Arbitrary) => fc.oneof(fc.constant(null), fc.constant(null), model) + +/** @internal */ +const isBigInt = (x: unknown) => typeof x === 'bigint' + +export const defaultDoubleConstraints = { + noNaN: true, + noDefaultInfinity: true, +} satisfies fc.DoubleConstraints + +const defaultIntBounds = [-0x1000, +0x1000, null] satisfies Bounds_int +const defaultNumberBounds = [-0x10000, +0x10000, null, false, false] satisfies Bounds_number +const defaultStringBounds = [0, +0x40] satisfies Bounds_string +const defaultArrayBounds = [0, +0x10] satisfies Bounds_array + +export const defaults = { + integer: defaultIntBounds, + number: defaultNumberBounds, + string: defaultStringBounds, + array: defaultArrayBounds, +} + +const clampMin + : (min: T, max: T, predicate: (x: T | null) => x is T) => (x: T | null, y: T | null) => T | null + = (min, max, predicate) => (x, y) => { + if (!predicate(x)) { + return null + } else if (!predicate(y)) { + return x < min ? min : max < x ? max : x + } else { + const z = x < y ? x : y + return z < min ? min : max < z ? max : z + } + } + +const clampMax + : (min: T, max: T, predicate: (x: T | null) => x is T) => (x: T | null, y: T | null) => T | null + = (min, max, predicate) => (x, y) => { + if (!predicate(x)) { + return null + } else if (!predicate(y)) { + return x < min ? min : max < x ? max : x + } else { + const z = x > y ? x : y + return z < min ? min : max < z ? max : z + } + } + +const clampIntMin = clampMin(defaults.integer[0], defaults.integer[1], Number_isSafeInteger) +const clampIntMax = clampMax(defaults.integer[0], defaults.integer[1], Number_isSafeInteger) +const clampNumberMin = clampMin(defaults.number[0], defaults.number[1], Number_isFinite) +const clampNumberMax = clampMin(defaults.number[0], defaults.number[1], Number_isFinite) +const clampStringMin = clampMin(defaults.string[0], defaults.string[1], Number_isNatural) +const clampStringMax = clampMax(defaults.string[0], defaults.string[1], Number_isNatural) +const clampArrayMin = clampMin(defaults.array[0], defaults.array[1], Number_isNatural) +const clampArrayMax = clampMax(defaults.array[0], defaults.array[1], Number_isNatural) + +export const makeInclusiveBounds = (model: fc.Arbitrary) => ({ minimum: model, maximum: model }) + +export { Bounds_int as int } +interface Bounds_int extends newtype<[ + minimum: number | null, + maximum: number | null, + multipleOf: number | null, +]> {} + +const Bounds_int + : (model: fc.Arbitrary) => fc.Arbitrary + = (model) => fc.tuple(nullable(model), nullable(model), nullable(model)).map(([x, y, multipleOf]) => [ + clampIntMin(x, y), + clampIntMax(y, x), + multipleOf, + // clampInt(multipleOf), + ]) + +export { Bounds_string as string } +interface Bounds_string extends newtype<[ + minLength: number | null, + maxLength: number | null, +]> {} + +const Bounds_string + : (model: fc.Arbitrary) => fc.Arbitrary + = (model) => fc.tuple(nullable(model), nullable(model), nullable(model)).map( + ([x, y, length]) => Number_isNatural(length) + ? [null, null] satisfies [any, any] + // [clampString(length), clampString(length)] + : [clampStringMin(x, y), clampStringMax(y, x)] + ) + +export { Bounds_number as number } +interface Bounds_number extends newtype<[ + minimum: number | null, + maximum: number | null, + multipleOf: number | null, + exclusiveMinimum: boolean, + exclusiveMaximum: boolean, +]> {} + +const deltaIsSubEpsilon = (x: number, y: number) => Math.abs(x - y) < Number.EPSILON + +const Bounds_number + : (model: fc.Arbitrary) => fc.Arbitrary + = (model) => fc.tuple( + nullable(model), + nullable(model), + nullable(model), + fc.boolean(), + fc.boolean() + ).map( + fn.flow( + ([x, y, multipleOf, minExcluded, maxExcluded]): Bounds_number => [ + clampNumberMin(x, y), + clampNumberMax(y, x), + // clampNumber(multipleOf), + multipleOf, + minExcluded, + maxExcluded, + ], + ([min, max, multipleOf, minExcluded, maxExcluded]): Bounds_number => [ + min, + ((minExcluded || maxExcluded) && min != null && max != null && deltaIsSubEpsilon(min, max)) ? max + 1 : max, + multipleOf, + minExcluded, + maxExcluded + ], + ) + ) + +export { Bounds_array as array } +interface Bounds_array extends newtype<[ + minLength: number | null, + maxLength: number | null, +]> {} + +const Bounds_array + : (model: fc.Arbitrary) => fc.Arbitrary + = (model) => fc.tuple( + nullable(model), + nullable(model), + fc.constant(null) + ).map(([x, y, exactLength]) => + Number_isNatural(exactLength) + ? [null, null] + : [clampArrayMin(x, y), clampArrayMax(y, x)] + ) + + +export const integerBoundsToIntegerConstraints + : (bounds?: Bounds_int) => fc.IntegerConstraints + = (bounds = defaultIntBounds) => { + const [min, max, multipleOf] = bounds + return { + max: max ?? void 0, + min: min ?? void 0, + } satisfies fc.IntegerConstraints + } + +export const numberBoundsToDoubleConstraints + : (bounds?: Bounds_number) => fc.DoubleConstraints + = (bounds = defaultNumberBounds) => { + const [min, max, multipleOf, minExcluded, maxExcluded] = bounds + return { + ...defaultDoubleConstraints, max: max ?? void 0, + min: min ?? void 0, + minExcluded, + maxExcluded, + } satisfies fc.DoubleConstraints + } + +export const stringBoundsToStringConstraints + : (bounds?: Bounds_string) => fc.StringConstraints + = ([minLength, maxLength] = defaultStringBounds) => ({ + minLength: minLength ?? void 0, + maxLength: maxLength ?? void 0 + }) satisfies fc.StringConstraints + +export const arrayBoundsToArrayConstraints + : (bounds?: Bounds_array) => fc.ArrayConstraints + = ([minLength, maxLength] = defaultArrayBounds) => ({ + minLength: minLength ?? void 0, + maxLength: maxLength ?? void 0 + }) satisfies fc.ArrayConstraints diff --git a/packages/json-schema-test/src/generator-options.ts b/packages/json-schema-test/src/generator-options.ts new file mode 100644 index 00000000..348c4849 --- /dev/null +++ b/packages/json-schema-test/src/generator-options.ts @@ -0,0 +1,333 @@ +import * as fc from 'fast-check' + +import type { SeedMap } from './generator.js' +import * as Bounds from './generator-bounds.js' +import { byTag } from './generator-seed.js' +import { TypeNames } from '@traversable/json-schema-types' + +export type ArrayParams = { + minLength?: number + maxLength?: number +} + +export type IntegerParams = { + minimum?: number + maximum?: number + multipleOf?: number +} + +export type NumberParams = { + minimum?: number + maximum?: number + minExcluded?: boolean + maxExcluded?: boolean + multipleOf?: number +} + +export type BigIntParams = { + minimum?: bigint + maximum?: bigint + multipleOf?: bigint +} + +export type StringParams = { + /* prefix?: string, postfix?: string, pattern?: string, substring?: string, length?: number */ + minLength?: number + maxLength?: number +} + +export type Params = { + array?: ArrayParams + boolean?: {} + const?: {} + enum?: {} + integer?: IntegerParams + intersection?: {} + never?: {} + null?: {} + number?: NumberParams + object?: {} + record?: {} + string?: StringParams + tuple?: {} + union?: {} + unknown?: {} +} + +export interface Options extends Partial>, Constraints {} + +export type InferConfigType = S extends Options ? T : never + +export interface OptionsBase< + T = never, + K extends + | string & keyof T + = string & keyof T +> { + include: readonly K[] + exclude: readonly K[] + root: '*' | K + sortBias: { [K in keyof SeedMap]: number } + forceInvalid: boolean + minDepth: number + // minDepth: 1 | 2 | 3 | 4 | 5 +} +export interface Config extends OptionsBase, byTypeName {} + +export type Constraints = { + array?: { minLength?: number, maxLength?: number } + boolean?: {} + const?: {} + enum?: {} + integer?: { min: undefined | number, max: undefined | number, multipleOf?: number } & fc.IntegerConstraints + intersection?: {} + never?: {} + null?: {} + number?: { min?: undefined | number, max?: undefined | number, multipleOf?: number } & fc.DoubleConstraints + object?: ObjectConstraints + record?: RecordConstraints + string?: fc.StringConstraints + tuple?: fc.ArrayConstraints + union?: fc.ArrayConstraints + unknown?: {} + ['*']?: fc.OneOfConstraints +} + +export interface byTypeName extends Required> { + object: fc.UniqueArrayConstraintsRecommended<[k: string, v: unknown], string> + array: fc.IntegerConstraints +} + +export type RecordConstraints = fc.DictionaryConstraints & { + additionalPropertiesOnly?: boolean + patternPropertiesOnly?: boolean +} + +export type ObjectConstraints = + & Omit, 'minLength' | 'maxLength'> + & { + minKeys?: number + maxKeys?: number + size?: fc.SizeForArbitrary + } + +const objectDefaults = { + minKeys: 1, + maxKeys: 3, + size: 'xsmall', + selector: ([k]) => k, + comparator: 'SameValueZero', + depthIdentifier: fc.createDepthIdentifier(), +} satisfies ObjectConstraints + +export const defaultConstraints = { + object: objectDefaults, + array: { + minLength: 0, + maxLength: 0x10 + }, + boolean: {}, + const: {}, + enum: {}, + integer: { + min: undefined, + max: undefined, + multipleOf: Number.NaN, + }, + intersection: {}, + never: {}, + null: {}, + number: { + min: -0x10000, + max: 0x10000, + multipleOf: Number.NaN, + noNaN: true, + noDefaultInfinity: true, + minExcluded: false, + maxExcluded: false, + noInteger: false, + }, + record: { + additionalPropertiesOnly: false, + patternPropertiesOnly: false, + depthIdentifier: fc.createDepthIdentifier(), + maxKeys: 3, + minKeys: 1, + noNullPrototype: false, + size: 'xsmall', + } satisfies RecordConstraints, + string: { + minLength: Bounds.defaults.string[0], + maxLength: Bounds.defaults.string[1], + size: 'xsmall', + unit: 'grapheme-ascii', + } satisfies fc.StringConstraints, + tuple: { + minLength: 1, + maxLength: 3, + size: 'xsmall', + depthIdentifier: fc.createDepthIdentifier(), + } satisfies fc.ArrayConstraints, + union: { + depthIdentifier: fc.createDepthIdentifier(), + minLength: 1, + maxLength: 3, + size: 'xsmall', + } satisfies fc.ArrayConstraints, + unknown: {}, + ['*']: { + maxDepth: 3, + depthIdentifier: fc.createDepthIdentifier(), + depthSize: 'xsmall', + withCrossShrink: true, + } satisfies fc.OneOfConstraints, +} as const satisfies { [K in keyof Constraints]-?: Required } + +export const defaults = { + exclude: [], + forceInvalid: false, + include: TypeNames, + minDepth: -1, + root: '*', + sortBias: byTag, +} as const satisfies OptionsBase + +export function parseOptions(options?: Opts): Config> +export function parseOptions(options?: Options): Config +export function parseOptions(options: Options = defaults as never): Config { + const { + exclude = defaults.exclude, + forceInvalid = defaults.forceInvalid, + include = defaults.include, + minDepth: rootMinDepth = defaults.minDepth, + root = defaults.root, + sortBias = defaults.sortBias, + ['*']: { + maxDepth: starMaxDepth = defaultConstraints['*'].maxDepth, + depthSize: starDepthSize = defaultConstraints['*'].depthSize, + ...STAR + } = defaultConstraints['*'], + array: { + maxLength: arrayMax = defaultConstraints.array.maxLength, + minLength: arrayMin = defaultConstraints.array.minLength, + ...ARRAY + } = defaultConstraints.array, + boolean = defaultConstraints.boolean, + const: const_ = defaultConstraints.const, + enum: enum_ = defaultConstraints.enum, + integer: { + max: intMax, + min: intMin, + // ...INT + } = defaultConstraints.integer, + intersection = defaultConstraints.intersection, + never = defaultConstraints.never, + null: null_ = defaultConstraints.null, + number: { + max: numberMax, + maxExcluded: numberMaxExcluded, + min: numberMin, + minExcluded: numberMinExcluded, + // ...NUMBER + } = defaultConstraints.number, + record: { + maxKeys: recordMaxKeys = defaultConstraints.record.maxKeys, + minKeys: recordMinKeys = defaultConstraints.record.minKeys, + size: recordSize = defaultConstraints.record.size, + ...RECORD + } = defaultConstraints.record, + string: { + minLength: stringMinLength, + maxLength: stringMaxLength, + size: stringSize = defaultConstraints.string.size, + // ...STRING + } = defaultConstraints.string, + tuple: { + maxLength: tupleMaxLength = defaultConstraints.tuple.maxLength, + minLength: tupleMinLength = defaultConstraints.tuple.minLength, + ...TUPLE + } = defaultConstraints.tuple, + union: { + minLength: unionMinLength = defaultConstraints.union.minLength, + maxLength: unionMaxLength = defaultConstraints.union.maxLength, + size: unionSize = defaultConstraints.union.size, + ...UNION + } = defaultConstraints.union, + unknown = defaultConstraints.unknown, + object: { + maxKeys: objectMaxKeys = defaultConstraints.object.maxKeys, + minKeys: objectMinKeys = defaultConstraints.object.minKeys, + size: objectSize = defaultConstraints.object.size, + ...OBJECT + } = { + maxKeys: defaultConstraints.object.maxKeys, + minKeys: defaultConstraints.object.minKeys, + size: defaultConstraints.object.size, + }, + } = options + return { + exclude: exclude, + forceInvalid, + include: include.length === 0 || include[0] === '*' ? defaults.include : include, + minDepth: rootMinDepth, + root, + sortBias: { ...defaults.sortBias, ...sortBias }, + ['*']: { + ...STAR, + depthSize: starDepthSize, + maxDepth: starMaxDepth, + }, + object: { + ...OBJECT, + minLength: objectMinKeys, + maxLength: objectMaxKeys, + size: objectSize, + }, + array: { + ...ARRAY, + min: arrayMin, + max: arrayMax, + }, + boolean, + integer: { + // ...INT, + max: intMax, + min: intMin, + }, + intersection, + const: const_, + enum: enum_, + never, + null: null_, + number: { + max: numberMax, + min: numberMin, + maxExcluded: numberMaxExcluded, + minExcluded: numberMinExcluded, + }, + record: { + ...RECORD, + maxKeys: recordMaxKeys, + minKeys: recordMinKeys, + size: recordSize, + }, + string: { + // ...STRING, + minLength: stringMinLength, + maxLength: stringMaxLength, + size: stringSize, + }, + tuple: { + ...TUPLE, + minLength: tupleMinLength, + maxLength: tupleMaxLength, + }, + union: { + ...UNION, + minLength: unionMinLength, + maxLength: unionMaxLength, + size: unionSize, + }, + unknown, + } +} diff --git a/packages/json-schema-test/src/generator-seed.ts b/packages/json-schema-test/src/generator-seed.ts new file mode 100644 index 00000000..591992ca --- /dev/null +++ b/packages/json-schema-test/src/generator-seed.ts @@ -0,0 +1,195 @@ +import type * as T from '@traversable/registry' +import type { newtype } from '@traversable/registry' +import { fn, Object_keys } from '@traversable/registry' + +import * as Bounds from './generator-bounds.js' +import { TypeName } from '@traversable/json-schema-types' +import { Json } from '@traversable/json' + +export type Tag = byTag[keyof byTag] +export type byTag = typeof byTag +export const byTag = { + boolean: 15 as const, + never: 35 as const, + null: 40 as const, + unknown: 55 as const, + integer: 100 as const, + number: 200 as const, + string: 250 as const, + enum: 350 as const, + const: 550 as const, + array: 1000 as const, + intersection: 6000 as const, + record: 7000 as const, + object: 7500 as const, + tuple: 8000 as const, + union: 8500 as const, +} as const satisfies Record + +export function invert>(x: { [K in keyof T]: T[K] }): { [K in keyof T as T[K]]: K } +export function invert(x: {}) { + return Object_keys(x).reduce((acc, k) => (acc[x[k]] = k, acc), {}) +} + +export type bySeed = typeof bySeed +export const bySeed = invert(byTag) + +export type Seed = + & Seed.TerminalMap + & Seed.BoundableMap + & Seed.ValueMap + & Seed.UnaryMap + +export declare namespace Seed { + type Fixpoint = [ + tag: Tag, children?: + unknown, + bounds?: + | Seed.Array[2] + | Seed.Object[2] + | Seed.Record[2] + ] + type F = + | Seed.Nullary + | Seed.Unary + type Nullary = + | Seed.Terminal + | Seed.Boundable + | Seed.Value + type Unary = + | Seed.Array + | Seed.Record + | Seed.Object + | Seed.Tuple + | Seed.Union + | Seed.Intersection + + interface Free extends T.HKT { [-1]: Seed.F } + //////////////// + /// nullary + interface Boolean extends newtype<[byTag['boolean']]> {} + interface Never extends newtype<[byTag['never']]> {} + interface Null extends newtype<[byTag['null']]> {} + interface Unknown extends newtype<[byTag['unknown']]> {} + type Terminal = TerminalMap[keyof TerminalMap] + type TerminalMap = { + boolean: Boolean + never: Never + null: Null + unknown: Unknown + } + //////////////// + /// boundable + interface Integer extends newtype<[seed: byTag['integer'], bounds?: Bounds.int]> {} + interface Number extends newtype<[seed: byTag['number'], bounds?: Bounds.number]> {} + interface String extends newtype<[seed: byTag['string'], bounds?: Bounds.string]> {} + type Boundable = BoundableMap[keyof BoundableMap] + type BoundableMap = { + integer: Integer + number: Number + string: String + } + //////////////// + /// value + interface Const extends newtype<[seed: byTag['const'], value: Json]> {} + interface Enum extends newtype<[seed: byTag['enum'], value: Exclude[]]> {} + type Value = ValueMap[keyof ValueMap] + type ValueMap = { + const: Const + enum: Enum + } + //////////////// + /// unary + interface Array extends newtype<[seed: byTag['array'], def: T, bounds?: Bounds.array]> {} + type UnaryMap = { + array: Seed.Array + record: Seed.Record + object: Seed.Object + tuple: Seed.Tuple + union: Seed.Union + intersection: Seed.Intersection + } + type Composite = + | Seed.Array + | Seed.Record + | Seed.Object + | Seed.Tuple + type fromComposite = { + [byTag.array]: unknown[] + [byTag.record]: globalThis.Record + [byTag.object]: { [x: string]: unknown } + [byTag.tuple]: unknown[] + } + //////////////// + /// applicative + interface Union extends newtype<[seed: byTag['union'], def: T[]]> {} + interface Tuple extends newtype<[seed: byTag['tuple'], def: T[]]> {} + interface Object extends newtype<[seed: byTag['object'], def: [K: string, V: T][], req: string[]]> {} + //////////////// + /// binary + interface Record extends newtype<[seed: byTag['record'], additionalProperties?: T, patternProperties?: [K: string, V: T]]> {} + interface Intersection extends newtype<[seed: byTag['intersection'], def: T[]]> {} +} + +export const Functor: T.Functor.Ix> = { + map(f) { + return (x) => { + switch (true) { + default: return x satisfies never + case x[0] === byTag.boolean: return x + case x[0] === byTag.never: return x + case x[0] === byTag.null: return x + case x[0] === byTag.unknown: return x + case x[0] === byTag.integer: return x + case x[0] === byTag.number: return x + case x[0] === byTag.string: return x + case x[0] === byTag.enum: return x + case x[0] === byTag.const: return x + case x[0] === byTag.array: return [x[0], f(x[1]), x[2]] + case x[0] === byTag.intersection: return [x[0], x[1].map(f)] + case x[0] === byTag.tuple: return [x[0], x[1].map(f)] + case x[0] === byTag.union: return [x[0], x[1].map(f)] + case x[0] === byTag.object: return [x[0], x[1].map(([k, v]) => [k, f(v)] satisfies [any, any]), x[2]] + case x[0] === byTag.record: { + return x[1] && x[2] + ? [x[0], f(x[1]), [x[2][0], f(x[2][1])] satisfies [any, any]] + : x[2] ? [x[0], undefined, [x[2][0], f(x[2][1])] satisfies [any, any]] + : x[1] + ? [x[0], f(x[1])] satisfies [any, any] + : [x[0]] satisfies [any] + } + } + } + }, + mapWithIndex(f) { + return (x, isProperty) => { + switch (true) { + default: return x satisfies never + case x[0] === byTag.boolean: return x + case x[0] === byTag.never: return x + case x[0] === byTag.null: return x + case x[0] === byTag.unknown: return x + case x[0] === byTag.integer: return x + case x[0] === byTag.number: return x + case x[0] === byTag.string: return x + case x[0] === byTag.enum: return x + case x[0] === byTag.const: return x + case x[0] === byTag.array: return [x[0], f(x[1], false, x), x[2]] + case x[0] === byTag.tuple: return [x[0], x[1].map((_) => f(_, false, x))] + case x[0] === byTag.union: return [x[0], x[1].map((_) => f(_, isProperty, x))] + case x[0] === byTag.intersection: return [x[0], x[1].map((_) => f(_, isProperty, x))] + case x[0] === byTag.record: return x[1] && x[2] + ? [x[0], f(x[1], false, x), [x[2][0], f(x[2][1], false, x)] satisfies [any, any]] + : x[2] ? [x[0], undefined, [x[2][0], f(x[2][1], false, x)] satisfies [any, any]] + : x[1] + ? [x[0], f(x[1], false, x)] + : [x[0]] + case x[0] === byTag.object: return [x[0], x[1].map(([k, v]) => [k, f(v, true, x)] satisfies [any, any]), x[2]] + } + } + } +} + +export const fold + : (g: (src: Seed.F, ix: boolean, x: Seed.Fixpoint) => T) => (src: Seed.F, isProperty?: boolean) => T + = (g) => (src, isProperty = false) => fn.catamorphism(Functor, false)(g)(src, isProperty) diff --git a/packages/json-schema-test/src/generator.ts b/packages/json-schema-test/src/generator.ts new file mode 100644 index 00000000..1a642ae3 --- /dev/null +++ b/packages/json-schema-test/src/generator.ts @@ -0,0 +1,560 @@ +import * as fc from 'fast-check' + +import type { newtype, inline } from '@traversable/registry' +import { + Array_isArray, + fn, + isKeyOf, + isObject, + getRandomElementOf, + mutateRandomElementOf, + mutateRandomValueOf, + Number_isFinite, + Number_isNatural, + Number_isSafeInteger, + Object_assign, + Object_entries, + Object_fromEntries, + Object_keys, + omit, + pair, + pick, + symbol, + Object_create, + PATTERN, + escapeRegExp, +} from '@traversable/registry' +import type { TypeName } from '@traversable/json-schema-types' +import { JsonSchema } from '@traversable/json-schema-types' + +type Config = import('./generator-options.js').Config +import * as Config from './generator-options.js' +import * as Bounds from './generator-bounds.js' +import type { Tag } from './generator-seed.js' +import { byTag, bySeed, Seed, fold } from './generator-seed.js' + +const identifier = fc.stringMatching(new RegExp(PATTERN.identifier, 'u')) +const pattern = identifier.map((ident) => ident.replaceAll('$', '')) + +const literalValue = fc.oneof( + fc.string({ minLength: Bounds.defaults.string[0], maxLength: Bounds.defaults.string[1] }), + // identifier, + fc.double({ min: Bounds.defaults.number[0], max: Bounds.defaults.number[1], noNaN: true }), + fc.boolean(), +) + +/** + * TODO: break this out into a proper json builder + */ +const jsonValue = fc.oneof( + literalValue, + fc.array(literalValue), + fc.dictionary(identifier, literalValue) +) + +const TerminalMap = { + boolean: fn.const(fc.tuple(fc.constant(byTag.boolean))), + never: fn.const(fc.tuple(fc.constant(byTag.never))), + null: fn.const(fc.tuple(fc.constant(byTag.null))), + unknown: fn.const(fc.tuple(fc.constant(byTag.unknown))), +} satisfies { [K in keyof Seed.TerminalMap]: SeedBuilder } + +const integerBounds = Bounds.int(fc.integer()) +const numberBounds = Bounds.number(fc.double()) +const stringBounds = Bounds.string(fc.nat()) + +const BoundableMap = { + integer: fn.const(fc.tuple(fc.constant(byTag.integer), integerBounds)), + number: fn.const(fc.tuple(fc.constant(byTag.number), numberBounds)), + string: fn.const(fc.tuple(fc.constant(byTag.string), stringBounds)), +} satisfies { [K in keyof Seed.BoundableMap]: SeedBuilder } + +const ValueMap = { + const: fn.const(fc.tuple(fc.constant(byTag.const), jsonValue)), + enum: fn.const(fc.tuple(fc.constant(byTag.enum), fc.array(literalValue, { minLength: 1 }))), +} satisfies { [K in keyof Seed.ValueMap]: SeedBuilder } + +const UnaryMap = { + array: (tie) => fc.tuple(fc.constant(byTag.array), tie('*'), Bounds.array(fc.nat())), + tuple: (tie, $) => fc.tuple(fc.constant(byTag.tuple), fc.array(tie('*'), $)), + union: (tie, $) => fc.tuple(fc.constant(byTag.union), fc.array(tie('*'), $)), + record: (tie, $) => fc.tuple( + fc.constant(byTag.record), + tie('*'), + fc.tuple(pattern, tie('*')), + fc.constantFrom(1, 2, 3), + ).map(([type, additionalProperties, patternProperties, switcher]) => + $.additionalPropertiesOnly ? [type, additionalProperties, undefined] + : $.patternPropertiesOnly ? [type, undefined, patternProperties] + : switcher === 1 ? [type, additionalProperties, patternProperties] + : switcher === 2 ? [type, undefined, patternProperties] + : switcher === 3 ? [type, additionalProperties, undefined] + : switcher satisfies never + ), + object: (tie, $) => fc.tuple( + fc.constant(byTag.object), + fc.uniqueArray(fc.tuple(identifier, tie('*')), $), + fc.nat(), + ).map(([type, xs, ix]) => { + const required = xs.slice(0, ix % xs.length).map(([k]) => k) + return [ + type, + xs, + required, + ] satisfies [any, any, any] + }), + intersection: (tie) => entries(tie('*'), { minLength: 2 }).map(fn.flow( + (xs) => pair( + xs.slice(0, Math.ceil(xs.length / 2)), + xs.slice(Math.ceil(xs.length / 2)), + ), + ([l, r]) => pair( + pair(byTag.object, l), + pair(byTag.object, r), + ), + (both) => pair(byTag.intersection, both), + )), +} satisfies { [K in keyof Seed.UnaryMap]: SeedBuilder } + +const TerminalSeeds = fn.map(Object_keys(TerminalMap), (tag) => byTag[tag]) +const BoundableSeeds = fn.map(Object_keys(BoundableMap), (tag) => byTag[tag]) + +export interface SeedBuilder { + (tie: fc.LetrecTypedTie, $: Config.byTypeName[K]): fc.Arbitrary +} + +export interface SeedMap extends newtype<{ [K in keyof Seed]: SeedBuilder }> {} +export const SeedMap = { + ...TerminalMap, + ...BoundableMap, + ...ValueMap, + ...UnaryMap, +} satisfies SeedMap + +export function isTerminal(x: unknown): x is Seed.Terminal | Seed.Boundable +export function isTerminal(x: unknown) { + if (!Array_isArray(x)) return false + else { + const tag = x[0] as never + return TerminalSeeds.includes(tag) || BoundableSeeds.includes(tag) + } +} + +export const pickAndSortNodes + : (nodes: readonly ([keyof SeedMap, unknown])[]) => ($: Config) => (keyof SeedMap)[] + = (nodes) => ({ include, exclude, sortBias } = Config.defaults as never) => + nodes + .map(([k]) => k) + .filter((x) => + (include ? include.includes(x as never) : true) && + (exclude ? !exclude.includes(x as never) : true) + ) + .sort((l, r) => sortBias[l] < sortBias[r] ? -1 : sortBias[l] > sortBias[r] ? 1 : 0) + +export function JsonSchema_Integer(bounds: Bounds.int = Bounds.defaults.integer): JsonSchema.Integer { + const [min, max /*, multipleOf */] = bounds + let schema: JsonSchema.Integer = { type: 'integer' as const } + if (Number_isSafeInteger(min)) schema.minimum = min + if (Number_isSafeInteger(max)) schema.maximum = max + // if (Number_isSafeInteger(multipleOf)) schema.multipleOf = multipleOf + return schema +} + +export function JsonSchema_Number(bounds: Bounds.number = Bounds.defaults.number): JsonSchema.Number { + const [min, max, /* multipleOf */, minExcluded, maxExcluded] = bounds + let schema: JsonSchema.Number = { type: 'number' } + if (Number_isFinite(min)) { + if (minExcluded) schema.exclusiveMinimum = min + else schema.minimum = min + } + if (Number_isFinite(max)) { + if (maxExcluded) schema.exclusiveMaximum = max + else schema.maximum = max + } + return schema +} + +export function JsonSchema_String(bounds: Bounds.string = Bounds.defaults.string): JsonSchema.String { + const [min, max, exactLength] = bounds + let schema: JsonSchema.String = { type: 'string' } + if (Number_isNatural(exactLength)) { + schema.minLength = exactLength + schema.maxLength = exactLength + return schema + } else { + if (Number_isNatural(min)) schema.minLength = min + if (Number_isNatural(max)) schema.maxLength = max + return schema + } +} + +export function JsonSchema_Array( + items: T, bounds: Bounds.array = Bounds.defaults.array +): JsonSchema.Array { + const [min, max] = bounds + let schema: JsonSchema.Array = { type: 'array', items } + if (Number_isNatural(min)) schema.minItems = min + if (Number_isNatural(max)) schema.maxItems = max + return schema +} + +const branchNames = [ + 'array', + 'object', + 'record', + 'tuple', +] as const satisfies TypeName[] + +export interface Builder extends inline<{ [K in Tag]+?: fc.Arbitrary }> { + root?: fc.Arbitrary + invalid?: fc.Arbitrary + ['*']: fc.Arbitrary +} + +export function Builder(base: Gen.Base): + >( + options?: Options, + overrides?: Partial> + ) => (tie: fc.LetrecLooselyTypedTie) => Builder + +export function Builder(base: Gen.Base) { + return >(options?: Options, overrides?: Partial>) => { + const $ = Config.parseOptions(options) + return (tie: fc.LetrecLooselyTypedTie) => { + const builder: { [x: string]: fc.Arbitrary } = fn.pipe( + { ...base, ...overrides }, + (x) => pick(x, $.include), + (x) => omit(x, $.exclude), + (x) => fn.map(x, (f, k) => f(tie, $[k as never])), + ) + const nodes = pickAndSortNodes(Object_entries(builder) as [k: keyof SeedMap, unknown][])($) + builder['*'] = fc.oneof($['*'], ...nodes.map((k) => builder[k])) + const root = isKeyOf(builder, $.root) && builder[$.root] + let leaf = builder['*'] + + if ($.minDepth > 0) { + let branchName = getRandomElementOf(branchNames) + do { + if (branchName === 'object') leaf = fc.tuple(fc.constant(byTag.object), entries(builder['*'])) + else if (branchName === 'tuple') leaf = fc.tuple(fc.constant(byTag.tuple), fc.array(builder['*'])) + } while (--$.minDepth > 0) + } + + return Object_assign( + builder, { + ...root && { root }, + ['*']: leaf + }) + } + } +} + +export declare namespace Gen { + type Base = { [K in keyof T]: (tie: fc.LetrecLooselyTypedTie, constraints: $[K & keyof $]) => fc.Arbitrary } + type Values = never | T[Exclude] + type InferArb = S extends fc.Arbitrary ? T : never + interface Builder extends newtype { ['*']: fc.Arbitrary>> } + type BuildBuilder, Out extends {} = BuilderBase> = never | Builder + type BuilderBase, $ extends ParseOptions = ParseOptions> = never | + & ([$['root']] extends [never] ? unknown : { root: fc.Arbitrary<$['root']> }) + & { [K in Exclude<$['include'], $['exclude']>]: fc.Arbitrary } + type ParseOptions> = never | { + include: Options['include'] extends readonly unknown[] ? Options['include'][number] : keyof T + exclude: Options['exclude'] extends readonly unknown[] ? Options['exclude'][number] : never + root: Options['root'] extends keyof T ? T[Options['root']] : never + } +} + +/** + * ## {@link Gen `Gen`} + */ +export function Gen(base: Gen.Base): + >( + options?: Options, + overrides?: Partial> + ) => Gen.BuildBuilder + +export function Gen(base: Gen.Base) { + return >( + options?: Options, + overrides?: Partial> + ): Builder => { + return fc.letrec(Builder(base)(options, overrides)) + } +} + +function entries(model: fc.Arbitrary, options?: fc.UniqueArrayConstraintsRecommended<[k: string, T], unknown>) { + return fc.uniqueArray( + fc.tuple(identifier, model), + { selector: options?.selector || (([k]) => k), ...options }, + ) +} + +function intersect(x: unknown, y: unknown) { + return !isObject(x) ? y : !isObject(y) ? x : Object_assign(x, y) +} + +const keyFromPattern = (key: string) => { + if (key.startsWith('^')) key = key.slice(1) + if (key.endsWith('$')) key = key.slice(0, -1) + return fc.sample(fc.stringMatching(new RegExp(`^${escapeRegExp(key)}$`)), 1)[0] +} + +const GeneratorByTag = { + boolean: () => fc.boolean(), + never: () => fc.constant(void 0 as never), + null: () => fc.constant(null), + unknown: () => fc.anything(), + integer: (x) => fc.integer(Bounds.integerBoundsToIntegerConstraints(x[1])), + number: (x) => fc.double(Bounds.numberBoundsToDoubleConstraints(x[1])), + string: (x) => fc.string(Bounds.stringBoundsToStringConstraints(x[1])), + const: (x) => fc.constant(x[1]), + enum: (x) => fc.constant(x[1][0]), + array: (x) => fc.array(x[1], Bounds.arrayBoundsToArrayConstraints(x[2])), + record: (x) => + x[1] && x[2] ? fc.tuple( + fc.dictionary(fc.string(), x[1]), + fc.record({ [keyFromPattern(x[2][0])]: x[2][1] }) + ).map(([l, r]) => ({ ...l, ...r })) + : x[1] ? fc.dictionary(fc.string(), x[1]) + : x[2] ? fc.record({ [keyFromPattern(x[2][0])]: x[2][1] }) + : fc.constant({}) + , + tuple: (x) => fc.tuple(...x[1]), + union: (x) => fc.oneof(...(x[1] || [fc.constant(void 0 as never)])), + object: (x) => fc.record(Object_fromEntries(x[1]), { requiredKeys: x[2] }), + // object: (x) => fc.record(Object_fromEntries(x[1]), x[2].length === 0 ? {} : { requiredKeys: x[2] }), + intersection: (x) => fc.tuple(...x[1]).map((xs) => xs.reduce((x, y) => intersect(x, y), Object_create(null))), +} satisfies { + [K in keyof Seed]: (x: Seed>[K], $: Config) => fc.Arbitrary +} + +/** + * ## {@link seedToValidDataGenerator `seedToValidDataGenerator`} + * + * Convert a seed into an valid data generator. + * + * Valid in this context means that it will always satisfy the zod schema that the seed produces. + * + * To use it, you'll need to have [fast-check](https://github.com/dubzzz/fast-check) installed. + * + * To convert a seed to a zod schema, use {@link seedToSchema `seedToSchema`}. + * + * To convert a seed to an _invalid_ data generator, use {@link seedToInvalidDataGenerator `seedToInvalidDataGenerator`}. + */ +export function seedToValidDataGenerator(seed: Seed.F): fc.Arbitrary +export function seedToValidDataGenerator(seed: Seed.F): fc.Arbitrary { + return fold>( + (x) => GeneratorByTag[bySeed[x[0]]](x as never) + )(seed as never) +} + +/** + * ## {@link seedToInvalidDataGenerator `seedToInvalidDataGenerator`} + * + * Convert a seed into an invalid data generator. + * + * Invalid in this context means that it will never satisfy the zod schema that the seed produces. + * + * To convert a seed to a zod schema, use {@link seedToSchema `seedToSchema`}. + * + * To convert a seed to an _valid_ data generator, use + * {@link seedToValidDataGenerator `seedToValidDataGenerator`}. + */ +export function seedToInvalidDataGenerator(seed: T): fc.Arbitrary +export function seedToInvalidDataGenerator(seed: Seed.F): fc.Arbitrary +export function seedToInvalidDataGenerator(seed: Seed.F): fc.Arbitrary { + return fold>((x) => { + switch (x[0]) { + case byTag.record: return GeneratorByTag.record(x).map(mutateRandomValueOf) + case byTag.array: return GeneratorByTag.array(x).map(mutateRandomElementOf) + case byTag.tuple: return GeneratorByTag.tuple(x).map(mutateRandomElementOf) + case byTag.object: return GeneratorByTag.object(x).map(mutateRandomValueOf) + default: return GeneratorByTag[bySeed[x[0]]](x as never) + // default: return GeneratorByTag[bySeed[x[0]]](x as never, $) + } + })(seed as never) +} + +/** + * ## {@link SeedGenerator `SeedGenerator`} + * + * Pseudo-random seed generator. + * + * The generator supports a wide range of discoverable configuration options via the + * optional `options` argument. + * + * Many of those options are forwarded to the corresponding `fast-check` arbitrary. + * + * **Note:** support for `options.minDepth` is experimental. If you use it, be advised that + * even with a minimum depth of 1, the schemas produced will be quite large. Using this option + * in your CI/CD pipeline is not recommended. + * + * See also: + * - {@link SeedGenerator `SeedGenerator`} + * + * @example + * // TODO + */ +export const SeedGenerator = Gen(SeedMap) + +export const seedsThatPreventGeneratingValidData = [ + 'never', +] satisfies SchemaGenerator.Options['exclude'] + +export const seedsThatPreventGeneratingInvalidData = [ + 'never', + 'unknown', +] satisfies SchemaGenerator.Options['exclude'] + +/** + * ## {@link SeedValidDataGenerator `SeedValidDataGenerator`} + * + * A seed generator that can be interpreted to produce reliably valid data. + * + * This was originally developed to test for parity between various schema libraries. + * + * Note that certain schemas make generating valid data impossible + * (like {@link JsonSchema.Never `JsonSchema.Never`}). For this reason, those schemas + * will not be seeded. + * + * To see the list of excluded schemas, see + * {@link seedsThatPreventGeneratingValidData `seedsThatPreventGeneratingValidData`}. + * + * See also: + * - {@link SeedInvalidDataGenerator `SeedInvalidDataGenerator`} + * + * @example + * // TODO + */ + +export const SeedValidDataGenerator = SeedGenerator({ exclude: seedsThatPreventGeneratingValidData })['*'] + +/** + * ## {@link SeedInvalidDataGenerator `zx.SeedInvalidDataGenerator`} + * + * A seed generator that can be interpreted to produce reliably invalid data. + * + * This was originally developed to test for parity between various schema libraries. + * + * Note that certain schemas make generating invalid data impossible + * (like {@link JsonSchema.Unknown `JsonSchema.Unknown`}). For this reason, those schemas + * will not be seeded. + * + * To see the list of excluded schemas, see + * {@link seedsThatPreventGeneratingInvalidData `zx.seedsThatPreventGeneratingInvalidData`}. + * + * See also: + * - {@link SeedValidDataGenerator `zx.SeedValidDataGenerator`} + * + * @example + * // TODO + */ +export const SeedInvalidDataGenerator = fn.pipe( + SeedGenerator({ exclude: seedsThatPreventGeneratingInvalidData }), + ($) => fc.oneof( + $.object, + $.tuple, + $.array, + $.record, + ) +) + +/** + * ## {@link SchemaGenerator `zx.SchemaGenerator`} + * + * A zod schema generator that can be interpreted to produce an arbitrary `zod` schema (v4, classic). + * + * The generator supports a wide range of configuration options that are discoverable via the + * optional `options` argument. + * + * Many of those options are forwarded to the corresponding `fast-check` arbitrary. + * + * **Note:** support for `options.minDepth` is experimental. If you use it, be advised that + * _even with a minimum depth of 1_, the schemas produced will be **quite** large. Using this option + * in your CI/CD pipeline is _not_ recommended. + * + * See also: + * - {@link SeedGenerator `zx.SeedGenerator`} + * + * @example + * // TODO + */ + +export const SchemaGenerator = fn.flow( + SeedGenerator, + builder => builder['*'], + (arb) => arb.map(seedToSchema), +) + +export declare namespace SchemaGenerator { + type Options = Config.Options +} + +/** + * ## {@link seedToSchema `zx.seedToSchema`} + * + * Interpreter that converts a seed value into the corresponding zod schema. + * + * To get a seed, use {@link SeedGenerator `zx.SeedGenerator`}. + */ +// export function seedToSchema(seed: T): Seed.schemaFromComposite[T[0]] +export function seedToSchema(seed: Seed.F): JsonSchema +export function seedToSchema(seed: Seed.F) { + return fold((x) => { + switch (true) { + default: return fn.exhaustive(x) + case x[0] === byTag.boolean: return { type: 'boolean' } + case x[0] === byTag.never: return { enum: [] as [] } + case x[0] === byTag.null: return { type: 'null' } + case x[0] === byTag.unknown: return {} + case x[0] === byTag.integer: return JsonSchema_Integer(x[1]) + case x[0] === byTag.number: return JsonSchema_Number(x[1]) + case x[0] === byTag.string: return JsonSchema_String(x[1]) + case x[0] === byTag.const: return { const: x[1] } + case x[0] === byTag.enum: return { enum: x[1] } + case x[0] === byTag.array: return JsonSchema_Array(x[1], x[2]) + case x[0] === byTag.intersection: return { allOf: x[1] } + case x[0] === byTag.record: return { + type: 'object', + ...x[1] && { additionalProperties: x[1] }, + ...x[2] && { patternProperties: { [x[2][0]]: x[2][1] } }, + } + case x[0] === byTag.object: return { type: 'object', properties: Object_fromEntries(x[1]), required: x[2] ?? [] } + case x[0] === byTag.tuple: return { type: 'array', prefixItems: x[1] } + case x[0] === byTag.union: return { anyOf: x[1] } + } + })(seed as never) +} + +/** + * ## {@link seedToValidData `seedToValidData`} + * + * Given a seed, generates an single example of valid data. + * + * Valid in this context means that it will always satisfy the zod schema that the seed produces. + * + * To convert a seed to a zod schema, use {@link seedToSchema `seedToSchema`}. + * + * To convert a seed to a single example of _invalid_ data, use {@link seedToInvalidData `seedToInvalidData`}. + */ +export const seedToValidData = fn.flow( + seedToValidDataGenerator, + (model) => fc.sample(model, 1)[0], +) + +/** + * ## {@link seedToInvalidData `seedToInvalidData`} + * + * Given a seed, generates an single example of invalid data. + * + * Invalid in this context means that it will never satisfy the zod schema that the seed produces. + * + * To convert a seed to a zod schema, use {@link seedToSchema `seedToSchema`}. + * + * To convert a seed to a single example of _valid_ data, use {@link seedToValidData `seedToValidData`}. + */ +export const seedToInvalidData = fn.flow( + seedToInvalidDataGenerator, + (model) => fc.sample(model, 1)[0], +) diff --git a/packages/json-schema-test/src/index.ts b/packages/json-schema-test/src/index.ts new file mode 100644 index 00000000..34c78cc6 --- /dev/null +++ b/packages/json-schema-test/src/index.ts @@ -0,0 +1,2 @@ +export * from './exports.js' +export * as jsonSchemaTest from './exports.js' diff --git a/packages/json-schema-test/src/version.ts b/packages/json-schema-test/src/version.ts new file mode 100644 index 00000000..660ff1ca --- /dev/null +++ b/packages/json-schema-test/src/version.ts @@ -0,0 +1,3 @@ +import pkg from './__generated__/__manifest__.js' +export const VERSION = `${pkg.name}@${pkg.version}` as const +export type VERSION = typeof VERSION diff --git a/packages/json-schema-test/test/generator.fuzz.test.ts b/packages/json-schema-test/test/generator.fuzz.test.ts new file mode 100644 index 00000000..c94a0bb7 --- /dev/null +++ b/packages/json-schema-test/test/generator.fuzz.test.ts @@ -0,0 +1,72 @@ +import * as vi from 'vitest' +import * as fc from 'fast-check' + +import type { JsonSchema } from '@traversable/json-schema-types' +import { check } from '@traversable/json-schema-types' +import { jsonSchemaTest } from '@traversable/json-schema-test' + +const stringify = (_: string, v: unknown) => + typeof v === 'symbol' ? String(v) : typeof v === 'bigint' ? `${v}n` : v + +type LogFailureDeps = { + msg: string + seed: jsonSchemaTest.Seed.Seed.Fixpoint + schema: JsonSchema + errors?: unknown[] + data: unknown +} + +const fail = (e: unknown, { msg, seed, data }: LogFailureDeps) => { + console.group(`\r\n\nFAILURE: ${msg}`) + console.error('\r\nError:', e) + console.debug('\r\nseed: ', JSON.stringify(seed, stringify, 2)) + console.debug('\r\ndata: ', data === '' ? '' : data) + console.debug('\r\n\n') + console.groupEnd() + vi.assert.fail(`\r\nFAILURE: ${msg}`) +} + +vi.describe('ใ€–๏ธโ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traversable/json-schema-testโณ', () => { + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒjsonSchemaTest.SeedValidDataGeneratorโณ: integration test', () => { + fc.assert( + fc.property( + jsonSchemaTest.SeedValidDataGenerator, + (seed) => { + const schema = jsonSchemaTest.seedToSchema(seed) + const validData = jsonSchemaTest.seedToValidData(seed) + const result = check.classic(schema)(validData) + + if (result === false) { + console.log('schema', schema) + fail('expected check to succeed', { msg: 'check(schema)(validData)', data: validData, schema, seed }) + vi.assert.fail() + } + } + ), { + endOnFailure: true, + examples: [ + [[200, [-0, null, null, true, false]]] + ], + // numRuns: 10_000, + }) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒjsonSchemaTest.SeedInvalidDataGeneratorโณ: integration test', () => { + fc.assert( + fc.property( + jsonSchemaTest.SeedInvalidDataGenerator, + (seed) => { + const schema = jsonSchemaTest.seedToSchema(seed) + const invalidData = jsonSchemaTest.seedToInvalidData(seed) + const result = check.classic(schema)(invalidData) + if (result === true) { + fail('expected check to fail', { msg: 'check(schema)(validData)', data: invalidData, schema, seed }) + vi.assert.fail() + } + } + ), { + endOnFailure: true, + // numRuns: 10_000, + }) + }) +}) diff --git a/packages/json-schema-test/test/version.test.ts b/packages/json-schema-test/test/version.test.ts new file mode 100644 index 00000000..39372410 --- /dev/null +++ b/packages/json-schema-test/test/version.test.ts @@ -0,0 +1,10 @@ +import * as vi from 'vitest' +import pkg from '../package.json' with { type: 'json' } +import { VERSION } from '@traversable/json-schema-test' + +vi.describe('ใ€–โ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traversable/json-schema-testโณ', () => { + vi.it('ใ€–โ›ณ๏ธใ€—โ€บ โฒVERSIONโณ', () => { + const expected = `${pkg.name}@${pkg.version}` + vi.assert.equal(VERSION, expected) + }) +}) diff --git a/packages/json-schema-test/tsconfig.build.json b/packages/json-schema-test/tsconfig.build.json new file mode 100644 index 00000000..3f5dbdaa --- /dev/null +++ b/packages/json-schema-test/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.src.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/build.tsbuildinfo", + "types": ["node"], + "declarationDir": "build/dts", + "outDir": "build/esm", + "stripInternal": true + }, + "references": [ + { "path": "../json" }, + { "path": "../json-schema-types" }, + { "path": "../registry" } + ] +} diff --git a/packages/json-schema-test/tsconfig.json b/packages/json-schema-test/tsconfig.json new file mode 100644 index 00000000..2c291d21 --- /dev/null +++ b/packages/json-schema-test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [], + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "tsconfig.test.json" } + ] +} diff --git a/packages/json-schema-test/tsconfig.src.json b/packages/json-schema-test/tsconfig.src.json new file mode 100644 index 00000000..ac21055d --- /dev/null +++ b/packages/json-schema-test/tsconfig.src.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/src.tsbuildinfo", + "rootDir": "src", + "types": ["node"], + "outDir": "build/src" + }, + "references": [ + { "path": "../json" }, + { "path": "../json-schema-types" }, + { "path": "../registry" } + ], + "include": ["src"] +} diff --git a/packages/json-schema-test/tsconfig.test.json b/packages/json-schema-test/tsconfig.test.json new file mode 100644 index 00000000..03b368eb --- /dev/null +++ b/packages/json-schema-test/tsconfig.test.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo", + "rootDir": "test", + "types": ["node"], + "noEmit": true + }, + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "../json" }, + { "path": "../json-schema-types" }, + { "path": "../registry" } + ], + "include": ["test"] +} diff --git a/packages/json-schema-test/vite.config.ts b/packages/json-schema-test/vite.config.ts new file mode 100644 index 00000000..64dba4ad --- /dev/null +++ b/packages/json-schema-test/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import sharedConfig from '../../vite.config.js' + +const localConfig = defineConfig({}) + +export default mergeConfig(sharedConfig, localConfig) \ No newline at end of file diff --git a/packages/json-schema-types/README.md b/packages/json-schema-types/README.md new file mode 100644 index 00000000..cb1ee0fd --- /dev/null +++ b/packages/json-schema-types/README.md @@ -0,0 +1,42 @@ +
+

แฏ“๐˜๐—ฟ๐—ฎ๐˜ƒ๐—ฒ๐—ฟ๐˜€๐—ฎ๐—ฏ๐—น๐—ฒ/๐—ท๐˜€๐—ผ๐—ป-๐˜€๐—ฐ๐—ต๐—ฒ๐—บ๐—ฎ-๐˜๐˜†๐—ฝ๐—ฒ๐˜€

+
+ +

+ A package containing Json Schema types and type predicates. +

+ +
+ NPM Version +   + TypeScript +   + Static Badge +   + npm +   +
+ +
+ + Static Badge +   + Static Badge +   +
+ +
+ Demo (StackBlitz) +   โ€ข   + TypeScript Playground +   โ€ข   + npm +
+
+
+
+ +## Why? + +The types in `@traversable/json-schema-types` are non-recursive. diff --git a/packages/json-schema-types/package.json b/packages/json-schema-types/package.json new file mode 100644 index 00000000..fdb96d92 --- /dev/null +++ b/packages/json-schema-types/package.json @@ -0,0 +1,46 @@ +{ + "name": "@traversable/json-schema-types", + "type": "module", + "version": "0.0.0", + "private": false, + "description": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/traversable/schema.git", + "directory": "packages/json-schema-types" + }, + "bugs": { + "url": "https://github.com/traversable/schema/issues", + "email": "ahrjarrett@gmail.com" + }, + "@traversable": { + "generateExports": { "include": ["**/*.ts"] }, + "generateIndex": { "include": ["**/*.ts"] } + }, + "publishConfig": { + "access": "public", + "directory": "dist", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "bench": "echo NOTHING TO BENCH", + "build": "pnpm build:esm && pnpm build:cjs && pnpm build:annotate", + "build:annotate": "babel build --plugins annotate-pure-calls --out-dir build --source-maps", + "build:esm": "tsc -b tsconfig.build.json", + "build:cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps", + "check": "tsc -b tsconfig.json", + "clean": "pnpm run \"/^clean:.*/\"", + "clean:build": "rm -rf .tsbuildinfo dist build", + "clean:deps": "rm -rf node_modules", + "test": "vitest" + }, + "peerDependencies": { + "@traversable/json": "workspace:^", + "@traversable/registry": "workspace:^" + }, + "devDependencies": { + "@traversable/json": "workspace:^", + "@traversable/registry": "workspace:^" + } +} diff --git a/packages/json-schema-types/src/__generated__/__manifest__.ts b/packages/json-schema-types/src/__generated__/__manifest__.ts new file mode 100644 index 00000000..8e5d2c63 --- /dev/null +++ b/packages/json-schema-types/src/__generated__/__manifest__.ts @@ -0,0 +1,46 @@ +export default { + "name": "@traversable/json-schema-types", + "type": "module", + "version": "0.0.0", + "private": false, + "description": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/traversable/schema.git", + "directory": "packages/json-schema-types" + }, + "bugs": { + "url": "https://github.com/traversable/schema/issues", + "email": "ahrjarrett@gmail.com" + }, + "@traversable": { + "generateExports": { "include": ["**/*.ts"] }, + "generateIndex": { "include": ["**/*.ts"] } + }, + "publishConfig": { + "access": "public", + "directory": "dist", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "bench": "echo NOTHING TO BENCH", + "build": "pnpm build:esm && pnpm build:cjs && pnpm build:annotate", + "build:annotate": "babel build --plugins annotate-pure-calls --out-dir build --source-maps", + "build:esm": "tsc -b tsconfig.build.json", + "build:cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps", + "check": "tsc -b tsconfig.json", + "clean": "pnpm run \"/^clean:.*/\"", + "clean:build": "rm -rf .tsbuildinfo dist build", + "clean:deps": "rm -rf node_modules", + "test": "vitest" + }, + "peerDependencies": { + "@traversable/json": "workspace:^", + "@traversable/registry": "workspace:^" + }, + "devDependencies": { + "@traversable/json": "workspace:^", + "@traversable/registry": "workspace:^" + } +} as const \ No newline at end of file diff --git a/packages/json-schema-types/src/check.ts b/packages/json-schema-types/src/check.ts new file mode 100644 index 00000000..06d0fa33 --- /dev/null +++ b/packages/json-schema-types/src/check.ts @@ -0,0 +1,679 @@ +import * as T from '@traversable/registry' +import { + accessor, + escape, + escapeRegExp, + fn, + isQuoted, + Number_isSafeInteger, + Number_isFinite, + Math_min, + Math_max, + Number_isNatural, + Object_entries, + Object_keys, + Object_hasOwn, + Object_values, + parseKey, + stringifyKey, +} from '@traversable/registry' +import { Json } from '@traversable/json' + +import * as F from './functor.js' +import { toType } from './to-type.js' +import * as JsonSchema from './types.js' +type JsonSchema = import('./types.js').JsonSchema + +export const checkJson = Json.fold<(x: unknown) => boolean>((x) => { + switch (true) { + default: return (void (x satisfies never), () => false) + case Json.isScalar(x): + return (u) => u === x + case Json.isArray(x): + return (u) => Json.isArray(u) + && u.length === x.length + && x.every((predicate, i) => predicate(u[i])) + case Json.isObject(x): { + const predicates = Object_entries(x) + return (u) => { + if (!Json.isObject(u)) { + return false + } else { + const keys = Object_keys(u) + if (keys.length !== predicates.map(([k]) => k).length) + return false + else + return predicates.every( + ([k, predicate]) => + Object_hasOwn(u, k) + && predicate(u[k]) + ) + } + } + } + } +}) + +const fold = F.fold<(x: unknown) => boolean>((x) => { + switch (true) { + default: return (void (x satisfies never), () => false) + case JsonSchema.isNever(x): return () => false + case JsonSchema.isConst(x): return checkJson(x.const as Json<(x: unknown) => boolean>) + case JsonSchema.isNull(x): return (u) => u === null + case JsonSchema.isBoolean(x): return (u) => u === false || u === true + case JsonSchema.isUnion(x): { + if (x.anyOf.length === 0) return () => false + else if (x.anyOf.length === 1) return x.anyOf[0] + else return (u) => x.anyOf.some((p) => p(u)) + } + case JsonSchema.isIntersection(x): { + if (x.allOf.length === 0) return () => true + else if (x.allOf.length === 1) return x.allOf[0] + else return (u) => x.allOf.every((p) => p(u)) + } + case JsonSchema.isEnum(x): return (u) => + u !== undefined + && Json.isScalar(u) + && x.enum.includes(u) + case JsonSchema.isInteger(x): { + const { maximum, minimum, multipleOf } = x + if (Number_isSafeInteger(maximum) && Number_isSafeInteger(minimum) && Number_isSafeInteger(multipleOf)) + return (u) => Number_isSafeInteger(u) + && minimum <= u + && u <= maximum + && u % multipleOf === 0 + else if (Number_isSafeInteger(maximum) && Number_isSafeInteger(minimum)) + return (u) => Number_isSafeInteger(u) + && minimum <= u + && u <= maximum + else if (Number_isSafeInteger(maximum) && Number_isSafeInteger(multipleOf)) + return (u) => Number_isSafeInteger(u) + && u <= maximum + && u % multipleOf === 0 + else if (Number_isSafeInteger(maximum)) + return (u) => Number_isSafeInteger(u) + && u <= maximum + else if (Number_isSafeInteger(minimum) && Number_isSafeInteger(multipleOf)) + return (u) => Number_isSafeInteger(u) + && minimum <= u + && u % multipleOf === 0 + else if (Number_isSafeInteger(minimum)) + return (u) => Number_isSafeInteger(u) + && minimum <= u + else if (Number_isSafeInteger(multipleOf)) + return (u) => Number_isSafeInteger(u) + && u % multipleOf === 0 + else + return (u) => Number_isSafeInteger(u) + } + case JsonSchema.isNumber(x): { + const { exclusiveMaximum: xMax, exclusiveMinimum: xMin, maximum: max, minimum: min, multipleOf } = x + const maximum = Number_isFinite(xMax) && Number_isFinite(max) ? Math_min(xMax, max) : Number_isFinite(xMax) ? xMax : max + const minimum = Number_isFinite(xMin) && Number_isFinite(min) ? Math_max(xMin, min) : Number_isFinite(xMin) ? xMin : min + const exclusiveMaximum = Number_isFinite(xMax) && xMax === maximum + const exclusiveMinimum = Number_isFinite(xMin) && xMin === minimum + if (Number_isFinite(maximum) && Number_isFinite(minimum) && Number_isFinite(multipleOf)) + if (exclusiveMaximum && exclusiveMinimum) + return (u) => + Number_isFinite(u) + && minimum < u + && u < maximum + && u % multipleOf === 0 + else if (exclusiveMaximum) + return (u) => + Number_isFinite(u) + && minimum <= u + && u < maximum + && u % multipleOf === 0 + else if (exclusiveMinimum) + return (u) => + Number_isFinite(u) + && minimum < u + && u <= maximum + && u % multipleOf === 0 + else + return (u) => + Number_isFinite(u) + && minimum <= u + && u <= maximum + && u % multipleOf === 0 + else if (Number_isFinite(maximum) && Number_isFinite(minimum)) + if (exclusiveMaximum && exclusiveMinimum) + return (u) => + Number_isFinite(u) + && minimum < u + && u < maximum + else if (exclusiveMaximum) + return (u) => + Number_isFinite(u) + && minimum <= u + && u < maximum + else if (exclusiveMinimum) + return (u) => + Number_isFinite(u) + && minimum < u + && u <= maximum + else + return (u) => + Number_isFinite(u) + && minimum <= u + && u <= maximum + else if (Number_isFinite(maximum) && Number_isFinite(multipleOf)) + if (exclusiveMaximum) + return (u) => + Number_isFinite(u) + && u < maximum + && u % multipleOf === 0 + else + return (u) => + Number_isFinite(u) + && u <= maximum + && u % multipleOf === 0 + else if (Number_isFinite(maximum)) + if (exclusiveMaximum) + return (u) => + Number_isFinite(u) + && u < maximum + else + return (u) => + Number_isFinite(u) + && u <= maximum + else if (Number_isFinite(minimum) && Number_isFinite(multipleOf)) + if (exclusiveMinimum) + return (u) => + Number_isFinite(u) + && minimum < u + && u % multipleOf === 0 + else + return (u) => + Number_isFinite(u) + && minimum <= u + && u % multipleOf === 0 + else if (Number_isFinite(minimum)) + if (exclusiveMinimum) + return (u) => + Number_isFinite(u) + && minimum < u + else + return (u) => + Number_isFinite(u) + && minimum <= u + else if (Number_isFinite(multipleOf)) + return (u) => + Number_isFinite(u) + && u % multipleOf === 0 + else + return (u) => + Number_isFinite(u) + } + case JsonSchema.isString(x): { + const { maxLength, minLength } = x + if (Number_isNatural(maxLength) && Number_isNatural(minLength)) + return (u) => + typeof u === 'string' + && minLength <= u.length + && u.length <= maxLength + else if (Number_isNatural(maxLength)) + return (u) => + typeof u === 'string' + && u.length <= maxLength + else if (Number_isNatural(minLength)) + return (u) => + typeof u === 'string' + && minLength <= u.length + else + return (u) => + typeof u === 'string' + } + case JsonSchema.isArray(x): { + const { items: predicate, maxItems, minItems } = x + if (predicate) + if (Number_isNatural(maxItems) && Number_isNatural(minItems)) + return (u) => + Json.isArray(u) + && u.every(predicate) + && minItems <= u.length + && u.length <= maxItems + else if (Number_isNatural(maxItems)) + return (u) => + Json.isArray(u) + && u.every(predicate) + && u.length <= maxItems + else if (Number_isNatural(minItems)) + return (u) => + Json.isArray(u) + && u.every(predicate) + && minItems <= u.length + else return (u) => + Json.isArray(u) + && u.every(predicate) + else + if (Number_isNatural(maxItems) && Number_isNatural(minItems)) + return (u) => + Json.isArray(u) + && minItems <= u.length + && u.length <= maxItems + else if (Number_isNatural(maxItems)) + return (u) => + Json.isArray(u) + && u.length <= maxItems + else if (Number_isNatural(minItems)) + return (u) => + Json.isArray(u) + && minItems <= u.length + else + return Json.isArray + } + case JsonSchema.isTuple(x): { + const { items: predicate, maxItems, minItems, prefixItems: predicates } = x + if (predicate) + if (Number_isNatural(maxItems) && Number_isNatural(minItems)) + return (u) => + Json.isArray(u) + && minItems <= u.length + && u.length <= maxItems + && predicates.every((p, i) => p(u[i])) + && u.slice(predicates.length).every(predicate) + else if (Number_isNatural(maxItems)) + return (u) => + Json.isArray(u) + && u.length <= maxItems + && predicates.every((p, i) => p(u[i])) + && u.slice(predicates.length).every(predicate) + else if (Number_isNatural(minItems)) + return (u) => + Json.isArray(u) + && minItems <= u.length + && predicates.every((p, i) => p(u[i])) + && u.slice(predicates.length).every(predicate) + else return (u) => + Json.isArray(u) + && predicates.every((p, i) => p(u[i])) + && u.slice(predicates.length).every(predicate) + else + if (Number_isNatural(maxItems) && Number_isNatural(minItems)) + return (u) => + Json.isArray(u) + && minItems <= u.length + && u.length <= maxItems + && predicates.every((p, i) => p(u[i])) + else if (Number_isNatural(maxItems)) + return (u) => + Json.isArray(u) + && u.length <= maxItems + && predicates.every((p, i) => p(u[i])) + else if (Number_isNatural(minItems)) + return (u) => + Json.isArray(u) + && minItems <= u.length + && predicates.every((p, i) => p(u[i])) + else return (u) => + Json.isArray(u) + && predicates.every((p, i) => p(u[i])) + } + case JsonSchema.isRecord(x): { + const { additionalProperties, patternProperties } = x + if (additionalProperties && patternProperties) { + const patterns = Object_entries(patternProperties) + return (u) => { + if (!Json.isObject(u)) + return false + else { + const keys = Object_keys(u) + return keys.every( + (key) => { + const [, predicate] = patterns.find(([p]) => new RegExp(escapeRegExp(p)).test(key)) || [] + return ( + predicate + && predicate(u[key]) + ) + || additionalProperties(u[key]) + } + ) + } + } + } else if (additionalProperties) { + return (u) => + Json.isObject(u) + && Object_values(u).every(additionalProperties) + } else if (patternProperties) { + const patterns = Object_entries(patternProperties) + return (u) => { + if (!Json.isObject(u)) { + return false + } else { + const keys = Object_keys(u) + return keys.every( + (key) => { + const [, predicate] = patterns.find(([p]) => new RegExp(escapeRegExp(p)).test(key)) || [] + return predicate && predicate(u[key]) + } + ) + } + } + } else return Json.isObject + } + case JsonSchema.isObject(x): { + const { properties, required } = x + const predicates = Object_entries(properties) + return (u) => + Json.isObject(u) + && predicates.every( + ([k, predicate]) => + !required.includes(k) + ? ( + !Object_hasOwn(u, k) + || predicate(u[k]) + ) + : ( + Object_hasOwn(u, k) + && predicate(u[k]) + ) + ) + } + case JsonSchema.isUnknown(x): return () => true + } +}) + +function literalValueToString(x: Json.Scalar) { + if (typeof x === 'string') { + const escaped = escape(x) + return `"${escaped}"` + } else { + return typeof x === 'bigint' ? `${x}n` : `${x}` + } +} + +const JsonFunctor: T.Functor.Ix = { + map: Json.Functor.map, + mapWithIndex(f) { + return (xs, VAR) => { + switch (true) { + default: return xs satisfies never + case Json.isScalar(xs): return xs + case Json.isArray(xs): return fn.map(xs, (x, i) => f(x, `${VAR}${accessor(i, false)}`, xs)) + case Json.isObject(xs): return fn.map(xs, (x, k) => f(x, `${VAR}${accessor(k, false)}`, xs)) + } + } + }, +} + +const foldJson = fn.catamorphism(JsonFunctor, 'value') + +function compileJson(x: Json, varName: string) { + return foldJson((x, VAR) => { + switch (true) { + default: return x satisfies never + case x == null: + case x === true: + case x === false: + case typeof x === 'number': return `${VAR} === ${x}` + case typeof x === 'string': return `${VAR} === "${escape(x)}"` + case Json.isArray(x): return `Array.isArray(${VAR}) && ${VAR}.length === ${x.length}${x.length === 0 ? '' : ' && '}${x.join(' && ')}` + case Json.isObject(x): { + const values = Object_values(x) + return `!!${VAR} && typeof ${VAR} === 'object'${values.length === 0 ? '' : ' && '}${values.join(' && ')}` + } + } + })(x as never, varName) +} + +const compile = F.compile((x, ix, input) => { + const VAR = ix.varName + switch (true) { + default: return x satisfies never + case JsonSchema.isNever(x): return 'false' + case JsonSchema.isNull(x): return `${VAR} === null` + case JsonSchema.isBoolean(x): return `typeof ${VAR} === "boolean"` + case JsonSchema.isUnion(x): { + if (x.anyOf.length === 0) return 'false' + else if (x.anyOf.length === 1) return x.anyOf[0] + else return x.anyOf.length === 0 ? 'false' : `(${x.anyOf.map((v) => `(${v})`).join(' || ')})` + } + case JsonSchema.isIntersection(x): { + if (x.allOf.length === 0) return 'false' + else if (x.allOf.length === 1) return x.allOf[0] + return x.allOf.length === 0 ? 'true' : `(${x.allOf.map((v) => `(${v})`).join(' && ')})` + } + case JsonSchema.isEnum(x): { + const members = x.enum.map((v) => `${VAR} === ${literalValueToString(v)}`) + const OPEN = x.enum.length > 1 ? '(' : '' + const CLOSE = x.enum.length > 1 ? ')' : '' + return OPEN + members.join(' || ') + CLOSE + } + case JsonSchema.isConst(x): { + // TODO: handle arrays and objects + return compileJson(x.const, VAR) + } + case JsonSchema.isInteger(x): { + const { minimum: min, maximum: max, multipleOf } = x + const CHECK = `Number.isSafeInteger(${VAR})` + const MIN_CHECK = !Number_isSafeInteger(min) ? '' : ` && ${min} <= ${VAR}` + const MAX_CHECK = !Number_isSafeInteger(max) ? '' : ` && ${VAR} <= ${max}` + const MULTIPLE_OF = !Number_isSafeInteger(multipleOf) ? '' : ` && ${VAR} % ${multipleOf} === 0` + const OPEN = MIN_CHECK.length > 0 || MAX_CHECK.length > 0 ? '(' : '' + const CLOSE = MIN_CHECK.length > 0 || MAX_CHECK.length > 0 ? ')' : '' + return '' + + OPEN + + CHECK + + MIN_CHECK + + MAX_CHECK + + MULTIPLE_OF + + CLOSE + } + case JsonSchema.isNumber(x): { + const { minimum: min, maximum: max, exclusiveMinimum: xMin, exclusiveMaximum: xMax, multipleOf } = x + const CHECK = `Number.isFinite(${VAR})` + const MIN_CHECK + = Number_isFinite(xMin) ? ` && ${xMin} < ${VAR}` + : Number_isFinite(min) ? ` && ${min} <= ${VAR}` + : '' + const MAX_CHECK + = Number_isFinite(xMax) ? ` && ${VAR} < ${xMax}` + : Number_isFinite(max) ? ` && ${VAR} <= ${max}` + : '' + const MULTIPLE_OF = !Number_isFinite(multipleOf) ? '' : ` && ${VAR} % ${multipleOf} === 0` + const OPEN = MIN_CHECK.length > 0 || MAX_CHECK.length > 0 ? '(' : '' + const CLOSE = MIN_CHECK.length > 0 || MAX_CHECK.length > 0 ? ')' : '' + return '' + + OPEN + + CHECK + + MIN_CHECK + + MAX_CHECK + + MULTIPLE_OF + + CLOSE + } + case JsonSchema.isString(x): { + const { minLength: min, maxLength: max } = x + const CHECK = `typeof ${VAR} === "string"` + const MIN_CHECK = Number_isNatural(min) ? ` && ${min} <= ${VAR}.length` : '' + const MAX_CHECK = Number_isNatural(max) ? ` && ${VAR}.length <= ${max}` : '' + const OPEN = MIN_CHECK.length > 0 || MAX_CHECK.length > 0 ? '(' : '' + const CLOSE = MIN_CHECK.length > 0 || MAX_CHECK.length > 0 ? ')' : '' + return '' + + OPEN + + CHECK + + MIN_CHECK + + MAX_CHECK + + CLOSE + } + case JsonSchema.isTuple(x): { + const { items: rest, prefixItems, minItems, maxItems } = x + const REST = typeof rest !== 'string' ? '' : ` && ${VAR}.slice(${prefixItems.length}).every((value) => ${rest})` + const BODY = `${prefixItems.length > 0 ? ' && (' : ''}${prefixItems.join(' && ')}${prefixItems.length > 0 ? ')' : ''}` + const MIN_CHECK = Number_isNatural(minItems) ? ` && ${minItems} <= ${VAR}.length` : '' + const MAX_CHECK = Number_isNatural(maxItems) ? ` && ${VAR}.length <= ${maxItems}` : '' + return '' + + `Array.isArray(${VAR})` + + MIN_CHECK + + MAX_CHECK + + BODY + + REST + } + case JsonSchema.isArray(x): { + const { items, minItems, maxItems } = x + const BODY = ` && ${VAR}.every((value) => ${items})` + const MIN_CHECK = Number_isNatural(minItems) ? ` && ${minItems} <= ${VAR}.length` : '' + const MAX_CHECK = Number_isNatural(maxItems) ? ` && ${VAR}.length <= ${maxItems}` : '' + return '' + + `Array.isArray(${VAR})` + + MIN_CHECK + + MAX_CHECK + + BODY + } + case JsonSchema.isRecord(x): { + const { additionalProperties, patternProperties } = x + const CHECK = `!!${VAR} && typeof ${VAR} === "object"` + if (patternProperties !== undefined) { + const patterns = Object_entries(patternProperties) + return [ + `${CHECK} && Object.entries(${VAR}).every(([key, value]) => {`, + ...patterns.map(([pattern, predicate]) => `if (/${pattern.length === 0 ? '^$' : pattern}/.test(key)) return ${predicate}`), + additionalProperties === undefined ? null : `return ${additionalProperties}`, + `return true`, + `})` + ].filter((_) => _ !== null).join('\n') + } else if (additionalProperties !== undefined) { + return `${CHECK} && Object.entries(${VAR}).every(([key, value]) => ${additionalProperties})` + } else { + return CHECK + } + } + case JsonSchema.isObject(x): { + const { properties, required } = x + const CHECK = `!!${VAR} && typeof ${VAR} === "object"` + const CHILDREN = Object_entries(properties).map(([k, v]) => !required.includes(k) + ? `(!Object.hasOwn(${VAR}, ${JSON.stringify(parseKey(k))}) || ${v})` + : v + ) + const BODY = CHILDREN.length === 0 ? '' : ` && ${CHILDREN.join(' && ')}` + return '' + + CHECK + + BODY + } + case JsonSchema.isUnknown(x): return 'true' + } +}) + +function buildFunctionBody(schema: JsonSchema): string { + let BODY = compile(schema) + if (BODY.startsWith('(') && BODY.endsWith(')')) BODY = BODY.slice(1, -1) + return BODY +} + +export declare namespace check { + type Options = toType.Options & { + /** + * Configure the name of the generated check function + * @default "check" + */ + functionName?: string + /** + * Whether the returned predicate should infer the return type from + * the JSON Schema input and act as a type-guard for that type. + * + * For this to work properly, the JSON Schema you pass will need to + * be a _TypeScript value_ that has been declared with the `as const` + * modifier, or passed as a literal value (rather than a JSON value). + * + * **Note:** Inferring a type guard can be expensive, so by default + * this is an opt-in feature. + * + * @default false + */ + asTypeGuard?: true + } +} + +check.classic = check_classic +check.writeable = check_writeable + +/** + * ## {@link check `JsonSchema.check`} + * + * Given a JSON Schema spec, returns a predicate function that accepts any input + * and returns a boolean indicating whether the input satisfied the spec. + * + * Pros: + * - {@link check `JsonSchema.check`} has better performance than {@link check_writeable `JsonSchema.check.writeable`} + * + * Cons: + * - {@link check `JsonSchema.check`} does not work in environments that disallow the use of the native + * [`Function` constructor](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/Function), + * which means you can't use it with Cloudflare workers + * + * See also: + * - {@link check_classic `JsonSchema.check.classic`} + * - {@link check_writeable `JsonSchema.check.writeable`} + * + * @example + * import { check } from '@traversable/json-schema-types' + * + * const myCheck = check({ type: 'boolean' }) + * + * console.log(myCheck(false)) // => true + * console.log(myCheck('true')) // => false + * + * const myTypeGuard = check({ type: 'boolean' }, { asTypeGuard: true }) + * + * declare const input: unknown + * + * if (myTypeGuard(input)) { + * input + * // ^? const input: boolean + * } + */ +export function check(schema: T): (x: unknown) => boolean +export function check(schema: T, options: Pick): (x: unknown) => x is toType +export function check(schema: T) { + return globalThis.Function( + 'value', + 'return ' + buildFunctionBody(schema) + ) +} + +/** + * ## {@link check_writeable `JsonSchema.check.writeable`} + * + * Given a JSON Schema spec, returns a validation function in **stringified form**. + * + * Pros: + * - {@link check_writeable `JsonSchema.check.writeable`} has the same performance as + * {@link check `JsonSchema.check`} _and_ works in any environment + * + * Cons: + * - You have to write the schemas to disc somehow, and you'll definitely want to + * set up a build step that keeps them in sync with the JSON Schema spec. + * + * See also: + * - {@link check `JsonSchema.check`} + * - {@link check_classic `JsonSchema.check.classic`} + */ +function check_writeable(schema: T, options?: check.Options): string { + const FUNCTION_NAME = options?.functionName ?? 'check' + return ` + function ${FUNCTION_NAME} (value) { + return ${buildFunctionBody(schema)} + } + `.trim() +} + +/** + * ## {@link check_classic `JsonSchema.check.classic`} + * + * Given a JSON Schema spec, returns a predicate function that accepts any input + * and returns a boolean indicating whether the input satisfied the spec. + * + * Pros: + * - {@link check_classic `JsonSchema.check.classic`} works in any environment, including Cloudflare workers + * + * Cons: + * - {@link check_classic `JsonSchema.check.classic`} has worse performance than {@link check `JsonSchema.check`} + * + * See also: + * - {@link check `JsonSchema.check`} + * - {@link check_writeable `JsonSchema.check.writeable`} + */ +function check_classic(schema: T): (x: unknown) => boolean { + return fold(schema as JsonSchema<(x: unknown) => boolean>) +} diff --git a/packages/json-schema-types/src/exports.ts b/packages/json-schema-types/src/exports.ts new file mode 100644 index 00000000..1ad96ed2 --- /dev/null +++ b/packages/json-schema-types/src/exports.ts @@ -0,0 +1,10 @@ +export * from './version.js' + +export * from './types.js' + +export type { Algebra, CompilerIndex, Index } from './functor.js' +export { CompilerFunctor, Functor, fold, defaultCompilerIndex, defaultIndex } from './functor.js' + +export { check, checkJson } from './check.js' +export { toType } from './to-type.js' +export { TypeName, TypeNames } from './typename.js' diff --git a/packages/json-schema-types/src/functor.ts b/packages/json-schema-types/src/functor.ts new file mode 100644 index 00000000..f5da4c9b --- /dev/null +++ b/packages/json-schema-types/src/functor.ts @@ -0,0 +1,230 @@ +import type * as T from '@traversable/registry' +import { fn, accessor, symbol } from '@traversable/registry' +import * as JsonSchema from './types.js' + +export interface Index { + dataPath: (string | number)[] + schemaPath: (keyof any)[] + isOptional: boolean +} + +export interface CompilerIndex extends Index { + varName: string + isProperty: boolean +} + +export type Algebra = (x: JsonSchema.F, ix: Index, input: JsonSchema.Fixpoint) => T +export type IndexedAlgebra = (x: JsonSchema.F, ix: Ix, input: JsonSchema.Fixpoint) => T + +export const defaultIndex = { + dataPath: [], + schemaPath: [], + isOptional: false, +} satisfies Index + +export const defaultCompilerIndex = { + ...defaultIndex, + isProperty: false, + varName: 'value', +} satisfies CompilerIndex + + +export const Functor: T.Functor.Ix = { + map(f) { + return (x) => { + switch (true) { + default: return x satisfies never + case JsonSchema.isNullary(x): return x + case JsonSchema.isArray(x): return { ...x, items: f(x.items) } + case JsonSchema.isObject(x): return { ...x, properties: fn.map(x.properties, f) } + case JsonSchema.isUnion(x): return { anyOf: fn.map(x.anyOf, f) } + case JsonSchema.isIntersection(x): return { allOf: fn.map(x.allOf, f) } + case JsonSchema.isTuple(x): { + const { items, prefixItems, ...xs } = x + return { ...xs, ...items && { items: f(items) }, prefixItems: fn.map(x.prefixItems, f) } + } + case JsonSchema.isRecord(x): { + const { additionalProperties: a, patternProperties: p, ...xs } = x + return { ...xs, ...a && { additionalProperties: f(a) }, ...p && { patternProperties: fn.map(p, f) } } + } + case JsonSchema.isUnknown(x): return x + } + } + }, + mapWithIndex(f) { + return (x, ix) => { + switch (true) { + default: return x satisfies never + case JsonSchema.isNullary(x): return x + case JsonSchema.isArray(x): return { + ...x, + items: f(x.items, { ...ix, schemaPath: [...ix.schemaPath, symbol.array] }, x) + } + case JsonSchema.isUnion(x): return { + anyOf: fn.map( + x.anyOf, + (v) => f(v, { ...ix, schemaPath: [...ix.schemaPath, symbol.union] }, x) + ) + } + case JsonSchema.isIntersection(x): return { + allOf: fn.map( + x.allOf, + (v) => f(v, { ...ix, schemaPath: [...ix.schemaPath, symbol.intersect] }, x) + ) + } + case JsonSchema.isObject(x): { + return { + ...x, + properties: fn.map( + x.properties, + (v, k) => f(v, { + isOptional: !x.required.includes(k), + dataPath: [...ix.dataPath, k], + schemaPath: [...ix.schemaPath, k], + }, x) + ) + } + } + case JsonSchema.isTuple(x): { + const { items, prefixItems, ...xs } = x + return { + ...xs, + ...items && { items: f(items, { ...ix, schemaPath: [...ix.schemaPath, symbol.tuple] }, x) }, + prefixItems: fn.map( + prefixItems, + (v, i) => f(v, { ...ix, dataPath: [...ix.dataPath, i], schemaPath: [...ix.schemaPath, i] }, x) + ) + } + } + case JsonSchema.isRecord(x): { + const { additionalProperties: a, patternProperties: p, ...xs } = x + return { + ...xs, + ...a && { additionalProperties: f(a, { ...ix, schemaPath: [...ix.schemaPath, symbol.record] }, x) }, + ...p && { patternProperties: fn.map(p, (v, k) => f(v, { ...ix, schemaPath: [...ix.schemaPath, symbol.record, k] }, x)) }, + } + } + case JsonSchema.isUnknown(x): return x + } + } + }, +} + +export const fold = fn.catamorphism(Functor, defaultIndex) + +export const CompilerFunctor: T.Functor.Ix = { + map: Functor.map, + mapWithIndex(g) { + return (x, _ix) => { + const ix = { ..._ix, isProperty: false } satisfies CompilerIndex + switch (true) { + default: return x satisfies never + case JsonSchema.isNullary(x): return x + case JsonSchema.isArray(x): return { + ...x, + items: g(x.items, { + dataPath: ix.dataPath, + isOptional: ix.isOptional, + isProperty: ix.isProperty, + schemaPath: [...ix.schemaPath, symbol.array], + varName: 'value', + }, x) + } + case JsonSchema.isUnion(x): return { + anyOf: fn.map( + x.anyOf, + (v, i) => g(v, { + dataPath: ix.dataPath, + isOptional: ix.isOptional, + isProperty: ix.isProperty, + schemaPath: [...ix.schemaPath, symbol.union, i], + varName: ix.varName, + }, x) + ) + } + case JsonSchema.isIntersection(x): return { + allOf: fn.map( + x.allOf, + (v, i) => g(v, { + dataPath: ix.dataPath, + isOptional: ix.isOptional, + isProperty: ix.isProperty, + schemaPath: [...ix.schemaPath, symbol.intersect, i], + varName: ix.varName, + }, x) + ) + } + case JsonSchema.isObject(x): { + return { + ...x, + properties: fn.map( + x.properties, + (v, k) => g(v, { + dataPath: [...ix.dataPath, k], + isOptional: !x.required.includes(k), + isProperty: true, + schemaPath: [...ix.schemaPath, k], + varName: ix.varName + accessor(k, ix.isOptional), + }, x) + ) + } + } + case JsonSchema.isTuple(x): { + const { items, prefixItems, ...xs } = x + return { + ...xs, + ...items && + ({ + items: g(items, { + dataPath: ix.dataPath, + isOptional: ix.isOptional, + isProperty: false, + schemaPath: [...ix.schemaPath, symbol.tuple], + varName: 'value', + }, x) + }), + prefixItems: fn.map( + prefixItems, + (v, i) => g(v, { + dataPath: [...ix.dataPath, i], + isOptional: ix.isOptional, + isProperty: ix.isProperty, + schemaPath: [...ix.schemaPath, i], + varName: ix.varName + accessor(i, ix.isOptional), + }, x) + ) + } + } + case JsonSchema.isRecord(x): { + const { additionalProperties: a, patternProperties: p, ...xs } = x + return { + ...xs, + ...a && + ({ + additionalProperties: g(a, { + dataPath: ix.dataPath, + isOptional: ix.isOptional, + isProperty: false, + schemaPath: [...ix.schemaPath, symbol.record], + varName: 'value', + }, x) + }), + ...p && + ({ + patternProperties: fn.map(p, (v, k) => g(v, { + dataPath: ix.dataPath, + isOptional: ix.isOptional, + isProperty: false, + schemaPath: [...ix.schemaPath, symbol.record, k], + varName: 'value', + }, x)) + }), + } + } + case JsonSchema.isUnknown(x): return x + } + } + } +} + +export const compile = fn.catamorphism(CompilerFunctor, defaultCompilerIndex) diff --git a/packages/json-schema-types/src/index.ts b/packages/json-schema-types/src/index.ts new file mode 100644 index 00000000..89b339e9 --- /dev/null +++ b/packages/json-schema-types/src/index.ts @@ -0,0 +1,3 @@ +export * from './exports.js' +export type JsonSchema = import('./types.js').JsonSchema +export * as JsonSchema from './exports.js' diff --git a/packages/json-schema-types/src/to-type.ts b/packages/json-schema-types/src/to-type.ts new file mode 100644 index 00000000..6e48cf4a --- /dev/null +++ b/packages/json-schema-types/src/to-type.ts @@ -0,0 +1,109 @@ +import type { Force } from '@traversable/registry' +import { escape, Object_entries, Object_keys, Object_values, parseKey, stringifyKey } from '@traversable/registry' +import { Json } from '@traversable/json' + +import { fold } from './functor.js' +import * as JsonSchema from './types.js' +type JsonSchema = import('./types.js').JsonSchema + +const jsonSchemaToType = fold((x) => { + switch (true) { + default: return x satisfies never + case JsonSchema.isNever(x): return 'never' + case JsonSchema.isNull(x): return 'null' + case JsonSchema.isBoolean(x): return 'boolean' + case JsonSchema.isInteger(x): return 'number' + case JsonSchema.isNumber(x): return 'number' + case JsonSchema.isString(x): return 'string' + case JsonSchema.isConst(x): return Json.toString(x.const) + case JsonSchema.isUnion(x): return x.anyOf.join(' | ') + case JsonSchema.isIntersection(x): return x.allOf.join(' & ') + case JsonSchema.isArray(x): return `Array<${x.items}>` + case JsonSchema.isEnum(x): return x.enum.map((v) => typeof v === 'string' ? `"${escape(v)}"` : `${v}`).join(' | ') + case JsonSchema.isTuple(x): return `[${x.prefixItems.join(', ')}${typeof x.items === 'string' ? `, ...${x.items}[]` : ''}]` + case JsonSchema.isObject(x): { + const xs = Object_entries(x.properties).map(([k, v]) => `${parseKey(k)}${x.required.includes(k) ? '' : '?'}: ${v}`) + return xs.length === 0 ? '{}' : `{ ${xs.join(', ')} }` + } + case JsonSchema.isRecord(x): { + if (!x.patternProperties) return `Record` + else { + const patternKeys = Object_keys(x.patternProperties).map((k) => `${stringifyKey(k)}`).join(' | ') + const patternValues = Object_values(x.patternProperties).join(' | ') + return x.additionalProperties + ? `Record & Record<${patternKeys}, ${patternValues}>` + : `Record<${patternKeys}, ${patternValues}>` + } + } + case JsonSchema.isUnknown(x): return 'unknown' + } +}) + +/** + * ## {@link toType `JsonSchema.toType`} + * + * Convert a [JSON Schema](https://json-schema.org/) document into its corresponding TypeScript type. + * + * @example + * import { JsonSchema } from '@traversable/json-schema' + * + * const MyJsonSchema = { type: 'boolean' } + * + * console.log(JsonSchema.toType(MyJsonSchema)) + * // => "boolean" + * + * // If you'd like to give the generated type a name, use the `typeName` option: + * + * console.log(JsonSchema.toType(MyJsonSchema, { typeName: 'MyType' })) + * // => "type MyType = boolean" + */ +export function toType(schema: JsonSchema, options?: toType.Options): string { + const TYPE_NAME = typeof options?.typeName === 'string' ? `type ${options.typeName} = ` : '' + return `${TYPE_NAME}${jsonSchemaToType(schema)}` +} + +export declare namespace toType { + type Options = { + /** + * ### {@link Options `JsonSchema.Options.typeName`} + * + * By default, {@link toType `JsonSchema.toType`} will generate an "inline" TypeScript type. + * Use this option to have {@link toType `JsonSchema.toType`} generate a type alias with the + * name you provide. + */ + typeName?: string + } +} + +type Intersect = S extends [infer H, ...infer T] ? Intersect> : Out + +export type toType + = [keyof S] extends [never] ? unknown + : S extends { anyOf: infer T extends readonly any[] } + ? T[number] extends infer R ? R extends R ? toType : never : never + : S extends { allOf: infer T } ? Intersect + : S extends { type: 'null' } ? null + : S extends { type: 'boolean' } ? boolean + : S extends { type: 'integer' } ? number + : S extends { type: 'number' } ? number + : S extends { type: 'string' } ? string + : S extends { const: any } ? S['const'] + : S extends { enum: readonly any[] } ? S['enum'][number] + : S extends { type: 'array', items: false, prefixItems: infer T } ? { [I in keyof T]: toType } + : S extends { type: 'array', items: any, prefixItems: readonly any[] } + ? [...S['prefixItems'], ...S['items'][]] extends infer T + ? { [I in keyof T]: toType } + : never + : S extends { type: 'array', prefixItems: infer T } ? { [I in keyof T]: toType } + : S extends { type: 'array', items: infer T } ? toType[] + : S extends { type: 'object', additionalProperties: infer R, patternProperties: infer T } ? + & Record> + & Record> + : S extends { type: 'object', additionalProperties: infer R } ? Record> + : S extends { type: 'object', patternProperties: infer T } ? Record> + : S extends { type: 'object', properties: infer T, required: infer KS extends string[] } ? + Force< + & { [K in keyof T as K extends KS[number] ? K : never]-?: toType } + & { [K in keyof T as K extends KS[number] ? never : K]+?: toType } + > + : never diff --git a/packages/json-schema-types/src/typename.ts b/packages/json-schema-types/src/typename.ts new file mode 100644 index 00000000..20c080ed --- /dev/null +++ b/packages/json-schema-types/src/typename.ts @@ -0,0 +1,22 @@ +import { Object_keys } from '@traversable/registry' + +export type TypeName = typeof TypeName[keyof typeof TypeName] +export const TypeName = { + never: 'never', + unknown: 'unknown', + null: 'null', + boolean: 'boolean', + integer: 'integer', + number: 'number', + string: 'string', + enum: 'enum', + const: 'const', + array: 'array', + tuple: 'tuple', + object: 'object', + record: 'record', + union: 'union', + intersection: 'intersection', +} as const + +export const TypeNames = Object_keys(TypeName) diff --git a/packages/json-schema-types/src/types.ts b/packages/json-schema-types/src/types.ts new file mode 100644 index 00000000..e4190a66 --- /dev/null +++ b/packages/json-schema-types/src/types.ts @@ -0,0 +1,360 @@ +import type { Json } from '@traversable/json' +import type { HKT } from '@traversable/registry' +import { Array_isArray, has } from '@traversable/registry' + +/** + * # {@link JsonSchema `JsonSchema`} + * + * If {@link T `T`} is not defined, {@link JsonSchema `JsonSchema`} returns a recursive + * Json Schema type. + * + * If {@link T `T`} **is** defined, {@link JsonSchema `JsonSchema`} returns a _non-recursive_ + * Json Schema type, where the "holes" (the parts that would be recursive) are filled with {@link T `T`}. + * This is what allows {@link JsonSchema `JsonSchema`} to support recursion schemes. + * + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00) + */ +export type JsonSchema = [T] extends [never] + ? Fixpoint + : F + +/** ## {@link Never `JsonSchema.Never`} */ +export type Never = + | { enum: [] } + | { not: Unknown } + +/** ## {@link Unknown `JsonSchema.Unknown`} */ +export interface Unknown {} + +/** ## {@link Null `JsonSchema.Null`} */ +export interface Null { type: 'null' } + +/** ## {@link Boolean `JsonSchema.Boolean`} */ +export interface Boolean { type: 'boolean' } + +/** ## {@link Integer `JsonSchema.Integer`} */ +export interface Integer extends Bounds.Numeric { type: 'integer' } + +/** ## {@link Number `JsonSchema.Number`} */ +export interface Number extends Bounds.Number { type: 'number' } + +/** ## {@link String `JsonSchema.String`} */ +export interface String extends Bounds.String { type: 'string' } + +/** ## {@link Enum `JsonSchema.Enum`} */ +export interface Enum { + /** + * ### {@link Enum `JsonSchema.Enum.enum`} + * + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.6.1.2) + */ + enum: Exclude[] +} + +/** ## {@link Const `JsonSchema.Const`} */ +export interface Const { + /** + * ### {@link Const `JsonSchema.Const.const`} + * + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.6.1.3) + */ + const: Json +} + +/** ## {@link Array `JsonSchema.Array`} */ +export interface Array extends Bounds.Items { + type: 'array' + /** + * ### {@link Array `JsonSchema.Array.items`} + * + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-00#rfc.section.10.3.1.2) + */ + items: T +} + +/** ## {@link Tuple `JsonSchema.Tuple`} */ +export interface Tuple extends Bounds.Items { + type: 'array' + /** + * ### {@link Tuple `JsonSchema.Tuple.items`} + * + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-00#rfc.section.10.3.1.2) + */ + items?: false | T + /** + * ### {@link Tuple `JsonSchema.Tuple.prefixItems`} + * + * See also: + * the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-00#rfc.section.10.3.1.1) + */ + prefixItems: T[] +} + +/** ## {@link Object `JsonSchema.Object`} */ +export interface Object { + type: 'object' + required: string[] + /** + * ### {@link Object `JsonSchema.Object.properties`} + * + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-00#rfc.section.10.3.2.1) + */ + properties: { [x: string]: T } +} + +/** ## {@link Record `JsonSchema.Record`} */ +export interface Record { + type: 'object' + /** + * ### {@link Record `JsonSchema.Record.additionalProperties`} + * + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-00#rfc.section.10.3.2.3) + */ + additionalProperties?: T + /** + * ### {@link Record `JsonSchema.Record.patternProperties`} + * + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-00#rfc.section.10.3.2.2) + */ + patternProperties?: globalThis.Record +} + +/** ## {@link Union `JsonSchema.Union`} */ +export interface Union { + /** + * ### {@link Union `JsonSchema.Union.anyOf`} + * + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-00#rfc.section.10.2.1.3) + */ + anyOf: readonly T[] +} + +/** ## {@link Intersection `JsonSchema.Intersection`} */ +export interface Intersection { + /** + * ### {@link Intersection `JsonSchema.Intersection.allOf`} + * + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-00#rfc.section.10.2.1.1) + */ + allOf: readonly T[] +} + +export type Scalar = + | Null + | Boolean + | Integer + | Number + | String + +export type Nullary = + | Never + // | Unknown + | Scalar + | Enum + | Const + +export type Unary = + | Array + | Tuple + | Object + | Record + | Union + | Intersection + +export type Fixpoint = + | Nullary + | Unknown + | Array + | Tuple + | Object + | Record + | Union + | Intersection + +export type F = + | Nullary + | Unknown + | Array + | Tuple + | Object + | Record + | Union + | Intersection + +// | Unary + +export interface Free extends HKT { [-1]: F } + +export declare namespace Bounds { + interface Numeric { + /** + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.6.2.4) + */ + minimum?: number + /** + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.6.2.2) + */ + maximum?: number + /** + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.6.2.1) + */ + multipleOf?: number + } + + interface Number extends Bounds.Numeric { + /** + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.6.2.5) + */ + exclusiveMinimum?: number + /** + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.6.2.3) + */ + exclusiveMaximum?: number + } + + interface String { + /** + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.6.3.2) + */ + minLength?: number + /** + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.6.3.1) + */ + maxLength?: number + /** + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.6.3.3) + */ + pattern?: string + } + + interface Items { + /** + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.6.4.2) + */ + minItems?: number + /** + * See also: + * - the [spec](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.6.4.1) + */ + maxItems?: number + } +} + +export function isNever(x: unknown): x is Never { + return has('enum', (_): _ is [] => Array_isArray(_) && _.length === 0)(x) + || has('not', isUnknown)(x) +} + +export function isConst(x: unknown): x is Const { + return has('const')(x) +} + +export function isEnum(x: unknown): x is Enum { + return has('enum', (_): _ is Scalar[] => Array_isArray(_) && _.length > 0)(x) +} + +export function isUnknown(x: unknown): x is Unknown { + return !!x && x !== null && Object.keys(x).length === 0 +} + +export function isNull(x: unknown): x is Null { + return has('type', (_) => _ === 'null')(x) +} + +export function isInteger(x: unknown): x is Integer { + return has('type', (_) => _ === 'integer')(x) +} + +export function isBoolean(x: unknown): x is Boolean { + return has('type', (_) => _ === 'boolean')(x) +} + +export function isNumber(x: unknown): x is Number { + return has('type', (_) => _ === 'number')(x) +} + +export function isString(x: unknown): x is String { + return has('type', (_) => _ === 'string')(x) +} + +export function isArray(x: F): x is Array +export function isArray(x: unknown): x is Array +export function isArray(x: unknown): x is Array { + return has('type', (_) => _ === 'array')(x) + && !has('prefixItems')(x) +} + +export function isTuple(x: F): x is Tuple +export function isTuple(x: unknown): x is Tuple +export function isTuple(x: unknown): x is Tuple { + return has('type', (_) => _ === 'array')(x) + && has('prefixItems')(x) +} + +export function isObject(x: F): x is Object +export function isObject(x: unknown): x is Object +export function isObject(x: unknown): x is Object { + return has('type', (_) => _ === 'object')(x) + && !has('additionalProperties')(x) + && !has('patternProperties')(x) +} + +export function isRecord(x: F): x is Record +export function isRecord(x: unknown): x is Record +export function isRecord(x: unknown): x is Record { + return has('type', (_) => _ === 'object')(x) + && (has('additionalProperties')(x) || has('patternProperties')(x)) +} + +export function isUnion(x: F): x is Union +export function isUnion(x: unknown): x is Union +export function isUnion(x: unknown): x is Union { + return has('anyOf', Array_isArray)(x) +} + +export function isIntersection(x: F): x is Intersection +export function isIntersection(x: unknown): x is Intersection +export function isIntersection(x: unknown): x is Intersection { + return has('allOf', Array_isArray)(x) +} + +export function isNullary(x: unknown): x is Nullary { + return isNever(x) + // || isUnknown(x) + || isNull(x) + || isInteger(x) + || isNumber(x) + || isString(x) + || isEnum(x) + // || isConst(x) +} + +export function isUnary(x: F): x is Unary +export function isUnary(x: unknown): x is Unary +export function isUnary(x: unknown): x is Unary { + return isArray(x) + || isTuple(x) + || isObject(x) + || isRecord(x) + || isUnion(x) + || isIntersection(x) +} diff --git a/packages/json-schema-types/src/version.ts b/packages/json-schema-types/src/version.ts new file mode 100644 index 00000000..660ff1ca --- /dev/null +++ b/packages/json-schema-types/src/version.ts @@ -0,0 +1,3 @@ +import pkg from './__generated__/__manifest__.js' +export const VERSION = `${pkg.name}@${pkg.version}` as const +export type VERSION = typeof VERSION diff --git a/packages/json-schema-types/test/version.test.ts b/packages/json-schema-types/test/version.test.ts new file mode 100644 index 00000000..60ee172f --- /dev/null +++ b/packages/json-schema-types/test/version.test.ts @@ -0,0 +1,10 @@ +import * as vi from 'vitest' +import pkg from '../package.json' with { type: 'json' } +import { VERSION } from '@traversable/json-schema-types' + +vi.describe('ใ€–โ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traversable/json-schema-typesโณ', () => { + vi.it('ใ€–โ›ณ๏ธใ€—โ€บ โฒVERSIONโณ', () => { + const expected = `${pkg.name}@${pkg.version}` + vi.assert.equal(VERSION, expected) + }) +}) diff --git a/packages/json-schema-types/tsconfig.build.json b/packages/json-schema-types/tsconfig.build.json new file mode 100644 index 00000000..169a0055 --- /dev/null +++ b/packages/json-schema-types/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.src.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/build.tsbuildinfo", + "types": ["node"], + "declarationDir": "build/dts", + "outDir": "build/esm", + "stripInternal": true + }, + "references": [{ "path": "../json" }, { "path": "../registry" }] +} diff --git a/packages/json-schema-types/tsconfig.json b/packages/json-schema-types/tsconfig.json new file mode 100644 index 00000000..2c291d21 --- /dev/null +++ b/packages/json-schema-types/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [], + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "tsconfig.test.json" } + ] +} diff --git a/packages/json-schema-types/tsconfig.src.json b/packages/json-schema-types/tsconfig.src.json new file mode 100644 index 00000000..e39fe357 --- /dev/null +++ b/packages/json-schema-types/tsconfig.src.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/src.tsbuildinfo", + "rootDir": "src", + "types": ["node"], + "outDir": "build/src" + }, + "references": [{ "path": "../json" }, { "path": "../registry" }], + "include": ["src"] +} diff --git a/packages/json-schema-types/tsconfig.test.json b/packages/json-schema-types/tsconfig.test.json new file mode 100644 index 00000000..c27cc15d --- /dev/null +++ b/packages/json-schema-types/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo", + "rootDir": "test", + "types": ["node"], + "noEmit": true + }, + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "../json" }, + { "path": "../registry" } + ], + "include": ["test"] +} diff --git a/packages/json-schema-types/vite.config.ts b/packages/json-schema-types/vite.config.ts new file mode 100644 index 00000000..64dba4ad --- /dev/null +++ b/packages/json-schema-types/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import sharedConfig from '../../vite.config.js' + +const localConfig = defineConfig({}) + +export default mergeConfig(sharedConfig, localConfig) \ No newline at end of file diff --git a/packages/json-schema/README.md b/packages/json-schema/README.md new file mode 100644 index 00000000..b355e96b --- /dev/null +++ b/packages/json-schema/README.md @@ -0,0 +1,31 @@ +
+

แฏ“๐˜๐—ฟ๐—ฎ๐˜ƒ๐—ฒ๐—ฟ๐˜€๐—ฎ๐—ฏ๐—น๐—ฒ/๐—ท๐˜€๐—ผ๐—ป-๐˜€๐—ฐ๐—ต๐—ฒ๐—บ๐—ฎ

+
+ +

+ Apply rewriting rules to a Json Schema document. +

+ +
+ NPM Version +   + TypeScript +   + Static Badge +   + npm +   +
+ +
+ + Static Badge +   + Static Badge +   +
+
+
+ +Currently only applies to the [Draft 2020-12](https://json-schema.org/draft/2020-12) version of [Json Schema](https://json-schema.org/). diff --git a/packages/json-schema/package.json b/packages/json-schema/package.json new file mode 100644 index 00000000..f8c5c2fb --- /dev/null +++ b/packages/json-schema/package.json @@ -0,0 +1,54 @@ +{ + "name": "@traversable/json-schema", + "type": "module", + "version": "0.0.0", + "private": false, + "description": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/traversable/schema.git", + "directory": "packages/json-schema" + }, + "bugs": { + "url": "https://github.com/traversable/schema/issues", + "email": "ahrjarrett@gmail.com" + }, + "@traversable": { + "generateExports": { + "include": [ + "**/*.ts" + ] + }, + "generateIndex": { + "include": [ + "**/*.ts" + ] + } + }, + "publishConfig": { + "access": "public", + "directory": "dist", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "bench": "echo NOTHING TO BENCH", + "build": "pnpm build:esm && pnpm build:cjs && pnpm build:annotate", + "build:annotate": "babel build --plugins annotate-pure-calls --out-dir build --source-maps", + "build:esm": "tsc -b tsconfig.build.json", + "build:cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps", + "check": "tsc -b tsconfig.json", + "clean": "pnpm run \"/^clean:.*/\"", + "clean:build": "rm -rf .tsbuildinfo dist build", + "clean:deps": "rm -rf node_modules", + "test": "vitest" + }, + "dependencies": { + "@prettier/sync": "^0.5.2", + "@traversable/json-schema-types": "workspace:^", + "@traversable/registry": "workspace:^" + }, + "devDependencies": { + "@traversable/json-schema-test": "workspace:^" + } +} diff --git a/packages/json-schema/src/__generated__/__manifest__.ts b/packages/json-schema/src/__generated__/__manifest__.ts new file mode 100644 index 00000000..d4f1a841 --- /dev/null +++ b/packages/json-schema/src/__generated__/__manifest__.ts @@ -0,0 +1,50 @@ +export default { + "name": "@traversable/json-schema", + "type": "module", + "version": "0.0.0", + "private": false, + "description": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/traversable/schema.git", + "directory": "packages/json-schema" + }, + "bugs": { + "url": "https://github.com/traversable/schema/issues", + "email": "ahrjarrett@gmail.com" + }, + "@traversable": { + "generateExports": { + "include": ["**/*.ts"] + }, + "generateIndex": { + "include": ["**/*.ts"] + } + }, + "publishConfig": { + "access": "public", + "directory": "dist", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "bench": "echo NOTHING TO BENCH", + "build": "pnpm build:esm && pnpm build:cjs && pnpm build:annotate", + "build:annotate": "babel build --plugins annotate-pure-calls --out-dir build --source-maps", + "build:esm": "tsc -b tsconfig.build.json", + "build:cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps", + "check": "tsc -b tsconfig.json", + "clean": "pnpm run \"/^clean:.*/\"", + "clean:build": "rm -rf .tsbuildinfo dist build", + "clean:deps": "rm -rf node_modules", + "test": "vitest" + }, + "dependencies": { + "@prettier/sync": "^0.5.2", + "@traversable/json-schema-types": "workspace:^", + "@traversable/registry": "workspace:^" + }, + "devDependencies": { + "@traversable/json-schema-test": "workspace:^" + } +} as const \ No newline at end of file diff --git a/packages/json-schema/src/equals.ts b/packages/json-schema/src/equals.ts new file mode 100644 index 00000000..a3418826 --- /dev/null +++ b/packages/json-schema/src/equals.ts @@ -0,0 +1,588 @@ +import { check, JsonSchema, toType } from '@traversable/json-schema-types' +import { Json } from '@traversable/json' +import { + Equal, + ident, + joinPath, + Object_keys, + stringifyLiteral, + Object_entries, +} from '@traversable/registry' + +import type { Discriminated } from './utils.js' +import { areAllObjects, getTags, inlinePrimitiveCheck, isPrimitive, schemaOrdering } from './utils.js' + +export interface Scope extends JsonSchema.Index { + bindings: Map + useGlobalThis: equals.Options['useGlobalThis'] +} + +export type Path = (string | number)[] + +export type Builder = (left: Path, right: Path, index: Scope) => string + +const defaultIndex = () => ({ + ...JsonSchema.defaultIndex, + useGlobalThis: false, + bindings: new Map(), +}) satisfies Scope + +function isCompositeType(x: unknown) { + return JsonSchema.isObject(x) + || JsonSchema.isRecord(x) + || JsonSchema.isTuple(x) + || JsonSchema.isArray(x) +} + +function requiresObjectIs(x: unknown): boolean { + return JsonSchema.isNever(x) + || JsonSchema.isInteger(x) + || JsonSchema.isNumber(x) + || JsonSchema.isEnum(x) + || JsonSchema.isUnion(x) && x.anyOf.some(requiresObjectIs) + || JsonSchema.isUnknown(x) +} + +/** + * Specialization of + * [`TC39: SameValueZero`](https://tc39.es/ecma262/multipage/abstract-operations.html#sec-samevaluezero) + * that operates on numbers + */ +function SameNumberOrFail(l: (string | number)[], r: (string | number)[], ix: JsonSchema.Index) { + const X = joinPath(l, ix.isOptional) + const Y = joinPath(r, ix.isOptional) + return `if (${X} !== ${Y} && (${X} === ${X} || ${Y} === ${Y})) return false;` +} + +/** + * As specified by + * [`TC39: SameValue`](https://tc39.es/ecma262/multipage/abstract-operations.html#sec-samevalue) + */ +function SameValueOrFail(l: (string | number)[], r: (string | number)[], ix: JsonSchema.Index) { + const X = joinPath(l, ix.isOptional) + const Y = joinPath(r, ix.isOptional) + return `if (!Object.is(${X}, ${Y})) return false;` +} + +/** + * As specified by + * [`TC39: IsStrictlyEqual`](https://tc39.es/ecma262/multipage/abstract-operations.html#sec-isstrictlyequal) + */ +function StrictlyEqualOrFail(l: (string | number)[], r: (string | number)[], ix: JsonSchema.Index) { + const X = joinPath(l, ix.isOptional) + const Y = joinPath(r, ix.isOptional) + return `if (${X} !== ${Y}) return false;` +} + +function enumEquals(x: JsonSchema.Enum): Builder { + return function continueEnumEquals(LEFT, RIGHT, IX) { + return ( + x.enum.every((v) => typeof v === 'number') ? SameNumberOrFail(LEFT, RIGHT, IX) + : x.enum.some((v) => typeof v === 'number') ? SameValueOrFail(LEFT, RIGHT, IX) + : StrictlyEqualOrFail(LEFT, RIGHT, IX) + ) + } +} + +function arrayEquals(x: JsonSchema.Array): Builder { + return function continueArrayEquals(LEFT_PATH, RIGHT_PATH, IX) { + const LEFT = joinPath(LEFT_PATH, IX.isOptional) + const RIGHT = joinPath(RIGHT_PATH, IX.isOptional) + const LEFT_ITEM_IDENT = `${ident(LEFT, IX.bindings)}_item` + const RIGHT_ITEM_IDENT = `${ident(RIGHT, IX.bindings)}_item` + const LENGTH = ident('length', IX.bindings) + const DOT = IX.isOptional ? '?.' : '.' + return [ + `const ${LENGTH} = ${LEFT}${DOT}length;`, + `if (${LENGTH} !== ${RIGHT}${DOT}length) return false`, + `for (let ix = ${LENGTH}; ix-- !== 0;) {`, + `const ${LEFT_ITEM_IDENT} = ${LEFT}[ix];`, + `const ${RIGHT_ITEM_IDENT} = ${RIGHT}[ix];`, + x.items([LEFT_ITEM_IDENT], [RIGHT_ITEM_IDENT], IX), + `}`, + ].join('\n') + } +} + +function recordEquals(x: JsonSchema.Record): Builder { + return function continueRecordEquals(LEFT_PATH, RIGHT_PATH, IX) { + const LENGTH = ident('length', IX.bindings) + const KEY_IDENT = ident('key', IX.bindings) + const LEFT = joinPath(LEFT_PATH, IX.isOptional) + const RIGHT = joinPath(RIGHT_PATH, IX.isOptional) + const LEFT_IDENT = ident(LEFT, IX.bindings) + const RIGHT_IDENT = ident(RIGHT, IX.bindings) + const LEFT_KEYS_IDENT = `${LEFT_IDENT}_keys` + const RIGHT_KEYS_IDENT = `${RIGHT_IDENT}_keys` + const LEFT_VALUE_IDENT = ident(`${LEFT_IDENT}[k]`, IX.bindings) + const RIGHT_VALUE_IDENT = ident(`${RIGHT_IDENT}[k]`, IX.bindings) + const PATTERN_PROPERTIES = !x.patternProperties ? null + : Object_entries(x.patternProperties).map(([k, continuation]) => [ + `if(/${k.length === 0 ? '^$' : k}/.test(${KEY_IDENT})) {`, + continuation([LEFT_VALUE_IDENT], [RIGHT_VALUE_IDENT], { ...IX /* , isOptional: true */ }), + '}', + ].join('\n')).join('\n') + + const FOR_LOOP = [ + `for (let ix = ${LENGTH}; ix-- !== 0; ) {`, + `const ${KEY_IDENT} = ${LEFT_KEYS_IDENT}[ix];`, + `const ${LEFT_VALUE_IDENT} = ${LEFT}[${KEY_IDENT}];`, + `const ${RIGHT_VALUE_IDENT} = ${RIGHT}[${KEY_IDENT}];`, + !x.patternProperties ? null : PATTERN_PROPERTIES, + !x.additionalProperties ? null : x.additionalProperties([LEFT_VALUE_IDENT], [RIGHT_VALUE_IDENT], IX), + `}`, + ].filter((_) => _ !== null).join('\n') + + return [ + `const ${LEFT_KEYS_IDENT} = Object.keys(${LEFT});`, + `const ${RIGHT_KEYS_IDENT} = Object.keys(${RIGHT});`, + `const ${LENGTH} = ${LEFT_KEYS_IDENT}.length;`, + `if (${LENGTH} !== ${RIGHT_KEYS_IDENT}.length) return false;`, + FOR_LOOP, + ].filter((_) => _ !== null).join('\n') + } +} + +function unionEquals( + x: JsonSchema.Union, + input: JsonSchema.Union +): Builder { + if (x.anyOf.length === 0) { + return () => 'false' + } else if (x.anyOf.length === 1) { + return x.anyOf[0] + } else { + if (!areAllObjects(input.anyOf)) { + return nonDisjunctiveEquals(x, input) + } else { + const withTags = getTags(input.anyOf) + return withTags === null + ? nonDisjunctiveEquals(x, input) + : disjunctiveEquals(x, withTags) + } + } +} + +function nonDisjunctiveEquals( + x: JsonSchema.Union, + input: JsonSchema.Union +): Builder { + return function continueUnionEquals(LEFT_PATH, RIGHT_PATH, IX) { + const LEFT = joinPath(LEFT_PATH, IX.isOptional) + const RIGHT = joinPath(RIGHT_PATH, IX.isOptional) + const SATISFIED = ident('satisfied', IX.bindings) + const CHECKS = input.anyOf + .map((option, i) => [option, i] satisfies [any, any]) + .toSorted(schemaOrdering).map(([option, I]) => { + const continuation = x.anyOf[I] + if (isPrimitive(option)) { + return [ + `if (${inlinePrimitiveCheck( + option, + { path: LEFT_PATH, ident: LEFT }, + { path: RIGHT_PATH, ident: RIGHT }, + IX.useGlobalThis + )}) {`, + continuation([LEFT], [RIGHT], IX), + `${SATISFIED} = true;`, + `}`, + ].join('\n') + + } else { + const FUNCTION_NAME = ident('check', IX.bindings) + return [ + check.writeable(option, { functionName: FUNCTION_NAME }), + `if (${FUNCTION_NAME}(${LEFT}) && ${FUNCTION_NAME}(${RIGHT})) {`, + continuation([LEFT], [RIGHT], IX), + `${SATISFIED} = true;`, + `}` + ].join('\n') + } + }) + return [ + `let ${SATISFIED} = false;`, + ...CHECKS, + `if (!${SATISFIED}) return false;`, + ].join('\n') + } +} + +function disjunctiveEquals( + x: JsonSchema.Union, + [discriminant, TAGGED]: Discriminated +): Builder { + return function continueDisjunctiveEquals(LEFT_PATH, RIGHT_PATH, IX) { + const LEFT = joinPath(LEFT_PATH, false) + const RIGHT = joinPath(RIGHT_PATH, false) + const SATISFIED = ident('satisfied', IX.bindings) + return [ + `let ${SATISFIED} = false;`, + ...TAGGED.map(({ tag }, I) => { + const TAG = stringifyLiteral(tag) + const continuation = x.anyOf[I] + const LEFT_ACCESSOR = joinPath([LEFT, discriminant], IX.isOptional) + return [ + `if (${LEFT_ACCESSOR} === ${TAG}) {`, + continuation([LEFT], [RIGHT], IX), + `${SATISFIED} = true;`, + `}`, + ].join('\n') + }), + `if (!${SATISFIED}) return false;`, + ].join('\n') + } +} + +function intersectionEquals(x: JsonSchema.Intersection): Builder { + return function continueIntersectionEquals(LEFT_PATH, RIGHT_PATH, IX) { + const LEFT = joinPath(LEFT_PATH, IX.isOptional) + const RIGHT = joinPath(RIGHT_PATH, IX.isOptional) + return x.allOf.map((continuation) => continuation([LEFT], [RIGHT], IX)).join('\n') + } +} + +function tupleEquals( + x: JsonSchema.Tuple, + input: JsonSchema.Tuple +): Builder { + return function continueTupleEquals(LEFT_PATH, RIGHT_PATH, IX) { + const LEFT = joinPath(LEFT_PATH, false) // `false` because `*_PATH` already takes optionality into account + const RIGHT = joinPath(RIGHT_PATH, false) // `false` because `*_PATH` already takes optionality into account + // if we got `{ prefixItems: [] }`, just check that the lengths are the same + if (x.prefixItems.length === 0) return `if (${LEFT}.length !== ${RIGHT}.length) return false` + const LENGTH = ident('length', IX.bindings) + const LEFT_ITEM_IDENT = ident(`${LEFT}_item`, IX.bindings) + const RIGHT_ITEM_IDENT = ident(`${RIGHT}_item`, IX.bindings) + const LENGTH_CHECK = !x.items ? null : [ + `const ${LENGTH} = ${LEFT}.length;`, + `if (${LENGTH} !== ${RIGHT}.length) return false;`, + ].join('\n') + const FOR_LOOP = !x.items ? null : [ + `if (${LENGTH} > ${x.prefixItems.length}) {`, + `for (let ix = ${LENGTH}; ix-- !== ${x.prefixItems.length};) {`, + `const ${LEFT_ITEM_IDENT} = ${LEFT}[ix];`, + `const ${RIGHT_ITEM_IDENT} = ${RIGHT}[ix];`, + x.items?.([LEFT_ITEM_IDENT], [RIGHT_ITEM_IDENT], IX), + `}`, + `}`, + ].join('\n') + return [ + LENGTH_CHECK, + ...x.prefixItems.map((continuation, i) => { + if (!isCompositeType(input.prefixItems[i])) { + return continuation([LEFT, i], [RIGHT, i], IX) + } else { + const LEFT_ACCESSOR = joinPath([LEFT, i], IX.isOptional) + const RIGHT_ACCESSOR = joinPath([RIGHT, i], IX.isOptional) + return [ + `if (${LEFT_ACCESSOR} !== ${RIGHT_ACCESSOR}) {`, + continuation([LEFT, i], [RIGHT, i], IX), + `}`, + ].join('\n') + } + }), + FOR_LOOP, + ].filter((_) => _ !== null).join('\n') + } +} + +function optionalEquals( + continuation: Builder, + input: JsonSchema +): Builder { + return function continueOptionalEquals(LEFT_PATH, RIGHT_PATH, IX) { + const LEFT = joinPath(LEFT_PATH, IX.isOptional) + const RIGHT = joinPath(RIGHT_PATH, IX.isOptional) + return JsonSchema.isNullary(input) + ? [ + `if ((${LEFT} === undefined || ${RIGHT} === undefined) && ${LEFT} !== ${RIGHT}) return false`, + continuation(LEFT_PATH, RIGHT_PATH, IX), + ].join('\n') + : [ + `if ((${LEFT} === undefined || ${RIGHT} === undefined) && ${LEFT} !== ${RIGHT}) return false`, + `if (${LEFT} !== ${RIGHT}) {`, + continuation(LEFT_PATH, RIGHT_PATH, IX), + `}`, + ].join('\n') + } +} + +function objectEquals(x: JsonSchema.Object, input: JsonSchema.Object): Builder { + return function continueObjectEquals(LEFT_PATH, RIGHT_PATH, IX) { + const LEFT = joinPath(LEFT_PATH, false) // `false` because `*_PATH` already takes optionality into account + const RIGHT = joinPath(RIGHT_PATH, false) // `false` because `*_PATH` already takes optionality into account + const keys = Object_keys(x.properties) + // if we got `{ type: 'object', properties: {} }`, just check that the number of keys are the same + if (keys.length === 0) return `if (Object.keys(${LEFT}).length !== Object.keys(${RIGHT}).length) return false` + return [ + ...Object.entries(x.properties).map(([key, continuation]) => { + const isOptional = !x.required.includes(key) + const CHILD = isOptional + ? optionalEquals(continuation, input.properties[key])( + [LEFT, key], + [RIGHT, key], + { ...IX, isOptional } + ) + : continuation( + [LEFT, key], + [RIGHT, key], + { ...IX, isOptional } + ) + if (!isCompositeType(input.properties[key])) { + return CHILD + } else { + const LEFT_ACCESSOR = joinPath([LEFT, key], isOptional) + const RIGHT_ACCESSOR = joinPath([RIGHT, key], isOptional) + return [ + `if (${LEFT_ACCESSOR} !== ${RIGHT_ACCESSOR}) {`, + CHILD, + `}`, + ].join('\n') + } + }), + ].filter((_) => _ !== null).join('\n') + } +} + +const foldJson = Json.fold((x, _, input) => { + switch (true) { + default: return (void (x satisfies never), SameValueOrFail) + case x == null: return function continueJsonNullEquals(l, r, ix) { return StrictlyEqualOrFail(l, r, ix) } + case typeof x === 'number': return function continueJsonNumberEquals(l, r, ix) { return SameNumberOrFail(l, r, ix) } + case typeof x === 'string': return function continueJsonStringEquals(l, r, ix) { return StrictlyEqualOrFail(l, r, ix) } + case typeof x === 'boolean': return function continueJsonBooleanEquals(l, r, ix) { return StrictlyEqualOrFail(l, r, ix) } + case Json.isArray(x): { + if (!Json.isArray(input)) throw Error('illegal state') + return function continueJsonArrayEquals(LEFT_PATH, RIGHT_PATH, IX): string { + const LEFT = joinPath(LEFT_PATH, IX.isOptional) // `false` because `*_PATH` already takes optionality into account + const RIGHT = joinPath(RIGHT_PATH, IX.isOptional) // `false` because `*_PATH` already takes optionality into account + const LENGTH = ident('length', IX.bindings) + const DOT = IX.isOptional ? '?.' : '.' + const LENGTH_CHECK = [ + `const ${LENGTH} = ${LEFT}${DOT}length;`, + `if (${LENGTH} !== ${RIGHT}${DOT}length) return false;`, + ].join('\n') + return [ + LENGTH_CHECK, + ...x.map((continuation, i) => { + if (Json.isScalar(input[i])) { + return continuation([LEFT, i], [RIGHT, i], IX) + } else { + const LEFT_CHILD_ACCESSOR = joinPath([LEFT, i], IX.isOptional) + const RIGHT_CHILD_ACCESSOR = joinPath([RIGHT, i], IX.isOptional) + return [ + `if (${LEFT_CHILD_ACCESSOR} !== ${RIGHT_CHILD_ACCESSOR}) {`, + continuation([LEFT, i], [RIGHT, i], IX), + `}`, + ].join('\n') + } + }), + ].filter((_) => _ !== null).join('\n') + } + } + case Json.isObject(x): { + if (!Json.isObject(input)) throw Error('illegal state') + return function continueJsonObjectEquals(LEFT_PATH, RIGHT_PATH, IX): string { + + const LEFT = joinPath(LEFT_PATH, IX.isOptional) // `false` because `*_PATH` already takes optionality into account + const RIGHT = joinPath(RIGHT_PATH, IX.isOptional) // `false` because `*_PATH` already takes optionality into account + const keys = Object_keys(x) + // if we got `{ type: 'object', properties: {} }`, just check that the number of keys are the same + if (keys.length === 0) return `if (Object.keys(${LEFT}).length !== Object.keys(${RIGHT}).length) return false` + return [ + ...Object.entries(x).map(([key, continuation]) => { + const CHILD = continuation([LEFT, key], [RIGHT, key], IX) + if (Json.isScalar(input[key])) { + return CHILD + } else { + const LEFT_ACCESSOR = joinPath([LEFT, key], IX.isOptional) + const RIGHT_ACCESSOR = joinPath([RIGHT, key], IX.isOptional) + return [ + `if (${LEFT_ACCESSOR} !== ${RIGHT_ACCESSOR}) {`, + CHILD, + `}`, + ].join('\n') + } + }), + ].filter((_) => _ !== null).join('\n') + + } + } + } +}) + +const compile = JsonSchema.fold((x, _, input) => { + switch (true) { + default: return (void (x satisfies never), SameValueOrFail) + case JsonSchema.isConst(x): return foldJson(x.const as Json.Unary) + case JsonSchema.isNever(x): return function continueNeverEquals(l, r, ix) { return SameValueOrFail(l, r, ix) } + case JsonSchema.isNull(x): return function continueNullEquals(l, r, ix) { return StrictlyEqualOrFail(l, r, ix) } + case JsonSchema.isBoolean(x): return function continueBooleanEquals(l, r, ix) { return StrictlyEqualOrFail(l, r, ix) } + case JsonSchema.isInteger(x): return function continueIntegerEquals(l, r, ix) { return SameNumberOrFail(l, r, ix) } + case JsonSchema.isNumber(x): return function continueNumberEquals(l, r, ix) { return SameNumberOrFail(l, r, ix) } + case JsonSchema.isString(x): return function continueStringEquals(l, r, ix) { return StrictlyEqualOrFail(l, r, ix) } + case JsonSchema.isEnum(x): return enumEquals(x) + case JsonSchema.isArray(x): return arrayEquals(x) + case JsonSchema.isRecord(x): return recordEquals(x) + case JsonSchema.isIntersection(x): return intersectionEquals(x) + case JsonSchema.isTuple(x): return tupleEquals(x, input as JsonSchema.Tuple) + case JsonSchema.isUnion(x): return unionEquals(x, input as JsonSchema.Union) + case JsonSchema.isObject(x): return objectEquals(x, input as JsonSchema.Object) + case JsonSchema.isUnknown(x): return function continueUnknownEquals(l, r, ix) { return SameValueOrFail(l, r, ix) } + } +}) + +export declare namespace equals { + type Options = toType.Options & { + /** + * Configure the name of the generated equals function + * @default "equals" + */ + functionName?: string + /** + * Whether to access global identifiers like `Date` from the `globalThis` namespace + * @default false + */ + useGlobalThis?: boolean + } +} + +equals.writeable = equals_writeable + +/** + * ## {@link equals `JsonSchema.equals`} + * + * Derive an _equals function_ from a Json Schema spec. + * + * An "equals function" (see also, {@link Equal `Equal`}) is similar to + * lodash's `deepEquals` function, except more performant, because + * when the shape of the values being compared is known ahead of time, + * we can optimize ahead of time, and only check what's necessary. + * + * Note that the "equals function" generated by {@link equals `JsonSchema.equals`} + * **assumes that both values have already been validated**. Passing + * unvalidated values to the function might result in undefined behavior. + * + * See also: + * - {@link equals_writeable `JsonSchema.equals.writeable`} + * + * @example + * import { JsonSchema } from '@traversable/json-schema' + * + * const Address = { + * type: 'object', + * required: ['street1', 'city'], + * properties: { + * street1: { type: 'string' }, + * strret2: { type: 'string' }, + * city: { type: 'string' }, + * } + * } + * + * const addressEquals = JsonSchema.equals(Address) + * + * addressEquals( + * { street1: '221 Baker St', street2: '#B', city: 'London' }, + * { street1: '221 Baker St', street2: '#B', city: 'London' } + * ) // => true + * + * addressEquals( + * { street1: '221 Baker St', street2: '#B', city: 'London' }, + * { street1: '4 Privet Dr', city: 'Little Whinging' } + * ) // => false + */ + +export function equals(schema: T): Equal +export function equals(schema: JsonSchema) { + const index = defaultIndex() + const ROOT_CHECK = requiresObjectIs(schema) ? `if (Object.is(l, r)) return true` : `if (l === r) return true` + const BODY = compile(schema)(['l'], ['r'], index) + return JsonSchema.isNullary(schema) + ? globalThis.Function('l', 'r', [ + BODY, + 'return true' + ].join('\n')) + : globalThis.Function('l', 'r', [ + ROOT_CHECK, + BODY, + 'return true' + ].join('\n')) +} + +/** + * ## {@link equals_writeable `JsonSchema.equals.writeable`} + * + * Derive a "writeable" (stringified) _equals function_ from a Json Schema spec. + * + * An "equals function" (see also, {@link Equal `Equal`}) is similar to + * lodash's `deepEquals` function, except more performant, because + * when the shape of the values being compared is known ahead of time, + * we can optimize ahead of time, and only check what's necessary. + * + * Note that the "equals function" generated by {@link equals `JsonSchema.equals`} + * **assumes that both values have already been validated**. Passing + * unvalidated values to the function might result in undefined behavior. + * + * {@link equals_writeable `JsonSchema.equals.writeable`} accepts an optional + * configuration object as its second argument; documentation for those + * options are available via hover on autocompletion. + * + * See also: + * - {@link equals `JsonSchema.equals`} + * + * @example + * import { JsonSchema } from '@traversable/json-schema' + * + * const Address = { + * type: 'object', + * required: ['street1', 'city'], + * properties: { + * street1: { type: 'string' }, + * strret2: { type: 'string' }, + * city: { type: 'string' }, + * } + * } + * + * const addressEquals = JsonSchema.equals.writeable(Address) + * + * console.log(addressEquals) + * // => + * // function equals( + * // x: { street1: string; street2?: string; city: string; }, + * // y: { street1: string; street2?: string; city: string; } + * // ) => { + * // if (x === y) return true; + * // if (x.street1 !== y.street1) return false; + * // if (x.street2 !== y.street2) return false; + * // if (x.city !== y.city) return false; + * // return true; + * // } + */ + +function equals_writeable(schema: T, options?: equals.Options): string { + const index = { ...defaultIndex(), ...options } satisfies Scope + const compiled = compile(schema)(['l'], ['r'], index) + const FUNCTION_NAME = options?.functionName ?? 'equals' + const inputType = toType(schema, options) + const TYPE = options?.typeName ?? inputType + const ROOT_CHECK = requiresObjectIs(schema) ? `if (Object.is(l, r)) return true` : `if (l === r) return true` + const BODY = compiled.length === 0 ? null : compiled + return ( + JsonSchema.isNullary(schema) + ? [ + options?.typeName === undefined ? null : inputType, + `function ${FUNCTION_NAME} (l: ${TYPE}, r: ${TYPE}) {`, + BODY, + `return true;`, + `}`, + ] + : [ + options?.typeName === undefined ? null : inputType, + `function ${FUNCTION_NAME} (l: ${TYPE}, r: ${TYPE}) {`, + ROOT_CHECK, + BODY, + `return true;`, + `}` + ] + ).filter((_) => _ !== null).join('\n') +} diff --git a/packages/json-schema/src/exports.ts b/packages/json-schema/src/exports.ts new file mode 100644 index 00000000..fd0fdfda --- /dev/null +++ b/packages/json-schema/src/exports.ts @@ -0,0 +1,4 @@ +export { check, fold, Functor, toType } from '@traversable/json-schema-types' + +export * from './version.js' +export { equals } from './equals.js' diff --git a/packages/json-schema/src/index.ts b/packages/json-schema/src/index.ts new file mode 100644 index 00000000..aa5bd564 --- /dev/null +++ b/packages/json-schema/src/index.ts @@ -0,0 +1,3 @@ +export * from './exports.js' +export type JsonSchema = import('@traversable/json-schema-types').JsonSchema +export * as JsonSchema from './exports.js' diff --git a/packages/json-schema/src/utils.ts b/packages/json-schema/src/utils.ts new file mode 100644 index 00000000..ae84573b --- /dev/null +++ b/packages/json-schema/src/utils.ts @@ -0,0 +1,107 @@ +import type { Target } from '@traversable/registry' +import { intersectKeys } from '@traversable/registry' +import { JsonSchema } from '@traversable/json-schema-types' + +export type Tagged = { + shape: Record + tag: string | number | bigint | boolean | null | undefined +} + +export type Discriminated = [ + discriminant: string | number, + tagged: Tagged[] +] + +export type PathSpec = { + path: (string | number)[] + ident: string +} + +export const defaultPrevSpec = { + ident: 'prev', + path: ['prev'], +} satisfies PathSpec + +export const defaultNextSpec = { + ident: 'next', + path: ['next'], +} satisfies PathSpec + +export function isSpecialCase(x: unknown) { + return JsonSchema.isEnum(x) +} + +export function isNumeric(x: unknown) { + return JsonSchema.isInteger(x) + || JsonSchema.isNumber(x) +} + +export function isScalar(x: unknown) { + return JsonSchema.isBoolean(x) + || JsonSchema.isString(x) +} + +export function isTypelevelNullary(x: unknown) { + return JsonSchema.isUnknown(x) + || JsonSchema.isNever(x) +} + +export type Primitive = Target + +export function isPrimitive(x: unknown) { + return isScalar(x) + || isNumeric(x) + || isSpecialCase(x) +} + +export function schemaOrdering(x: readonly [JsonSchema, number], y: readonly [JsonSchema, number]) { + return isSpecialCase(x) ? -1 : isSpecialCase(y) ? 1 + : isNumeric(x) ? -1 : isNumeric(y) ? 1 + : isScalar(x) ? -1 : isScalar(y) ? 1 + : isTypelevelNullary(x) ? 1 : isTypelevelNullary(y) ? -1 + : JsonSchema.isNull(x) ? 1 : JsonSchema.isNull(y) ? -1 + : 0 +} + +export function inlinePrimitiveCheck(x: Primitive, LEFT_SPEC: PathSpec, RIGHT_SPEC?: PathSpec, useGlobalThis?: boolean) { + switch (true) { + default: return x satisfies never + case JsonSchema.isInteger(x): + case JsonSchema.isNumber(x): return `typeof ${LEFT_SPEC.ident} === 'number'${RIGHT_SPEC ? ` && typeof ${RIGHT_SPEC.ident} === 'number'` : ''}` + case JsonSchema.isString(x): return `typeof ${LEFT_SPEC.ident} === 'string'${RIGHT_SPEC ? ` && typeof ${RIGHT_SPEC.ident} === 'string'` : ''}` + case JsonSchema.isBoolean(x): return `typeof ${LEFT_SPEC.ident} === 'boolean'${RIGHT_SPEC ? ` && typeof ${RIGHT_SPEC.ident} === 'boolean'` : ''}` + case JsonSchema.isEnum(x): return !RIGHT_SPEC ? 'true' : `${LEFT_SPEC.ident} === ${RIGHT_SPEC.ident}` + } +} + +export function areAllObjects(xs: readonly unknown[]) { + return xs.every(JsonSchema.isObject) +} + +export function getTags(xs: readonly JsonSchema[]): Discriminated | null { + if (!areAllObjects(xs)) { + return null + } else { + const shapes = xs.map((x) => x.properties) + const discriminants = intersectKeys(...shapes) + const [discriminant] = discriminants + if (discriminants.length !== 1) return null + else { + let seen = new Set() + const withTags = shapes.map((shape) => { + const withTag = shape[discriminant] + if (!JsonSchema.isConst(withTag)) { + return null + } else { + if (typeof withTag.const !== 'string') return null + else { + seen.add(withTag.const) + return { shape, tag: withTag.const } + } + } + }) + if (withTags.every((_) => _ !== null) && withTags.length === seen.size) return [discriminant, withTags] + else return null + } + } +} diff --git a/packages/json-schema/src/version.ts b/packages/json-schema/src/version.ts new file mode 100644 index 00000000..660ff1ca --- /dev/null +++ b/packages/json-schema/src/version.ts @@ -0,0 +1,3 @@ +import pkg from './__generated__/__manifest__.js' +export const VERSION = `${pkg.name}@${pkg.version}` as const +export type VERSION = typeof VERSION diff --git a/packages/json-schema/test/check.fuzz.test.ts b/packages/json-schema/test/check.fuzz.test.ts new file mode 100644 index 00000000..67cb088e --- /dev/null +++ b/packages/json-schema/test/check.fuzz.test.ts @@ -0,0 +1,197 @@ +import * as vi from 'vitest' +import * as fc from 'fast-check' +import prettier from '@prettier/sync' + +import { JsonSchema } from '@traversable/json-schema' +import { fn } from '@traversable/registry' +import { jsonSchemaTest } from '@traversable/json-schema-test' + +const format = (src: string) => prettier.format(src, { parser: 'typescript', semi: false }) + +const stringify = (_: string, v: unknown) => + typeof v === 'symbol' ? String(v) : typeof v === 'bigint' ? `${v}n` : v + +type LogFailureDeps = { + msg: string + seed: jsonSchemaTest.Seed.Seed.Fixpoint + schema: JsonSchema + errors?: unknown[] + data: unknown +} + +const fail = (e: unknown, { msg, seed, data, schema }: LogFailureDeps) => { + console.group(`\r\n\nFAILURE: ${msg}`) + console.error('\r\nError:', e) + console.debug('\r\nseed: ', JSON.stringify(seed, stringify, 2)) + console.debug('\r\ndata: ', data === '' ? '' : data) + console.debug('\r\ncheck: ', format(JsonSchema.check.writeable(schema))) + console.debug('\r\nschema: ', JSON.stringify(schema, null, 2)) + console.debug('\r\n\n') + console.groupEnd() + vi.assert.fail(`\r\nFAILURE: ${msg}`) +} + +const validSeedWithAdditionalProps = jsonSchemaTest.SeedGenerator({ + exclude: jsonSchemaTest.seedsThatPreventGeneratingValidData, + record: { additionalPropertiesOnly: true }, +})['*'] + +const InvalidSeedAdditionalProps = jsonSchemaTest.SeedGenerator({ + exclude: jsonSchemaTest.seedsThatPreventGeneratingInvalidData, + record: { additionalPropertiesOnly: true }, +}) + +const InvalidSeedPatternProps = jsonSchemaTest.SeedGenerator({ + exclude: jsonSchemaTest.seedsThatPreventGeneratingInvalidData, + record: { patternPropertiesOnly: true }, +}) + +const validSeedWithPatternProps = jsonSchemaTest.SeedGenerator({ + exclude: jsonSchemaTest.seedsThatPreventGeneratingValidData, + record: { patternPropertiesOnly: true }, +})['*'] + +const invalidSeedWithAdditionalProps = fn.pipe( + InvalidSeedAdditionalProps, + ($) => fc.oneof( + $.object, + $.tuple, + $.array, + $.record, + ) +) + +const invalidSeedWithPatternProps = fn.pipe( + InvalidSeedPatternProps, + ($) => fc.oneof( + $.object, + $.tuple, + $.array, + $.record, + ) +) + +vi.describe('ใ€–๏ธโ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traversable/json-schemaโณ', () => { + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.checkโณ: accepts valid data (additionalProperties)', () => { + fc.assert( + fc.property( + validSeedWithAdditionalProps, + (seed) => { + const schema = jsonSchemaTest.seedToSchema(seed) + const validData = jsonSchemaTest.seedToValidData(seed) + const check = JsonSchema.check(schema) + const result = check(validData) + if (result === false) { + fail('expected check to succeed', { msg: 'check(schema)(validData)', data: validData, schema, seed }) + vi.assert.fail() + } + } + ), { + endOnFailure: true, + examples: [], + // numRuns: 10_000, + }) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.checkโณ: rejects invalid data (additionalProperties)', () => { + fc.assert( + fc.property( + invalidSeedWithAdditionalProps, + (seed) => { + const schema = jsonSchemaTest.seedToSchema(seed) + const invalidData = jsonSchemaTest.seedToInvalidData(seed) + const result = JsonSchema.check(schema)(invalidData) + if (result === true) { + fail('expected check to fail', { msg: 'check(schema)(validData)', data: invalidData, schema, seed }) + vi.assert.fail() + } + } + ), { + endOnFailure: true, + examples: [], + // numRuns: 10_000, + }) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.checkโณ: accepts valid data (patternProperties)', () => { + fc.assert( + fc.property( + validSeedWithPatternProps, + (seed) => { + const schema = jsonSchemaTest.seedToSchema(seed) + const validData = jsonSchemaTest.seedToValidData(seed) + const check = JsonSchema.check(schema) + const result = check(validData) + if (result === false) { + fail('expected check to succeed', { msg: 'check(schema)(validData)', data: validData, schema, seed }) + vi.assert.fail() + } + } + ), { + endOnFailure: true, + examples: [], + // numRuns: 10_000, + }) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.checkโณ: rejects invalid data (patternProperties)', () => { + fc.assert( + fc.property( + invalidSeedWithPatternProps, + (seed) => { + const schema = jsonSchemaTest.seedToSchema(seed) + const invalidData = jsonSchemaTest.seedToInvalidData(seed) + const result = JsonSchema.check(schema)(invalidData) + if (result === true) { + fail('expected check to fail', { msg: 'check(schema)(validData)', data: invalidData, schema, seed }) + vi.assert.fail() + } + } + ), { + endOnFailure: true, + examples: [], + // numRuns: 10_000, + }) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.classicโณ: accepts valid data', () => { + fc.assert( + fc.property( + jsonSchemaTest.SeedValidDataGenerator, + (seed) => { + const schema = jsonSchemaTest.seedToSchema(seed) + const validData = jsonSchemaTest.seedToValidData(seed) + const result = JsonSchema.check.classic(schema)(validData) + if (result === false) { + console.log('schema', schema) + fail('expected check to succeed', { msg: 'check(schema)(validData)', data: validData, schema, seed }) + vi.assert.fail() + } + } + ), { + endOnFailure: true, + examples: [], + // numRuns: 10_000, + }) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.classicโณ: rejects invalid data', () => { + fc.assert( + fc.property( + jsonSchemaTest.SeedInvalidDataGenerator, + (seed) => { + const schema = jsonSchemaTest.seedToSchema(seed) + const invalidData = jsonSchemaTest.seedToInvalidData(seed) + const result = JsonSchema.check.classic(schema)(invalidData) + if (result === true) { + fail('expected check to fail', { msg: 'check(schema)(validData)', data: invalidData, schema, seed }) + vi.assert.fail() + } + } + ), { + endOnFailure: true, + examples: [], + // numRuns: 10_000, + }) + }) +}) diff --git a/packages/json-schema/test/check.test.ts b/packages/json-schema/test/check.test.ts new file mode 100644 index 00000000..d2d64f2f --- /dev/null +++ b/packages/json-schema/test/check.test.ts @@ -0,0 +1,288 @@ +import * as vi from 'vitest' +import * as fc from 'fast-check' +import { JsonSchema } from '@traversable/json-schema' +import { jsonSchemaTest } from '@traversable/json-schema-test' +import prettier from '@prettier/sync' + +const format = (src: string) => prettier.format(src, { parser: 'typescript', semi: false }) + +vi.describe('ใ€–๏ธโ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traversable/json-schemaโณ', () => { + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.writeableโณ: JsonSchema.Never', () => { + vi.expect.soft(format( + JsonSchema.check.writeable({ enum: [] }) + )).toMatchInlineSnapshot + (` + "function check(value) { + return false + } + " + `) + + vi.expect.soft(format( + JsonSchema.check.writeable({ not: {} }) + )).toMatchInlineSnapshot + (` + "function check(value) { + return false + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.writeableโณ: JsonSchema.Unknown', () => { + vi.expect.soft(format( + JsonSchema.check.writeable({}) + )).toMatchInlineSnapshot + (` + "function check(value) { + return true + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.writeableโณ: JsonSchema.Null', () => { + vi.expect.soft(format( + JsonSchema.check.writeable({ type: 'null' }) + )).toMatchInlineSnapshot + (` + "function check(value) { + return value === null + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.writeableโณ: JsonSchema.Boolean', () => { + vi.expect.soft(format( + JsonSchema.check.writeable({ type: 'boolean' }) + )).toMatchInlineSnapshot + (` + "function check(value) { + return typeof value === "boolean" + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.writeableโณ: JsonSchema.Integer', () => { + vi.expect.soft(format( + JsonSchema.check.writeable({ type: 'integer' }) + )).toMatchInlineSnapshot + (` + "function check(value) { + return Number.isSafeInteger(value) + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.writeableโณ: JsonSchema.Number', () => { + vi.expect.soft(format( + JsonSchema.check.writeable({ type: 'number' }) + )).toMatchInlineSnapshot + (` + "function check(value) { + return Number.isFinite(value) + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.writeableโณ: JsonSchema.String', () => { + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.writeableโณ: JsonSchema.Enum', () => { + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.writeableโณ: JsonSchema.Const', () => { + vi.expect.soft(format( + JsonSchema.check.writeable({ const: true }) + )).toMatchInlineSnapshot + (` + "function check(value) { + return value === true + } + " + `) + + vi.expect.soft(format( + JsonSchema.check.writeable({ const: [] }) + )).toMatchInlineSnapshot + (` + "function check(value) { + return Array.isArray(value) && value.length === 0 + } + " + `) + + + vi.expect.soft(format( + JsonSchema.check.writeable({ const: [true] }) + )).toMatchInlineSnapshot + (` + "function check(value) { + return Array.isArray(value) && value.length === 1 && value[0] === true + } + " + `) + + vi.expect.soft(format( + JsonSchema.check.writeable( + { const: { a: [true] } } + ) + )).toMatchInlineSnapshot + (` + "function check(value) { + return ( + !!value && + typeof value === "object" && + Array.isArray(value.a) && + value.a.length === 1 && + value.a[0] === true + ) + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.writeableโณ: JsonSchema.Union', () => { + vi.expect.soft(format( + JsonSchema.check.writeable( + { + anyOf: [] + } + ) + )).toMatchInlineSnapshot + (` + "function check(value) { + return false + } + " + `) + + vi.expect.soft(format( + JsonSchema.check.writeable( + { + anyOf: [ + { + type: 'string' + } + ] + } + ) + )).toMatchInlineSnapshot + (` + "function check(value) { + return typeof value === "string" + } + " + `) + + vi.expect.soft(format( + JsonSchema.check.writeable( + { + anyOf: [ + { + type: "object", + properties: { + w_k$_: { + type: "boolean" + }, + $4$DLOs7sB: { + type: "boolean" + } + }, + required: [ + "w_k$_" + ] + }, + { + type: "null" + } + ] + } + ) + )).toMatchInlineSnapshot + (` + "function check(value) { + return ( + (!!value && + typeof value === "object" && + typeof value.w_k$_ === "boolean" && + (!Object.hasOwn(value, "$4$DLOs7sB") || + typeof value.$4$DLOs7sB === "boolean")) || + value === null + ) + } + " + `) + + vi.expect.soft(format( + JsonSchema.check.writeable( + { + anyOf: [ + { + const: "<$\"{hyu" + } + ] + } + ) + )).toMatchInlineSnapshot + (` + "function check(value) { + return value === '<$"{hyu' + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.writeableโณ: JsonSchema.Intersection', () => { + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.writeableโณ: JsonSchema.Array', () => { + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.writeableโณ: JsonSchema.Record', () => { + vi.expect.soft(format( + JsonSchema.check.writeable({ type: 'object', additionalProperties: { type: 'boolean' } }) + )).toMatchInlineSnapshot + (` + "function check(value) { + return ( + !!value && + typeof value === "object" && + Object.entries(value).every(([key, value]) => typeof value === "boolean") + ) + } + " + `) + + vi.expect.soft(format( + JsonSchema.check.writeable({ + type: 'object', + patternProperties: { "abc": { type: 'boolean' } }, + }) + )).toMatchInlineSnapshot + (` + "function check(value) { + return ( + !!value && + typeof value === "object" && + Object.entries(value).every(([key, value]) => { + if (/abc/.test(key)) return typeof value === "boolean" + return true + }) + ) + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.writeableโณ: JsonSchema.Tuple', () => { + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.check.writeableโณ: JsonSchema.Object', () => { + }) + +}) diff --git a/packages/json-schema/test/equals.fuzz.test.ts b/packages/json-schema/test/equals.fuzz.test.ts new file mode 100644 index 00000000..275fb0c0 --- /dev/null +++ b/packages/json-schema/test/equals.fuzz.test.ts @@ -0,0 +1,114 @@ +import * as vi from 'vitest' +import * as fc from 'fast-check' +import prettier from '@prettier/sync' + +import { JsonSchema } from '@traversable/json-schema' +import { deriveUnequalValue } from '@traversable/registry' +import { jsonSchemaTest } from '@traversable/json-schema-test' + +const format = (src: string) => prettier.format(src, { parser: 'typescript', semi: false }) + +const exclude = [ + 'never', + 'unknown', +] as const + +const additionalPropsGenerator = jsonSchemaTest.SeedGenerator({ exclude, record: { additionalPropertiesOnly: true } })['*'] +const patternPropsGenerator = jsonSchemaTest.SeedGenerator({ exclude, record: { patternPropertiesOnly: true } })['*'] + +const stringify = (x: unknown) => + JSON.stringify(x, (_k, v) => typeof v === 'symbol' ? `Symbol(${v.description})` : typeof v === 'bigint' ? `${v}n` : v, 2) + +type LogFailureDeps = { + schema: JsonSchema + left: unknown + right: unknown +} + +const logFailureEqualData = ({ schema, left, right }: LogFailureDeps) => { + console.group('\n\n\rFAILURE: property test for JsonSchema.equals (with EQUAL data)\n\n\r') + console.debug('schema:\n\r', JSON.stringify(schema, null, 2), '\n\r') + console.debug('JsonSchema.equals.writeable(schema):\n\r', JsonSchema.equals.writeable(schema, { typeName: 'Type' }), '\n\r') + console.debug('stringify(left):\n\r', stringify(left), '\n\r') + console.debug('stringify(right):\n\r', stringify(right), '\n\r') + console.debug('left:\n\r', left, '\n\r') + console.debug('right:\n\r', right, '\n\r') + console.groupEnd() +} + +const logFailureUnequalData = ({ schema, left, right }: LogFailureDeps) => { + console.group('\n\n\rFAILURE: property test for JsonSchema.equals (with UNEQUAL data)\n\n\r') + console.debug('schema:\n\r', JSON.stringify(schema, null, 2), '\n\r') + console.debug('JsonSchema.equals.writeable(schema):\n\r', JsonSchema.equals.writeable(schema, { typeName: 'Type' }), '\n\r') + console.debug('stringify(left):\n\r', stringify(left), '\n\r') + console.debug('stringify(right):\n\r', stringify(right), '\n\r') + console.debug('left:\n\r', left, '\n\r') + console.debug('right:\n\r', right, '\n\r') + console.groupEnd() +} + +vi.describe('ใ€–๏ธโ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traversable/json-schemaโณ', () => { + vi.test('ใ€–โ›ณ๏ธใ€—โ€บ โฒJsonSchema.equalsโณ: equal data (additional props only)', () => { + fc.assert( + fc.property( + additionalPropsGenerator, + (seed) => { + const schema = jsonSchemaTest.seedToSchema(seed) + const arbitrary = jsonSchemaTest.seedToValidDataGenerator(seed) + const cloneArbitrary = fc.clone(arbitrary, 2) + const [[cloned1, cloned2]] = fc.sample(cloneArbitrary, 1) + try { + const equals = JsonSchema.equals(schema) + vi.assert.isTrue(equals(cloned1, cloned2)) + } + catch (e) { + console.error('ERROR:', e) + console.log('JsonSchema.equals.writeable(schema)', JsonSchema.equals.writeable(schema)) + logFailureEqualData({ schema, left: cloned1, right: cloned2 }) + vi.expect.fail(`Equal data failed for JsonSchema.equal with schema:\n\n${stringify(schema)}`) + } + } + ), { + endOnFailure: true, + examples: [ + [[550, "<$\"{hyu"]], + [[8500, [[7500, [["V9$_", [550, "<$\"{hyu"]]], []]]]], + [[7500, [["x", [15]], ["i5$fuCjQ$", [550, [false]]]], ["x"]]], + [[7500, [["k1$_", [550, [-1.1953822100946693e-73, "*b)", "", 4.540248974559217e-80, false, "WM&RFqu", 4.308650669841548e-87]]]], []]], + ], + // numRuns: 10_000, + }) + }) + + vi.test.skip('ใ€–โ›ณ๏ธใ€—โ€บ โฒJsonSchema.equalsโณ: unequal data (additional props only)', () => { + fc.assert( + fc.property( + additionalPropsGenerator, + (seed) => { + const schema = jsonSchemaTest.seedToSchema(seed) + const arbitrary = jsonSchemaTest.seedToValidDataGenerator(seed) + const [data] = fc.sample(arbitrary, 1) + const unequal = deriveUnequalValue(data) + try { + const equals = JsonSchema.equals(schema) + vi.assert.isFalse(equals(data, unequal)) + } + catch (e) { + console.error('ERROR:', e) + logFailureUnequalData({ schema, left: data, right: unequal }) + vi.expect.fail(`Unequal data failed for JsonSchema.equal with schema:\n\n${stringify(schema)}`) + } + } + ), { + endOnFailure: true, + examples: [ + [[7500, [["H$py51N$", [15]]], []]], + [[7500, [["$9$$_v__", [15]]], []]], + [[7500, [["o2", [100, [-4096, null, -1829861584]]]], []]], + [[7500, [["J_$", [250, [null, null]]]], []]], + ], + numRuns: 10_000, + }) + }) + +}) diff --git a/packages/json-schema/test/equals.test.ts b/packages/json-schema/test/equals.test.ts new file mode 100644 index 00000000..67cbe4e0 --- /dev/null +++ b/packages/json-schema/test/equals.test.ts @@ -0,0 +1,626 @@ +import * as vi from 'vitest' +import { JsonSchema } from '@traversable/json-schema' +import prettier from '@prettier/sync' + +const format = (src: string) => prettier.format(src, { parser: 'typescript', semi: false }) + +vi.describe('ใ€–๏ธโ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traversable/json-schemaโณ', () => { + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.equals.writeableโณ: JsonSchema.Never', () => { + vi.expect.soft(format( + JsonSchema.equals.writeable({ not: {} }) + )).toMatchInlineSnapshot + (` + "function equals(l: never, r: never) { + if (!Object.is(l, r)) return false + return true + } + " + `) + vi.expect.soft(format( + JsonSchema.equals.writeable({ enum: [] }) + )).toMatchInlineSnapshot + (` + "function equals(l: never, r: never) { + if (!Object.is(l, r)) return false + return true + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.equals.writeableโณ: JsonSchema.Unknown', () => { + vi.expect.soft(format( + JsonSchema.equals.writeable({}) + )).toMatchInlineSnapshot + (` + "function equals(l: unknown, r: unknown) { + if (Object.is(l, r)) return true + if (!Object.is(l, r)) return false + return true + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.equals.writeableโณ: JsonSchema.Null', () => { + vi.expect.soft(format( + JsonSchema.equals.writeable({ type: 'null' }) + )).toMatchInlineSnapshot + (` + "function equals(l: null, r: null) { + if (l !== r) return false + return true + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.equals.writeableโณ: JsonSchema.Boolean', () => { + vi.expect.soft(format( + JsonSchema.equals.writeable({ type: 'boolean' }) + )).toMatchInlineSnapshot + (` + "function equals(l: boolean, r: boolean) { + if (l === r) return true + if (l !== r) return false + return true + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.equals.writeableโณ: JsonSchema.Integer', () => { + vi.expect.soft(format( + JsonSchema.equals.writeable({ type: 'integer' }) + )).toMatchInlineSnapshot + (` + "function equals(l: number, r: number) { + if (l !== r && (l === l || r === r)) return false + return true + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.equals.writeableโณ: JsonSchema.Number', () => { + vi.expect.soft(format( + JsonSchema.equals.writeable({ type: 'number' }) + )).toMatchInlineSnapshot + (` + "function equals(l: number, r: number) { + if (l !== r && (l === l || r === r)) return false + return true + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.equals.writeableโณ: JsonSchema.String', () => { + vi.expect.soft(format( + JsonSchema.equals.writeable({ type: 'string' }) + )).toMatchInlineSnapshot + (` + "function equals(l: string, r: string) { + if (l !== r) return false + return true + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.equals.writeableโณ: JsonSchema.Enum', () => { + vi.expect.soft(format( + JsonSchema.equals.writeable({ enum: [] }) + )).toMatchInlineSnapshot + (` + "function equals(l: never, r: never) { + if (!Object.is(l, r)) return false + return true + } + " + `) + vi.expect.soft(format( + JsonSchema.equals.writeable({ enum: [1] }) + )).toMatchInlineSnapshot + (` + "function equals(l: 1, r: 1) { + if (l !== r && (l === l || r === r)) return false + return true + } + " + `) + vi.expect.soft(format( + JsonSchema.equals.writeable({ enum: ["1", false, 2] }) + )).toMatchInlineSnapshot + (` + "function equals(l: "1" | false | 2, r: "1" | false | 2) { + if (!Object.is(l, r)) return false + return true + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.equals.writeableโณ: JsonSchema.Const', () => { + vi.expect.soft(format( + JsonSchema.equals.writeable({ const: true }) + )).toMatchInlineSnapshot + (` + "function equals(l: true, r: true) { + if (l === r) return true + if (l !== r) return false + return true + } + " + `) + + vi.expect.soft(format( + JsonSchema.equals.writeable({ const: [] }) + )).toMatchInlineSnapshot + (` + "function equals(l: [], r: []) { + if (l === r) return true + const length = l.length + if (length !== r.length) return false + return true + } + " + `) + + vi.expect.soft(format( + JsonSchema.equals.writeable({ const: [true] }) + )).toMatchInlineSnapshot + (` + "function equals(l: [true], r: [true]) { + if (l === r) return true + const length = l.length + if (length !== r.length) return false + if (l[0] !== r[0]) return false + return true + } + " + `) + + vi.expect.soft(format( + JsonSchema.equals.writeable( + { const: { a: [true] } } + ) + )).toMatchInlineSnapshot + (` + "function equals(l: { a: [true] }, r: { a: [true] }) { + if (l === r) return true + if (l.a !== r.a) { + const length = l.a.length + if (length !== r.a.length) return false + if (l.a[0] !== r.a[0]) return false + } + return true + } + " + `) + + vi.expect.soft(format( + JsonSchema.equals.writeable( + { + type: 'object', + required: [], + properties: { + a: { const: [true] } + } + }, + { typeName: 'Type' } + ) + )).toMatchInlineSnapshot + (` + "type Type = { a?: [true] } + function equals(l: Type, r: Type) { + if (l === r) return true + if ((l?.a === undefined || r?.a === undefined) && l?.a !== r?.a) return false + if (l?.a !== r?.a) { + const length = l?.a?.length + if (length !== r?.a?.length) return false + if (l?.a?.[0] !== r?.a?.[0]) return false + } + return true + } + " + `) + + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.equals.writeableโณ: JsonSchema.Array', () => { + vi.expect.soft(format( + JsonSchema.equals.writeable( + { type: 'array', items: {} } + ) + )).toMatchInlineSnapshot + (` + "function equals(l: Array, r: Array) { + if (l === r) return true + const length = l.length + if (length !== r.length) return false + for (let ix = length; ix-- !== 0; ) { + const l_item = l[ix] + const r_item = r[ix] + if (!Object.is(l_item, r_item)) return false + } + return true + } + " + `) + + vi.expect.soft(format( + JsonSchema.equals.writeable({ + type: 'array', + items: { + type: 'array', + items: { + type: 'array', + items: { + type: 'string' + } + } + } + }) + )).toMatchInlineSnapshot + (` + "function equals( + l: Array>>, + r: Array>>, + ) { + if (l === r) return true + const length = l.length + if (length !== r.length) return false + for (let ix = length; ix-- !== 0; ) { + const l_item = l[ix] + const r_item = r[ix] + const length1 = l_item.length + if (length1 !== r_item.length) return false + for (let ix = length1; ix-- !== 0; ) { + const l_item_item = l_item[ix] + const r_item_item = r_item[ix] + const length2 = l_item_item.length + if (length2 !== r_item_item.length) return false + for (let ix = length2; ix-- !== 0; ) { + const l_item_item_item = l_item_item[ix] + const r_item_item_item = r_item_item[ix] + if (l_item_item_item !== r_item_item_item) return false + } + } + } + return true + } + " + `) + + vi.expect.soft(format( + JsonSchema.equals.writeable({ + type: 'array', + items: { + type: 'object', + required: ['a'], + properties: { + a: { + type: 'array', + items: { + type: 'object', + required: ['b'], + properties: { + b: { + type: 'array', + items: { + type: 'string' + } + }, + c: { type: 'string' } + } + } + }, + d: { + type: 'array', + items: { + type: 'object', + required: ['f'], + properties: { + e: { + type: 'array', + items: { + type: 'string' + } + }, + f: { type: 'string' } + } + } + } + } + } + }, { typeName: 'Type' }), + )).toMatchInlineSnapshot + (` + "type Type = Array<{ + a: Array<{ b: Array; c?: string }> + d?: Array<{ e?: Array; f: string }> + }> + function equals(l: Type, r: Type) { + if (l === r) return true + const length = l.length + if (length !== r.length) return false + for (let ix = length; ix-- !== 0; ) { + const l_item = l[ix] + const r_item = r[ix] + if (l_item.a !== r_item.a) { + const length1 = l_item.a.length + if (length1 !== r_item.a.length) return false + for (let ix = length1; ix-- !== 0; ) { + const l_item_a_item = l_item.a[ix] + const r_item_a_item = r_item.a[ix] + if (l_item_a_item.b !== r_item_a_item.b) { + const length2 = l_item_a_item.b.length + if (length2 !== r_item_a_item.b.length) return false + for (let ix = length2; ix-- !== 0; ) { + const l_item_a_item_b_item = l_item_a_item.b[ix] + const r_item_a_item_b_item = r_item_a_item.b[ix] + if (l_item_a_item_b_item !== r_item_a_item_b_item) return false + } + } + if ( + (l_item_a_item?.c === undefined || r_item_a_item?.c === undefined) && + l_item_a_item?.c !== r_item_a_item?.c + ) + return false + if (l_item_a_item?.c !== r_item_a_item?.c) return false + } + } + if (l_item?.d !== r_item?.d) { + if ( + (l_item?.d === undefined || r_item?.d === undefined) && + l_item?.d !== r_item?.d + ) + return false + if (l_item?.d !== r_item?.d) { + const length3 = l_item?.d?.length + if (length3 !== r_item?.d?.length) return false + for (let ix = length3; ix-- !== 0; ) { + const l_item__d_item = l_item?.d[ix] + const r_item__d_item = r_item?.d[ix] + if (l_item__d_item?.e !== r_item__d_item?.e) { + if ( + (l_item__d_item?.e === undefined || + r_item__d_item?.e === undefined) && + l_item__d_item?.e !== r_item__d_item?.e + ) + return false + if (l_item__d_item?.e !== r_item__d_item?.e) { + const length4 = l_item__d_item?.e?.length + if (length4 !== r_item__d_item?.e?.length) return false + for (let ix = length4; ix-- !== 0; ) { + const l_item__d_item__e_item = l_item__d_item?.e[ix] + const r_item__d_item__e_item = r_item__d_item?.e[ix] + if (l_item__d_item__e_item !== r_item__d_item__e_item) + return false + } + } + } + if (l_item__d_item.f !== r_item__d_item.f) return false + } + } + } + } + return true + } + " + `) + + vi.expect.soft(format( + JsonSchema.equals.writeable( + { + type: 'array', + items: { + type: 'number' + } + } + ) + )).toMatchInlineSnapshot + (` + "function equals(l: Array, r: Array) { + if (l === r) return true + const length = l.length + if (length !== r.length) return false + for (let ix = length; ix-- !== 0; ) { + const l_item = l[ix] + const r_item = r[ix] + if (l_item !== r_item && (l_item === l_item || r_item === r_item)) + return false + } + return true + } + " + `) + + vi.expect.soft(format( + JsonSchema.equals.writeable( + { + type: 'array', + items: { + type: 'object', + required: ['c'], + properties: { + c: { + type: 'object', + required: ['d', 'e'], + properties: { + d: { type: 'string' }, + e: { + type: 'array', + items: { type: 'string' } + } + } + } + } + } + }, + { typeName: 'Type' } + ) + )).toMatchInlineSnapshot + (` + "type Type = Array<{ c: { d: string; e: Array } }> + function equals(l: Type, r: Type) { + if (l === r) return true + const length = l.length + if (length !== r.length) return false + for (let ix = length; ix-- !== 0; ) { + const l_item = l[ix] + const r_item = r[ix] + if (l_item.c !== r_item.c) { + if (l_item.c.d !== r_item.c.d) return false + if (l_item.c.e !== r_item.c.e) { + const length1 = l_item.c.e.length + if (length1 !== r_item.c.e.length) return false + for (let ix = length1; ix-- !== 0; ) { + const l_item_c_e_item = l_item.c.e[ix] + const r_item_c_e_item = r_item.c.e[ix] + if (l_item_c_e_item !== r_item_c_e_item) return false + } + } + } + } + return true + } + " + `) + + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.equals.writeableโณ: JsonSchema.Tuple', () => { + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.equals.writeableโณ: JsonSchema.Record', () => { + vi.expect.soft(format( + JsonSchema.equals.writeable({ + type: 'object', + additionalProperties: { + type: 'boolean' + } + }) + )).toMatchInlineSnapshot + (` + "function equals(l: Record, r: Record) { + if (l === r) return true + const l_keys = Object.keys(l) + const r_keys = Object.keys(r) + const length = l_keys.length + if (length !== r_keys.length) return false + for (let ix = length; ix-- !== 0; ) { + const key = l_keys[ix] + const l_k_ = l[key] + const r_k_ = r[key] + if (l_k_ !== r_k_) return false + } + return true + } + " + `) + + vi.expect.soft(format( + JsonSchema.equals.writeable({ + type: 'object', + patternProperties: { "abc": { type: 'boolean' } }, + }, { typeName: 'Type' }) + )).toMatchInlineSnapshot + (` + "type Type = Record<"abc", boolean> + function equals(l: Type, r: Type) { + if (l === r) return true + const l_keys = Object.keys(l) + const r_keys = Object.keys(r) + const length = l_keys.length + if (length !== r_keys.length) return false + for (let ix = length; ix-- !== 0; ) { + const key = l_keys[ix] + const l_k_ = l[key] + const r_k_ = r[key] + if (/abc/.test(key)) { + if (l_k_ !== r_k_) return false + } + } + return true + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.equals.writeableโณ: JsonSchema.Object', () => { + + vi.expect.soft(format( + JsonSchema.equals.writeable({ + type: 'object', + required: [], + properties: { + a: { + type: 'boolean' + } + } + }) + )).toMatchInlineSnapshot + (` + "function equals(l: { a?: boolean }, r: { a?: boolean }) { + if (l === r) return true + if ((l?.a === undefined || r?.a === undefined) && l?.a !== r?.a) return false + if (l?.a !== r?.a) { + if (l?.a !== r?.a) return false + } + return true + } + " + `) + }) + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.equals.writeableโณ: JsonSchema.Intersection', () => { + }) + + function equals(l: { V9$_?: "<$\"{hyu" }, r: { V9$_?: "<$\"{hyu" }) { + if (l === r) return true + let satisfied = false + if (l.V9$_ === "<$\"{hyu") { + if ((l?.V9$_ === undefined || r?.V9$_ === undefined) && l?.V9$_ !== r?.V9$_) return false + if (l?.V9$_ !== r?.V9$_) { + if (l?.V9$_ !== r?.V9$_) return false + } + satisfied = true + } + if (!satisfied) return false + return true + } + + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒJsonSchema.equals.writeableโณ: JsonSchema.Union', () => { + vi.expect.soft( + JsonSchema.equals.writeable( + { + "anyOf": [ + { + "type": "object", + "properties": { + "V9$_": { + "const": "<$\"{hyu" + } + }, + "required": [] + } + ] + } + ) + ).toMatchInlineSnapshot + (` + "function equals (l: { V9$_?: "<$\\"{hyu" }, r: { V9$_?: "<$\\"{hyu" }) { + if (l === r) return true + if ((l?.V9$_ === undefined || r?.V9$_ === undefined) && l?.V9$_ !== r?.V9$_) return false + if (l?.V9$_ !== r?.V9$_) { + if (l?.V9$_ !== r?.V9$_) return false; + } + return true; + }" + `) + }) + +}) + diff --git a/packages/json-schema/test/to-type.integration.test.ts b/packages/json-schema/test/to-type.integration.test.ts new file mode 100644 index 00000000..0cddf456 --- /dev/null +++ b/packages/json-schema/test/to-type.integration.test.ts @@ -0,0 +1,87 @@ +import * as vi from 'vitest' +import * as fc from 'fast-check' +import * as path from 'node:path' +import * as fs from 'node:fs' +import { JsonSchema } from '@traversable/json-schema' +import * as JsonSchemaTest from '@traversable/json-schema-test' +import prettier from '@prettier/sync' + +const format = (src: string) => prettier.format(src, { parser: 'typescript', semi: false }) + +const NUM_RUNS = 100 +export const DIR = path.join(path.resolve(), 'packages', 'json-schema', 'test', '__generated__') +export const PATH = { + dir: DIR, + target: path.join(DIR, 'to-type.generated.ts'), +} as const + +if (!fs.existsSync(PATH.dir)) fs.mkdirSync(PATH.dir) +if (!fs.existsSync(PATH.target)) fs.writeFileSync(PATH.target, '') + +const TYPELEVEL = [ + `type Intersect = S extends [infer H, ...infer T] ? Intersect> : Out`, + ``, + `type ToType`, + ` = [keyof S] extends [never] ? unknown`, + ` : S extends { anyOf: infer T extends readonly any[] }`, + ` ? T[number] extends infer R ? R extends R ? ToType : never : never`, + ` : S extends { allOf: infer T } ? Intersect`, + ` : S extends { type: 'null' } ? null`, + ` : S extends { type: 'boolean' } ? boolean`, + ` : S extends { type: 'integer' } ? number`, + ` : S extends { type: 'number' } ? number`, + ` : S extends { type: 'string' } ? string`, + ` : S extends { const: any } ? S['const']`, + ` : S extends { enum: readonly any[] } ? S['enum'][number]`, + ` : S extends { type: 'array', items: false, prefixItems: infer T } ? { [I in keyof T]: ToType }`, + ` : S extends { type: 'array', items: any, prefixItems: readonly any[] }`, + ` ? [...S['prefixItems'], ...S['items'][]] extends infer T`, + ` ? { [I in keyof T]: ToType }`, + ` : never`, + ` : S extends { type: 'array', prefixItems: infer T } ? { [I in keyof T]: ToType }`, + ` : S extends { type: 'array', items: infer T } ? ToType[]`, + ` : S extends { type: 'object', additionalProperties: infer R, patternProperties: infer T } ?`, + ` & Record>`, + ` & Record>`, + ` : S extends { type: 'object', additionalProperties: infer R } ? Record>`, + ` : S extends { type: 'object', patternProperties: infer T } ? Record>`, + ` : S extends { type: 'object', properties: infer T, required: infer KS extends string[] } ?`, + ` Force<`, + ` & { [K in keyof T as K extends KS[number] ? K : never]-?: ToType }`, + ` & { [K in keyof T as K extends KS[number] ? never : K]+?: ToType }`, + ` >`, + ` : never`, +].join('\n') + +vi.describe('ใ€–โ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traverable/json-schemaโณ: integration tests', () => { + const seeds = fc.sample(JsonSchemaTest.SeedGenerator()['*'], NUM_RUNS) + const schemas = seeds.map((seed) => JsonSchemaTest.seedToSchema(seed)) + const types = schemas.map((schema, ix) => { + return [ + `type Type_${ix} = ${JsonSchema.toType(schema)}`, + `declare const term_${ix}: ${JSON.stringify(schema, null, 2)}`, + `declare const test_${ix}: ToType`, + ``, + `vi.assertType(equals()(test_${ix}))`, + ].filter((_) => _ !== null).join('\n') + }).join('\n\n') + + const content = [ + `import * as vi from 'vitest'`, + `import type { Force } from '@traversable/registry'`, + ``, + TYPELEVEL, + ``, + 'type Equals =', + ' (() => F extends S ? true : false) extends', + ' (() => F extends T ? true : false) ? true : false', + 'declare function equals(): (x: T) => Equals', + '', + types, + ].join('\n') + + vi.it('ใ€–โ›ณ๏ธใ€—โ€บ โฒ@traverable/zodโณ: it writes', () => { + vi.assert.isTrue(fs.existsSync(PATH.target)) + fs.writeFileSync(PATH.target, format(content)) + }) +}) diff --git a/packages/json-schema/test/version.test.ts b/packages/json-schema/test/version.test.ts new file mode 100644 index 00000000..72af361d --- /dev/null +++ b/packages/json-schema/test/version.test.ts @@ -0,0 +1,10 @@ +import * as vi from 'vitest' +import pkg from '../package.json' with { type: 'json' } +import { VERSION } from '@traversable/json-schema' + +vi.describe('ใ€–โ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traversable/json-schemaโณ', () => { + vi.it('ใ€–โ›ณ๏ธใ€—โ€บ โฒVERSIONโณ', () => { + const expected = `${pkg.name}@${pkg.version}` + vi.assert.equal(VERSION, expected) + }) +}) diff --git a/packages/json-schema/tsconfig.build.json b/packages/json-schema/tsconfig.build.json new file mode 100644 index 00000000..26e9d218 --- /dev/null +++ b/packages/json-schema/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.src.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/build.tsbuildinfo", + "types": ["node"], + "declarationDir": "build/dts", + "outDir": "build/esm", + "stripInternal": true + }, + "references": [ + { "path": "../json-schema-types" }, + { "path": "../registry" } + ] +} diff --git a/packages/json-schema/tsconfig.json b/packages/json-schema/tsconfig.json new file mode 100644 index 00000000..2c291d21 --- /dev/null +++ b/packages/json-schema/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [], + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "tsconfig.test.json" } + ] +} diff --git a/packages/json-schema/tsconfig.src.json b/packages/json-schema/tsconfig.src.json new file mode 100644 index 00000000..fc0cb04a --- /dev/null +++ b/packages/json-schema/tsconfig.src.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/src.tsbuildinfo", + "rootDir": "src", + "types": ["node"], + "outDir": "build/src" + }, + "references": [ + { "path": "../json-schema-types" }, + { "path": "../registry" }, + ], + "include": ["src"] +} diff --git a/packages/json-schema/tsconfig.test.json b/packages/json-schema/tsconfig.test.json new file mode 100644 index 00000000..4a41937e --- /dev/null +++ b/packages/json-schema/tsconfig.test.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo", + "rootDir": "test", + "types": ["node"], + "noEmit": true + }, + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "../json-schema-test" }, + { "path": "../json-schema-types" }, + { "path": "../registry" } + ], + "include": ["test"] +} diff --git a/packages/json-schema/vite.config.ts b/packages/json-schema/vite.config.ts new file mode 100644 index 00000000..64dba4ad --- /dev/null +++ b/packages/json-schema/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import sharedConfig from '../../vite.config.js' + +const localConfig = defineConfig({}) + +export default mergeConfig(sharedConfig, localConfig) \ No newline at end of file diff --git a/packages/json/src/functor.ts b/packages/json/src/functor.ts index 4178554c..c3c260d8 100644 --- a/packages/json/src/functor.ts +++ b/packages/json/src/functor.ts @@ -153,19 +153,13 @@ export declare namespace Functor { } } -const IndexedFunctor - : T.Functor.Ix = { +const IndexedFunctor: T.Functor.Ix = { ...Functor, mapWithIndex(f) { return (x, { depth, path }) => { switch (true) { default: return fn.exhaustive(x) - case x === null: - case x === undefined: - case x === true: - case x === false: - case typeof x === 'number': - case typeof x === 'string': return x + case isScalar(x): return x case isArray(x): return fn.map(x, (s, i) => f(s, { path: [...path, i], depth: depth + 1 }, x)) case isObject(x): return fn.map(x, (s, k) => f(s, { path: [...path, k], depth: depth + 1 }, x)) } @@ -173,7 +167,7 @@ const IndexedFunctor }, } -export const fold = fn.cata(Functor) +export const fold = fn.catamorphism(IndexedFunctor, defaultIndex) export const unfold = fn.ana(Functor) export const foldWithIndex = fn.cataIx(IndexedFunctor) @@ -195,4 +189,4 @@ export const toString = (x: unknown) => !isJson(x) : '{ ' + xs.map(([k, v]) => `${parseKey(k)}: ${v}`).join(', ') + ' }' } } - }) + })(x as Json) diff --git a/packages/registry/src/exports.ts b/packages/registry/src/exports.ts index 95897af8..12b16019 100644 --- a/packages/registry/src/exports.ts +++ b/packages/registry/src/exports.ts @@ -26,6 +26,7 @@ type URI_ = typeof URI_[keyof typeof URI_] export { NS, SCOPE } from './uri.js' export { VERSION } from './version.js' +export { PATTERN } from './pattern.js' export type { TypeName } from './typeName.js' export { deriveUnequalValue } from './unequal.js' export { typeName } from './typeName.js' @@ -49,6 +50,7 @@ export { joinPath, keyAccessor, parseKey, + escapeRegExp, stringifyKey, stringifyLiteral, } from './parse.js' diff --git a/packages/registry/src/function.ts b/packages/registry/src/function.ts index 88579ee0..83573cc6 100644 --- a/packages/registry/src/function.ts +++ b/packages/registry/src/function.ts @@ -131,12 +131,27 @@ export function para(F: Functor) { } } -export function catamorphism(F: Functor.Ix, initialIndex: NoInfer) { - return (g: (src: Kind, ix: Ix, x: Kind) => T) => { - return function loop(src: Kind, ix: Ix): T { - return g(F.mapWithIndex(loop)(src, ix ?? initialIndex), ix ?? initialIndex, src) +export function catamorphism(F: Functor.Ix): (g: (src: Kind) => T) => (src: Kind) => T + +export function catamorphism(F: Functor.Ix, initialIndex: NoInfer): + (g: (src: Kind, ix: Ix, x: Kind) => T) => (src: Kind, ix?: Ix) => T + +export function catamorphism( + F: Functor.Ix | Functor.Ix, + initialIndex?: never +): + | ((g: (src: Kind) => T) => (src: Kind) => T) + | ((g: (src: Kind, ix: Ix, x: Kind) => T) => (src: Kind, ix: Ix) => T) { + return initialIndex === undefined + ? function fold(g: (src: Kind) => T) { + return function loop(src: Kind): T { + return g(F.map(loop)(src)) + } + } : function foldWithIndex(g: (src: Kind, ix: Ix, x: Kind) => T) { + return function loop(src: Kind, ix: Ix): T { + return g(F.mapWithIndex(loop)(src, ix as never ?? initialIndex), ix ?? initialIndex, src) + } } - } } type MapFn> = (src: S[K], k: K, xs: S) => T diff --git a/packages/registry/src/has.ts b/packages/registry/src/has.ts index 71ee547b..70d8ba57 100644 --- a/packages/registry/src/has.ts +++ b/packages/registry/src/has.ts @@ -29,7 +29,6 @@ export function get(x: unknown, ks: (keyof any)[]) { let k: keyof any | undefined while ((k = ks.shift()) !== undefined) { if (hasOwn(out, k)) void (out = out[k]) - else if (k === '') continue else return symbol.notfound } return out diff --git a/packages/registry/src/parse.ts b/packages/registry/src/parse.ts index 4b73b621..8613b7dd 100644 --- a/packages/registry/src/parse.ts +++ b/packages/registry/src/parse.ts @@ -1,3 +1,5 @@ +import { Object_hasOwn } from './globalThis.js' + const PATTERN = { singleQuoted: /(?<=^').+?(?='$)/, doubleQuoted: /(?<=^").+?(?="$)/, @@ -123,7 +125,7 @@ export function stringifyKey(key: string) { } export function stringifyLiteral(v: string | number | bigint | boolean | null | undefined) { - return typeof v === 'string' ? stringifyKey(v) : typeof v=== 'bigint' ? `${v}n` : `${v}` + return typeof v === 'string' ? stringifyKey(v) : typeof v === 'bigint' ? `${v}n` : `${v}` } export function keyAccessor(key: keyof any | undefined, isOptional: boolean) { @@ -150,3 +152,58 @@ export function joinPath(path: (string | number)[], isOptional: boolean) { '' ) } + +const WHITESPACES = '\ +\u0009\u000A\u000B\u000C\u000D\u0020\u00A0\u1680\u2000\u2001\ +\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\ +\u205F\u3000\u2028\u2029\uFEFF' + +const FIRST_DIGIT_OR_ASCII = /^[0-9a-z]/i +const SYNTAX_SOLIDUS = /^[$()*+./?[\\\]^{|}]/ +const OTHER_PUNCTUATORS_AND_WHITESPACES = new RegExp('^[!"#%&\',\\-:;<=>@`~' + WHITESPACES + ']') + +function escapeChar(char: string) { + const hex = char.charCodeAt(0).toString(16) + return hex.length < 3 ? '\\x' + hex.padStart(2, '0') : '\\u' + hex.padStart(4, '0') +} +const ControlEscape = { + '\u0009': 't', + '\u000A': 'n', + '\u000B': 'v', + '\u000C': 'f', + '\u000D': 'r' +} + +/** + * Source: https://github.com/zloirock/core-js/blob/0c6207b3a87e5e3dfda849859e722452a4127fc1/packages/core-js/modules/es.regexp.escape.js + */ +export function escapeRegExp(s: string) { + let length = s.length + let result = new Array(length) + + for (let i = 0; i < length; i++) { + let chr = s.charAt(i) + if (i === 0 && FIRST_DIGIT_OR_ASCII.exec(chr)) { + result[i] = escapeChar(chr) + } else if (Object_hasOwn(ControlEscape, chr)) { + result[i] = '\\' + ControlEscape[chr as never] + } else if (SYNTAX_SOLIDUS.exec(chr)) { + result[i] = '\\' + chr + } else if (OTHER_PUNCTUATORS_AND_WHITESPACES.exec(chr)) { + result[i] = escapeChar(chr) + } else { + let charCode = chr.charCodeAt(0) + // single UTF-16 code unit + if ((charCode & 0xF800) !== 0xD800) result[i] = chr + // unpaired surrogate + else if (charCode >= 0xDC00 || i + 1 >= length || (s.charCodeAt(i + 1) & 0xFC00) !== 0xDC00) result[i] = escapeChar(chr) + // surrogate pair + else { + result[i] = chr + result[++i] = s.charAt(i) + } + } + } + + return result.join('') +} diff --git a/packages/registry/src/pattern.ts b/packages/registry/src/pattern.ts new file mode 100644 index 00000000..ba071780 --- /dev/null +++ b/packages/registry/src/pattern.ts @@ -0,0 +1,5 @@ +export const PATTERN = { + alphanumeric: '^[a-zA-Z0-9]*$', + identifier: '^[$_a-zA-Z][$_a-zA-Z0-9]*$', + exponential: 'e[-|+]?', +} as const diff --git a/packages/registry/src/unequal.ts b/packages/registry/src/unequal.ts index 6d053cf9..75eb6d27 100644 --- a/packages/registry/src/unequal.ts +++ b/packages/registry/src/unequal.ts @@ -9,7 +9,7 @@ const entriesHaveOnlyNullableValues = (xs: readonly [any, any][]) => xs.length > && arrayHasOnlyNullableValues(xs.map(([k]) => k)) && arrayHasOnlyNullableValues(xs.map(([, v]) => v)) -export function deriveUnequalValue(x: unknown) { +export function deriveUnequalValue(x: unknown, onEmptyObject?: T) { let mutated = false function go(x: {} | null | undefined): unknown { switch (true) { @@ -54,7 +54,7 @@ export function deriveUnequalValue(x: unknown) { case isObject(x): { const entries = Object.entries(x) if (entries.length === 0) - return (void (mutated = true), { '': null }) + return (void (mutated = true), onEmptyObject ?? { '': null }) if (arrayHasOnlyNullableValues(entries.map(([, v]) => v))) return (void (mutated = true), map(x, (x) => x === null ? undefined : null)) else diff --git a/packages/schema-compiler/src/json.ts b/packages/schema-compiler/src/json.ts index c6fac471..f78a441d 100644 --- a/packages/schema-compiler/src/json.ts +++ b/packages/schema-compiler/src/json.ts @@ -119,30 +119,32 @@ export const getWeight = (x: Json): number => { } } -export const sort = fn.flow( - Json.fold((x) => { - switch (true) { - default: return fn.exhaustive(x) - case x === undefined: - case x === null: - case typeof x === 'boolean': - case typeof x === 'number': - case typeof x === 'string': return { tag: URI.bottom, def: x } - case Json.isArray(x): { - return { tag: URI.array, def: [...x] } +export const sort + : (src: Json, ix?: Json.Functor.Index | undefined) => Fixpoint + = fn.flow( + Json.fold((x) => { + switch (true) { + default: return fn.exhaustive(x) + case x === undefined: + case x === null: + case typeof x === 'boolean': + case typeof x === 'number': + case typeof x === 'string': return { tag: URI.bottom, def: x } + case Json.isArray(x): { + return { tag: URI.array, def: [...x] } + } + case Json.isObject(x): return { tag: URI.object, def: Object_entries(x) } } - case Json.isObject(x): return { tag: URI.object, def: Object_entries(x) } - } - }), - fold((x) => { - switch (true) { - default: return fn.exhaustive(x) - case x.tag === URI.bottom: return x - case x.tag === URI.array: return { tag: URI.array, def: bindPreSortIndices(x.def).sort(comparator) } - case x.tag === URI.object: return { tag: URI.object, def: x.def.sort(([, l], [, r]) => comparator(l, r)) } - } - }), -) + }), + fold((x) => { + switch (true) { + default: return fn.exhaustive(x) + case x.tag === URI.bottom: return x + case x.tag === URI.array: return { tag: URI.array, def: bindPreSortIndices(x.def).sort(comparator) } + case x.tag === URI.object: return { tag: URI.object, def: x.def.sort(([, l], [, r]) => comparator(l, r)) } + } + }), + ) as never export function interpreter(x: IR>, ix: Index): IR export function interpreter(x: IR>, ix: Index): { @@ -188,7 +190,7 @@ export function interpreter(x: IR>, ix: Index): { export function generate(json: Json, index?: Index): string export function generate(json: Json, index?: Index) { return fn.pipe( - sort(json), + sort(json as never), (sorted) => fold(interpreter)(sorted, index).def, ) } \ No newline at end of file diff --git a/packages/schema-errors/src/fold.ts b/packages/schema-errors/src/fold.ts index 8b6c56d7..8ef7e21e 100644 --- a/packages/schema-errors/src/fold.ts +++ b/packages/schema-errors/src/fold.ts @@ -20,7 +20,7 @@ export const json case JSON.isArray(x): return Json.array(x, options) case JSON.isObject(x): return Json.object(x, options) } - })(json) + })(json as JSON.Unary) export const gather : (schema: S, options?: Options) => Validator diff --git a/packages/schema-errors/src/json.ts b/packages/schema-errors/src/json.ts index 1faf5567..ce81c783 100644 --- a/packages/schema-errors/src/json.ts +++ b/packages/schema-errors/src/json.ts @@ -82,4 +82,4 @@ export const fold case JSON.isArray(x): return array(x, options) case JSON.isObject(x): return object(x, options) } - })(json) + })(json as never) diff --git a/packages/schema-seed/src/seed.ts b/packages/schema-seed/src/seed.ts index 2ca38218..8f43aedc 100644 --- a/packages/schema-seed/src/seed.ts +++ b/packages/schema-seed/src/seed.ts @@ -1,7 +1,7 @@ import { fc } from '@fast-check/vitest' import type * as T from '@traversable/registry' -import { fn, parseKey, symbol, unsafeCompact, URI } from '@traversable/registry' +import { fn, parseKey, PATTERN, symbol, unsafeCompact, URI } from '@traversable/registry' import { Json } from '@traversable/json' import type { SchemaOptions } from '@traversable/schema' import { t } from '@traversable/schema' @@ -172,7 +172,7 @@ export const isBounded = (x: number) => x <= -GREATEST_LOWER_BOUND || +GREATEST_ export type UniqueArrayDefaults = fc.UniqueArrayConstraintsRecommended -const identifier = fc.stringMatching(new RegExp('^[$_a-zA-Z][$_a-zA-Z0-9]*$', 'u')) +const identifier = fc.stringMatching(new RegExp(PATTERN.identifier, 'u')) const entries = (model: fc.Arbitrary, constraints?: UniqueArrayDefaults) => fc.uniqueArray( fc.tuple(identifier, model), @@ -1676,15 +1676,9 @@ const extensibleArbitrary = (constraints?: Constraints) => */ const data = (constraints?: Constraints) => fc.letrec(seed(constraints)).tree.chain(toArbitrary) -export const PATTERN = { - alphanumeric: '^[a-zA-Z0-9]*$', - ident: '^[$_a-zA-Z][$_a-zA-Z0-9]*$', - exponential: 'e[-|+]?', -} as const satisfies Record - export const REG_EXP = { alphanumeric: new RegExp(PATTERN.alphanumeric, 'u'), - ident: new RegExp(PATTERN.ident, 'u'), + ident: new RegExp(PATTERN.identifier, 'u'), exponential: new RegExp(PATTERN.exponential, 'u'), } satisfies Record diff --git a/packages/schema-to-json-schema/src/arbitrary.ts b/packages/schema-to-json-schema/src/arbitrary.ts index 48d93158..e2b24200 100644 --- a/packages/schema-to-json-schema/src/arbitrary.ts +++ b/packages/schema-to-json-schema/src/arbitrary.ts @@ -1,8 +1,9 @@ import { fc } from '@fast-check/vitest' +import { PATTERN } from '@traversable/registry' import type * as Spec from '@traversable/schema-to-json-schema/specification' import type { JsonSchema } from '@traversable/schema-to-json-schema' -const identifier = fc.stringMatching(new RegExp('^[$_a-zA-Z][$_a-zA-Z0-9]*$', 'u')) +const identifier = fc.stringMatching(new RegExp(PATTERN.identifier, 'u')) export function withPartialKeysOf>( model: fc.Arbitrary, diff --git a/packages/schema/README.md b/packages/schema/README.md index 6197c549..9132b58c 100644 --- a/packages/schema/README.md +++ b/packages/schema/README.md @@ -56,7 +56,7 @@ Let's look at a concrete example of how `@traversable/schema` can be used as a r For this example, we'll be using [`@traversable/zod`](https://github.com/traversable/schema/tree/main/packages/zod), since zod is the library most users are familiar with. -Let's write a function that takes an arbitrary zod schema as input an stringifies it. +Let's write a function that takes an arbitrary zod schema as input and stringifies it. > [!NOTE] > This functionality is already available off-the shelf via `zx.toString`. diff --git a/packages/schema/test/seed.ts b/packages/schema/test/seed.ts index a24eaa75..d9adcda4 100644 --- a/packages/schema/test/seed.ts +++ b/packages/schema/test/seed.ts @@ -1,7 +1,7 @@ import { fc } from '@fast-check/vitest' import type * as T from '@traversable/registry' -import { Equal, fn, Number_isSafeInteger, parseKey, symbol, unsafeCompact, URI } from '@traversable/registry' +import { Equal, fn, Number_isSafeInteger, parseKey, PATTERN, symbol, unsafeCompact, URI } from '@traversable/registry' import { Json } from '@traversable/json' import type { SchemaOptions } from '@traversable/schema' import { t, Predicate } from '@traversable/schema' @@ -176,7 +176,7 @@ export const isBounded = (x: number) => x <= -GREATEST_LOWER_BOUND || +GREATEST_ export type UniqueArrayDefaults = fc.UniqueArrayConstraintsRecommended -const identifier = fc.stringMatching(new RegExp('^[$_a-zA-Z][$_a-zA-Z0-9]*$', 'u')) +const identifier = fc.stringMatching(new RegExp(PATTERN.identifier, 'u')) const entries = (model: fc.Arbitrary, constraints?: UniqueArrayDefaults) => fc.uniqueArray( fc.tuple(identifier, model), @@ -1685,15 +1685,9 @@ const extensibleArbitrary = (constraints?: Constraints) => */ const data = (constraints?: Constraints) => fc.letrec(seed(constraints)).tree.chain(toArbitrary) -export const PATTERN = { - alphanumeric: '^[a-zA-Z0-9]*$', - ident: '^[$_a-zA-Z][$_a-zA-Z0-9]*$', - exponential: 'e[-|+]?', -} as const satisfies Record - export const REG_EXP = { alphanumeric: new RegExp(PATTERN.alphanumeric, 'u'), - ident: new RegExp(PATTERN.ident, 'u'), + ident: new RegExp(PATTERN.identifier, 'u'), exponential: new RegExp(PATTERN.exponential, 'u'), } satisfies Record diff --git a/packages/schema/test/to-zod.ts b/packages/schema/test/to-zod.ts index bc786222..ed762524 100644 --- a/packages/schema/test/to-zod.ts +++ b/packages/schema/test/to-zod.ts @@ -64,7 +64,7 @@ export function fromJson(json: Json, options?: Options) { return z.object(parsed) } } - })(json) + })(json as Json) } export function stringFromJson(json: Json, options?: Options, index?: Json.Functor.Index): string diff --git a/packages/typebox-test/src/exports.ts b/packages/typebox-test/src/exports.ts index f3ac421f..c64c42fb 100644 --- a/packages/typebox-test/src/exports.ts +++ b/packages/typebox-test/src/exports.ts @@ -10,8 +10,8 @@ export { SchemaGenerator, SeedGenerator, SeedMap, - SeedReproduciblyInvalidGenerator, - SeedReproduciblyValidGenerator, + SeedInvalidDataGenerator, + SeedValidDataGenerator, seedToInvalidData, seedToInvalidDataGenerator, seedToSchema, diff --git a/packages/typebox-test/src/generator-bounds.ts b/packages/typebox-test/src/generator-bounds.ts index 4e360bbb..48c5a6de 100644 --- a/packages/typebox-test/src/generator-bounds.ts +++ b/packages/typebox-test/src/generator-bounds.ts @@ -74,8 +74,6 @@ interface Bounds_int extends newtype<[ multipleOf: number | null, ]> {} - - const Bounds_int : (model: fc.Arbitrary) => fc.Arbitrary = (model) => fc.tuple(nullable(model), nullable(model), nullable(model)).map(([x, y, multipleOf]) => [ diff --git a/packages/typebox-test/src/generator-options.ts b/packages/typebox-test/src/generator-options.ts index 200ae344..2f3bfde0 100644 --- a/packages/typebox-test/src/generator-options.ts +++ b/packages/typebox-test/src/generator-options.ts @@ -6,7 +6,6 @@ import { TypeNames } from './typename.js' import { byTag } from './generator-seed.js' export type ArrayParams = { - /* length?: number */ minLength?: number maxLength?: number } @@ -217,76 +216,6 @@ export const defaultConstraints = { } satisfies fc.OneOfConstraints, } as const satisfies { [K in keyof Constraints]-?: Required } - -export const paramsDefaults = { - object: objectDefaults, - any: {}, - array: { - minLength: Bounds.defaults.array[0], - maxLength: Bounds.defaults.array[1], - }, - bigint: { - min: Bounds.defaults.bigint[0], - max: Bounds.defaults.bigint[1], - }, - boolean: {}, - date: {}, - integer: { - min: Bounds.defaults.int[0], - max: Bounds.defaults.int[1] - }, - intersect: {}, - literal: {}, - never: {}, - null: {}, - number: { - min: Bounds.defaults.number[0], - max: Bounds.defaults.number[1], - noNaN: true, - noDefaultInfinity: true, - minExcluded: false, - maxExcluded: false, - noInteger: false, - }, - optional: {}, - record: { - depthIdentifier: fc.createDepthIdentifier(), - maxKeys: 3, - minKeys: 1, - noNullPrototype: false, - size: 'xsmall', - } satisfies fc.DictionaryConstraints, - string: { - minLength: Bounds.defaults.string[0], - maxLength: Bounds.defaults.string[1], - size: 'xsmall', - unit: 'grapheme-ascii', - } satisfies fc.StringConstraints, - symbol: {}, - tuple: { - minLength: 1, - maxLength: 3, - size: 'xsmall', - depthIdentifier: fc.createDepthIdentifier(), - } satisfies fc.ArrayConstraints, - undefined: {}, - union: { - depthIdentifier: fc.createDepthIdentifier(), - minLength: 1, - maxLength: 3, - size: 'xsmall', - } satisfies fc.ArrayConstraints, - unknown: {}, - void: {}, - ['*']: { - maxDepth: 3, - depthIdentifier: fc.createDepthIdentifier(), - depthSize: 'xsmall', - withCrossShrink: true, - } satisfies fc.OneOfConstraints, -} satisfies Record - - export const defaults = { exclude: [], forceInvalid: false, diff --git a/packages/typebox-test/src/generator.ts b/packages/typebox-test/src/generator.ts index bc69d1f2..1e96f3f1 100644 --- a/packages/typebox-test/src/generator.ts +++ b/packages/typebox-test/src/generator.ts @@ -20,6 +20,7 @@ import { Object_values, omit, pair, + PATTERN, pick, symbol, Object_create, @@ -32,7 +33,7 @@ import type { Tag } from './generator-seed.js' import { byTag, bySeed, Seed, fold } from './generator-seed.js' import type { TypeName } from './typename.js' -const identifier = fc.stringMatching(new RegExp('^[$_a-zA-Z][$_a-zA-Z0-9]*$', 'u')) +const identifier = fc.stringMatching(new RegExp(PATTERN.identifier, 'u')) function getDefaultValue(x: T.TSchema) { return x._zod.def.type === 'undefined' || x._zod.def.type === 'void' ? undefined : {} @@ -437,7 +438,7 @@ const seedsThatPreventGeneratingInvalidData = [ ] satisfies SchemaGenerator.Options['exclude'] /** - * ## {@link SeedReproduciblyValidGenerator `SeedReproduciblyValidGenerator`} + * ## {@link SeedValidDataGenerator `SeedValidDataGenerator`} * * A seed generator that can be interpreted to produce reliably valid data. * @@ -453,14 +454,14 @@ const seedsThatPreventGeneratingInvalidData = [ * {@link seedsThatPreventGeneratingValidData `seedsThatPreventGeneratingValidData`}. * * See also: - * - {@link SeedReproduciblyInvalidGenerator `SeedReproduciblyInvalidGenerator`} + * - {@link SeedInvalidDataGenerator `SeedInvalidDataGenerator`} * * @example * import * as fc from 'fast-check' * import { z } from 'zod' * import { zx } from '@traversable/zod' * - * const [seed] = fc.sample(zx.SeedReproduciblyValidGenerator, 1) + * const [seed] = fc.sample(zx.SeedValidDataGenerator, 1) * const ZodSchema = zx.seedToSchema(seed) * const dataset = fc.sample(zx.seedToValidData(seed), 5) * @@ -469,10 +470,10 @@ const seedsThatPreventGeneratingInvalidData = [ * console.log(results) // => [true, true, true, true, true] */ -export const SeedReproduciblyValidGenerator = SeedGenerator({ exclude: seedsThatPreventGeneratingValidData })['*'] +export const SeedValidDataGenerator = SeedGenerator({ exclude: seedsThatPreventGeneratingValidData })['*'] /** - * ## {@link SeedReproduciblyInvalidGenerator `zx.SeedReproduciblyInvalidGenerator`} + * ## {@link SeedInvalidDataGenerator `zx.SeedInvalidDataGenerator`} * * A seed generator that can be interpreted to produce reliably invalid data. * @@ -488,14 +489,14 @@ export const SeedReproduciblyValidGenerator = SeedGenerator({ exclude: seedsThat * {@link seedsThatPreventGeneratingInvalidData `zx.seedsThatPreventGeneratingInvalidData`}. * * See also: - * - {@link SeedReproduciblyValidGenerator `zx.SeedReproduciblyValidGenerator`} + * - {@link SeedValidDataGenerator `zx.SeedValidDataGenerator`} * * @example * import * as fc from 'fast-check' * import { z } from 'zod' * import { zx } from '@traversable/zod' * - * const [seed] = fc.sample(zx.SeedReproduciblyInvalidGenerator, 1) + * const [seed] = fc.sample(zx.SeedInvalidDataGenerator, 1) * const ZodSchema = zx.seedToSchema(seed) * const dataset = fc.sample(zx.seedToInvalidData(seed), 5) * @@ -503,7 +504,7 @@ export const SeedReproduciblyValidGenerator = SeedGenerator({ exclude: seedsThat * * console.log(results) // => [false, false, false, false, false] */ -export const SeedReproduciblyInvalidGenerator = fn.pipe( +export const SeedInvalidDataGenerator = fn.pipe( SeedGenerator({ exclude: seedsThatPreventGeneratingInvalidData }), ($) => fc.oneof( $.object, diff --git a/packages/typebox-test/test/generator.test.ts b/packages/typebox-test/test/generator.test.ts index a37f7077..7127e444 100644 --- a/packages/typebox-test/test/generator.test.ts +++ b/packages/typebox-test/test/generator.test.ts @@ -21,8 +21,8 @@ type LogFailureDeps = { const fail = (e: unknown, { msg, seed, schema, data, errors }: LogFailureDeps) => { console.group(`\r\n\nFAILURE: ${msg}`) console.error('\r\nError:', e) - console.debug('\r\nTypeBox Error: (JSON.stringify)', JSON.stringify([...errors], stringify, 2)) - console.debug('\r\nTypeBox: ', [...errors]) + console.debug('\r\nboxTest Error: (JSON.stringify)', JSON.stringify([...errors], stringify, 2)) + console.debug('\r\nboxTest Errors: ', [...errors]) console.debug('\r\nseed: ', JSON.stringify(seed, stringify, 2)) // console.debug('\r\nschema: ', box.toString(schema)) console.debug('\r\ndata: ', data === '' ? '' : data) @@ -32,16 +32,16 @@ const fail = (e: unknown, { msg, seed, schema, data, errors }: LogFailureDeps) = } vi.describe( - 'ใ€–๏ธโ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traversable/typeboxโณ', + 'ใ€–๏ธโ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traversable/typebox-testโณ', // { timeout: 20_000 }, () => { vi.it( - 'ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒbox.SeedReproduciblyValidGeneratorโณ: integration test', + 'ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒboxTest.SeedValidDataGeneratorโณ: integration test', // { timeout: 10_000 }, () => { fc.assert( fc.property( - boxTest.SeedReproduciblyValidGenerator, + boxTest.SeedValidDataGenerator, (seed) => { const schema = boxTest.seedToSchema(seed) const validData = boxTest.seedToValidData(seed) @@ -61,12 +61,12 @@ vi.describe( ) vi.it( - 'ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒbox.SeedReproduciblyInvalidGeneratorโณ: integration test', + 'ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒboxTest.SeedInvalidDataGeneratorโณ: integration test', // { timeout: 10_000 }, () => { fc.assert( fc.property( - boxTest.SeedReproduciblyInvalidGenerator, + boxTest.SeedInvalidDataGenerator, (seed) => { const schema = boxTest.seedToSchema(seed) const invalidData = boxTest.seedToInvalidData(seed) diff --git a/packages/typebox/test/equals.fuzz.test.ts b/packages/typebox/test/equals.fuzz.test.ts new file mode 100644 index 00000000..1b03cdb9 --- /dev/null +++ b/packages/typebox/test/equals.fuzz.test.ts @@ -0,0 +1,98 @@ +import * as vi from 'vitest' +import * as fc from 'fast-check' +import * as T from '@sinclair/typebox' +import { box } from '@traversable/typebox' +import { boxTest } from '@traversable/typebox-test' +import prettier from "@prettier/sync" +import { deriveUnequalValue } from '@traversable/registry' + +const format = (source: string) => prettier.format(source, { parser: 'typescript', semi: false }) + +const exclude = [ + 'never', + 'unknown', + 'any', +] as const + +const generator = boxTest.SeedGenerator({ exclude })['*'] + +const stringify = (x: unknown) => + JSON.stringify(x, (_k, v) => typeof v === 'symbol' ? `Symbol(${v.description})` : typeof v === 'bigint' ? `${v}n` : v, 2) + +type LogFailureDeps = { + schema: T.TSchema + left: unknown + right: unknown +} + +const logFailureEqualData = ({ schema, left, right }: LogFailureDeps) => { + console.group('\n\n\rFAILURE: property test for box.equals (with EQUAL data)\n\n\r') + console.debug('box.toString(schema):\n\r', box.toString(schema), '\n\r') + console.debug('box.equals.writeable(schema):\n\r', format(box.equals.writeable(schema, { typeName: 'Type' })), '\n\r') + console.debug('stringify(left):\n\r', stringify(left), '\n\r') + console.debug('stringify(right):\n\r', stringify(right), '\n\r') + console.debug('left:\n\r', left, '\n\r') + console.debug('right:\n\r', right, '\n\r') + console.groupEnd() +} + +const logFailureUnequalData = ({ schema, left, right }: LogFailureDeps) => { + console.group('\n\n\rFAILURE: property test for box.equals (with UNEQUAL data)\n\n\r') + console.debug('box.toString(schema):\n\r', box.toString(schema), '\n\r') + console.debug('box.equals.writeable(schema):\n\r', format(box.equals.writeable(schema, { typeName: 'Type' })), '\n\r') + console.debug('stringify(left):\n\r', format(stringify(left)), '\n\r') + console.debug('stringify(right):\n\r', format(stringify(right)), '\n\r') + console.debug('left:\n\r', left, '\n\r') + console.debug('right:\n\r', right, '\n\r') + console.groupEnd() +} + +vi.describe('ใ€–โ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traversable/zodโณ: fuzz tests', () => { + vi.test('ใ€–โ›ณ๏ธใ€—โ€บ โฒbox.equalsโณ: equal data', () => { + fc.assert( + fc.property( + generator, + (seed) => { + const schema = boxTest.seedToSchema(seed) + const arbitrary = boxTest.seedToValidDataGenerator(seed) + const cloneArbitrary = fc.clone(arbitrary, 2) + const [[cloned1, cloned2]] = fc.sample(cloneArbitrary, 1) + const equals = box.equals(schema) + try { vi.assert.isTrue(equals(cloned1, cloned2)) } + catch (e) { + console.error('ERROR:', e) + logFailureEqualData({ schema, left: cloned1, right: cloned2 }) + vi.expect.fail(`Equal data failed for box.equal with schema:\n\n${box.toString(schema)}`) + } + } + ), { + endOnFailure: true, + examples: [], + // numRuns: 10_000, + }) + }) + + vi.test('ใ€–โ›ณ๏ธใ€—โ€บ โฒbox.equalsโณ: unequal data', () => { + fc.assert( + fc.property( + generator, + (seed) => { + const schema = boxTest.seedToSchema(seed) + const arbitrary = boxTest.seedToValidDataGenerator(seed) + const [data] = fc.sample(arbitrary, 1) + const unequal = deriveUnequalValue(data) + const equals = box.equals(schema) + try { vi.assert.isFalse(equals(data, unequal)) } + catch (e) { + console.error('ERROR:', e) + logFailureUnequalData({ schema, left: data, right: unequal }) + vi.expect.fail(`Unequal data failed for box.equal with schema:\n\n${box.toString(schema)}`) + } + } + ), { + endOnFailure: true, + examples: [], + // numRuns: 10_000, + }) + }) +}) diff --git a/packages/typebox/tsconfig.test.json b/packages/typebox/tsconfig.test.json index c27cc15d..91d645ac 100644 --- a/packages/typebox/tsconfig.test.json +++ b/packages/typebox/tsconfig.test.json @@ -9,7 +9,8 @@ "references": [ { "path": "tsconfig.src.json" }, { "path": "../json" }, - { "path": "../registry" } + { "path": "../registry" }, + { "path": "../typebox-test" } ], "include": ["test"] } diff --git a/packages/zod-test/src/exports.ts b/packages/zod-test/src/exports.ts index 46e6db14..a1e36432 100644 --- a/packages/zod-test/src/exports.ts +++ b/packages/zod-test/src/exports.ts @@ -11,8 +11,8 @@ export { SchemaGenerator, SeedGenerator, SeedMap, - SeedReproduciblyInvalidGenerator, - SeedReproduciblyValidGenerator, + SeedInvalidDataGenerator, + SeedValidDataGenerator, isTerminal, pickAndSortNodes, seedToInvalidData, diff --git a/packages/zod-test/src/generator-options.ts b/packages/zod-test/src/generator-options.ts index 6e15b180..61d33fc8 100644 --- a/packages/zod-test/src/generator-options.ts +++ b/packages/zod-test/src/generator-options.ts @@ -258,92 +258,6 @@ export const defaultConstraints = { } satisfies fc.OneOfConstraints, } as const satisfies { [K in keyof Constraints]-?: Required } - -export const paramsDefaults = { - object: objectDefaults, - any: {}, - array: { - minLength: Bounds.defaults.array[0], - maxLength: Bounds.defaults.array[1], - }, - bigint: { - min: Bounds.defaults.bigint[0], - max: Bounds.defaults.bigint[1], - }, - boolean: {}, - catch: {}, - custom: {}, - date: {}, - default: {}, - enum: {}, - file: {}, - int: { - min: Bounds.defaults.int[0], - max: Bounds.defaults.int[1] - }, - intersection: {}, - lazy: {}, - literal: {}, - map: {}, - nan: {}, - never: {}, - nonoptional: {}, - null: {}, - nullable: {}, - number: { - min: Bounds.defaults.number[0], - max: Bounds.defaults.number[1], - noNaN: true, - noDefaultInfinity: true, - minExcluded: false, - maxExcluded: false, - noInteger: false, - }, - optional: {}, - pipe: {}, - readonly: {}, - record: { - depthIdentifier: fc.createDepthIdentifier(), - maxKeys: 3, - minKeys: 1, - noNullPrototype: false, - size: 'xsmall', - } satisfies fc.DictionaryConstraints, - set: {}, - string: { - minLength: Bounds.defaults.string[0], - maxLength: Bounds.defaults.string[1], - size: 'xsmall', - unit: 'grapheme-ascii', - } satisfies fc.StringConstraints, - success: {}, - symbol: {}, - template_literal: {}, - transform: {}, - tuple: { - minLength: 1, - maxLength: 3, - size: 'xsmall', - depthIdentifier: fc.createDepthIdentifier(), - } satisfies fc.ArrayConstraints, - undefined: {}, - union: { - depthIdentifier: fc.createDepthIdentifier(), - minLength: 1, - maxLength: 3, - size: 'xsmall', - } satisfies fc.ArrayConstraints, - unknown: {}, - void: {}, - promise: {}, - ['*']: { - maxDepth: 3, - depthIdentifier: fc.createDepthIdentifier(), - depthSize: 'xsmall', - withCrossShrink: true, - } satisfies fc.OneOfConstraints, -} - export const unsupportedSchemas = ['promise'] satisfies (keyof SeedMap)[] export const defaults = { diff --git a/packages/zod-test/src/generator.ts b/packages/zod-test/src/generator.ts index e01c15c3..2923b029 100644 --- a/packages/zod-test/src/generator.ts +++ b/packages/zod-test/src/generator.ts @@ -22,6 +22,7 @@ import { Object_values, omit, pair, + PATTERN, pick, symbol, } from '@traversable/registry' @@ -37,7 +38,7 @@ import { removePrototypeMethods, } from './utils.js' -const identifier = fc.stringMatching(new RegExp('^[$_a-zA-Z][$_a-zA-Z0-9]*$', 'u')) +const identifier = fc.stringMatching(new RegExp(PATTERN.identifier, 'u')) function getDefaultValue(x: z.ZodType) { return x._zod.def.type === 'undefined' || x._zod.def.type === 'void' ? undefined : {} @@ -417,7 +418,7 @@ function intersect(x: unknown, y: unknown) { const GeneratorByTag = { any: () => fc.anything(), boolean: () => fc.boolean(), - date: () => fc.date(), + date: () => fc.date({ noInvalidDate: true }), file: () => file, nan: () => fc.constant(Number.NaN), never: () => fc.constant(void 0 as never), @@ -582,7 +583,7 @@ const seedsThatPreventGeneratingInvalidData = [ ] satisfies SchemaGenerator.Options['exclude'] /** - * ## {@link SeedReproduciblyValidGenerator `SeedReproduciblyValidGenerator`} + * ## {@link SeedValidDataGenerator `SeedValidDataGenerator`} * * A seed generator that can be interpreted to produce reliably valid data. * @@ -598,14 +599,14 @@ const seedsThatPreventGeneratingInvalidData = [ * {@link seedsThatPreventGeneratingValidData `seedsThatPreventGeneratingValidData`}. * * See also: - * - {@link SeedReproduciblyInvalidGenerator `SeedReproduciblyInvalidGenerator`} + * - {@link SeedInvalidDataGenerator `SeedInvalidDataGenerator`} * * @example * import * as fc from 'fast-check' * import { z } from 'zod' * import { zxTest } from '@traversable/zod-test' * - * const [seed] = fc.sample(zxTest.SeedReproduciblyValidGenerator, 1) + * const [seed] = fc.sample(zxTest.SeedValidDataGenerator, 1) * const ZodSchema = zxTest.seedToSchema(seed) * const dataset = fc.sample(zxTest.seedToValidData(seed), 5) * @@ -614,10 +615,10 @@ const seedsThatPreventGeneratingInvalidData = [ * console.log(results) // => [true, true, true, true, true] */ -export const SeedReproduciblyValidGenerator = SeedGenerator({ exclude: seedsThatPreventGeneratingValidData })['*'] +export const SeedValidDataGenerator = SeedGenerator({ exclude: seedsThatPreventGeneratingValidData })['*'] /** - * ## {@link SeedReproduciblyInvalidGenerator `zxTest.SeedReproduciblyInvalidGenerator`} + * ## {@link SeedInvalidDataGenerator `zxTest.SeedInvalidDataGenerator`} * * A seed generator that can be interpreted to produce reliably invalid data. * @@ -633,14 +634,14 @@ export const SeedReproduciblyValidGenerator = SeedGenerator({ exclude: seedsThat * {@link seedsThatPreventGeneratingInvalidData `zxTest.seedsThatPreventGeneratingInvalidData`}. * * See also: - * - {@link SeedReproduciblyValidGenerator `zxTest.SeedReproduciblyValidGenerator`} + * - {@link SeedValidDataGenerator `zxTest.SeedValidDataGenerator`} * * @example * import * as fc from 'fast-check' * import { z } from 'zod' * import { zxTest } from '@traversable/zod-test' * - * const [seed] = fc.sample(zxTest.SeedReproduciblyInvalidGenerator, 1) + * const [seed] = fc.sample(zxTest.SeedInvalidDataGenerator, 1) * const ZodSchema = zxTest.seedToSchema(seed) * const dataset = fc.sample(zxTest.seedToInvalidData(seed), 5) * @@ -648,7 +649,7 @@ export const SeedReproduciblyValidGenerator = SeedGenerator({ exclude: seedsThat * * console.log(results) // => [false, false, false, false, false] */ -export const SeedReproduciblyInvalidGenerator = fn.pipe( +export const SeedInvalidDataGenerator = fn.pipe( SeedGenerator({ exclude: seedsThatPreventGeneratingInvalidData }), ($) => fc.oneof( $.object, diff --git a/packages/zod-test/test/generator.test.ts b/packages/zod-test/test/generator.test.ts index 41497fd0..964af086 100644 --- a/packages/zod-test/test/generator.test.ts +++ b/packages/zod-test/test/generator.test.ts @@ -2,7 +2,6 @@ import * as vi from 'vitest' import * as fc from 'fast-check' import type { z } from 'zod' -// import { zx } from '@traversable/zod' import { zxTest } from '@traversable/zod-test' const stringify = (_: string, v: unknown) => @@ -27,53 +26,44 @@ const fail = (e: unknown, { msg, seed, result, data }: LogFailureDeps) => { vi.assert.fail(`\r\nFAILURE: ${msg}`) } -vi.describe( - 'ใ€–๏ธโ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traversable/zodโณ', - // { timeout: 20_000 }, - () => { - vi.it( - 'ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒzxTest.SeedReproduciblyValidGeneratorโณ: integration test', - // { timeout: 10_000 }, - () => { - fc.assert( - fc.property( - zxTest.SeedReproduciblyValidGenerator, - (seed) => { - const schema = zxTest.seedToSchema(seed) - const data = zxTest.seedToValidData(seed) - let result = schema.safeParse(data) - try { vi.assert.isTrue(result.success) } - catch (e) { fail(e, { msg: 'schema.parse(validData)', data, result, seed }) } - } - ), { - endOnFailure: true, - examples: [], - // numRuns: 10_000, - }) - } - ) +vi.describe('ใ€–๏ธโ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traversable/zod-testโณ', () => { + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒzxTest.SeedValidDataGeneratorโณ: integration test', () => { + fc.assert( + fc.property( + zxTest.SeedValidDataGenerator, + (seed) => { + const schema = zxTest.seedToSchema(seed) + const data = zxTest.seedToValidData(seed) + let result = schema.safeParse(data) + try { vi.assert.isTrue(result.success) } + catch (e) { fail(e, { msg: 'schema.parse(validData)', data, result, seed }) } + } + ), { + endOnFailure: true, + examples: [ + [[20]], + ], + // numRuns: 10_000, + }) + }) - vi.it( - 'ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒzxTest.SeedReproduciblyInvalidGeneratorโณ: integration test', - // { timeout: 10_000 }, - () => { - fc.assert( - fc.property( - zxTest.SeedReproduciblyInvalidGenerator, - (seed) => { - const schema = zxTest.seedToSchema(seed) - const data = zxTest.seedToInvalidData(seed) - let result = schema.safeParse(data) - try { vi.assert.isFalse(result.success) } - catch (e) { fail(e, { msg: 'schema.parse(invalidData)', data, result, seed }) } - } - ), { - endOnFailure: true, - examples: [], - // numRuns: 10_000, - }) - } - ) + vi.it('ใ€–๏ธโ›ณ๏ธใ€—โ€บ โฒzxTest.SeedInvalidDataGeneratorโณ: integration test', () => { + fc.assert( + fc.property( + zxTest.SeedInvalidDataGenerator, + (seed) => { + const schema = zxTest.seedToSchema(seed) + const data = zxTest.seedToInvalidData(seed) + let result = schema.safeParse(data) + try { vi.assert.isFalse(result.success) } + catch (e) { fail(e, { msg: 'schema.parse(invalidData)', data, result, seed }) } + } + ), { + endOnFailure: true, + examples: [], + // numRuns: 10_000, + }) } -) + ) +}) diff --git a/packages/zod-types/README.md b/packages/zod-types/README.md new file mode 100644 index 00000000..02380363 --- /dev/null +++ b/packages/zod-types/README.md @@ -0,0 +1,29 @@ +
+

แฏ“๐˜๐—ฟ๐—ฎ๐˜ƒ๐—ฒ๐—ฟ๐˜€๐—ฎ๐—ฏ๐—น๐—ฒ/๐˜‡๐—ผ๐—ฑ-๐˜๐˜†๐—ฝ๐—ฒ๐˜€

+
+ +

+ "Low-level" primitives like types and predicates. Most of this package is re-exported by @traversable/zod, so you probably won't need to use it directly. +

+ +
+ NPM Version +   + TypeScript +   + Static Badge +   + npm +   +
+ +
+ + Static Badge +   + Static Badge +   +
+
+
diff --git a/packages/zod-types/package.json b/packages/zod-types/package.json new file mode 100644 index 00000000..b2d0f015 --- /dev/null +++ b/packages/zod-types/package.json @@ -0,0 +1,54 @@ +{ + "name": "@traversable/zod-types", + "type": "module", + "version": "0.0.0", + "private": false, + "description": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/traversable/schema.git", + "directory": "packages/zod-types" + }, + "bugs": { + "url": "https://github.com/traversable/schema/issues", + "email": "ahrjarrett@gmail.com" + }, + "@traversable": { + "generateExports": { "include": ["**/*.ts"] }, + "generateIndex": { "include": ["**/*.ts"] } + }, + "publishConfig": { + "access": "public", + "directory": "dist", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "bench": "echo NOTHING TO BENCH", + "build": "pnpm build:esm && pnpm build:cjs && pnpm build:annotate", + "build:annotate": "babel build --plugins annotate-pure-calls --out-dir build --source-maps", + "build:esm": "tsc -b tsconfig.build.json", + "build:cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps", + "check": "tsc -b tsconfig.json", + "clean": "pnpm run \"/^clean:.*/\"", + "clean:build": "rm -rf .tsbuildinfo dist build", + "clean:deps": "rm -rf node_modules", + "test": "vitest" + }, + "dependencies": { + "@traversable/json": "workspace:^", + "@traversable/registry": "workspace:^" + }, + "peerDependencies": { + "zod": "4" + }, + "peerDependenciesMeta": { + "zod": { + "optional": false + } + }, + "devDependencies": { + "@traversable/json": "workspace:^", + "@traversable/registry": "workspace:^" + } +} diff --git a/packages/zod-types/src/__generated__/__manifest__.ts b/packages/zod-types/src/__generated__/__manifest__.ts new file mode 100644 index 00000000..6234a86f --- /dev/null +++ b/packages/zod-types/src/__generated__/__manifest__.ts @@ -0,0 +1,54 @@ +export default { + "name": "@traversable/zod-types", + "type": "module", + "version": "0.0.0", + "private": false, + "description": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/traversable/schema.git", + "directory": "packages/zod-types" + }, + "bugs": { + "url": "https://github.com/traversable/schema/issues", + "email": "ahrjarrett@gmail.com" + }, + "@traversable": { + "generateExports": { "include": ["**/*.ts"] }, + "generateIndex": { "include": ["**/*.ts"] } + }, + "publishConfig": { + "access": "public", + "directory": "dist", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "bench": "echo NOTHING TO BENCH", + "build": "pnpm build:esm && pnpm build:cjs && pnpm build:annotate", + "build:annotate": "babel build --plugins annotate-pure-calls --out-dir build --source-maps", + "build:esm": "tsc -b tsconfig.build.json", + "build:cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps", + "check": "tsc -b tsconfig.json", + "clean": "pnpm run \"/^clean:.*/\"", + "clean:build": "rm -rf .tsbuildinfo dist build", + "clean:deps": "rm -rf node_modules", + "test": "vitest" + }, + "dependencies": { + "@traversable/json": "workspace:^", + "@traversable/registry": "workspace:^" + }, + "peerDependencies": { + "zod": "4" + }, + "peerDependenciesMeta": { + "zod": { + "optional": false + } + }, + "devDependencies": { + "@traversable/json": "workspace:^", + "@traversable/registry": "workspace:^" + } +} as const \ No newline at end of file diff --git a/packages/zod-types/src/exports.ts b/packages/zod-types/src/exports.ts new file mode 100644 index 00000000..cb2381c8 --- /dev/null +++ b/packages/zod-types/src/exports.ts @@ -0,0 +1,6 @@ +export * from './version.js' +export * from './typename.js' +export * from './utils.js' +export * as F from './functor.js' +export type { Z, Algebra, CompilerAlgebra, CompilerIndex, Index } from './functor.js' +export { fold, compile, Functor, IndexedFunctor } from './functor.js' diff --git a/packages/zod/src/functor.ts b/packages/zod-types/src/functor.ts similarity index 99% rename from packages/zod/src/functor.ts rename to packages/zod-types/src/functor.ts index 679a595e..ebfe6732 100644 --- a/packages/zod/src/functor.ts +++ b/packages/zod-types/src/functor.ts @@ -810,3 +810,4 @@ export const nullaryTypeNames = [ export const isNullaryTypeName = (x: unknown): x is NullaryTypeName => typeof x === 'string' && nullaryTypeNames.includes(x as never) export const isNullary = (x: unknown): x is Z.Nullary => has('_zod', 'def', 'type', isNullaryTypeName)(x) export const isUnary = (x: unknown): x is Z.Unary => hasTypeName(x) && !isNullary(x) + diff --git a/packages/zod-types/src/index.ts b/packages/zod-types/src/index.ts new file mode 100644 index 00000000..9fd152fb --- /dev/null +++ b/packages/zod-types/src/index.ts @@ -0,0 +1 @@ +export * from './exports.js' \ No newline at end of file diff --git a/packages/zod/src/typename.ts b/packages/zod-types/src/typename.ts similarity index 100% rename from packages/zod/src/typename.ts rename to packages/zod-types/src/typename.ts diff --git a/packages/zod/src/utils.ts b/packages/zod-types/src/utils.ts similarity index 99% rename from packages/zod/src/utils.ts rename to packages/zod-types/src/utils.ts index 9a46601c..506fe82d 100644 --- a/packages/zod/src/utils.ts +++ b/packages/zod-types/src/utils.ts @@ -177,7 +177,7 @@ export function serializeShort(json: unknown): string { return xs.length === 0 ? '{}' : '{' + xs.map(([k, v]) => parseKey(k) + ': ' + v).join(',') + '}]' } } - })(json as Json) + })(json as Json.Unary) } diff --git a/packages/zod-types/src/version.ts b/packages/zod-types/src/version.ts new file mode 100644 index 00000000..f4e013bf --- /dev/null +++ b/packages/zod-types/src/version.ts @@ -0,0 +1,13 @@ +import pkgJson from './__generated__/__manifest__.js' +import zodPkgJson from 'zod/package.json' with { type: 'json' } + +export const VERSION = `${pkgJson.name}@${pkgJson.version}` as const +export type VERSION = typeof VERSION + +export type RAISE_ISSUE_URL = typeof RAISE_ISSUE_URL +export const RAISE_ISSUE_URL = `${pkgJson.bugs.url}` as const + +export type ZOD_CHANGELOG = typeof ZOD_CHANGELOG +export const ZOD_CHANGELOG = 'https://v4.zod.dev/v4/changelog' + +export const ZOD_VERSION = zodPkgJson.version diff --git a/packages/zod-types/test/version.test.ts b/packages/zod-types/test/version.test.ts new file mode 100644 index 00000000..915d170e --- /dev/null +++ b/packages/zod-types/test/version.test.ts @@ -0,0 +1,10 @@ +import * as vi from 'vitest' +import pkg from '../package.json' with { type: 'json' } +import { VERSION } from '@traversable/zod-types' + +vi.describe('ใ€–โ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traversable/zod-typesโณ', () => { + vi.it('ใ€–โ›ณ๏ธใ€—โ€บ โฒVERSIONโณ', () => { + const expected = `${pkg.name}@${pkg.version}` + vi.assert.equal(VERSION, expected) + }) +}) diff --git a/packages/zod-types/tsconfig.build.json b/packages/zod-types/tsconfig.build.json new file mode 100644 index 00000000..169a0055 --- /dev/null +++ b/packages/zod-types/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.src.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/build.tsbuildinfo", + "types": ["node"], + "declarationDir": "build/dts", + "outDir": "build/esm", + "stripInternal": true + }, + "references": [{ "path": "../json" }, { "path": "../registry" }] +} diff --git a/packages/zod-types/tsconfig.json b/packages/zod-types/tsconfig.json new file mode 100644 index 00000000..2c291d21 --- /dev/null +++ b/packages/zod-types/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [], + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "tsconfig.test.json" } + ] +} diff --git a/packages/zod-types/tsconfig.src.json b/packages/zod-types/tsconfig.src.json new file mode 100644 index 00000000..e39fe357 --- /dev/null +++ b/packages/zod-types/tsconfig.src.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/src.tsbuildinfo", + "rootDir": "src", + "types": ["node"], + "outDir": "build/src" + }, + "references": [{ "path": "../json" }, { "path": "../registry" }], + "include": ["src"] +} diff --git a/packages/zod-types/tsconfig.test.json b/packages/zod-types/tsconfig.test.json new file mode 100644 index 00000000..c27cc15d --- /dev/null +++ b/packages/zod-types/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo", + "rootDir": "test", + "types": ["node"], + "noEmit": true + }, + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "../json" }, + { "path": "../registry" } + ], + "include": ["test"] +} diff --git a/packages/zod-types/vite.config.ts b/packages/zod-types/vite.config.ts new file mode 100644 index 00000000..64dba4ad --- /dev/null +++ b/packages/zod-types/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import sharedConfig from '../../vite.config.js' + +const localConfig = defineConfig({}) + +export default mergeConfig(sharedConfig, localConfig) \ No newline at end of file diff --git a/packages/zod/README.md b/packages/zod/README.md index 86197f33..31a270d5 100644 --- a/packages/zod/README.md +++ b/packages/zod/README.md @@ -2,7 +2,7 @@

แฏ“๐˜๐—ฟ๐—ฎ๐˜ƒ๐—ฒ๐—ฟ๐˜€๐—ฎ๐—ฏ๐—น๐—ฒ/๐˜‡๐—ผ๐—ฑ


-

@traversable/zod or zx is an expansion pack for zod.

+

@traversable/zod or zx is a schema rewriter for zod.

NPM Version @@ -166,10 +166,56 @@ console.log(addressCheck) // => ### `zx.clone` -`zx.clone` lets users derive a specialized "deep clone" function that works with values that have been already validated. +`zx.clone` lets users derive a specialized ["deep clone"](https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy) function that works with values that have been already validated. Because the values have already been validated, clone times are significantly faster than alternatives like [`window.structuredClone`](https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone) and [`lodash.cloneDeep`](https://www.npmjs.com/package/lodash.clonedeep). +#### Performance comparison + +Here's a [Bolt sandbox](https://bolt.new/~/mitata-kytjqemn) if you'd like to run the benchmarks yourself. + +``` + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Array (avg) โ”‚ Object (avg) โ”‚ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ window.structuredClone โ”‚ 4.8x faster โ”‚ 5.3x faster โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Lodash.cloneDeep โ”‚ 9.1x faster โ”‚ 13.7x faster โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +[This article](https://dev.to/ahrjarrett) goes into more detail about what makes `zx.clone` so fast. + +#### Example + +```typescript +import { assert } from 'vitest' +import { z } from 'zod' +import { zx } from '@traversable/zod' + +const Address = z.object({ + street1: z.string(), + strret2: z.optional(z.string()), + city: z.string(), +}) + +const clone = zx.clone(Address) + +const sherlock = { street1: '221 Baker St', street2: '#B', city: 'London' } +const harry = { street1: '4 Privet Dr', city: 'Little Whinging' } + +const sherlockCloned = clone(sherlock) +const harryCloned = clone(harry) + +// values are deeply equal: +assert.deepEqual(sherlockCloned, sherlock) // โœ… +assert.deepEqual(harryCloned, harry) // โœ… + +// values are fresh copies: +assert.notEqual(sherlockCloned, sherlock) // โœ… +assert.notEqual(harryCloned, harry) // โœ… +``` + #### See also - [`zx.clone.writeable`](https://github.com/traversable/schema/tree/main/packages/zod#zxclonewriteable) @@ -183,6 +229,48 @@ Because the values have already been validated, clone times are significantly fa Compared to [`zx.clone`](https://github.com/traversable/schema/tree/main/packages/zod#zxclone), `zx.clone.writeable` returns the clone function in _stringified_ ("writeable") form. +#### Example + +```typescript +import { z } from 'zod' +import { zx } from '@traversable/zod' + +const Address = z.object({ + street1: z.string(), + strret2: z.optional(z.string()), + city: z.string(), +}) + +const clone = zx.clone.writeable(Address, { + typeName: 'Address', + functionName: 'cloneAddress', +}) + +console.log(clone) +// => +// type Address = { +// street1: string; +// street2?: string; +// city: string; +// } +// function cloneAddress(prev: Address): Address { +// const next = Object.create(null) +// const prev_street1 = prev.street1 +// const next_street1 = prev_street1 +// next.street1 = next_street1 +// const prev_street2 = prev.street2 +// let next_street2 +// if (prev_street2 !== undefined) { +// next_street2 = prev_street2 +// next.street2 = next_street2 +// } +// const prev_city = prev.city +// const next_city = prev_city +// next.city = next_city +// return next +// } +``` + #### See also - [`zx.clone`](https://github.com/traversable/schema/tree/main/packages/zod#zxclone) @@ -198,16 +286,16 @@ Because the values have already been validated, comparison times are significant Here's a [Bolt sandbox](https://bolt.new/~/mitata-b2vwmctk) if you'd like to run the benchmarks yourself. ``` - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ Array (avg) โ”‚ Object (avg) โ”‚ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ NodeJS.isDeepStrictEqual โ”‚ 40.3x faster โ”‚ 56.5x faster โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ Lodash.isEqual โ”‚ 53.7x faster โ”‚ 60.1x faster โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Array (avg) โ”‚ Object (avg) โ”‚ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ NodeJS.isDeepStrictEqual โ”‚ 40.3x faster โ”‚ 56.5x faster โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Lodash.isEqual โ”‚ 53.7x faster โ”‚ 60.1x faster โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` -[This article](https://dev.to/ahrjarrett/how-i-built-javascripts-fastest-deep-equals-function-51n8) that goes into more detail about why `zx.equals` is so fast. +[This article](https://dev.to/ahrjarrett/how-i-built-javascripts-fastest-deep-equals-function-51n8) goes into more detail about what makes `zx.equals` so fast. #### Notes - Best performance @@ -1069,6 +1157,54 @@ Use `zx.fold` to define a recursive traversal of a zod schema. Useful when build Compared to the rest of the library, it's fairly "low-level", so unless you're doing something pretty advanced you probably won't need to use it directly. +#### Example + +Let's write a function that takes an arbitrary zod schema as input and stringifies it. + +> [!NOTE] +> This functionality is already available off-the shelf via `zx.toString`. +> We'll be building this example from scratch using `zx.fold` for illustrative purposes. + +```typescript +import { zx } from '@traversable/schema' + +const toString = zx.fold((x) => { + // ๐™˜____๐™˜ this type parameter fills in the "holes" below + switch (true) { + case zx.tagged('null')(x): return 'z.null()' + case zx.tagged('number')(x): return 'z.number()' + case zx.tagged('string')(x): return 'z.string()' + case zx.tagged('boolean')(x): return 'z.boolean()' + case zx.tagged('undefined')(x): return 'z.undefined()' + case zx.tagged('array')(x): return `${x._zod.def.element}.array()` + // ^? method element: string + case zx.tagged('optional')(x): return `${x._zod.def.innerType}.optional()` + // ^? method innerType: string + case zx.tagged('tuple')(x): return `z.tuple([${x._zod.def.items.join(', ')}])` + // ^? method items: string[] + case zx.tagged('record')(x): return `z.record(${x._zod.def.keyType}, ${x._zod.def.valueType})` + // ^? method keyType: string + case zx.tagged('object')(x): + return `z.object({ ${Object.entries(x._zod.def.shape).map(([k, v]) => `${k}: ${v}`).join(', ')} })` + // ^? method shape: { [x: string]: string } + default: throw Error(`Unimplemented: ${x._zod.def.type}`) + // ^^ there's nothing stopping you from implementing the rest! + } +}) + +// Let's test it out: + +console.log( + zx.toString( + z.object({ A: z.array(z.string()), B: z.optional(z.tuple([z.number(), z.boolean()])) }) + ) +) +// => z.object({ A: z.array(z.string()), B: z.optional(z.tuple([z.number(), z.boolean()])) }) +``` + +Our "naive" implementation is actually more robust than it might seem -- in fact, that's how `zx.toString` is [actually defined](https://github.com/traversable/schema/blob/main/packages/zod/src/to-string.ts). + + ### `zx.Functor` > [!NOTE] diff --git a/packages/zod/package.json b/packages/zod/package.json index 80c644b6..5c233a18 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -45,7 +45,8 @@ }, "dependencies": { "@traversable/json": "workspace:^", - "@traversable/registry": "workspace:^" + "@traversable/registry": "workspace:^", + "@traversable/zod-types": "workspace:^" }, "devDependencies": { "@jsonjoy.com/util": "^1.6.0", diff --git a/packages/zod/src/__generated__/__manifest__.ts b/packages/zod/src/__generated__/__manifest__.ts index 51176b83..381b263d 100644 --- a/packages/zod/src/__generated__/__manifest__.ts +++ b/packages/zod/src/__generated__/__manifest__.ts @@ -41,7 +41,8 @@ export default { }, "dependencies": { "@traversable/json": "workspace:^", - "@traversable/registry": "workspace:^" + "@traversable/registry": "workspace:^", + "@traversable/zod-types": "workspace:^" }, "devDependencies": { "@jsonjoy.com/util": "^1.6.0", diff --git a/packages/zod/src/check.ts b/packages/zod/src/check.ts index c709167e..5143a793 100644 --- a/packages/zod/src/check.ts +++ b/packages/zod/src/check.ts @@ -14,10 +14,13 @@ import { isQuoted, } from '@traversable/registry' -import type { CompilerAlgebra as Algebra, Z } from './functor.js' -import * as F from './functor.js' -import { hasTypeName, tagged } from './typename.js' -import { Invariant, isOptional } from './utils.js' +import type { CompilerAlgebra as Algebra, Z } from '@traversable/zod-types' +import { F, hasTypeName, tagged, isOptional, Invariant } from '@traversable/zod-types' + +// import type { } from './functor.js' +// import * as F from './functor.js' +// import { hasTypeName, tagged } from './typename.js' +// import { Invariant, isOptional } from './utils.js' const unsupported = [ 'custom', @@ -220,9 +223,9 @@ export function buildFunctionBody(type: z.core.$ZodType): string { return BODY } -function writeableCheck(schema: z.ZodType, options?: check.Options): string -function writeableCheck(schema: z.core.$ZodType, options?: check.Options): string -function writeableCheck(schema: z.ZodType | z.core.$ZodType, options?: check.Options): string { +function check_writeable(schema: z.ZodType, options?: check.Options): string +function check_writeable(schema: z.core.$ZodType, options?: check.Options): string +function check_writeable(schema: z.ZodType | z.core.$ZodType, options?: check.Options): string { const FUNCTION_NAME = options?.functionName ?? 'check' return ` function ${FUNCTION_NAME} (value) { @@ -263,5 +266,5 @@ export declare namespace check { type Unsupported = typeof unsupported } -check.writeable = writeableCheck +check.writeable = check_writeable check.unsupported = unsupported diff --git a/packages/zod/src/clone.ts b/packages/zod/src/clone.ts index b7e47439..d79d38f4 100644 --- a/packages/zod/src/clone.ts +++ b/packages/zod/src/clone.ts @@ -1,12 +1,11 @@ import { z } from 'zod' import { ident, joinPath, Object_keys, stringifyKey, stringifyLiteral } from '@traversable/registry' - -import * as F from './functor.js' -import { check } from './check.js' -import { toType } from './to-type.js' -import { AnyTypeName, hasTypeName, tagged, TypeName } from './typename.js' -import type { Discriminated, PathSpec } from './utils.js' +import type { AnyTypeName, Discriminated, PathSpec } from '@traversable/zod-types' import { + F, + hasTypeName, + tagged, + TypeName, areAllObjects, defaultNextSpec, defaultPrevSpec, @@ -15,7 +14,10 @@ import { isOptional, isPrimitive, schemaOrdering, -} from './utils.js' +} from '@traversable/zod-types' + +import { check } from './check.js' +import { toType } from './to-type.js' export type Builder = (prev: PathSpec, next: PathSpec, ix: Scope) => string @@ -473,7 +475,7 @@ const interpret = F.fold((x, _, input) => { case tagged('intersection')(x): return intersectionWriteable(x) case tagged('tuple')(x): return tupleWriteable(x, input as z.ZodTuple) case tagged('object')(x): return objectWriteable(x, input as z.ZodObject) - case isUnsupported(x): return import('./utils.js').then(({ Invariant }) => + case isUnsupported(x): return import('@traversable/zod-types').then(({ Invariant }) => Invariant.Unimplemented(x._zod.def.type, 'zx.clone')) as never } }) @@ -526,7 +528,7 @@ export declare namespace clone { * - {@link clone_writeable `zx.clone.writeable`} * * @example - * import * as vi from 'vitest' + * import { assert } from 'vitest' * import { z } from 'zod' * import { zx } from '@traversable/zod' * @@ -538,19 +540,19 @@ export declare namespace clone { * * const clone = zx.clone(Address) * - * const sherlock = { street1: '4 Privet Dr', city: 'Little Whinging' } - * const harry = { street1: '221 Baker St', street2: '#B', city: 'London' } + * const sherlock = { street1: '221 Baker St', street2: '#B', city: 'London' } + * const harry = { street1: '4 Privet Dr', city: 'Little Whinging' } * - * const clonedSherlock = clone(sherlock) - * const clonedHarry = clone(harry) + * const sherlockCloned = clone(sherlock) + * const harryCloned = clone(harry) * * // values are deeply equal: - * vi.assert.deepEqual(clonedSherlock, sherlock) // โœ… - * vi.assert.deepEqual(clonedHarry, harry) // โœ… + * assert.deepEqual(sherlockCloned, sherlock) // โœ… + * assert.deepEqual(harryCloned, harry) // โœ… * * // values are fresh copies: - * vi.assert.notEqual(clonedSherlock, sherlock) // โœ… - * vi.assert.notEqual(clonedHarry, harry) // โœ… + * assert.notEqual(sherlockCloned, sherlock) // โœ… + * assert.notEqual(harryCloned, harry) // โœ… */ export function clone(type: T): (cloneMe: z.infer) => z.infer diff --git a/packages/zod/src/deep-nonnullable.ts b/packages/zod/src/deep-nonnullable.ts index be849eab..65b4a4a5 100644 --- a/packages/zod/src/deep-nonnullable.ts +++ b/packages/zod/src/deep-nonnullable.ts @@ -1,10 +1,10 @@ import * as z from 'zod' import type { newtype, Primitive } from '@traversable/registry' import { fn } from '@traversable/registry' -import * as F from './functor.js' +import type { Atoms } from '@traversable/zod-types' +import { F, tagged } from '@traversable/zod-types' + import { toString } from './to-string.js' -import { tagged } from './typename.js' -import type { Atoms } from './utils.js' export type deepNonNullable = T extends Primitive ? T diff --git a/packages/zod/src/deep-nullable.ts b/packages/zod/src/deep-nullable.ts index e7a976c7..0060f35b 100644 --- a/packages/zod/src/deep-nullable.ts +++ b/packages/zod/src/deep-nullable.ts @@ -1,10 +1,10 @@ import * as z from 'zod' import type { newtype, Primitive } from '@traversable/registry' import { fn } from '@traversable/registry' -import * as F from './functor.js' +import type { Atoms } from '@traversable/zod-types' +import { F, tagged } from '@traversable/zod-types' + import { toString } from './to-string.js' -import { tagged } from './typename.js' -import type { Atoms } from './utils.js' export type deepNullable = T extends Primitive ? T diff --git a/packages/zod/src/deep-optional.ts b/packages/zod/src/deep-optional.ts index 5d53aaae..ba4a2118 100644 --- a/packages/zod/src/deep-optional.ts +++ b/packages/zod/src/deep-optional.ts @@ -1,9 +1,10 @@ import { z } from 'zod' import type { newtype, Primitive } from '@traversable/registry' import { fn } from '@traversable/registry' -import * as F from './functor.js' +import type { Atoms } from '@traversable/zod-types' +import { F } from '@traversable/zod-types' + import { toString } from './to-string.js' -import type { Atoms } from './utils.js' export type deepOptional = T extends Primitive ? undefined | T diff --git a/packages/zod/src/deep-partial.ts b/packages/zod/src/deep-partial.ts index 1f1524de..45c2c12d 100644 --- a/packages/zod/src/deep-partial.ts +++ b/packages/zod/src/deep-partial.ts @@ -1,10 +1,10 @@ import { z } from 'zod' import type { newtype, Primitive } from '@traversable/registry' import { fn } from '@traversable/registry' -import * as F from './functor.js' +import type { Atoms } from '@traversable/zod-types' +import { F, tagged } from '@traversable/zod-types' + import { toString } from './to-string.js' -import { tagged } from './typename.js' -import type { Atoms } from './utils.js' export type deepPartial = T extends Primitive ? T diff --git a/packages/zod/src/deep-readonly.ts b/packages/zod/src/deep-readonly.ts index 8a6caa5f..a37b14c4 100644 --- a/packages/zod/src/deep-readonly.ts +++ b/packages/zod/src/deep-readonly.ts @@ -1,10 +1,10 @@ import { z } from 'zod' import type { newtype, Primitive } from '@traversable/registry' import { fn } from '@traversable/registry' -import * as F from './functor.js' +import type { Atoms } from '@traversable/zod-types' +import { F, tagged } from '@traversable/zod-types' + import { toString } from './to-string.js' -import { tagged } from './typename.js' -import type { Atoms } from './utils.js' export type deepReadonly = T extends Primitive ? T diff --git a/packages/zod/src/deep-required.ts b/packages/zod/src/deep-required.ts index 6559f61c..8d8bcf5e 100644 --- a/packages/zod/src/deep-required.ts +++ b/packages/zod/src/deep-required.ts @@ -1,10 +1,10 @@ import { z } from 'zod' import type { newtype, Primitive } from '@traversable/registry' import { fn } from '@traversable/registry' -import * as F from './functor.js' +import type { Atoms } from '@traversable/zod-types' +import { F, tagged } from '@traversable/zod-types' + import { toString } from './to-string.js' -import { tagged } from './typename.js' -import type { Atoms } from './utils.js' export type deepRequired = T extends Primitive ? T & {} diff --git a/packages/zod/src/default-value.ts b/packages/zod/src/default-value.ts index 5d54250a..c26b9116 100644 --- a/packages/zod/src/default-value.ts +++ b/packages/zod/src/default-value.ts @@ -1,10 +1,7 @@ import { z } from 'zod' import type { Primitive } from '@traversable/registry' import { Array_isArray, fn, has, isPrimitive, Object_assign, Object_fromEntries, Object_keys } from '@traversable/registry' - -import * as F from './functor.js' -import { tagged, TypeName } from './typename.js' -import { Invariant } from './utils.js' +import { F, tagged, TypeName } from '@traversable/zod-types' export type Fixpoint = | undefined @@ -178,7 +175,7 @@ export function defaultValue>( : fn.pipe(Object_keys(keyType), (keys) => fn.map(keys, (k) => [k, x._zod.def.valueType ?? CATCH_ALL]), Object_fromEntries) } /** @deprecated */ - case tagged('promise')(x): return Invariant.Unimplemented('promise', 'withDefault') + case tagged('promise')(x): return import('@traversable/zod-types').then(({ Invariant }) => Invariant.Unimplemented('promise', 'withDefault')) } })(type) } diff --git a/packages/zod/src/equals.ts b/packages/zod/src/equals.ts index e972facf..7e321c71 100644 --- a/packages/zod/src/equals.ts +++ b/packages/zod/src/equals.ts @@ -9,13 +9,11 @@ import { stringifyKey, stringifyLiteral, } from '@traversable/registry' +import type { Discriminated } from '@traversable/zod-types' +import { F, hasTypeName, tagged, TypeName, areAllObjects, getTags, inlinePrimitiveCheck, isPrimitive, schemaOrdering } from '@traversable/zod-types' -import * as F from './functor.js' import { check } from './check.js' import { toType } from './to-type.js' -import { hasTypeName, tagged, TypeName } from './typename.js' -import type { Discriminated } from './utils.js' -import { areAllObjects, getTags, inlinePrimitiveCheck, isPrimitive, schemaOrdering } from './utils.js' export type Path = (string | number)[] @@ -110,7 +108,7 @@ export const defaults = { [TypeName.int]: Equal.SameValueNumber, [TypeName.bigint]: Equal.IsStrictlyEqual, [TypeName.number]: Equal.SameValueNumber, - [TypeName.string]: Object_is, + [TypeName.string]: Equal.IsStrictlyEqual, [TypeName.literal]: Object_is, [TypeName.date]: ((l, r) => Object_is(l?.getTime(), r?.getTime())) satisfies Equal, [TypeName.file]: Object_is, @@ -637,7 +635,7 @@ const fold = F.fold>((x) => { case tagged('intersection')(x): return intersection.fromZod(x) // TODO: handle `keyType`? case tagged('record')(x): return record.fromZod(x) - case isUnsupported(x): return import('./utils.js').then(({ Invariant }) => + case isUnsupported(x): return import('@traversable/zod-types').then(({ Invariant }) => Invariant.Unimplemented(x._zod.def.type, 'zx.equals')) as never } }) @@ -663,7 +661,7 @@ const compileWriteable = F.compile((x, ix, input) => { case tagged('union')(x): return union.writeable(x, input as z.ZodUnion) case tagged('object')(x): return object.writeable(x, input as z.ZodObject) case tagged('record')(x): return record.writeable(x) - case isUnsupported(x): return import('./utils.js').then(({ Invariant }) => + case isUnsupported(x): return import('@traversable/zod-types').then(({ Invariant }) => Invariant.Unimplemented(x._zod.def.type, 'zx.equals')) as never } }) diff --git a/packages/zod/src/exports.ts b/packages/zod/src/exports.ts index 0b83971d..595370cd 100644 --- a/packages/zod/src/exports.ts +++ b/packages/zod/src/exports.ts @@ -1,5 +1,6 @@ -export * from './version.js' +export { fold, IndexedFunctor as Functor, isOptional, isOptionalDeep, tagged, typeof } from '@traversable/zod-types' +export { RAISE_ISSUE_URL, VERSION, ZOD_CHANGELOG, ZOD_VERSION } from './version.js' export { check } from './check.js' export { clone } from './clone.js' export { deepNonNullable } from './deep-nonnullable.js' @@ -10,13 +11,10 @@ export { deepReadonly } from './deep-readonly.js' export { deepRequired } from './deep-required.js' export { defaultValue } from './default-value.js' export { equals } from './equals.js' -export { fold, IndexedFunctor as Functor } from './functor.js' export { fromConstant } from './json.js' export { toPaths } from './to-paths.js' export { toString } from './to-string.js' export { toType } from './to-type.js' -export { tagged, typeof } from './typename.js' -export { isOptional, isOptionalDeep } from './utils.js' export { /** @experimental */ diff --git a/packages/zod/src/lens.ts b/packages/zod/src/lens.ts index 5b0c1c58..9b71c1be 100644 --- a/packages/zod/src/lens.ts +++ b/packages/zod/src/lens.ts @@ -13,9 +13,9 @@ import { Profunctor, symbol, } from '@traversable/registry' +import { tagged } from '@traversable/zod-types' import { defaultValue } from './default-value.js' -import { tagged } from './typename.js' export interface RegisterDSL {} // /** TODO: set up module augmentation */ diff --git a/packages/zod/src/to-paths.ts b/packages/zod/src/to-paths.ts index bef26a96..5b6f503a 100644 --- a/packages/zod/src/to-paths.ts +++ b/packages/zod/src/to-paths.ts @@ -1,9 +1,7 @@ import { z } from 'zod' import type { Returns } from '@traversable/registry' import { fn, Object_entries } from '@traversable/registry' - -import * as F from './functor.js' -import { NS, Sym, tagged } from './typename.js' +import { tagged, F, NS, Sym } from '@traversable/zod-types' export type Path = [(keyof any)[], z.ZodType['_zod']['def']['type']] diff --git a/packages/zod/src/to-string.ts b/packages/zod/src/to-string.ts index e5daa498..6b6a361f 100644 --- a/packages/zod/src/to-string.ts +++ b/packages/zod/src/to-string.ts @@ -1,12 +1,8 @@ import { z } from 'zod' import type { Showable } from '@traversable/registry' import { has, Number_isNatural, Object_entries, parseKey } from '@traversable/registry' - -import type { Z } from './functor.js' -import * as F from './functor.js' -import type { Options as v4_Options } from './utils.js' -import { Ctx, defaults as v4_defaults, serializeShort, Warn } from './utils.js' -import { tagged } from './typename.js' +import type { Options as v4_Options } from '@traversable/zod-types' +import { defaults as v4_defaults, tagged, serializeShort, Ctx, F, Z, Warn } from '@traversable/zod-types' export interface Options extends v4_Options {} export interface Config extends Required {} diff --git a/packages/zod/src/to-type.ts b/packages/zod/src/to-type.ts index 90f47ec6..f0d80ed1 100644 --- a/packages/zod/src/to-type.ts +++ b/packages/zod/src/to-type.ts @@ -1,9 +1,7 @@ import { z } from 'zod' import { escape, parseKey, stringifyKey } from '@traversable/registry' +import { hasTypeName, tagged, serializeShort, Ctx, F, Z, Warn, isOptionalDeep } from '@traversable/zod-types' -import * as F from './functor.js' -import { isOptionalDeep } from './utils.js' -import { hasTypeName, tagged } from './typename.js' const unsupported = [ 'custom', @@ -167,7 +165,7 @@ const algebra = F.compile((x, ix, input) => { const body = [...req, ...opt.map((item) => `_?: ${item.startsWith('undefined | ') ? item.substring('undefined | '.length) : item}`)] return `[${body.join(', ')}${and}]` } - case isUnsupported(x): return import('./utils.js').then(({ Invariant }) => Invariant.Unimplemented(x._zod.def.type, 'toType')) as never + case isUnsupported(x): return import('@traversable/zod-types').then(({ Invariant }) => Invariant.Unimplemented(x._zod.def.type, 'toType')) as never } }) diff --git a/packages/zod/test/check.fuzz.test.ts b/packages/zod/test/check.fuzz.test.ts index a3b478dc..14a66fc7 100644 --- a/packages/zod/test/check.fuzz.test.ts +++ b/packages/zod/test/check.fuzz.test.ts @@ -85,7 +85,7 @@ vi.describe('ใ€–โ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traversable/zodโณ: fuzz tests', () => { vi.test('ใ€–โ›ณ๏ธใ€—โ€บ โฒzx.checkโณ: fuzz test -- invalid data', () => { fc.assert( fc.property( - zxTest.SeedReproduciblyInvalidGenerator, + zxTest.SeedInvalidDataGenerator, (seed) => { const schema = zxTest.seedToSchema(seed) const invalidData = zxTest.seedToInvalidData(seed) diff --git a/packages/zod/test/equals.test.ts b/packages/zod/test/equals.test.ts index 71fef250..bdc5a2b3 100644 --- a/packages/zod/test/equals.test.ts +++ b/packages/zod/test/equals.test.ts @@ -453,6 +453,97 @@ vi.describe('ใ€–โ›ณ๏ธใ€—โ€นโ€นโ€น โฒ@traversable/zodโณ: zx.equals.writeable', }) vi.test('ใ€–โ›ณ๏ธใ€—โ€บ โฒzx.equals.writeableโณ: z.array', () => { + vi.expect.soft(format( + zx.equals.writeable( + z.array( + z.object({ + a: z.array( + z.object({ + b: z.array(z.string()), + c: z.optional(z.string()), + }) + ), + d: z.optional( + z.array( + z.object({ + e: z.optional(z.array(z.string())), + f: z.string(), + }) + ) + ) + }) + ), { typeName: 'Type' } + ) + )).toMatchInlineSnapshot + (` + "type Type = Array<{ + a: Array<{ b: Array; c?: string }> + d?: Array<{ e?: Array; f: string }> + }> + function equals(l: Type, r: Type) { + if (l === r) return true + const length = l.length + if (length !== r.length) return false + for (let ix = length; ix-- !== 0; ) { + const l_item = l[ix] + const r_item = r[ix] + if (l_item.a !== r_item.a) { + const length2 = l_item.a.length + if (length2 !== r_item.a.length) return false + for (let ix = length2; ix-- !== 0; ) { + const l_item_a_item = l_item.a[ix] + const r_item_a_item = r_item.a[ix] + if (l_item_a_item.b !== r_item_a_item.b) { + const length4 = l_item_a_item.b.length + if (length4 !== r_item_a_item.b.length) return false + for (let ix = length4; ix-- !== 0; ) { + const l_item_a_item_b_item = l_item_a_item.b[ix] + const r_item_a_item_b_item = r_item_a_item.b[ix] + if (l_item_a_item_b_item !== r_item_a_item_b_item) return false + } + } + if ( + (l_item_a_item.c === undefined || r_item_a_item.c === undefined) && + l_item_a_item.c !== r_item_a_item.c + ) + return false + if (l_item_a_item.c !== r_item_a_item.c) return false + } + } + if ( + (l_item.d === undefined || r_item.d === undefined) && + l_item.d !== r_item.d + ) + return false + if (l_item.d !== r_item.d) { + const length5 = l_item.d.length + if (length5 !== r_item.d.length) return false + for (let ix = length5; ix-- !== 0; ) { + const l_item_d_item = l_item.d[ix] + const r_item_d_item = r_item.d[ix] + if ( + (l_item_d_item.e === undefined || r_item_d_item.e === undefined) && + l_item_d_item.e !== r_item_d_item.e + ) + return false + if (l_item_d_item.e !== r_item_d_item.e) { + const length7 = l_item_d_item.e.length + if (length7 !== r_item_d_item.e.length) return false + for (let ix = length7; ix-- !== 0; ) { + const l_item_d_item_e_item = l_item_d_item.e[ix] + const r_item_d_item_e_item = r_item_d_item.e[ix] + if (l_item_d_item_e_item !== r_item_d_item_e_item) return false + } + } + if (l_item_d_item.f !== r_item_d_item.f) return false + } + } + } + return true + } + " + `) + vi.expect.soft(format( zx.equals.writeable( z.array(z.number()) diff --git a/packages/zod/tsconfig.build.json b/packages/zod/tsconfig.build.json index 169a0055..472d2392 100644 --- a/packages/zod/tsconfig.build.json +++ b/packages/zod/tsconfig.build.json @@ -7,5 +7,9 @@ "outDir": "build/esm", "stripInternal": true }, - "references": [{ "path": "../json" }, { "path": "../registry" }] + "references": [ + { "path": "../json" }, + { "path": "../registry" }, + { "path": "../zod-types" }, + ] } diff --git a/packages/zod/tsconfig.src.json b/packages/zod/tsconfig.src.json index e39fe357..c97672af 100644 --- a/packages/zod/tsconfig.src.json +++ b/packages/zod/tsconfig.src.json @@ -6,6 +6,10 @@ "types": ["node"], "outDir": "build/src" }, - "references": [{ "path": "../json" }, { "path": "../registry" }], + "references": [ + { "path": "../json" }, + { "path": "../registry" }, + { "path": "../zod-types" } + ], "include": ["src"] } diff --git a/packages/zod/tsconfig.test.json b/packages/zod/tsconfig.test.json index bcc83378..8d245874 100644 --- a/packages/zod/tsconfig.test.json +++ b/packages/zod/tsconfig.test.json @@ -11,7 +11,8 @@ { "path": "tsconfig.src.json" }, { "path": "../json" }, { "path": "../registry" }, - { "path": "../zod-test" } + { "path": "../zod-test" }, + { "path": "../zod-types" } ], "include": ["test"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01e20018..c6833657 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -307,6 +307,46 @@ importers: version: link:../registry/dist publishDirectory: dist + packages/json-schema: + dependencies: + '@prettier/sync': + specifier: ^0.5.2 + version: 0.5.5(prettier@3.6.2) + '@traversable/json-schema-types': + specifier: workspace:^ + version: link:../json-schema-types/dist + '@traversable/registry': + specifier: workspace:^ + version: link:../registry/dist + devDependencies: + '@traversable/json-schema-test': + specifier: workspace:^ + version: link:../json-schema-test/dist + publishDirectory: dist + + packages/json-schema-test: + dependencies: + '@traversable/json': + specifier: workspace:^ + version: link:../json/dist + '@traversable/json-schema-types': + specifier: workspace:^ + version: link:../json-schema-types/dist + '@traversable/registry': + specifier: workspace:^ + version: link:../registry/dist + publishDirectory: dist + + packages/json-schema-types: + devDependencies: + '@traversable/json': + specifier: workspace:^ + version: link:../json/dist + '@traversable/registry': + specifier: workspace:^ + version: link:../registry/dist + publishDirectory: dist + packages/registry: publishDirectory: dist @@ -472,6 +512,9 @@ importers: '@traversable/registry': specifier: workspace:^ version: link:../registry/dist + '@traversable/zod-types': + specifier: workspace:^ + version: link:../zod-types/dist devDependencies: '@jsonjoy.com/util': specifier: ^1.6.0 @@ -548,6 +591,19 @@ importers: version: 4.0.5 publishDirectory: dist + packages/zod-types: + dependencies: + '@traversable/json': + specifier: workspace:^ + version: link:../json/dist + '@traversable/registry': + specifier: workspace:^ + version: link:../registry/dist + zod: + specifier: '4' + version: 4.0.5 + publishDirectory: dist + packages: '@ampproject/remapping@2.3.0': diff --git a/tsconfig.base.json b/tsconfig.base.json index c2474a34..0b434fbc 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -39,6 +39,16 @@ "packages/derive-validators/src/*.js" ], "@traversable/json": ["packages/json/src/index.js"], + "@traversable/json-schema": ["packages/json-schema/src/index.js"], + "@traversable/json-schema-test": [ + "packages/json-schema-test/src/index.js" + ], + "@traversable/json-schema-test/*": ["packages/json-schema-test/*.js"], + "@traversable/json-schema-types": [ + "packages/json-schema-types/src/index.js" + ], + "@traversable/json-schema-types/*": ["packages/json-schema-types/*.js"], + "@traversable/json-schema/*": ["packages/json-schema/*.js"], "@traversable/json/*": ["packages/json/src/*.js"], "@traversable/registry": ["packages/registry/src/index.js"], "@traversable/registry/*": ["packages/registry/src/*.js"], @@ -73,6 +83,8 @@ "@traversable/zod": ["packages/zod/src/index.js"], "@traversable/zod-test": ["packages/zod-test/src/index.js"], "@traversable/zod-test/*": ["packages/zod-test/*.js"], + "@traversable/zod-types": ["packages/zod-types/src/index.js"], + "@traversable/zod-types/*": ["packages/zod-types/*.js"], "@traversable/zod/*": ["packages/zod/*.js"] } } diff --git a/tsconfig.build.json b/tsconfig.build.json index bb2b2a59..b77cf636 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -5,6 +5,9 @@ { "path": "packages/derive-codec/tsconfig.build.json" }, { "path": "packages/derive-equals/tsconfig.build.json" }, { "path": "packages/derive-validators/tsconfig.build.json" }, + { "path": "packages/json-schema-test/tsconfig.build.json" }, + { "path": "packages/json-schema-types/tsconfig.build.json" }, + { "path": "packages/json-schema/tsconfig.build.json" }, { "path": "packages/json/tsconfig.build.json" }, { "path": "packages/registry/tsconfig.build.json" }, { "path": "packages/schema-compiler/tsconfig.build.json" }, @@ -17,6 +20,7 @@ { "path": "packages/typebox-test/tsconfig.build.json" }, { "path": "packages/typebox/tsconfig.build.json" }, { "path": "packages/zod-test/tsconfig.build.json" }, + { "path": "packages/zod-types/tsconfig.build.json" }, { "path": "packages/zod/tsconfig.build.json" } ] } diff --git a/tsconfig.json b/tsconfig.json index 28b3d501..6233b2ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,9 @@ { "path": "packages/derive-equals" }, { "path": "packages/derive-validators" }, { "path": "packages/json" }, + { "path": "packages/json-schema" }, + { "path": "packages/json-schema-test" }, + { "path": "packages/json-schema-types" }, { "path": "packages/registry" }, { "path": "packages/schema" }, { "path": "packages/schema-compiler" }, @@ -17,6 +20,7 @@ { "path": "packages/typebox" }, { "path": "packages/typebox-test" }, { "path": "packages/zod" }, - { "path": "packages/zod-test" } + { "path": "packages/zod-test" }, + { "path": "packages/zod-types" } ] }