Skip to content

Commit

Permalink
perf: speed up object validation a LOT (#101)
Browse files Browse the repository at this point in the history
  • Loading branch information
vladfrangu committed May 4, 2022
1 parent 741490f commit 817278e
Show file tree
Hide file tree
Showing 2 changed files with 214 additions and 40 deletions.
144 changes: 112 additions & 32 deletions src/validators/ObjectValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,26 @@ import { ValidationError } from '../lib/errors/ValidationError';
import { Result } from '../lib/Result';
import type { MappedObjectValidator, NonNullObject } from '../lib/util-types';
import { BaseValidator } from './BaseValidator';
import { LiteralValidator } from './LiteralValidator';
import { NullishValidator } from './NullishValidator';
import { UnionValidator } from './UnionValidator';

export class ObjectValidator<T extends NonNullObject> extends BaseValidator<T> {
public readonly shape: MappedObjectValidator<T>;
public readonly strategy: ObjectValidatorStrategy;
private readonly keys: readonly (keyof T)[];
private readonly keys: readonly (keyof T)[] = [];
private readonly handleStrategy: (value: NonNullObject) => Result<T, CombinedPropertyError>;

private readonly requiredKeys = new Map<keyof T, BaseValidator<unknown>>();
private readonly possiblyUndefinedKeys = new Map<keyof T, BaseValidator<unknown>>();

public constructor(
shape: MappedObjectValidator<T>,
strategy: ObjectValidatorStrategy = ObjectValidatorStrategy.Ignore,
constraints: readonly IConstraint<T>[] = []
) {
super(constraints);
this.shape = shape;
this.keys = Object.keys(shape) as (keyof T)[];
this.strategy = strategy;

switch (this.strategy) {
Expand All @@ -36,6 +41,37 @@ export class ObjectValidator<T extends NonNullObject> extends BaseValidator<T> {
this.handleStrategy = (value) => this.handlePassthroughStrategy(value);
break;
}

const shapeEntries = Object.entries(shape) as [keyof T, BaseValidator<T>][];
this.keys = shapeEntries.map(([key]) => key);

for (const [key, validator] of shapeEntries) {
if (validator instanceof UnionValidator) {
const [possiblyLiteralOrNullishPredicate] = validator['validators'];

if (possiblyLiteralOrNullishPredicate instanceof NullishValidator) {
this.possiblyUndefinedKeys.set(key, validator);
} else if (possiblyLiteralOrNullishPredicate instanceof LiteralValidator) {
if (possiblyLiteralOrNullishPredicate.expected === undefined) {
this.possiblyUndefinedKeys.set(key, validator);
} else {
this.requiredKeys.set(key, validator);
}
} else {
this.requiredKeys.set(key, validator);
}
} else if (validator instanceof NullishValidator) {
this.possiblyUndefinedKeys.set(key, validator);
} else if (validator instanceof LiteralValidator) {
if (validator.expected === undefined) {
this.possiblyUndefinedKeys.set(key, validator);
} else {
this.requiredKeys.set(key, validator);
}
} else {
this.requiredKeys.set(key, validator);
}
}
}

public get strict(): ObjectValidator<{ [Key in keyof T]-?: T[Key] }> {
Expand Down Expand Up @@ -91,58 +127,102 @@ export class ObjectValidator<T extends NonNullObject> extends BaseValidator<T> {
return Reflect.construct(this.constructor, [this.shape, this.strategy, this.constraints]);
}

private handleIgnoreStrategy(value: NonNullObject, errors: [PropertyKey, BaseError][] = []): Result<T, CombinedPropertyError> {
const entries = {} as T;
let i = this.keys.length;
private handleIgnoreStrategy(value: NonNullObject): Result<T, CombinedPropertyError> {
const errors: [PropertyKey, BaseError][] = [];
const finalObject = {} as T;
const inputEntries = new Map(Object.entries(value) as [keyof T, unknown][]);

while (i--) {
const key = this.keys[i];
const result = this.shape[key].run(value[key as keyof NonNullObject]);
const runPredicate = (key: keyof T, predicate: BaseValidator<unknown>) => {
const result = predicate.run(value[key as keyof NonNullObject]);

if (result.isOk()) {
entries[key] = result.value;
finalObject[key] = result.value as T[keyof T];
} else {
const error = result.error!;
if (error instanceof ValidationError && error.given === undefined) {
errors.push([key, new MissingPropertyError(key)]);
} else {
errors.push([key, error]);
errors.push([key, error]);
}
};

for (const [key, predicate] of this.requiredKeys) {
if (inputEntries.delete(key)) {
runPredicate(key, predicate);
} else {
errors.push([key, new MissingPropertyError(key)]);
}
}

// Early exit if there are no more properties to validate in the object and there are errors to report
if (inputEntries.size === 0) {
return errors.length === 0 //
? Result.ok(finalObject)
: Result.err(new CombinedPropertyError(errors));
}

// In the event the remaining keys to check are less than the number of possible undefined keys, we check those
// as it could yield a faster execution
const checkInputEntriesInsteadOfSchemaKeys = this.possiblyUndefinedKeys.size > inputEntries.size;

if (checkInputEntriesInsteadOfSchemaKeys) {
for (const [key] of inputEntries) {
const predicate = this.possiblyUndefinedKeys.get(key);

if (predicate) {
runPredicate(key, predicate);
}
}
} else {
for (const [key, predicate] of this.possiblyUndefinedKeys) {
if (inputEntries.delete(key)) {
runPredicate(key, predicate);
}
}
}

return errors.length === 0 //
? Result.ok(entries)
? Result.ok(finalObject)
: Result.err(new CombinedPropertyError(errors));
}

private handleStrictStrategy(value: NonNullObject): Result<T, CombinedPropertyError> {
const errors: [PropertyKey, BaseError][] = [];
const finalResult = {} as T;
const keysToIterateOver = [...new Set([...Object.keys(value), ...this.keys])].reverse();
let i = keysToIterateOver.length;
const inputEntries = new Map(Object.entries(value) as [keyof T, unknown][]);

while (i--) {
const key = keysToIterateOver[i] as string;
const runPredicate = (key: keyof T, predicate: BaseValidator<unknown>) => {
const result = predicate.run(value[key as keyof NonNullObject]);

if (Object.prototype.hasOwnProperty.call(this.shape, key)) {
const result = this.shape[key as keyof MappedObjectValidator<T>].run(value[key as keyof NonNullObject]);
if (result.isOk()) {
finalResult[key] = result.value as T[keyof T];
} else {
const error = result.error!;
errors.push([key, error]);
}
};

if (result.isOk()) {
finalResult[key as keyof T] = result.value;
} else {
const error = result.error!;
if (error instanceof ValidationError && error.given === undefined) {
errors.push([key, new MissingPropertyError(key)]);
} else {
errors.push([key, error]);
}
}
for (const [key, predicate] of this.requiredKeys) {
if (inputEntries.delete(key)) {
runPredicate(key, predicate);
} else {
errors.push([key, new MissingPropertyError(key)]);
}
}

continue;
for (const [key, predicate] of this.possiblyUndefinedKeys) {
// All of these validators are assumed to be possibly undefined, so if we have gone through the entire object and there's still validators,
// safe to assume we're done here
if (inputEntries.size === 0) {
break;
}

errors.push([key, new UnknownPropertyError(key, value[key as keyof NonNullObject])]);
if (inputEntries.delete(key)) {
runPredicate(key, predicate);
}
}

if (inputEntries.size !== 0) {
for (const [key, value] of inputEntries.entries()) {
errors.push([key, new UnknownPropertyError(key, value)]);
}
}

return errors.length === 0 //
Expand Down
110 changes: 102 additions & 8 deletions tests/validators/object.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { CombinedPropertyError, MissingPropertyError, s, UnknownPropertyError, ValidationError } from '../../src';
import {
CombinedError,
CombinedPropertyError,
ExpectedValidationError,
MissingPropertyError,
s,
UnknownPropertyError,
ValidationError
} from '../../src';
import { expectError } from '../common/macros/comparators';

describe('ObjectValidator', () => {
Expand Down Expand Up @@ -40,12 +48,74 @@ describe('ObjectValidator', () => {
expectError(
() => predicate.parse({ username: 42, password: true }),
new CombinedPropertyError([
['password', new ValidationError('s.string', 'Expected a string primitive', true)],
['username', new ValidationError('s.string', 'Expected a string primitive', 42)]
['username', new ValidationError('s.string', 'Expected a string primitive', 42)],
['password', new ValidationError('s.string', 'Expected a string primitive', true)]
])
);
});

test('GIVEN LiteralValidator with undefined THEN it should be counted as a possibly undefined key', () => {
const predicate = s.object({
owo: s.undefined
});

expect(predicate['possiblyUndefinedKeys'].size).toEqual(1);
});

test('GIVEN LiteralValidator with null THEN it should be counted as a required key', () => {
const predicate = s.object({
owo: s.null
});

expect(predicate['requiredKeys'].size).toEqual(1);
});

test('GIVEN NullishValidator then it should count as a possibly undefined key', () => {
const predicate = s.object({
owo: s.nullish
});

expect(predicate['possiblyUndefinedKeys'].size).toEqual(1);
});

test('GIVEN UnionValidator with NullishValidator inside THEN it should be counted as a possibly undefined key', () => {
const predicate = s.object({
owo: s.string.nullish
});

expect(predicate['possiblyUndefinedKeys'].size).toEqual(1);
});

test("GIVEN UnionValidator with LiteralValidator with 'owo' THEN it should be counted as a required key", () => {
const predicate = s.object({
owo: s.union(s.literal('owo'), s.number)
});

expect(predicate['requiredKeys'].size).toEqual(1);
});

test('GIVEN UnionValidator with LiteralValidator with null THEN it should be counted as a required key', () => {
const predicate = s.object({
owo: s.union(s.string, s.literal(null))
});

expect(predicate['requiredKeys'].size).toEqual(1);
});

// Unit test for lines 167-190 of ObjectValidator.ts
test('GIVEN a big schema THEN it should validate using the shortest possible solution', () => {
const predicate = s.object({
a: s.string,
b: s.string,
c: s.string.optional,
d: s.string.optional,
e: s.string.optional
});

expect(predicate.parse({ a: 'a', b: 'b', c: 'c', d: 'd' })).toStrictEqual({ a: 'a', b: 'b', c: 'c', d: 'd' });
expect(predicate.parse({ a: 'a', b: 'b', c: 'c', d: 'd', e: 'e', f: 'f', g: 'g' })).toStrictEqual({ a: 'a', b: 'b', c: 'c', d: 'd', e: 'e' });
});

describe('Strict', () => {
const strictPredicate = predicate.strict;

Expand Down Expand Up @@ -81,8 +151,8 @@ describe('ObjectValidator', () => {
() => strictPredicate.parse({ username: 42, foo: 'owo' }),
new CombinedPropertyError([
['username', new ValidationError('s.string', 'Expected a string primitive', 42)],
['foo', new UnknownPropertyError('foo', 'owo')],
['password', new MissingPropertyError('password')]
['password', new MissingPropertyError('password')],
['foo', new UnknownPropertyError('foo', 'owo')]
])
);
});
Expand All @@ -93,9 +163,14 @@ describe('ObjectValidator', () => {

test('GIVEN matching keys and values without optional keys THEN returns no errors', () => {
expect(optionalStrict.parse({ username: 'Sapphire', password: 'helloworld' })).toStrictEqual({
username: 'Sapphire',
password: 'helloworld'
});

expect(optionalStrict.parse({ username: 'Sapphire', password: 'helloworld', optionalKey: 'pog' })).toStrictEqual({
username: 'Sapphire',
password: 'helloworld',
optionalKey: undefined
optionalKey: 'pog'
});
});
});
Expand All @@ -116,6 +191,25 @@ describe('ObjectValidator', () => {
new CombinedPropertyError([['password', new MissingPropertyError('password')]])
);
});

test('GIVEN matching keys with an optional key that fails validation, THEN throws CombinedPropertyError with ValidationError', () => {
const predicate = ignorePredicate.extend({
owo: s.boolean.optional
});

expectError(
() => predicate.parse({ username: 'Sapphire', password: 'helloworld', owo: 'owo' }),
new CombinedPropertyError([
[
'owo',
new CombinedError([
new ExpectedValidationError('s.literal(V)', 'Expected values to be equals', 'owo', undefined),
new ValidationError('s.boolean', 'Expected a boolean primitive', 'owo')
])
]
])
);
});
});

describe('Passthrough', () => {
Expand All @@ -140,8 +234,8 @@ describe('ObjectValidator', () => {
describe('Partial', () => {
const partialPredicate = predicate.partial;

test('GIVEN empty object THEN returns an object with undefined values', () => {
expect(partialPredicate.parse({})).toStrictEqual({ username: undefined, password: undefined });
test('GIVEN empty object THEN returns an empty object', () => {
expect(partialPredicate.parse({})).toStrictEqual({});
});
});

Expand Down

0 comments on commit 817278e

Please sign in to comment.