Skip to content

Commit

Permalink
refactor(model): use classes for Validators
Browse files Browse the repository at this point in the history
  • Loading branch information
Lodin committed Aug 5, 2024
1 parent bc66791 commit 06362c6
Showing 1 changed file with 172 additions and 116 deletions.
288 changes: 172 additions & 116 deletions packages/ts/models/src/validators.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
const { create, entries, getPrototypeOf, getOwnPropertyDescriptors } = Object;

export class ValidationError extends Error {
readonly value: unknown;

Expand All @@ -24,127 +22,185 @@ export type ValidatableHTMLElement = HTMLElement &
| 'validity'
>;

export interface Validator {
name: string;
super: Validator;
error(value: unknown): ValidationError;
validate(value: unknown, state?: ValidityState): boolean;
bind(element: ValidatableHTMLElement): void;
}
export class Validator {
readonly name: string = 'Validator';

// eslint-disable-next-line @typescript-eslint/class-methods-use-this
bind(_element: ValidatableHTMLElement): void {
// do nothing
}

error(value: unknown, message = 'Invalid value'): ValidationError {
return new ValidationError(this.name, value, message);
}

export type ValidatorProps = Partial<Omit<Validator, 'error'> & { error: string }>;

export function createValidator(name: string, base: Validator, props: ValidatorProps): Validator {
return create(
base,
entries(getOwnPropertyDescriptors(props)).reduce<Record<string, PropertyDescriptor>>(
(acc, [key, { value, get, set }]) => {
acc[key] =
key === 'error' ? { value: (v: unknown) => new ValidationError(name, v, value) } : { value, get, set };
return acc;
},
{ name: { value: name } },
),
);
// eslint-disable-next-line @typescript-eslint/class-methods-use-this
validate(_value: unknown, _state: ValidityState | undefined): boolean {
return true;
}
}

export const Validator = create(null, {
name: {
value: 'Validator',
},
bind: {
value: () => {},
},
error: {
value(this: Validator) {
return new ValidationError(this.name, undefined, 'Invalid value');
},
},
validate: {
value: () => true,
},
super: {
get(this: Validator) {
return getPrototypeOf(this);
},
},
[Symbol.hasInstance]: {
value(this: Validator, o: unknown) {
return typeof o === 'object' && o != null && (this === o || Object.prototype.isPrototypeOf.call(this, o));
},
},
});

export const Required = createValidator('Required', Validator, {
bind(element) {
export class Required extends Validator {
override readonly name: string = 'Required';

override bind(element: ValidatableHTMLElement): void {
element.required = true;
},
validate: (value, state) => !state?.valueMissing && value != null,
});

export function Pattern(pattern: RegExp | string): Validator {
const _pattern = pattern instanceof RegExp ? pattern : new RegExp(pattern, 'u');

return createValidator('Pattern', Validator, {
bind(element) {
element.pattern = _pattern.source;
},
validate: (value, state) => !state?.patternMismatch && _pattern.test(String(value)),
});
}

override validate(value: unknown, state: ValidityState | undefined): boolean {
return !state?.valueMissing && value != null;
}

override error(value: unknown, message = 'Must present'): ValidationError {
return super.error(value, message);
}
}

export class Pattern extends Validator {
override readonly name: string = 'Pattern';

readonly #pattern: RegExp;

constructor(pattern: RegExp | string) {
super();
this.#pattern = typeof pattern === 'string' ? new RegExp(pattern, 'u') : pattern;
}

override bind(element: ValidatableHTMLElement): void {
element.pattern = this.#pattern.source;
}

override validate(value: unknown, state: ValidityState | undefined): boolean {
return !state?.patternMismatch && this.#pattern.test(String(value));
}

override error(value: unknown, message = `Must comply the pattern ${this.#pattern}`): ValidationError {
return super.error(value, message);
}
}

export const IsNumber = createValidator('IsNumber', Pattern(/^[0-9]*$/u), {
bind(element) {
export class IsNumber extends Pattern {
override readonly name: string = 'IsNumber';

constructor() {
super(/^[0-9.,]*$/u);
}

override bind(element: ValidatableHTMLElement): void {
super.bind(element);
element.type = 'number';
},
validate(this: Validator, value, state) {
return !state?.typeMismatch && this.super.validate(value) && isFinite(Number(value));
},
});

export const Email = createValidator('Email', Pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/u), {
error: 'Must be a well-formed email address',
});

export const Null = createValidator('Null', Validator, {
validate: (value) => value == null,
error: 'Must be null',
});

export const NotNull = createValidator('NotNull', Required, {
error: 'Must not be null',
});

export const NotEmpty = createValidator('NotEmpty', Required, {
validate(this: Validator, value) {
return this.super.validate(value) && (typeof value === 'string' || Array.isArray(value)) && value.length > 0;
},
error: 'Must not be empty',
});

export const NotBlank = createValidator('NotBlank', Required, {
validate(this: Validator, value) {
return this.super.validate(value) && typeof value === 'string' && /\S/u.test(value);
},
error: 'Must not be blank',
});

export function Min(min: number): Validator {
return createValidator('Min', IsNumber, {
validate: (value, state) => !state?.rangeUnderflow && IsNumber.validate(value) && Number(value) >= min,
bind(element) {
element.min = String(min);
},
error: `Must be greater than or equal to ${min}`,
});
}

override validate(value: unknown, state: ValidityState | undefined): boolean {
return super.validate(value, state) && isFinite(Number(value));
}

override error(value: unknown, message = 'Must be a number'): ValidationError {
return super.error(value, message);
}
}

export class Email extends Pattern {
override readonly name: string = 'Email';

constructor() {
super(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/u);
}

override bind(element: ValidatableHTMLElement): void {
super.bind(element);
element.type = 'email';
}

override error(value: unknown): ValidationError {
return super.error(value, 'Must be a well-formed email address');
}
}

export function Max(max: number): Validator {
return createValidator('Max', IsNumber, {
validate: (value, state) => !state?.rangeOverflow && IsNumber.validate(value) && Number(value) <= max,
bind(element) {
element.max = String(max);
},
error: `Must be less than or equal to ${max}`,
});
export class Null extends Validator {
override readonly name: string = 'Null';

override validate(value: unknown, _: ValidityState | undefined): boolean {
return value == null;
}

override error(value: unknown, message = 'Must be null'): ValidationError {
return super.error(value, message);
}
}

export class NotNull extends Required {
override readonly name: string = 'NotNull';

override error(value: unknown, message = 'Must not be null'): ValidationError {
return super.error(value, message);
}
}

export class NotEmpty extends Required {
override readonly name: string = 'NotEmpty';

override validate(value: unknown, state: ValidityState | undefined): boolean {
return super.validate(value, state) && (typeof value === 'string' || Array.isArray(value)) && value.length > 0;
}

override error(value: unknown, message = 'Must not be empty'): ValidationError {
return super.error(value, message);
}
}

export class NotBlank extends Required {
override readonly name: string = 'NotBlank';

override validate(value: unknown, state: ValidityState | undefined): boolean {
return super.validate(value, state) && typeof value === 'string' && /\S/u.test(value);
}

override error(value: unknown, message = 'Must not be blank'): ValidationError {
return super.error(value, message);
}
}

export class Min extends IsNumber {
readonly #min: number;

constructor(min: number) {
super();
this.#min = min;
}

override bind(element: ValidatableHTMLElement): void {
super.bind(element);
element.min = String(this.#min);
}

override validate(value: unknown, state: ValidityState | undefined): boolean {
return !state?.rangeUnderflow && super.validate(value, state) && Number(value) >= this.#min;
}

override error(value: unknown, message = `Must be greater than or equal to ${this.#min}`): ValidationError {
return super.error(value, message);
}
}

export class Max extends IsNumber {
readonly #max: number;

constructor(max: number) {
super();
this.#max = max;
}

override bind(element: ValidatableHTMLElement): void {
super.bind(element);
element.max = String(this.#max);
}

override validate(value: unknown, state: ValidityState | undefined): boolean {
return !state?.rangeOverflow && super.validate(value, state) && Number(value) <= this.#max;
}

override error(value: unknown, message = `Must be less than or equal to ${this.#max}`): ValidationError {
return super.error(value, message);
}
}

0 comments on commit 06362c6

Please sign in to comment.