From 174c30b1b2ee24b48a76424c093b9a9329eb8e31 Mon Sep 17 00:00:00 2001 From: Han Yeong-woo Date: Thu, 10 Oct 2024 21:37:42 +0900 Subject: [PATCH 1/3] Add base of `uniqueItems` action --- library/src/actions/index.ts | 1 + library/src/actions/uniqueItems/index.ts | 1 + .../actions/uniqueItems/uniqueItems.test-d.ts | 50 ++++++++ .../actions/uniqueItems/uniqueItems.test.ts | 108 ++++++++++++++++ .../src/actions/uniqueItems/uniqueItems.ts | 116 ++++++++++++++++++ 5 files changed, 276 insertions(+) create mode 100644 library/src/actions/uniqueItems/index.ts create mode 100644 library/src/actions/uniqueItems/uniqueItems.test-d.ts create mode 100644 library/src/actions/uniqueItems/uniqueItems.test.ts create mode 100644 library/src/actions/uniqueItems/uniqueItems.ts 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..ea1d97563 --- /dev/null +++ b/library/src/actions/uniqueItems/uniqueItems.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from 'vitest'; +import type { StringIssue } from '../../schemas/index.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.todo('for invalid(duplicated) content', () => { + // const input = [15, 5, 3, 15]; + // expect( + // action['~validate']({ typed: true, value: input }, {}) + // ).toStrictEqual({ + // typed: true, + // value: input, + // issues: [ + // ], + // } satisfies PartialDataset>); + }); + }); +}); diff --git a/library/src/actions/uniqueItems/uniqueItems.ts b/library/src/actions/uniqueItems/uniqueItems.ts new file mode 100644 index 000000000..58e79c693 --- /dev/null +++ b/library/src/actions/uniqueItems/uniqueItems.ts @@ -0,0 +1,116 @@ +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 checkMap = new Map(); + for (let index = 0; index < dataset.value.length; index++) { + const item = dataset.value[index]; + if (checkMap.has(item)) { + _addIssue(this, 'item', dataset, config, { + input: item, + path: [ + // TODO: this is a placeholder value. + // I would appreciate it if you could take a look at the questions in the PR. + { + type: 'array', + origin: 'value', + input: dataset.value, + key: 5, + value: item, + }, + ], + }); + } else { + checkMap.set(item, []); + } + } + } + return dataset; + }, + }; +} From 282a63da203c0aca9923754737cd916fa4b11ca5 Mon Sep 17 00:00:00 2001 From: Han Yeong-woo Date: Thu, 10 Oct 2024 21:39:14 +0900 Subject: [PATCH 2/3] Add support for `uniqueItems` in `convertAction` --- .../to-json-schema/src/convertAction.test.ts | 51 +++++++++++++++++++ packages/to-json-schema/src/convertAction.ts | 18 ++++++- 2 files changed, 68 insertions(+), 1 deletion(-) 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 From b1ca088597c2fcf1b20e243f8aa7e18dc16507f2 Mon Sep 17 00:00:00 2001 From: Han Yeong-woo Date: Sun, 13 Oct 2024 20:21:33 +0900 Subject: [PATCH 3/3] Update `uniqueItems` action --- .../actions/uniqueItems/uniqueItems.test.ts | 49 +++++++++++++++---- .../src/actions/uniqueItems/uniqueItems.ts | 11 ++--- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/library/src/actions/uniqueItems/uniqueItems.test.ts b/library/src/actions/uniqueItems/uniqueItems.test.ts index ea1d97563..e35611b66 100644 --- a/library/src/actions/uniqueItems/uniqueItems.test.ts +++ b/library/src/actions/uniqueItems/uniqueItems.test.ts @@ -1,5 +1,6 @@ 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, @@ -93,16 +94,44 @@ describe('uniqueItems', () => { abortPipeEarly: undefined, }; - test.todo('for invalid(duplicated) content', () => { - // const input = [15, 5, 3, 15]; - // expect( - // action['~validate']({ typed: true, value: input }, {}) - // ).toStrictEqual({ - // typed: true, - // value: input, - // issues: [ - // ], - // } satisfies PartialDataset>); + 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 index 58e79c693..24e7b15d3 100644 --- a/library/src/actions/uniqueItems/uniqueItems.ts +++ b/library/src/actions/uniqueItems/uniqueItems.ts @@ -87,26 +87,25 @@ export function uniqueItems( message, '~validate'(dataset, config) { if (dataset.typed) { - const checkMap = new Map(); + const set = new Set(); + for (let index = 0; index < dataset.value.length; index++) { const item = dataset.value[index]; - if (checkMap.has(item)) { + if (set.has(item)) { _addIssue(this, 'item', dataset, config, { input: item, path: [ - // TODO: this is a placeholder value. - // I would appreciate it if you could take a look at the questions in the PR. { type: 'array', origin: 'value', input: dataset.value, - key: 5, + key: index, value: item, }, ], }); } else { - checkMap.set(item, []); + set.add(item); } } }