generated from sapphiredev/sapphire-template
-
-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add NativeEnum validator (#54)
- Loading branch information
1 parent
e6827c5
commit 7359042
Showing
9 changed files
with
272 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import type { InspectOptionsStylized } from 'node:util'; | ||
import { BaseError, customInspectSymbolStackLess } from './BaseError'; | ||
|
||
export class UnknownEnumValueError extends BaseError { | ||
public readonly value: string | number; | ||
public readonly enumKeys: string[]; | ||
public readonly enumMappings: Map<string | number, string | number>; | ||
|
||
public constructor(value: string | number, keys: string[], enumMappings: Map<string | number, string | number>) { | ||
super('Expected the value to be one of the following enum values:'); | ||
|
||
this.value = value; | ||
this.enumKeys = keys; | ||
this.enumMappings = enumMappings; | ||
} | ||
|
||
public toJSON() { | ||
return { | ||
name: this.name, | ||
value: this.value, | ||
enumKeys: this.enumKeys, | ||
enumMappings: [...this.enumMappings.entries()] | ||
}; | ||
} | ||
|
||
protected [customInspectSymbolStackLess](depth: number, options: InspectOptionsStylized): string { | ||
const value = options.stylize(this.value.toString(), 'string'); | ||
if (depth < 0) { | ||
return options.stylize(`[UnknownEnumValueError: ${value}]`, 'special'); | ||
} | ||
|
||
const padding = `\n ${options.stylize('|', 'undefined')} `; | ||
const pairs = this.enumKeys | ||
.map((key) => { | ||
const enumValue = this.enumMappings.get(key)!; | ||
return `${options.stylize(key, 'string')} or ${options.stylize( | ||
enumValue.toString(), | ||
typeof enumValue === 'number' ? 'number' : 'string' | ||
)}`; | ||
}) | ||
.join(padding); | ||
|
||
const header = `${options.stylize('UnknownEnumValueError', 'special')} > ${value}`; | ||
const message = options.stylize(this.message, 'regexp'); | ||
const pairsBlock = `${padding}${pairs}`; | ||
return `${header}\n ${message}\n${pairsBlock}`; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
import { UnknownEnumValueError } from '../lib/errors/UnknownEnumValueError'; | ||
import { ValidationError } from '../lib/errors/ValidationError'; | ||
import { Result } from '../lib/Result'; | ||
import { BaseValidator } from './imports'; | ||
|
||
export class NativeEnumValidator<T extends NativeEnumLike> extends BaseValidator<T[keyof T]> { | ||
public readonly enumShape: T; | ||
public readonly hasNumericElements: boolean = false; | ||
private readonly enumKeys: string[]; | ||
private readonly enumMapping = new Map<string | number, T[keyof T]>(); | ||
|
||
public constructor(enumShape: T) { | ||
super(); | ||
this.enumShape = enumShape; | ||
|
||
this.enumKeys = Object.keys(enumShape).filter((key) => { | ||
return typeof enumShape[enumShape[key]] !== 'number'; | ||
}); | ||
|
||
for (const key of this.enumKeys) { | ||
const enumValue = enumShape[key] as T[keyof T]; | ||
|
||
this.enumMapping.set(key, enumValue); | ||
this.enumMapping.set(enumValue, enumValue); | ||
|
||
if (typeof enumValue === 'number') { | ||
this.hasNumericElements = true; | ||
this.enumMapping.set(`${enumValue}`, enumValue); | ||
} | ||
} | ||
} | ||
|
||
protected override handle(value: unknown): Result<T[keyof T], ValidationError | UnknownEnumValueError> { | ||
const typeOfValue = typeof value; | ||
|
||
// Step 1. Possible numeric enum | ||
if (typeOfValue === 'number' && !this.hasNumericElements) { | ||
return Result.err(new ValidationError('s.nativeEnum(T)', 'Expected the value to be a string', value)); | ||
} | ||
|
||
// Ensure type is string or number | ||
if (typeOfValue !== 'string' && typeOfValue !== 'number') { | ||
return Result.err(new ValidationError('s.nativeEnum(T)', 'Expected the value to be a string or number', value)); | ||
} | ||
|
||
const casted = value as string | number; | ||
|
||
const possibleEnumValue = this.enumMapping.get(casted); | ||
|
||
return typeof possibleEnumValue === 'undefined' | ||
? Result.err(new UnknownEnumValueError(casted, this.enumKeys, this.enumMapping)) | ||
: Result.ok(possibleEnumValue); | ||
} | ||
|
||
protected clone(): this { | ||
return Reflect.construct(this.constructor, [this.enumShape]); | ||
} | ||
} | ||
|
||
export interface NativeEnumLike { | ||
[key: string]: string | number; | ||
[key: number]: string; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
import { inspect } from 'node:util'; | ||
import { UnknownEnumValueError } from '../../../src'; | ||
|
||
describe('UnknownEnumValueError', () => { | ||
const error = new UnknownEnumValueError( | ||
'foo', | ||
['bar', 'baz'], | ||
new Map<string | number, string | number>([ | ||
['bar', 1], | ||
['baz', 'boo'] | ||
]) | ||
); | ||
|
||
test('GIVEN an instance THEN assigns fields correctly', () => { | ||
expect(error.message).toBe('Expected the value to be one of the following enum values:'); | ||
expect(error.value).toBe('foo'); | ||
expect(error.enumKeys).toEqual(['bar', 'baz']); | ||
expect(error.enumMappings).toStrictEqual( | ||
new Map<string | number, string | number>([ | ||
['bar', 1], | ||
['baz', 'boo'] | ||
]) | ||
); | ||
}); | ||
|
||
describe('inspect', () => { | ||
test('GIVEN an inspected instance THEN formats data correctly', () => { | ||
const content = inspect(error, { colors: false }); | ||
const expected = [ | ||
'UnknownEnumValueError > foo', // | ||
' Expected the value to be one of the following enum values:', | ||
'', | ||
' | bar or 1', | ||
' | baz or boo' | ||
]; | ||
|
||
expect(content.startsWith(expected.join('\n'))).toBe(true); | ||
}); | ||
|
||
test('GIVEN an inspected instance with negative depth THEN formats name only', () => { | ||
const content = inspect(error, { colors: false, depth: -1 }); | ||
const expected = [ | ||
'[UnknownEnumValueError: foo]' // | ||
]; | ||
|
||
expect(content.startsWith(expected.join('\n'))).toBe(true); | ||
}); | ||
}); | ||
|
||
describe('toJSON', () => { | ||
test('toJSON should return an object with name and property', () => { | ||
expect(error.toJSON()).toEqual({ | ||
name: 'Error', | ||
value: 'foo', | ||
enumKeys: ['bar', 'baz'], | ||
enumMappings: [ | ||
['bar', 1], | ||
['baz', 'boo'] | ||
] | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
import { s, UnknownEnumValueError, ValidationError } from '../../src'; | ||
import { expectClonedValidator, expectError } from '../common/macros/comparators'; | ||
|
||
describe('NativeEnumValidator', () => { | ||
describe('invalid inputs', () => { | ||
const predicate = s.nativeEnum({ hello: 'world' }); | ||
|
||
test.each([true, null, undefined, {}])('GIVEN %p THEN throws ValidationError', (value) => { | ||
expectError(() => predicate.parse(value), new ValidationError('s.nativeEnum(T)', 'Expected the value to be a string or number', value)); | ||
}); | ||
}); | ||
|
||
describe('string enum', () => { | ||
enum StringEnum { | ||
Hi = 'hi' | ||
} | ||
|
||
const stringPredicate = s.nativeEnum(StringEnum); | ||
|
||
test.each([ | ||
['Hi', StringEnum.Hi], | ||
[StringEnum.Hi, StringEnum.Hi] | ||
])('GIVEN a key or value of a native enum (%p) THEN returns the value', (value, expected) => { | ||
expect<StringEnum>(stringPredicate.parse(value)).toBe(expected); | ||
}); | ||
|
||
it('GIVEN a number input for a string enum THEN throws ValidationError', () => { | ||
expectError(() => stringPredicate.parse(1), new ValidationError('s.nativeEnum(T)', 'Expected the value to be a string', 1)); | ||
}); | ||
}); | ||
|
||
describe('number enum', () => { | ||
enum NumberEnum { | ||
Vladdy, | ||
Kyra, | ||
Favna | ||
} | ||
const numberPredicate = s.nativeEnum(NumberEnum); | ||
|
||
test.each([ | ||
['Vladdy', NumberEnum.Vladdy], | ||
[NumberEnum.Vladdy, NumberEnum.Vladdy] | ||
])('GIVEN a key or value of a native enum (%p) THEN returns the value', (input, expected) => { | ||
expect<NumberEnum>(numberPredicate.parse(input)).toBe(expected); | ||
}); | ||
}); | ||
|
||
describe('mixed enum', () => { | ||
enum MixedEnum { | ||
Sapphire = 'is awesome', | ||
Vladdy = 420 | ||
} | ||
|
||
const mixedPredicate = s.nativeEnum(MixedEnum); | ||
|
||
test.each([ | ||
['Sapphire', MixedEnum.Sapphire], | ||
[MixedEnum.Sapphire, MixedEnum.Sapphire], | ||
['Vladdy', MixedEnum.Vladdy], | ||
[MixedEnum.Vladdy, MixedEnum.Vladdy] | ||
])('GIVEN a key or value of a native enum (%p) THEN returns the value', (input, expected) => { | ||
expect<MixedEnum>(mixedPredicate.parse(input)).toBe(expected); | ||
}); | ||
}); | ||
|
||
describe('valid input but invalid enum value', () => { | ||
const predicate = s.nativeEnum({ owo: 42 }); | ||
|
||
test.each(['uwu', 69])('GIVEN valid type for input but not part of enum (%p) THEN throws ValidationError', (value) => { | ||
expectError(() => predicate.parse(value), new UnknownEnumValueError(value, ['owo'], new Map([['owo', 42]]))); | ||
}); | ||
}); | ||
|
||
test('GIVEN clone THEN returns similar instance', () => { | ||
const predicate = s.nativeEnum({ Example: 69 }); | ||
// @ts-expect-error Test clone | ||
const clonePredicate = predicate.clone(); | ||
|
||
expectClonedValidator(predicate, clonePredicate); | ||
expect(predicate.parse('Example')).toBe(69); | ||
expect(predicate.parse(69)).toBe(69); | ||
}); | ||
}); |