From 77adfa4c1b2a5bde1272d12279664d38066b81eb Mon Sep 17 00:00:00 2001 From: Vitaly Budovski Date: Sat, 20 Jul 2024 12:30:14 +1000 Subject: [PATCH 1/2] test: Fix ArraySchema invalid type test --- paseri-lib/src/schemas/array.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/paseri-lib/src/schemas/array.test.ts b/paseri-lib/src/schemas/array.test.ts index c087a11..2f89c99 100644 --- a/paseri-lib/src/schemas/array.test.ts +++ b/paseri-lib/src/schemas/array.test.ts @@ -23,11 +23,11 @@ test('Valid type', () => { }); test('Invalid type', () => { - const schema = p.string(); + const schema = p.array(p.number()); fc.assert( fc.property( - fc.anything().filter((value) => Array.isArray(value)), + fc.anything().filter((value) => !Array.isArray(value)), (data) => { const result = schema.safeParse(data); if (!result.ok) { From f040e7deae06babcaaf381f5a1431b46b2908c59 Mon Sep 17 00:00:00 2001 From: Vitaly Budovski Date: Sat, 20 Jul 2024 12:42:36 +1000 Subject: [PATCH 2/2] feature: Set validator --- .../docs/reference/Collections/set.mdx | 43 +++ paseri-lib/bench/set/type.bench.ts | 26 ++ paseri-lib/src/index.ts | 1 + paseri-lib/src/infer.ts | 8 +- paseri-lib/src/schemas/index.ts | 1 + paseri-lib/src/schemas/set.test.ts | 256 ++++++++++++++++++ paseri-lib/src/schemas/set.ts | 92 +++++++ 7 files changed, 424 insertions(+), 3 deletions(-) create mode 100644 paseri-docs/src/content/docs/reference/Collections/set.mdx create mode 100644 paseri-lib/bench/set/type.bench.ts create mode 100644 paseri-lib/src/schemas/set.test.ts create mode 100644 paseri-lib/src/schemas/set.ts diff --git a/paseri-docs/src/content/docs/reference/Collections/set.mdx b/paseri-docs/src/content/docs/reference/Collections/set.mdx new file mode 100644 index 0000000..ae601ea --- /dev/null +++ b/paseri-docs/src/content/docs/reference/Collections/set.mdx @@ -0,0 +1,43 @@ +--- +title: "Set" +sidebar: + order: 25 +--- + +```typescript +import * as p from '@vbudovski/paseri'; + +const schema = p.set(p.number()); +const data = new Set([1, 2, 3]); + +const result = schema.safeParse(data); +if (result.ok) { + // result.value typed as `Set`. +} +``` + +## Validators + +### `min` + +Consists of at least `size` elements. + +```typescript +p.set(p.number()).min(3); +``` + +### `max` + +Consists of at most `size` elements. + +```typescript +p.set(p.number()).max(3); +``` + +### `length` + +Consists of exactly `size` elements. + +```typescript +p.set(p.number()).size(3); +``` diff --git a/paseri-lib/bench/set/type.bench.ts b/paseri-lib/bench/set/type.bench.ts new file mode 100644 index 0000000..294cd4d --- /dev/null +++ b/paseri-lib/bench/set/type.bench.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import * as p from '../../src/index.ts'; + +const { bench } = Deno; + +const paseriSchema = p.set(p.number()); +const zodSchema = z.set(z.number()); + +const dataValid = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]); +const dataInvalid = null; + +bench('Paseri', { group: 'Type valid' }, () => { + paseriSchema.safeParse(dataValid); +}); + +bench('Zod', { group: 'Type valid' }, () => { + zodSchema.safeParse(dataValid); +}); + +bench('Paseri', { group: 'Type invalid' }, () => { + paseriSchema.safeParse(dataInvalid); +}); + +bench('Zod', { group: 'Type invalid' }, () => { + zodSchema.safeParse(dataInvalid); +}); diff --git a/paseri-lib/src/index.ts b/paseri-lib/src/index.ts index ccf355d..a03186d 100644 --- a/paseri-lib/src/index.ts +++ b/paseri-lib/src/index.ts @@ -8,6 +8,7 @@ export { number, object, record, + set, string, symbol, tuple, diff --git a/paseri-lib/src/infer.ts b/paseri-lib/src/infer.ts index 53c0377..94161c0 100644 --- a/paseri-lib/src/infer.ts +++ b/paseri-lib/src/infer.ts @@ -10,9 +10,11 @@ type Infer = Simplify< ? InferMapped : SchemaType extends Readonly> ? InferMapped - : SchemaType extends Schema - ? OutputType - : never + : SchemaType extends Set> + ? Set + : SchemaType extends Schema + ? OutputType + : never >; export type { Infer }; diff --git a/paseri-lib/src/schemas/index.ts b/paseri-lib/src/schemas/index.ts index 22ac2e0..585501d 100644 --- a/paseri-lib/src/schemas/index.ts +++ b/paseri-lib/src/schemas/index.ts @@ -7,6 +7,7 @@ export { null_ } from './null.ts'; export { number } from './number.ts'; export { object } from './object.ts'; export { record } from './record.ts'; +export { set } from './set.ts'; export { string } from './string.ts'; export { symbol } from './symbol.ts'; export { tuple } from './tuple.ts'; diff --git a/paseri-lib/src/schemas/set.test.ts b/paseri-lib/src/schemas/set.test.ts new file mode 100644 index 0000000..2c19471 --- /dev/null +++ b/paseri-lib/src/schemas/set.test.ts @@ -0,0 +1,256 @@ +import { expect } from '@std/expect'; +import { expectTypeOf } from 'expect-type'; +import fc from 'fast-check'; +import * as p from '../index.ts'; +import type { TreeNode } from '../issue.ts'; + +const { test } = Deno; + +test('Valid type', () => { + const schema = p.set(p.number()); + + fc.assert( + fc.property(fc.array(fc.float()), (data) => { + const dataAsSet = new Set(data); + + const result = schema.safeParse(dataAsSet); + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf>; + expect(result.value).toEqual(dataAsSet); + } else { + expect(result.ok).toBeTruthy(); + } + }), + ); +}); + +test('Invalid type', () => { + const schema = p.set(p.number()); + + fc.assert( + fc.property(fc.anything(), (data) => { + const result = schema.safeParse(data); + if (!result.ok) { + expect(result.issue).toEqual({ type: 'leaf', code: 'invalid_type' }); + } else { + expect(result.ok).toBeFalsy(); + } + }), + ); +}); + +test('Valid min', () => { + const schema = p.set(p.number()).min(3); + + fc.assert( + fc.property( + fc.array(fc.float(), { minLength: 3 }).filter((value) => new Set(value).size >= 3), + (data) => { + const dataAsSet = new Set(data); + + const result = schema.safeParse(dataAsSet); + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf>; + expect(result.value).toBe(dataAsSet); + } else { + expect(result.ok).toBeTruthy(); + } + }, + ), + ); +}); + +test('Invalid min', () => { + const schema = p.set(p.number()).min(3); + + fc.assert( + fc.property( + fc.array(fc.float(), { maxLength: 2 }).filter((value) => new Set(value).size <= 2), + (data) => { + const dataAsSet = new Set(data); + + const result = schema.safeParse(dataAsSet); + if (!result.ok) { + expect(result.issue).toEqual({ type: 'leaf', code: 'too_short' }); + } else { + expect(result.ok).toBeFalsy(); + } + }, + ), + ); +}); + +test('Valid max', () => { + const schema = p.set(p.number()).max(3); + + fc.assert( + fc.property( + fc.array(fc.float(), { maxLength: 3 }).filter((value) => new Set(value).size <= 3), + (data) => { + const dataAsSet = new Set(data); + + const result = schema.safeParse(dataAsSet); + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf>; + expect(result.value).toBe(dataAsSet); + } else { + expect(result.ok).toBeTruthy(); + } + }, + ), + ); +}); + +test('Invalid max', () => { + const schema = p.set(p.number()).max(3); + + fc.assert( + fc.property( + fc.array(fc.float(), { minLength: 4 }).filter((value) => new Set(value).size >= 4), + (data) => { + const dataAsSet = new Set(data); + + const result = schema.safeParse(dataAsSet); + if (!result.ok) { + expect(result.issue).toEqual({ type: 'leaf', code: 'too_long' }); + } else { + expect(result.ok).toBeFalsy(); + } + }, + ), + ); +}); + +test('Valid size', () => { + const schema = p.set(p.number()).size(3); + + fc.assert( + fc.property( + fc.array(fc.float(), { minLength: 3, maxLength: 3 }).filter((value) => new Set(value).size === 3), + (data) => { + const dataAsSet = new Set(data); + + const result = schema.safeParse(dataAsSet); + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf>; + expect(result.value).toBe(dataAsSet); + } else { + expect(result.ok).toBeTruthy(); + } + }, + ), + ); +}); + +test('Invalid size (too long)', () => { + const schema = p.set(p.number()).size(3); + + fc.assert( + fc.property( + fc.array(fc.float(), { minLength: 4 }).filter((value) => new Set(value).size >= 4), + (data) => { + const dataAsSet = new Set(data); + + const result = schema.safeParse(dataAsSet); + if (!result.ok) { + expect(result.issue).toEqual({ type: 'leaf', code: 'too_long' }); + } else { + expect(result.ok).toBeFalsy(); + } + }, + ), + ); +}); + +test('Invalid size (too short)', () => { + const schema = p.set(p.number()).size(3); + + fc.assert( + fc.property( + fc.array(fc.float(), { maxLength: 2 }).filter((value) => new Set(value).size <= 2), + (data) => { + const dataAsSet = new Set(data); + + const result = schema.safeParse(dataAsSet); + if (!result.ok) { + expect(result.issue).toEqual({ type: 'leaf', code: 'too_short' }); + } else { + expect(result.ok).toBeFalsy(); + } + }, + ), + ); +}); + +test('Invalid elements', () => { + const schema = p.set(p.number()); + const data = new Set([1, 'foo', 2, 'bar']); + + const result = schema.safeParse(data); + if (!result.ok) { + const expectedResult: TreeNode = { + type: 'join', + left: { type: 'nest', key: 1, child: { type: 'leaf', code: 'invalid_type' } }, + right: { type: 'nest', key: 3, child: { type: 'leaf', code: 'invalid_type' } }, + }; + expect(result.issue).toEqual(expectedResult); + } else { + expect(result.ok).toBeFalsy(); + } +}); + +test('Optional', () => { + const schema = p.set(p.number()).optional(); + + fc.assert( + fc.property(fc.option(fc.array(fc.float()), { nil: undefined }), (data) => { + const dataAsSet = new Set(data); + + const result = schema.safeParse(dataAsSet); + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf | undefined>; + expect(result.value).toEqual(dataAsSet); + } else { + expect(result.ok).toBeTruthy(); + } + }), + ); +}); + +test('Nullable', () => { + const schema = p.set(p.number()).nullable(); + + fc.assert( + fc.property(fc.option(fc.array(fc.float()), { nil: null }), (data) => { + const dataAsSet = new Set(data); + + const result = schema.safeParse(dataAsSet); + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf | null>; + expect(result.value).toEqual(dataAsSet); + } else { + expect(result.ok).toBeTruthy(); + } + }), + ); +}); + +test('Immutable', async (t) => { + await t.step('min', () => { + const original = p.set(p.string()); + const modified = original.min(3); + expect(modified).not.toEqual(original); + }); + + await t.step('max', () => { + const original = p.set(p.string()); + const modified = original.max(3); + expect(modified).not.toEqual(original); + }); + + await t.step('size', () => { + const original = p.set(p.string()); + const modified = original.size(3); + expect(modified).not.toEqual(original); + }); +}); diff --git a/paseri-lib/src/schemas/set.ts b/paseri-lib/src/schemas/set.ts new file mode 100644 index 0000000..735fa33 --- /dev/null +++ b/paseri-lib/src/schemas/set.ts @@ -0,0 +1,92 @@ +import type { Infer } from '../infer.ts'; +import { type TreeNode, addIssue } from '../issue.ts'; +import { type InternalParseResult, isIssue } from '../result.ts'; +import type { AnySchemaType } from './schema.ts'; +import { Schema } from './schema.ts'; + +class SetSchema extends Schema>> { + private readonly _element: ElementSchemaType; + private _minSize = 0; + private _maxSize = Number.POSITIVE_INFINITY; + + readonly issues = { + INVALID_TYPE: { type: 'leaf', code: 'invalid_type' }, + TOO_LONG: { type: 'leaf', code: 'too_long' }, + TOO_SHORT: { type: 'leaf', code: 'too_short' }, + } as const; + + constructor(element: ElementSchemaType) { + super(); + + this._element = element; + } + protected _clone(): SetSchema { + const cloned = new SetSchema(this._element); + cloned._minSize = this._minSize; + cloned._maxSize = this._maxSize; + + return cloned; + } + _parse(value: unknown): InternalParseResult>> { + if (!(value instanceof Set)) { + return this.issues.INVALID_TYPE; + } + + const size = value.size; + const maxSize = this._maxSize; + const minSize = this._minSize; + + if (size > maxSize) { + return this.issues.TOO_LONG; + } + + if (size < minSize) { + return this.issues.TOO_SHORT; + } + + const schema = this._element; + + let issue: TreeNode | undefined = undefined; + let i = 0; + for (const childValue of value) { + const issueOrSuccess = schema._parse(childValue); + if (issueOrSuccess !== undefined && isIssue(issueOrSuccess)) { + issue = addIssue(issue, { type: 'nest', key: i, child: issueOrSuccess }); + } + i++; + } + + if (issue) { + return issue; + } + + return undefined; + } + min(size: number): SetSchema { + const cloned = this._clone(); + cloned._minSize = size; + + return cloned; + } + max(size: number): SetSchema { + const cloned = this._clone(); + cloned._maxSize = size; + + return cloned; + } + size(size: number): SetSchema { + const cloned = this._clone(); + cloned._minSize = size; + cloned._maxSize = size; + + return cloned; + } +} + +function set( + ...args: ConstructorParameters> +): SetSchema { + return new SetSchema(...args); +} + +export { set };