diff --git a/library/src/actions/index.ts b/library/src/actions/index.ts index de638d32b..dbb0ad09e 100644 --- a/library/src/actions/index.ts +++ b/library/src/actions/index.ts @@ -80,6 +80,7 @@ export * from './trimEnd/index.ts'; export * from './trimStart/index.ts'; export * from './types.ts'; export * from './ulid/index.ts'; +export * from './uniqueItems/index.ts'; export * from './url/index.ts'; export * from './uuid/index.ts'; export * from './value/index.ts'; diff --git a/library/src/actions/uniqueItems/index.ts b/library/src/actions/uniqueItems/index.ts new file mode 100644 index 000000000..d2cbf9748 --- /dev/null +++ b/library/src/actions/uniqueItems/index.ts @@ -0,0 +1 @@ +export * from './uniqueItems.ts'; diff --git a/library/src/actions/uniqueItems/uniqueItems.test-d.ts b/library/src/actions/uniqueItems/uniqueItems.test-d.ts new file mode 100644 index 000000000..1251c87de --- /dev/null +++ b/library/src/actions/uniqueItems/uniqueItems.test-d.ts @@ -0,0 +1,50 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import type { InferInput, InferIssue, InferOutput } from '../../types/index.ts'; +import { + uniqueItems, + type UniqueItemsAction, + type UniqueItemsIssue, +} from './uniqueItems.ts'; + +describe('uniqueItems', () => { + describe('should return action object', () => { + test('with undefined message', () => { + type Action = UniqueItemsAction; + expectTypeOf(uniqueItems()).toEqualTypeOf(); + expectTypeOf( + uniqueItems(undefined) + ).toEqualTypeOf(); + }); + + test('with string message', () => { + expectTypeOf(uniqueItems('message')).toEqualTypeOf< + UniqueItemsAction + >(); + }); + + test('with function message', () => { + expectTypeOf( + uniqueItems string>(() => 'message') + ).toEqualTypeOf string>>(); + }); + }); + + describe('should infer correct types', () => { + type Input = [1, 'two', { value: 'three' }]; + type Action = UniqueItemsAction; + + test('of input', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test('of output', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test('of issue', () => { + expectTypeOf>().toEqualTypeOf< + UniqueItemsIssue + >(); + }); + }); +}); diff --git a/library/src/actions/uniqueItems/uniqueItems.test.ts b/library/src/actions/uniqueItems/uniqueItems.test.ts new file mode 100644 index 000000000..e35611b66 --- /dev/null +++ b/library/src/actions/uniqueItems/uniqueItems.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from 'vitest'; +import type { StringIssue } from '../../schemas/index.ts'; +import type { PartialDataset } from '../../types/dataset.ts'; +import { expectNoActionIssue } from '../../vitest/index.ts'; +import { + uniqueItems, + type UniqueItemsAction, + type UniqueItemsIssue, +} from './uniqueItems.ts'; + +describe('uniqueItems', () => { + describe('should return action object', () => { + const baseAction: Omit, 'message'> = { + kind: 'validation', + type: 'unique_items', + reference: uniqueItems, + expects: null, + async: false, + '~validate': expect.any(Function), + }; + + test('with undefined message', () => { + const action: UniqueItemsAction = { + ...baseAction, + message: undefined, + }; + expect(uniqueItems()).toStrictEqual(action); + expect(uniqueItems(undefined)).toStrictEqual( + action + ); + }); + + test('with string message', () => { + const message = 'message'; + expect(uniqueItems(message)).toStrictEqual({ + ...baseAction, + message, + } satisfies UniqueItemsAction); + }); + + test('with function message', () => { + const message = () => 'message'; + expect(uniqueItems(message)).toStrictEqual({ + ...baseAction, + message, + } satisfies UniqueItemsAction); + }); + }); + + describe('should return dataset without issues', () => { + const action = uniqueItems(); + + test('for untyped inputs', () => { + const issues: [StringIssue] = [ + { + kind: 'schema', + type: 'string', + input: null, + expected: 'string', + received: 'null', + message: 'message', + }, + ]; + expect( + action['~validate']({ typed: false, value: null, issues }, {}) + ).toStrictEqual({ + typed: false, + value: null, + issues, + }); + }); + + test('for empty array', () => { + expectNoActionIssue(action, [[]]); + }); + + test('for valid content', () => { + expectNoActionIssue(action, [[10, 11, 12, 13, 99]]); + }); + }); + + describe('should return dataset with issues', () => { + const action = uniqueItems('message'); + + const baseIssue: Omit, 'input' | 'received'> = { + kind: 'validation', + type: 'unique_items', + expected: null, + message: 'message', + requirement: undefined, + issues: undefined, + lang: undefined, + abortEarly: undefined, + abortPipeEarly: undefined, + }; + + test('for invalid(duplicated) content', () => { + const input = [5, 30, 2, 30, 8, 30]; + expect( + action['~validate']({ typed: true, value: input }, {}) + ).toStrictEqual({ + typed: true, + value: input, + issues: [ + { + ...baseIssue, + input: input[3], + received: `${input[3]}`, + path: [ + { + type: 'array', + origin: 'value', + input, + key: 3, + value: input[3], + }, + ], + }, + { + ...baseIssue, + input: input[5], + received: `${input[5]}`, + path: [ + { + type: 'array', + origin: 'value', + input, + key: 5, + value: input[5], + }, + ], + }, + ], + } satisfies PartialDataset>); + }); + }); +}); diff --git a/library/src/actions/uniqueItems/uniqueItems.ts b/library/src/actions/uniqueItems/uniqueItems.ts new file mode 100644 index 000000000..24e7b15d3 --- /dev/null +++ b/library/src/actions/uniqueItems/uniqueItems.ts @@ -0,0 +1,115 @@ +import type { + BaseIssue, + BaseValidation, + ErrorMessage, +} from '../../types/index.ts'; +import { _addIssue } from '../../utils/index.ts'; +import type { ArrayInput } from '../types.ts'; + +/** + * Unique items issue type. + */ +export interface UniqueItemsIssue + extends BaseIssue { + /** + * The issue kind. + */ + readonly kind: 'validation'; + /** + * The issue type. + */ + readonly type: 'unique_items'; + /** + * The expected input. + */ + readonly expected: null; +} + +/** + * Unique items action type. + */ +export interface UniqueItemsAction< + TInput extends ArrayInput, + TMessage extends ErrorMessage> | undefined, +> extends BaseValidation> { + /** + * The action type. + */ + readonly type: 'unique_items'; + /** + * The action reference. + */ + readonly reference: typeof uniqueItems; + /** + * The expected property. + */ + readonly expects: null; + /** + * The error message. + */ + readonly message: TMessage; +} + +/** + * Creates an unique items validation action. + * + * @returns An unique items action. + */ +export function uniqueItems(): UniqueItemsAction< + TInput, + undefined +>; + +/** + * Creates an unique items validation action. + * + * @param message The error message. + * + * @returns An unique items action. + */ +export function uniqueItems< + TInput extends ArrayInput, + const TMessage extends ErrorMessage> | undefined, +>(message: TMessage): UniqueItemsAction; + +export function uniqueItems( + message?: ErrorMessage> +): UniqueItemsAction< + unknown[], + ErrorMessage> | undefined +> { + return { + kind: 'validation', + type: 'unique_items', + reference: uniqueItems, + async: false, + expects: null, + message, + '~validate'(dataset, config) { + if (dataset.typed) { + const set = new Set(); + + for (let index = 0; index < dataset.value.length; index++) { + const item = dataset.value[index]; + if (set.has(item)) { + _addIssue(this, 'item', dataset, config, { + input: item, + path: [ + { + type: 'array', + origin: 'value', + input: dataset.value, + key: index, + value: item, + }, + ], + }); + } else { + set.add(item); + } + } + } + return dataset; + }, + }; +} diff --git a/packages/to-json-schema/src/convertAction.test.ts b/packages/to-json-schema/src/convertAction.test.ts index c09b71ac8..c04cc39b3 100644 --- a/packages/to-json-schema/src/convertAction.test.ts +++ b/packages/to-json-schema/src/convertAction.test.ts @@ -490,4 +490,55 @@ describe('convertAction', () => { 'The "transform" action cannot be converted to JSON Schema.' ); }); + + test('should convert unique items action for array', () => { + expect( + convertAction( + { + type: 'array', + }, + v.uniqueItems(), + undefined + ) + ).toStrictEqual({ + type: 'array', + uniqueItems: true, + }); + }); + + test('should throw error for unique items action with invalid type', () => { + expect(() => + convertAction({}, v.uniqueItems(), undefined) + ).toThrowError( + 'The "unique_items" action is not supported on type "undefined".' + ); + expect(() => + convertAction( + { type: 'string' }, + v.uniqueItems(), + undefined + ) + ).toThrowError( + 'The "unique_items" action is not supported on type "string".' + ); + }); + + test('should force conversion for unique items action with invalid type', () => { + expect( + convertAction({}, v.uniqueItems(), { force: true }) + ).toStrictEqual({ + uniqueItems: true, + }); + expect(console.warn).toHaveBeenLastCalledWith( + 'The "unique_items" action is not supported on type "undefined".' + ); + expect( + convertAction({ type: 'string' }, v.uniqueItems(), { + force: true, + }) + ).toStrictEqual({ type: 'string', uniqueItems: true }); + expect(console.warn).toHaveBeenLastCalledWith( + 'The "unique_items" action is not supported on type "string".' + ); + }); }); diff --git a/packages/to-json-schema/src/convertAction.ts b/packages/to-json-schema/src/convertAction.ts index 7852c1d37..9bcb7d93e 100644 --- a/packages/to-json-schema/src/convertAction.ts +++ b/packages/to-json-schema/src/convertAction.ts @@ -56,7 +56,11 @@ type Action = number, v.ErrorMessage> | undefined > - | v.TitleAction; + | v.TitleAction + | v.UniqueItemsAction< + v.ArrayInput, + v.ErrorMessage> | undefined + >; /** * Converts any supported Valibot action to the JSON Schema format. @@ -191,6 +195,18 @@ export function convertAction( break; } + case 'unique_items': { + if (jsonSchema.type !== 'array') { + throwOrWarn( + `The "${valibotAction.type}" action is not supported on type "${jsonSchema.type}".`, + config + ); + } + jsonSchema.uniqueItems = true; + + break; + } + default: { throwOrWarn( // @ts-expect-error