Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions src/validator/constraints/comparison/unique.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest';
import { ConstraintContext } from '@/validator/constraint-validator';
import { unique, UniqueOptions } from './unique';

describe('unique', () => {
it('returns no violations when array elements are unique', () => {
const value = ['7', 7, true];
const context: ConstraintContext = createContext('tags', value, {} as UniqueOptions);

const violations = unique(value, context);

expect(violations).toHaveLength(0);
});

it('returns one violation when an array contains duplicate elements', () => {
const value = ['admin', 'editor', 'admin'];
const context: ConstraintContext = createContext('tags', value, {} as UniqueOptions);

const violations = unique(value, context);

expect(violations).toHaveLength(1);
expect(violations[0]).toEqual({
path: 'tags',
constraint: 'unique',
message: 'This collection should contain only unique elements.',
value,
});
});

it('returns one violation when the value is not an array', () => {
const value = 'admin';
const context: ConstraintContext = createContext('tags', value, {} as UniqueOptions);

const violations = unique(value, context);

expect(violations).toHaveLength(1);
expect(violations[0]).toEqual({
path: 'tags',
constraint: 'unique',
message: 'This collection should contain only unique elements.',
value,
});
});

it('should bypass undefined value', () => {
const value = undefined;
const context: ConstraintContext = createContext('tags', value, { message: 'Tags must be unique' });

const violations = unique(value, context);

expect(violations).toHaveLength(0);
});

it('uses a custom message when provided', () => {
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,
});
});

it('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 collection should contain only unique elements.',
value,
});
});

describe('checks uniqueness using the configured field combination', () => {
it('with normalizer', () => {
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 === 'string' ? element.trim() : element),
});

const violations = unique(value, context);

expect(violations).toHaveLength(1);
expect(violations[0]).toEqual({
path: 'coordinates',
constraint: 'unique',
message: 'This collection should contain only unique elements.',
value,
});
});

it('without normalizer', () => {
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 collection should contain only unique elements.',
value,
});
});
});

function createContext(path: string, value: unknown, options: UniqueOptions): ConstraintContext<UniqueOptions> {
return {
path,
root: { [path]: value },
value,
constraint: 'unique',
options,
runNestedRules: () => [],
};
}
});
54 changes: 54 additions & 0 deletions src/validator/constraints/comparison/unique.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { ConstraintOptions } from '@/validator/constraint';
import { ConstraintContext } from '@/validator/constraint-validator';
import { Violation } from '@/validator/violation';

const DEFAULT_MESSAGE = 'This collection should contain only unique elements.';

export type UniqueOptions = ConstraintOptions<{
message?: string;
normalizer?: (value: unknown) => unknown;
fields?: string[];
}>;

export function unique(value: unknown, context: ConstraintContext<UniqueOptions>): Violation[] {
if (value === undefined) {
return [];
}
if (!Array.isArray(value)) {
return [
{
path: context.path,
constraint: context.constraint,
message: context.options.message ?? DEFAULT_MESSAGE,
value,
},
];
}

const fields = context.options.fields;
const normalizer = context.options.normalizer;
let normalized: unknown[];

if (fields !== undefined && fields.length > 0) {
const fieldCombinations: unknown[][] = value.map(element => fields.map(field => element[field]));
const normalizedCombinations: unknown[][] = fieldCombinations.map(fieldCombination =>
fieldCombination.map(fieldValue => (normalizer ? normalizer(fieldValue) : fieldValue)),
);
normalized = normalizedCombinations.map(combination => JSON.stringify(combination));
} else normalized = normalizer ? value.map(element => normalizer(element)) : value;

if (isUnique(normalized)) return [];

return [
{
path: context.path,
constraint: context.constraint,
message: context.options.message ?? DEFAULT_MESSAGE,
value,
},
];
}

function isUnique(value: unknown[]): boolean {
return new Set(value).size === value.length;
}
6 changes: 6 additions & 0 deletions src/validator/constraints/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { notBlank } from '@/validator/constraints/basic/not-blank';
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';
Expand All @@ -8,6 +9,9 @@ export const builtInConstraints = {
notBlank,
type,

// Comparison
unique,

// String
email,
slug,
Expand All @@ -17,4 +21,6 @@ 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 './basic/type';
export * from './other/compound';
Loading