diff --git a/zui/package.json b/zui/package.json index 6adb898f..fc4006bf 100644 --- a/zui/package.json +++ b/zui/package.json @@ -1,6 +1,6 @@ { "name": "@bpinternal/zui", - "version": "0.15.0", + "version": "0.16.0", "description": "A fork of Zod with additional features", "type": "module", "source": "./src/index.ts", diff --git a/zui/src/transforms/common/json-schema.ts b/zui/src/transforms/common/json-schema.ts index dd3cc4ff..dda03ac5 100644 --- a/zui/src/transforms/common/json-schema.ts +++ b/zui/src/transforms/common/json-schema.ts @@ -40,8 +40,8 @@ type BaseZuiJsonSchema = {}> = util.Satisfies< type _StringSchema = util.Satisfies<{ type: 'string' }, JSONSchema7> // TODO: support all string checks type _NumberSchema = util.Satisfies<{ type: 'number' | 'integer' }, JSONSchema7> // TODO: support all number checks type _BigIntSchema = util.Satisfies<{ type: 'integer' }, JSONSchema7> // TODO: support all bigint checks -type _BooleanSchema = util.Satisfies<{ type: 'boolean' }, JSONSchema7> // TODO: support all boolean checks -type _DateSchema = util.Satisfies<{ type: 'string'; format: 'date-time' }, JSONSchema7> +type _BooleanSchema = util.Satisfies<{ type: 'boolean' }, JSONSchema7> +type _DateSchema = util.Satisfies<{ type: 'string'; format: 'date-time' }, JSONSchema7> // TODO: support all date checks type _NullSchema = util.Satisfies<{ type: 'null' }, JSONSchema7> type _UndefinedSchema = util.Satisfies<{ not: true }, JSONSchema7> type _NeverSchema = util.Satisfies<{ not: true }, JSONSchema7> diff --git a/zui/src/transforms/zui-to-typescript-schema/array-checks.ts b/zui/src/transforms/zui-to-typescript-schema/array-checks.ts new file mode 100644 index 00000000..fea370e2 --- /dev/null +++ b/zui/src/transforms/zui-to-typescript-schema/array-checks.ts @@ -0,0 +1,19 @@ +import { primitiveToTypescriptValue as toTs } from '../common/utils' +import { ZodArrayDef } from '../../z/types/array' + +export const generateArrayChecks = (def: ZodArrayDef): string => { + const checks: string[] = [] + if (def.exactLength) { + const { value, message } = def.exactLength + checks.push(`.length(${toTs(value)}, ${toTs(message)})`) + } + if (def.minLength) { + const { value, message } = def.minLength + checks.push(`.min(${toTs(value)}, ${toTs(message)})`) + } + if (def.maxLength) { + const { value, message } = def.maxLength + checks.push(`.max(${toTs(value)}, ${toTs(message)})`) + } + return checks.join('') +} diff --git a/zui/src/transforms/zui-to-typescript-schema/bigint-checks.ts b/zui/src/transforms/zui-to-typescript-schema/bigint-checks.ts new file mode 100644 index 00000000..9840cfbf --- /dev/null +++ b/zui/src/transforms/zui-to-typescript-schema/bigint-checks.ts @@ -0,0 +1,25 @@ +import { primitiveToTypescriptValue as toTs } from '../common/utils' +import { ZodBigIntCheck, ZodBigIntDef } from '../../z/types/bigint' +import { util } from '../../z' + +export const generateBigIntChecks = (def: ZodBigIntDef): string => { + const checks = def.checks + if (checks.length === 0) { + return '' + } + return checks.map(_generateBigIntCheck).join('') +} + +const _generateBigIntCheck = (check: ZodBigIntCheck): string => { + switch (check.kind) { + case 'min': + return `.min(${toTs(check.value)}, ${toTs(check.message)})` + case 'max': + return `.max(${toTs(check.value)}, ${toTs(check.message)})` + case 'multipleOf': + return `.multipleOf(${toTs(check.value)}, ${toTs(check.message)})` + default: + type _assertion = util.AssertNever + return '' + } +} diff --git a/zui/src/transforms/zui-to-typescript-schema/date-checks.ts b/zui/src/transforms/zui-to-typescript-schema/date-checks.ts new file mode 100644 index 00000000..82209b5d --- /dev/null +++ b/zui/src/transforms/zui-to-typescript-schema/date-checks.ts @@ -0,0 +1,29 @@ +import { primitiveToTypescriptValue as toTs } from '../common/utils' +import { ZodDateCheck, ZodDateDef } from '../../z/types/date' +import { util } from '../../z' + +export const generateDateChecks = (def: ZodDateDef): string => { + const checks = def.checks + if (checks.length === 0) { + return '' + } + return checks.map(_generateDateCheck).join('') +} + +const _generateDateCheck = (check: ZodDateCheck): string => { + switch (check.kind) { + case 'min': + const minDate = dateTs(check.value) + return `.min(${minDate}, ${toTs(check.message)})` + case 'max': + const maxDate = dateTs(check.value) + return `.max(${maxDate}, ${toTs(check.message)})` + default: + type _assertion = util.AssertNever + return '' + } +} + +const dateTs = (d: number): string => { + return `new Date(${d})` +} diff --git a/zui/src/transforms/zui-to-typescript-schema/index.test.ts b/zui/src/transforms/zui-to-typescript-schema/index.test.ts index df39cf2a..e23a359c 100644 --- a/zui/src/transforms/zui-to-typescript-schema/index.test.ts +++ b/zui/src/transforms/zui-to-typescript-schema/index.test.ts @@ -13,11 +13,16 @@ const evalZui = (source: string): ZodSchema => { return evalResult.value } +const generate = (source: Z): Z => evalZui(toTypescript(source)) as Z + const assert = (source: ZodType) => ({ toGenerateItself() { - const actual = toTypescript(source) - const destination = evalZui(actual) - expect(source.isEqual(destination)).toBe(true) + const destination = generate(source) + let msg: string | undefined + try { + msg = `Expected ${JSON.stringify(source._def)} to equal ${JSON.stringify(destination._def)}` + } catch {} + expect(source.isEqual(destination), msg).toBe(true) }, toThrowErrorWhenGenerating() { const fn = () => toTypescript(source) @@ -26,29 +31,223 @@ const assert = (source: ZodType) => ({ }) describe.concurrent('toTypescriptZuiString', () => { - test('string', () => { - const schema = z.string() - assert(schema).toGenerateItself() + describe.concurrent('string', () => { + test('no checks', () => { + const schema = z.string() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([]) + }) + test('min', () => { + const schema = z.string().min(42) + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'min', value: 42, message: undefined }]) + }) + test('max', () => { + const schema = z.string().max(42) + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'max', value: 42, message: undefined }]) + }) + test('length', () => { + const schema = z.string().length(42) + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'length', value: 42, message: undefined }]) + }) + test('email', () => { + const schema = z.string().email() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'email', message: undefined }]) + }) + test('url', () => { + const schema = z.string().url() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'url', message: undefined }]) + }) + test('emoji', () => { + const schema = z.string().emoji() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'emoji', message: undefined }]) + }) + test('uuid', () => { + const schema = z.string().uuid() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'uuid', message: undefined }]) + }) + test('cuid', () => { + const schema = z.string().cuid() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'cuid', message: undefined }]) + }) + test('cuid2', () => { + const schema = z.string().cuid2() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'cuid2', message: undefined }]) + }) + test('ulid', () => { + const schema = z.string().ulid() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'ulid', message: undefined }]) + }) + test('includes', () => { + const schema = z.string().includes('banana') + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'includes', value: 'banana', message: undefined }]) + }) + test('startsWith', () => { + const schema = z.string().startsWith('banana') + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'startsWith', value: 'banana', message: undefined }]) + }) + test('endsWith', () => { + const schema = z.string().endsWith('banana') + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'endsWith', value: 'banana', message: undefined }]) + }) + test('regex', () => { + const schema = z.string().regex(/banana/g) + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'regex', regex: /banana/g, message: undefined }]) + }) + test('trim', () => { + const schema = z.string().trim() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'trim', message: undefined }]) + }) + test('toLowerCase', () => { + const schema = z.string().toLowerCase() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'toLowerCase', message: undefined }]) + }) + test('toUpperCase', () => { + const schema = z.string().toUpperCase() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'toUpperCase', message: undefined }]) + }) + test('datetime', () => { + const schema = z.string().datetime() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'datetime', message: undefined, offset: false, precision: null }]) + }) + test('ip', () => { + const schema = z.string().ip() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'ip', message: undefined }]) + }) }) - test('number', () => { - const schema = z.number() - assert(schema).toGenerateItself() + describe.concurrent('number', () => { + test('no checks', () => { + const schema = z.number() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([]) + }) + test('min', () => { + const schema = z.number().min(42) + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'min', value: 42, message: undefined, inclusive: true }]) + }) + test('max', () => { + const schema = z.number().max(42) + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'max', value: 42, message: undefined, inclusive: true }]) + }) + test('int', () => { + const schema = z.number().int() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'int', message: undefined }]) + }) + test('multipleOf', () => { + const schema = z.number().multipleOf(42) + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'multipleOf', value: 42, message: undefined }]) + }) + test('finite', () => { + const schema = z.number().finite() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'finite', message: undefined }]) + }) }) test('nan', () => { const schema = z.nan() assert(schema).toGenerateItself() }) - test('bigint', () => { - const schema = z.bigint() - assert(schema).toGenerateItself() + describe.concurrent('bigint', () => { + test('no checks', () => { + const schema = z.bigint() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([]) + }) + test('min', () => { + const schema = z.bigint().min(BigInt(42)) + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'min', value: BigInt(42), message: undefined, inclusive: true }]) + }) + test('max', () => { + const schema = z.bigint().min(BigInt(42)) + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'min', value: BigInt(42), message: undefined, inclusive: true }]) + }) + test('multipleOf', () => { + const schema = z.bigint().min(BigInt(42)) + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'min', value: BigInt(42), message: undefined, inclusive: true }]) + }) }) test('boolean', () => { const schema = z.boolean() assert(schema).toGenerateItself() }) - test('date', () => { - const schema = z.date() - assert(schema).toGenerateItself() + describe.concurrent('date', () => { + test('no checks', () => { + const schema = z.date() + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([]) + }) + + test('min', () => { + const min = new Date() + const schema = z.date().min(min) + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'min', value: min.getTime(), message: undefined }]) + }) + + test('max', () => { + const max = new Date() + const schema = z.date().max(max) + assert(schema).toGenerateItself() + const evaluated = generate(schema) + expect(evaluated._def.checks).toEqual([{ kind: 'max', value: max.getTime(), message: undefined }]) + }) }) test('undefined', () => { const schema = z.undefined() @@ -74,9 +273,26 @@ describe.concurrent('toTypescriptZuiString', () => { const schema = z.void() assert(schema).toGenerateItself() }) - test('array', () => { - const schema = z.array(z.string()) - assert(schema).toGenerateItself() + describe.concurrent('array', () => { + test('no checks', () => { + const schema = z.array(z.string()) + assert(schema).toGenerateItself() + }) + + test('min', () => { + const schema = z.array(z.string()).min(42) + assert(schema).toGenerateItself() + }) + + test('max', () => { + const schema = z.array(z.string()).max(42) + assert(schema).toGenerateItself() + }) + + test('length', () => { + const schema = z.array(z.string()).length(42) + assert(schema).toGenerateItself() + }) }) test('object', () => { const schema = z.object({ @@ -112,9 +328,21 @@ describe.concurrent('toTypescriptZuiString', () => { const schema = z.map(z.string(), z.number()) assert(schema).toGenerateItself() }) - test('set', () => { - const schema = z.set(z.string()) - assert(schema).toGenerateItself() + describe.concurrent('set', () => { + test('no checks', () => { + const schema = z.set(z.string()) + assert(schema).toGenerateItself() + }) + + test('min', () => { + const schema = z.set(z.string()).min(42) + assert(schema).toGenerateItself() + }) + + test('max', () => { + const schema = z.set(z.string()).max(42) + assert(schema).toGenerateItself() + }) }) test('function with no argument', () => { const schema = z.function().returns(z.void()) diff --git a/zui/src/transforms/zui-to-typescript-schema/index.ts b/zui/src/transforms/zui-to-typescript-schema/index.ts index f503d67c..b46ba268 100644 --- a/zui/src/transforms/zui-to-typescript-schema/index.ts +++ b/zui/src/transforms/zui-to-typescript-schema/index.ts @@ -8,6 +8,12 @@ import { } from '../common/utils' import * as errors from '../common/errors' import { zuiKey } from '../../ui/constants' +import { generateStringChecks } from './string-checks' +import { generateNumberChecks } from './number-checks' +import { generateBigIntChecks } from './bigint-checks' +import { generateDateChecks } from './date-checks' +import { generateArrayChecks } from './array-checks' +import { generateSetChecks } from './set-checks' /** * @@ -27,22 +33,22 @@ function sUnwrapZod(schema: z.Schema): string { switch (def.typeName) { case z.ZodFirstPartyTypeKind.ZodString: - return `z.string()${_addZuiExtensions(def)}${_maybeDescribe(def)}`.trim() + return `z.string()${generateStringChecks(def)}${_addZuiExtensions(def)}${_maybeDescribe(def)}`.trim() case z.ZodFirstPartyTypeKind.ZodNumber: - return `z.number()${_addZuiExtensions(def)}${_maybeDescribe(def)}`.trim() + return `z.number()${generateNumberChecks(def)}${_addZuiExtensions(def)}${_maybeDescribe(def)}`.trim() case z.ZodFirstPartyTypeKind.ZodNaN: return `z.nan()${_addZuiExtensions(def)}${_maybeDescribe(def)}`.trim() case z.ZodFirstPartyTypeKind.ZodBigInt: - return `z.bigint()${_addZuiExtensions(def)}${_maybeDescribe(def)}`.trim() + return `z.bigint()${generateBigIntChecks(def)}${_addZuiExtensions(def)}${_maybeDescribe(def)}`.trim() case z.ZodFirstPartyTypeKind.ZodBoolean: return `z.boolean()${_addZuiExtensions(def)}${_maybeDescribe(schema._def)}`.trim() case z.ZodFirstPartyTypeKind.ZodDate: - return `z.date()${_addZuiExtensions(def)}${_maybeDescribe(def)}`.trim() + return `z.date()${generateDateChecks(def)}${_addZuiExtensions(def)}${_maybeDescribe(def)}`.trim() case z.ZodFirstPartyTypeKind.ZodUndefined: return `z.undefined()${_addZuiExtensions(def)}${_maybeDescribe(def)}`.trim() @@ -63,7 +69,7 @@ function sUnwrapZod(schema: z.Schema): string { return `z.void()${_addZuiExtensions(def)}${_maybeDescribe(def)}`.trim() case z.ZodFirstPartyTypeKind.ZodArray: - return `z.array(${sUnwrapZod(def.type)})${_addZuiExtensions(def)}${_maybeDescribe(def)}` + return `z.array(${sUnwrapZod(def.type)})${generateArrayChecks(def)}${_addZuiExtensions(def)}${_maybeDescribe(def)}` case z.ZodFirstPartyTypeKind.ZodObject: const props = mapValues(def.shape(), (value) => { @@ -110,7 +116,7 @@ function sUnwrapZod(schema: z.Schema): string { return `z.map(${mapKeyType}, ${mapValueType})${_addZuiExtensions(def)}${_maybeDescribe(def)}`.trim() case z.ZodFirstPartyTypeKind.ZodSet: - return `z.set(${sUnwrapZod(def.valueType)})${_addZuiExtensions(def)}${_maybeDescribe(def)}`.trim() + return `z.set(${sUnwrapZod(def.valueType)})${generateSetChecks(def)}${_addZuiExtensions(def)}${_maybeDescribe(def)}`.trim() case z.ZodFirstPartyTypeKind.ZodFunction: const args = def.args.items.map(sUnwrapZod) diff --git a/zui/src/transforms/zui-to-typescript-schema/number-checks.ts b/zui/src/transforms/zui-to-typescript-schema/number-checks.ts new file mode 100644 index 00000000..9dc21d0f --- /dev/null +++ b/zui/src/transforms/zui-to-typescript-schema/number-checks.ts @@ -0,0 +1,29 @@ +import { primitiveToTypescriptValue as toTs } from '../common/utils' +import { ZodNumberCheck, ZodNumberDef } from '../../z/types/number' +import { util } from '../../z' + +export const generateNumberChecks = (def: ZodNumberDef): string => { + const checks = def.checks + if (checks.length === 0) { + return '' + } + return checks.map(_generateNumberCheck).join('') +} + +const _generateNumberCheck = (check: ZodNumberCheck): string => { + switch (check.kind) { + case 'min': + return `.min(${toTs(check.value)}, ${toTs(check.message)})` + case 'max': + return `.max(${toTs(check.value)}, ${toTs(check.message)})` + case 'int': + return `.int(${toTs(check.message)})` + case 'multipleOf': + return `.multipleOf(${toTs(check.value)}, ${toTs(check.message)})` + case 'finite': + return `.finite(${toTs(check.message)})` + default: + type _assertion = util.AssertNever + return '' + } +} diff --git a/zui/src/transforms/zui-to-typescript-schema/set-checks.ts b/zui/src/transforms/zui-to-typescript-schema/set-checks.ts new file mode 100644 index 00000000..17726bb4 --- /dev/null +++ b/zui/src/transforms/zui-to-typescript-schema/set-checks.ts @@ -0,0 +1,14 @@ +import { primitiveToTypescriptValue as toTs } from '../common/utils' +import { ZodSetDef } from '../../z/types/set' +export const generateSetChecks = (def: ZodSetDef): string => { + const checks: string[] = [] + if (def.minSize) { + const { value, message } = def.minSize + checks.push(`.min(${toTs(value)}, ${toTs(message)})`) + } + if (def.maxSize) { + const { value, message } = def.maxSize + checks.push(`.max(${toTs(value)}, ${toTs(message)})`) + } + return checks.join('') +} diff --git a/zui/src/transforms/zui-to-typescript-schema/string-checks.ts b/zui/src/transforms/zui-to-typescript-schema/string-checks.ts new file mode 100644 index 00000000..1e2edadb --- /dev/null +++ b/zui/src/transforms/zui-to-typescript-schema/string-checks.ts @@ -0,0 +1,66 @@ +import { primitiveToTypescriptValue as toTs, unknownToTypescriptValue } from '../common/utils' +import { ZodStringCheck, ZodStringDef } from '../../z/types/string' +import { util } from '../../z' + +export const generateStringChecks = (def: ZodStringDef): string => { + const checks = def.checks + if (checks.length === 0) { + return '' + } + return checks.map(_generateStringCheck).join('') +} + +const _generateStringCheck = (check: ZodStringCheck): string => { + switch (check.kind) { + case 'min': + return `.min(${toTs(check.value)}, ${toTs(check.message)})` + case 'max': + return `.max(${toTs(check.value)}, ${toTs(check.message)})` + case 'length': + return `.length(${toTs(check.value)}, ${toTs(check.message)})` + case 'email': + return `.email(${toTs(check.message)})` + case 'url': + return `.url(${toTs(check.message)})` + case 'emoji': + return `.emoji(${toTs(check.message)})` + case 'uuid': + return `.uuid(${toTs(check.message)})` + case 'cuid': + return `.cuid(${toTs(check.message)})` + case 'cuid2': + return `.cuid2(${toTs(check.message)})` + case 'ulid': + return `.ulid(${toTs(check.message)})` + case 'includes': + const includesOptions = unknownToTypescriptValue({ message: check.message, position: check.position }) + return `.includes(${toTs(check.value)}, ${includesOptions})` + case 'startsWith': + return `.startsWith(${toTs(check.value)}, ${toTs(check.message)})` + case 'endsWith': + return `.endsWith(${toTs(check.value)}, ${toTs(check.message)})` + case 'regex': + const tsRegex = String(check.regex) + return `.regex(${tsRegex}, ${toTs(check.message)})` + case 'trim': + return `.trim()` + case 'toLowerCase': + return `.toLowerCase()` + case 'toUpperCase': + return `.toUpperCase()` + case 'datetime': + const datetimePrecision = check.precision === null ? undefined : check.precision + const dateTimeOptions = unknownToTypescriptValue({ + message: check.message, + precision: datetimePrecision, + offset: check.offset, + }) + return `.datetime(${dateTimeOptions})` + case 'ip': + const ipOptions = unknownToTypescriptValue({ message: check.message, version: check.version }) + return `.ip(${ipOptions})` + default: + type _assertion = util.AssertNever + return '' + } +}