Skip to content

Commit

Permalink
feat(ArrayValidator): add length ranges (#53)
Browse files Browse the repository at this point in the history
  • Loading branch information
vladfrangu authored Mar 5, 2022
1 parent 7359042 commit e431d62
Show file tree
Hide file tree
Showing 22 changed files with 232 additions and 118 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ const stringArray = s.array(s.string);
const stringArray = s.string.array;
```

ShapeShift includes a handful of string-specific validations:
ShapeShift includes a handful of array-specific validations:

```typescript
s.string.array.lengthLt(5); // Must have less than 5 elements
Expand All @@ -210,6 +210,9 @@ s.string.array.lengthGt(5); // Must have more than 5 elements
s.string.array.lengthGe(5); // Must have 5 or more elements
s.string.array.lengthEq(5); // Must have exactly 5 elements
s.string.array.lengthNe(5); // Must not have exactly 5 elements
s.string.array.lengthRange(0, 4); // Must have at least 0 elements and less than 4 elements (in math, that is [0, 4))
s.string.array.lengthRangeInclusive(0, 4); // Must have at least 0 elements and at most 4 elements (in math, that is [0, 4])
s.string.array.lengthRangeExclusive(0, 4); // Must have more than 0 element and less than 4 elements (in math, that is (0, 4))
```

> **Note**: All `.length` methods define tuple types with the given amount of elements. For example, `s.string.array.lengthGe(2)`'s inferred type is `[string, string, ...string[]]`
Expand Down
35 changes: 34 additions & 1 deletion src/constraints/ArrayLengthConstraints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Result } from '../lib/Result';
import type { IConstraint } from './base/IConstraint';
import { Comparator, eq, ge, gt, le, lt, ne } from './util/operators';

export type ArrayConstraintName = `s.array(T).length${'Lt' | 'Le' | 'Gt' | 'Ge' | 'Eq' | 'Ne'}`;
export type ArrayConstraintName = `s.array(T).length${'Lt' | 'Le' | 'Gt' | 'Ge' | 'Eq' | 'Ne' | 'Range' | 'RangeInclusive' | 'RangeExclusive'}`;

function arrayLengthComparator<T>(comparator: Comparator, name: ArrayConstraintName, expected: string, length: number): IConstraint<T[]> {
return {
Expand Down Expand Up @@ -44,3 +44,36 @@ export function arrayLengthNe<T>(value: number): IConstraint<T[]> {
const expected = `expected.length !== ${value}`;
return arrayLengthComparator(ne, 's.array(T).lengthNe', expected, value);
}

export function arrayLengthRange<T>(start: number, endBefore: number): IConstraint<T[]> {
const expected = `expected.length >= ${start} && expected.length < ${endBefore}`;
return {
run(input: T[]) {
return input.length >= start && input.length < endBefore //
? Result.ok(input)
: Result.err(new ConstraintError('s.array(T).lengthRange', 'Invalid Array length', input, expected));
}
};
}

export function arrayLengthRangeInclusive<T>(start: number, end: number): IConstraint<T[]> {
const expected = `expected.length >= ${start} && expected.length <= ${end}`;
return {
run(input: T[]) {
return input.length >= start && input.length <= end //
? Result.ok(input)
: Result.err(new ConstraintError('s.array(T).lengthRangeInclusive', 'Invalid Array length', input, expected));
}
};
}

export function arrayLengthRangeExclusive<T>(startAfter: number, endBefore: number): IConstraint<T[]> {
const expected = `expected.length > ${startAfter} && expected.length < ${endBefore}`;
return {
run(input: T[]) {
return input.length > startAfter && input.length < endBefore //
? Result.ok(input)
: Result.err(new ConstraintError('s.array(T).lengthRangeExclusive', 'Invalid Array length', input, expected));
}
};
}
4 changes: 2 additions & 2 deletions src/constraints/StringConstraints.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { isIP, isIPv4, isIPv6 } from 'node:net';
import { ConstraintError } from '../lib/errors/ConstraintError';
import { Result } from '../lib/Result';
import type { IConstraint } from './base/IConstraint';
import { Comparator, eq, ge, gt, le, lt, ne } from './util/operators';
import { isIP, isIPv4, isIPv6 } from 'node:net';
import { validateEmail } from './util/emailValidator';
import { Comparator, eq, ge, gt, le, lt, ne } from './util/operators';

export type StringConstraintName =
| `s.string.${`length${'Lt' | 'Le' | 'Gt' | 'Ge' | 'Eq' | 'Ne'}` | 'regex' | 'url' | 'uuid' | 'email' | `ip${'v4' | 'v6' | ''}`}`;
Expand Down
14 changes: 8 additions & 6 deletions src/constraints/type-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ export type {
arrayLengthGt,
arrayLengthLe,
arrayLengthLt,
arrayLengthNe
arrayLengthNe,
arrayLengthRange,
arrayLengthRangeInclusive
} from './ArrayLengthConstraints';
export type { IConstraint } from './base/IConstraint';
export type { BigIntConstraintName, bigintEq, bigintGe, bigintGt, bigintLe, bigintLt, bigintNe, bigintDivisibleBy } from './BigIntConstraints';
export type { BigIntConstraintName, bigintDivisibleBy, bigintEq, bigintGe, bigintGt, bigintLe, bigintLt, bigintNe } from './BigIntConstraints';
export type { BooleanConstraintName, booleanFalse, booleanTrue } from './BooleanConstraints';
export type { DateConstraintName, dateEq, dateGe, dateGt, dateInvalid, dateLe, dateLt, dateNe, dateValid } from './DateConstraints';
export type {
Expand All @@ -28,17 +30,17 @@ export type {
} from './NumberConstraints';
export type {
StringConstraintName,
StringProtocol,
StringDomain,
UrlOptions,
stringEmail,
stringIp,
stringLengthEq,
stringLengthGe,
stringLengthGt,
stringLengthLe,
stringLengthLt,
stringLengthNe,
stringEmail,
StringProtocol,
stringRegex,
stringUrl,
stringIp
UrlOptions
} from './StringConstraints';
2 changes: 2 additions & 0 deletions src/type-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export type {
arrayLengthLe,
arrayLengthLt,
arrayLengthNe,
arrayLengthRange,
arrayLengthRangeInclusive,
BigIntConstraintName,
bigintDivisibleBy,
bigintEq,
Expand Down
43 changes: 37 additions & 6 deletions src/validators/ArrayValidator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import { arrayLengthEq, arrayLengthGe, arrayLengthGt, arrayLengthLe, arrayLengthLt, arrayLengthNe } from '../constraints/ArrayLengthConstraints';
import {
arrayLengthEq,
arrayLengthGe,
arrayLengthGt,
arrayLengthLe,
arrayLengthLt,
arrayLengthNe,
arrayLengthRange,
arrayLengthRangeExclusive,
arrayLengthRangeInclusive
} from '../constraints/ArrayLengthConstraints';
import type { IConstraint } from '../constraints/base/IConstraint';
import type { BaseError } from '../lib/errors/BaseError';
import { CombinedPropertyError } from '../lib/errors/CombinedPropertyError';
Expand All @@ -19,23 +29,44 @@ export class ArrayValidator<T> extends BaseValidator<T[]> {
}

public lengthLe<N extends number>(length: N): BaseValidator<ExpandSmallerTuples<[...Tuple<T, N>]>> {
return this.addConstraint(arrayLengthLe(length) as IConstraint<T[]>) as any;
return this.addConstraint(arrayLengthLe(length)) as any;
}

public lengthGt<N extends number>(length: N): BaseValidator<[...Tuple<T, N>, T, ...T[]]> {
return this.addConstraint(arrayLengthGt(length) as IConstraint<T[]>) as any;
return this.addConstraint(arrayLengthGt(length)) as any;
}

public lengthGe<N extends number>(length: N): BaseValidator<[...Tuple<T, N>, ...T[]]> {
return this.addConstraint(arrayLengthGe(length) as IConstraint<T[]>) as any;
return this.addConstraint(arrayLengthGe(length)) as any;
}

public lengthEq<N extends number>(length: N): BaseValidator<[...Tuple<T, N>]> {
return this.addConstraint(arrayLengthEq(length) as IConstraint<T[]>) as any;
return this.addConstraint(arrayLengthEq(length)) as any;
}

public lengthNe(length: number): BaseValidator<[...T[]]> {
return this.addConstraint(arrayLengthNe(length) as IConstraint<T[]>);
return this.addConstraint(arrayLengthNe(length)) as any;
}

public lengthRange<S extends number, E extends number>(
start: S,
endBefore: E
): BaseValidator<Exclude<ExpandSmallerTuples<UnshiftTuple<[...Tuple<T, E>]>>, ExpandSmallerTuples<UnshiftTuple<[...Tuple<T, S>]>>>> {
return this.addConstraint(arrayLengthRange(start, endBefore)) as any;
}

public lengthRangeInclusive<S extends number, E extends number>(
startAt: S,
endAt: E
): BaseValidator<Exclude<ExpandSmallerTuples<[...Tuple<T, E>]>, ExpandSmallerTuples<UnshiftTuple<[...Tuple<T, S>]>>>> {
return this.addConstraint(arrayLengthRangeInclusive(startAt, endAt)) as any;
}

public lengthRangeExclusive<S extends number, E extends number>(
startAfter: S,
endBefore: E
): BaseValidator<Exclude<ExpandSmallerTuples<UnshiftTuple<[...Tuple<T, E>]>>, ExpandSmallerTuples<[...Tuple<T, S>]>>> {
return this.addConstraint(arrayLengthRangeExclusive(startAfter, endBefore)) as any;
}

protected override clone(): this {
Expand Down
2 changes: 1 addition & 1 deletion src/validators/BigIntValidator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { IConstraint } from '../constraints/base/IConstraint';
import { bigintEq, bigintGe, bigintGt, bigintLe, bigintLt, bigintNe, bigintDivisibleBy } from '../constraints/BigIntConstraints';
import { bigintDivisibleBy, bigintEq, bigintGe, bigintGt, bigintLe, bigintLt, bigintNe } from '../constraints/BigIntConstraints';
import { ValidationError } from '../lib/errors/ValidationError';
import { Result } from '../lib/Result';
import { BaseValidator } from './imports';
Expand Down
8 changes: 4 additions & 4 deletions src/validators/TupleValidator.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { BaseValidator } from './imports';
import { Result } from '../lib/Result';
import { ValidationError } from '../lib/errors/ValidationError';
import { CombinedPropertyError } from '../lib/errors/CombinedPropertyError';
import type { IConstraint } from '../constraints/base/IConstraint';
import type { BaseError } from '../lib/errors/BaseError';
import { CombinedPropertyError } from '../lib/errors/CombinedPropertyError';
import { ValidationError } from '../lib/errors/ValidationError';
import { Result } from '../lib/Result';
import { BaseValidator } from './imports';

export class TupleValidator<T extends any[]> extends BaseValidator<[...T]> {
private readonly validators: BaseValidator<[...T]>[] = [];
Expand Down
89 changes: 66 additions & 23 deletions tests/validators/array.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,7 @@ describe('ArrayValidator', () => {
expect<[string] | []>(lengthLtPredicate.parse(value)).toEqual(value);
});

test.each([
[
['Hello', 'there'],
['foo', 'bar', 'baaz']
]
])('GIVEN %p THEN throws ConstraintError', (value) => {
test.each([[['Hello', 'there']], [['foo', 'bar', 'baaz']]])('GIVEN %p THEN throws ConstraintError', (value) => {
expectError(
() => lengthLtPredicate.parse(value),
new ConstraintError('s.array(T).lengthLt', 'Invalid Array length', value, 'expected.length < 2')
Expand All @@ -46,7 +41,7 @@ describe('ArrayValidator', () => {
describe('lengthLe', () => {
const lengthLePredicate = s.string.array.lengthLe(2);

test.each([[['Hello'], ['Hello', 'there']]])('GIVEN %p THEN returns given value', (value) => {
test.each([[['Hello']], [['Hello', 'there']]])('GIVEN %p THEN returns given value', (value) => {
expect<[string, string] | [string] | []>(lengthLePredicate.parse(value)).toEqual(value);
});

Expand All @@ -65,7 +60,7 @@ describe('ArrayValidator', () => {
expect<[string, string, string, ...string[]]>(lengthGtPredicate.parse(value)).toEqual(value);
});

test.each([[['Hello'], []]])('GIVEN %p THEN throws ConstraintError', (value) => {
test.each([[['Hello']], [[]]])('GIVEN %p THEN throws ConstraintError', (value) => {
expectError(
() => lengthGtPredicate.parse(value),
new ConstraintError('s.array(T).lengthGt', 'Invalid Array length', value, 'expected.length > 2')
Expand All @@ -76,16 +71,11 @@ describe('ArrayValidator', () => {
describe('lengthGe', () => {
const lengthGePredicate = s.string.array.lengthGe(2);

test.each([
[
['Hello', 'there'],
['foo', 'bar', 'baaz']
]
])('GIVEN %p THEN returns given value', (value) => {
test.each([[['Hello', 'there']], [['foo', 'bar', 'baaz']]])('GIVEN %p THEN returns given value', (value) => {
expect<[string, string, ...string[]]>(lengthGePredicate.parse(value)).toEqual(value);
});

test.each([[[], ['foo']]])('GIVEN %p THEN throws ConstraintError', (value) => {
test.each([[[]], [['foo']]])('GIVEN %p THEN throws ConstraintError', (value) => {
expectError(
() => lengthGePredicate.parse(value),
new ConstraintError('s.array(T).lengthGe', 'Invalid Array length', value, 'expected.length >= 2')
Expand All @@ -100,7 +90,7 @@ describe('ArrayValidator', () => {
expect<[string, string]>(lengthPredicate.parse(value)).toEqual(value);
});

test.each([[[], ['Hello']]])('GIVEN %p THEN throws ConstraintError', (value) => {
test.each([[[]], [['Hello']]])('GIVEN %p THEN throws ConstraintError', (value) => {
expectError(
() => lengthPredicate.parse(value),
new ConstraintError('s.array(T).lengthEq', 'Invalid Array length', value, 'expected.length === 2')
Expand All @@ -111,22 +101,75 @@ describe('ArrayValidator', () => {
describe('lengthNe', () => {
const lengthNotEqPredicate = s.string.array.lengthNe(2);

test.each([[['foo', 'bar', 'baaz'], ['foo']]])('GIVEN %p THEN returns given value', (value) => {
test.each([[['foo', 'bar', 'baaz']], [['foo']]])('GIVEN %p THEN returns given value', (value) => {
expect<string[]>(lengthNotEqPredicate.parse(value)).toEqual(value);
});

test.each([
[
['Hello', 'there'],
['foo', 'bar']
]
])('GIVEN %p THEN throws ConstraintError', (value) => {
test.each([[['Hello', 'there']], [['foo', 'bar']]])('GIVEN %p THEN throws ConstraintError', (value) => {
expectError(
() => lengthNotEqPredicate.parse(value),
new ConstraintError('s.array(T).lengthNe', 'Invalid Array length', value, 'expected.length !== 2')
);
});
});

describe('lengthRange', () => {
const lengthRangePredicate = s.string.array.lengthRange(0, 2);

test.each([[[] as string[]], [['foo']]])('GIVEN %p THEN returns given value', (value) => {
expect<[] | [string]>(lengthRangePredicate.parse(value)).toEqual(value);
});

test.each([[['hewwo', 'there']]])('GIVEN %p THEN throws ConstraintError', (value) => {
expectError(
() => lengthRangePredicate.parse(value),
new ConstraintError('s.array(T).lengthRange', 'Invalid Array length', value, 'expected.length >= 0 && expected.length < 2')
);
});
});

describe('lengthRangeInclusive', () => {
const lengthRangeInclusivePredicate = s.string.array.lengthRangeInclusive(0, 2);

test.each([[[] as string[]], [['foo']], [['hewwo', 'there']]])('GIVEN %p THEN returns given value', (value) => {
expect<[] | [string] | [string, string]>(lengthRangeInclusivePredicate.parse(value)).toEqual(value);
});

test.each([[['hewwo', 'there', 'buddy']]])('GIVEN %p THEN throws ConstraintError', (value) => {
expectError(
() => lengthRangeInclusivePredicate.parse(value),
new ConstraintError(
's.array(T).lengthRangeInclusive',
'Invalid Array length',
value,
'expected.length >= 0 && expected.length <= 2'
)
);
});

describe('lengthRangeExclusive', () => {
const lengthRangeExclusivePredicate = s.string.array.lengthRangeExclusive(0, 2);

test.each([[['foo']]])('GIVEN %p THEN returns given value', (value) => {
expect<[string]>(lengthRangeExclusivePredicate.parse(value)).toEqual(value);
});

test.each([[[] as string[]], [['hewwo', 'there']], [['hewwo', 'there', 'buddy']]])(
'GIVEN %p THEN throws ConstraintError',
(value) => {
expectError(
() => lengthRangeExclusivePredicate.parse(value),
new ConstraintError(
's.array(T).lengthRangeExclusive',
'Invalid Array length',
value,
'expected.length > 0 && expected.length < 2'
)
);
}
);
});
});
});

test('GIVEN clone THEN returns similar instance', () => {
Expand Down
Loading

0 comments on commit e431d62

Please sign in to comment.