diff --git a/src/validator/constraints/comparison/unique.test.ts b/src/validator/constraints/comparison/unique.test.ts new file mode 100644 index 0000000..48bd30e --- /dev/null +++ b/src/validator/constraints/comparison/unique.test.ts @@ -0,0 +1,311 @@ +import { ConstraintContext } from '@/validator/constraint-validator'; +import { describe, expect, test } from 'vitest'; +import { unique, UniqueOptions } from './unique'; + +describe('unique', () => { + describe('non-array values', () => { + test('validation passes when the value is undefined', () => { + const value = undefined; + const context: ConstraintContext = createContext('tags', value, { message: 'Tags must be unique' }); + + const violations = unique(value, context); + + expect(violations).toHaveLength(0); + }); + + test('validation fails when the value is null', () => { + const value = null; + const context: ConstraintContext = createContext('tags', value, {}); + + const violations = unique(value, context); + + expect(violations).toHaveLength(1); + expect(violations[0]).toEqual({ + path: 'tags', + constraint: 'unique', + message: 'This value must be a list.', + value, + }); + }); + + test('validation fails when the value is not an array', () => { + const value = 'admin'; + const context: ConstraintContext = createContext('tags', value, {}); + + const violations = unique(value, context); + + expect(violations).toHaveLength(1); + expect(violations[0]).toEqual({ + path: 'tags', + constraint: 'unique', + message: 'This value must be a list.', + value, + }); + }); + }); + + describe('arrays of scalars', () => { + test('validation passes when array elements are unique', () => { + const value = ['7', 7, true, null, undefined]; + const context: ConstraintContext = createContext('tags', value, {}); + + const violations = unique(value, context); + + expect(violations).toHaveLength(0); + }); + + test('validation passes when the array contains a null value', () => { + const value = [null]; + const context: ConstraintContext = createContext('tags', value, {}); + + const violations = unique(value, context); + + expect(violations).toHaveLength(0); + }); + + test('validation passes when the array contains an undefined value', () => { + const value = [undefined]; + const context: ConstraintContext = createContext('tags', value, {}); + + const violations = unique(value, context); + + expect(violations).toHaveLength(0); + }); + + test('validation fails when an array contains duplicate null values', () => { + const value = [null, null]; + const context: ConstraintContext = createContext('tags', value, {}); + + const violations = unique(value, context); + + expect(violations).toHaveLength(1); + expect(violations[0]).toEqual({ + path: 'tags', + constraint: 'unique', + message: 'This value must contain only unique items.', + value, + }); + }); + + test('validation fails when an array contains duplicate undefined values', () => { + const value = [undefined, undefined]; + const context: ConstraintContext = createContext('tags', value, {}); + + const violations = unique(value, context); + + expect(violations).toHaveLength(1); + expect(violations[0]).toEqual({ + path: 'tags', + constraint: 'unique', + message: 'This value must contain only unique items.', + value, + }); + }); + + test('validation fails when an array contains duplicate elements', () => { + const value = ['admin', 'editor', 'admin']; + const context: ConstraintContext = createContext('tags', value, {}); + + const violations = unique(value, context); + + expect(violations).toHaveLength(1); + expect(violations[0]).toEqual({ + path: 'tags', + constraint: 'unique', + message: 'This value must contain only unique items.', + value, + }); + }); + }); + + describe('arrays of arrays', () => { + test('validation passes when nested arrays have different values', () => { + const value = [['admin'], ['editor']]; + const context: ConstraintContext = createContext('tags', value, {}); + + const violations = unique(value, context); + + expect(violations).toHaveLength(0); + }); + + test('validation fails when nested arrays have the same values', () => { + const value = [['admin'], ['admin']]; + const context: ConstraintContext = createContext('tags', value, {}); + + const violations = unique(value, context); + + expect(violations).toHaveLength(1); + expect(violations[0]).toEqual({ + path: 'tags', + constraint: 'unique', + message: 'This value must contain only unique items.', + value, + }); + }); + }); + + describe('arrays of objects', () => { + test('validation passes when objects have different values', () => { + const value = [{ id: 1 }, { id: 2 }]; + const context: ConstraintContext = createContext('tags', value, {}); + + const violations = unique(value, context); + + expect(violations).toHaveLength(0); + }); + + test('validation fails when objects have the same values', () => { + const value = [{ id: 1 }, { id: 1 }]; + const context: ConstraintContext = createContext('tags', value, {}); + + const violations = unique(value, context); + + expect(violations).toHaveLength(1); + expect(violations[0]).toEqual({ + path: 'tags', + constraint: 'unique', + message: 'This value must contain only unique items.', + value, + }); + }); + + test('validation passes when the configured field combinations are unique', () => { + const value = [ + { latitude: 10, longitude: 20, label: 'first', name: 'first-location' }, + { latitude: 10, longitude: 30, label: 'first', name: 'last-location' }, + ]; + const context: ConstraintContext = createContext('coordinates', value, { + fields: ['latitude', 'longitude', 'label'], + }); + + const violations = unique(value, context); + + expect(violations).toHaveLength(0); + }); + + test('validation fails when the configured field combinations contain duplicates', () => { + const value = [ + { latitude: 10, longitude: 20, label: 'first', name: 'first-location' }, + { latitude: 10, longitude: 20, label: 'first', name: 'last-location' }, + ]; + const context: ConstraintContext = createContext('coordinates', value, { + fields: ['latitude', 'longitude', 'label'], + }); + + const violations = unique(value, context); + + expect(violations).toHaveLength(1); + expect(violations[0]).toEqual({ + path: 'coordinates', + constraint: 'unique', + message: 'This value must contain only unique items.', + value, + }); + }); + + test('validation fails when fields are configured and an element is not an object', () => { + const value = [{ id: 1 }, 'admin']; + const context: ConstraintContext = createContext('tags', value, { + fields: ['id'], + }); + + const violations = unique(value, context); + + expect(violations).toHaveLength(1); + expect(violations[0]).toEqual({ + path: 'tags', + constraint: 'unique', + message: 'This value must be a list of objects.', + value, + }); + }); + }); + + describe('options', () => { + test('validation uses a custom message when it fails', () => { + const value = ['admin', 'admin']; + const context: ConstraintContext = createContext('tags', value, { message: 'Tags must be unique' }); + + const violations = unique(value, context); + + expect(violations).toHaveLength(1); + expect(violations[0]).toEqual({ + path: 'tags', + constraint: 'unique', + message: 'Tags must be unique', + value, + }); + }); + + test('validation normalizes each array element before checking uniqueness', () => { + const value = [' admin ', 'admin']; + const context: ConstraintContext = createContext('tags', value, { + normalizer: (element: unknown) => (typeof element === 'string' ? element.trim() : element), + }); + + const violations = unique(value, context); + + expect(violations).toHaveLength(1); + expect(violations[0]).toEqual({ + path: 'tags', + constraint: 'unique', + message: 'This value must contain only unique items.', + value, + }); + }); + + test('validation normalizes each object before checking configured field combinations', () => { + const value = [ + { latitude: 10, longitude: 20, label: ' first', name: 'first-location' }, + { latitude: 10, longitude: 20, label: 'first', name: 'last-location' }, + ]; + const context: ConstraintContext = createContext('coordinates', value, { + fields: ['latitude', 'longitude', 'label'], + normalizer: (element: unknown) => + typeof element === 'object' && element !== null && 'label' in element + ? { ...element, label: String(element.label).trim() } + : element, + }); + + const violations = unique(value, context); + + expect(violations).toHaveLength(1); + expect(violations[0]).toEqual({ + path: 'coordinates', + constraint: 'unique', + message: 'This value must contain only unique items.', + value, + }); + }); + + test('validation fails when fields are configured and normalization returns a non-object element', () => { + const value = [{ id: 1 }, { id: 2 }]; + const context: ConstraintContext = createContext('tags', value, { + fields: ['id'], + normalizer: (element: unknown) => + typeof element === 'object' && element !== null && 'id' in element && element.id === 2 ? 'admin' : element, + }); + + const violations = unique(value, context); + + expect(violations).toHaveLength(1); + expect(violations[0]).toEqual({ + path: 'tags', + constraint: 'unique', + message: 'This value must be a list of objects.', + value, + }); + }); + }); + + function createContext(path: string, value: unknown, options: UniqueOptions): ConstraintContext { + return { + path, + root: { [path]: value }, + value, + constraint: 'unique', + options, + runNestedRules: () => [], + }; + } +}); diff --git a/src/validator/constraints/comparison/unique.ts b/src/validator/constraints/comparison/unique.ts new file mode 100644 index 0000000..25693cc --- /dev/null +++ b/src/validator/constraints/comparison/unique.ts @@ -0,0 +1,60 @@ +import { ConstraintOptions } from '@/validator/constraint'; +import { ConstraintContext } from '@/validator/constraint-validator'; +import { Violation } from '@/validator/violation'; +import { isDeepStrictEqual } from 'node:util'; + +const DEFAULT_MESSAGE = 'This value must contain only unique items.'; +const NOT_ARRAY_MESSAGE = 'This value must be a list.'; +const FIELDS_REQUIRE_OBJECTS_MESSAGE = 'This value must be a list of objects.'; + +export type UniqueOptions = ConstraintOptions<{ + message?: string; + normalizer?: (value: unknown) => unknown; + fields?: string[]; +}>; + +const createViolation = (value: unknown, context: ConstraintContext, message: string): Violation => ({ + path: context.path, + constraint: context.constraint, + message, + value, +}); + +const pickFields = (value: Record, fields: string[]): Record => + Object.fromEntries(fields.map(field => [field, value[field]])); + +const normalizeElements = (value: unknown[], normalizer?: (value: unknown) => unknown): unknown[] => + normalizer ? value.map(element => normalizer(element)) : value; + +const hasConfiguredFields = (fields?: string[]): fields is string[] => fields !== undefined && fields.length > 0; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const containsOnlyRecords = (values: unknown[]): values is Record[] => values.every(isRecord); + +const prepareComparableElements = (value: unknown[], fields?: string[]): unknown[] => + hasConfiguredFields(fields) ? value.map(element => pickFields(element as Record, fields)) : value; + +const hasOnlyUniqueValues = (values: unknown[]): boolean => + values.every((value, index) => + values.every((candidate, candidateIndex) => candidateIndex <= index || !isDeepStrictEqual(value, candidate)), + ); + +export function unique(value: unknown, context: ConstraintContext): Violation[] { + if (value === undefined) return []; + + if (!Array.isArray(value)) return [createViolation(value, context, context.options.message ?? NOT_ARRAY_MESSAGE)]; + + const normalizedElements = normalizeElements(value, context.options.normalizer); + + if (hasConfiguredFields(context.options.fields) && !containsOnlyRecords(normalizedElements)) { + return [createViolation(value, context, context.options.message ?? FIELDS_REQUIRE_OBJECTS_MESSAGE)]; + } + + const comparableElements = prepareComparableElements(normalizedElements, context.options.fields); + + if (hasOnlyUniqueValues(comparableElements)) return []; + + return [createViolation(value, context, context.options.message ?? DEFAULT_MESSAGE)]; +} diff --git a/src/validator/constraints/index.ts b/src/validator/constraints/index.ts index 75a267c..5aea751 100644 --- a/src/validator/constraints/index.ts +++ b/src/validator/constraints/index.ts @@ -1,13 +1,17 @@ import { notBlank } from '@/validator/constraints/basic/not-blank'; +import { type } from '@/validator/constraints/basic/type'; +import { unique } from '@/validator/constraints/comparison/unique'; import { email } from '@/validator/constraints/string/email'; import { slug } from '@/validator/constraints/string/slug'; -import { type } from '@/validator/constraints/basic/type'; export const builtInConstraints = { // Basic notBlank, type, + // Comparison + unique, + // String email, slug, @@ -17,4 +21,5 @@ export * from './string/email'; export * from './string/slug'; export * from './basic/not-blank'; export * from './basic/type'; +export { unique, type UniqueOptions } from './comparison/unique'; export * from './other/compound';