Skip to content

Commit

Permalink
feat(validation): add ability to do multi-validation and object valid…
Browse files Browse the repository at this point in the history
…ation
  • Loading branch information
macornwell committed Sep 18, 2024
1 parent c5a8f01 commit cd7b998
Show file tree
Hide file tree
Showing 6 changed files with 257 additions and 3 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "functional-models",
"version": "2.1.10",
"version": "2.1.11",
"description": "A library for creating JavaScript function based models.",
"main": "index.js",
"types": "index.d.ts",
Expand Down
4 changes: 2 additions & 2 deletions src/properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,8 @@ const Property = <
modelInstance: TModelInstance
) => Promise<TValue>)
: typeof instanceValue === 'function'
? (instanceValue as () => TValue)
: () => instanceValue
? (instanceValue as () => TValue)
: () => instanceValue
const r: ValueGetter<TValue, T, TModel, TModelInstance> = () => {
const result = method(instanceValue, modelData, instance)
return valueSelector(result)
Expand Down
12 changes: 12 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,23 @@ const createHeadAndTail = (values: readonly string[], joiner: string) => {
return [head, tail]
}

const flowFindFirst =
<T, TResult>(funcs: ((t: T) => undefined | TResult)[]) =>
(input: T) => {
return funcs.reduce((acc: undefined | TResult, func) => {
if (acc) {
return acc
}
return func(input)
}, undefined) as string | TResult
}

export {
loweredTitleCase,
toTitleCase,
createUuid,
isPromise,
createHeadAndTail,
singularize,
flowFindFirst,
}
60 changes: 60 additions & 0 deletions src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ import {
MaybeFunction,
PropertyValidatorComponentTypeAdvanced,
} from './interfaces'
import { flowFindFirst } from './utils'

const multiValidator = <T extends Arrayable<FunctionalValue>>(
validators: ValuePropertyValidatorComponent<T>[]
): ValuePropertyValidatorComponent<T> => {
const flow = flowFindFirst<T, string>(
validators as ((t: T) => undefined | string)[]
)
return flow as ValuePropertyValidatorComponent<T>
}

const TYPE_PRIMITIVES = {
boolean: 'boolean',
Expand Down Expand Up @@ -72,6 +82,10 @@ const isType =
const isNumber = isType<number>('number')
const isInteger = _trueOrError<number>(Number.isInteger, 'Must be an integer')

const isObject = multiValidator<object>([
isType<object>('object'),
x => (Array.isArray(x) ? 'Must be an object, but got an array' : undefined),
])
const isBoolean = isType<boolean>('boolean')
const isString = isType<string>('string')
const isArray = _trueOrError<readonly FunctionalValue[]>(
Expand Down Expand Up @@ -427,6 +441,49 @@ const referenceTypeMatch = <
}
}

const objectValidator = <T extends object>({
required,
keyToValidators,
}: {
required?: boolean
keyToValidators: {
[s: string]:
| ValuePropertyValidatorComponent<any>
| ValuePropertyValidatorComponent<any>[]
}
}): ValuePropertyValidatorComponent<T> => {
return (obj: T) => {
if (!obj) {
if (required) {
return 'Must include a value'
}
return undefined
}
const isNotObj = isObject(obj)
if (isNotObj) {
return isNotObj
}
return (
Object.entries(obj)
.reduce((acc, [key, value]) => {
const validators = keyToValidators[key]
if (!validators) {
return acc
}
const validator = Array.isArray(validators)
? multiValidator(validators)
: validators
const error = validator(value)
if (error) {
return acc.concat(`${key}: ${error}`)
}
return acc
}, [] as string[])
.join(', ') || undefined
)
}
}

export {
isNumber,
isBoolean,
Expand All @@ -450,4 +507,7 @@ export {
isValid,
TYPE_PRIMITIVES,
referenceTypeMatch,
multiValidator,
isObject,
objectValidator,
}
55 changes: 55 additions & 0 deletions test/src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,64 @@ import {
createUuid,
toTitleCase,
isPromise,
flowFindFirst,
} from '../../src/utils'

describe('/src/utils.ts', () => {
describe('#flowFindFirst()', () => {
it('should run 2 out of the 3 functions when the first returns undefined, and the second returns a string', () => {
const input = [
sinon.stub().callsFake(v => (v === 'cat' ? 'cat' : undefined)),
sinon.stub().callsFake(v => (v === 'dog' ? 'dog' : undefined)),
sinon
.stub()
.callsFake(v => (v === 'mongoose' ? 'mongoose' : undefined)),
]
const input2 = 'dog'
const actual = flowFindFirst(input)(input2)
assert.isFalse(input[2].called)
})
it('should return "dog"', () => {
const input = [
sinon.stub().callsFake(v => (v === 'cat' ? 'cat' : undefined)),
sinon.stub().callsFake(v => (v === 'dog' ? 'dog' : undefined)),
sinon
.stub()
.callsFake(v => (v === 'mongoose' ? 'mongoose' : undefined)),
]
const input2 = 'dog'
const actual = flowFindFirst(input)(input2)
const expected = 'dog'
assert.equal(actual, expected)
})
it('should run 3 out of the 3 functions when all of them return undefined', () => {
const input = [
sinon.stub().callsFake(v => (v === 'cat' ? 'cat' : undefined)),
sinon.stub().callsFake(v => (v === 'dog' ? 'dog' : undefined)),
sinon
.stub()
.callsFake(v => (v === 'mongoose' ? 'mongoose' : undefined)),
]
const input2 = 'snake'
flowFindFirst(input)(input2)
const actual = input.reduce((count, func) => (count += func.callCount), 0)
const expected = 3
assert.equal(actual, expected)
})
it('should return undefined when all fail', () => {
const input = [
sinon.stub().callsFake(v => (v === 'cat' ? 'cat' : undefined)),
sinon.stub().callsFake(v => (v === 'dog' ? 'dog' : undefined)),
sinon
.stub()
.callsFake(v => (v === 'mongoose' ? 'mongoose' : undefined)),
]
const input2 = 'snake'
const actual = flowFindFirst(input)(input2)
assert.isUndefined(actual)
})
})

describe('#isPromise()', () => {
it('should return false if null is passed in', () => {
const actual = isPromise(null)
Expand Down
127 changes: 127 additions & 0 deletions test/src/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ import {
createPropertyValidator,
TYPE_PRIMITIVES,
referenceTypeMatch,
multiValidator,
isObject,
objectValidator,
} from '../../src/validation'
import {
ModelValidatorComponent,
Expand Down Expand Up @@ -60,6 +63,130 @@ const EMPTY_MODEL = BaseModel<EMPTY_MODEL_TYPE>('EmptyModel', {
const EMPTY_MODEL_INSTANCE = EMPTY_MODEL.create({})

describe('/src/validation.ts', () => {
describe('#objectValidator()', () => {
it('should return multiple errors if different properties error', () => {
const keyToValidators = {
myKey: [(obj: object) => undefined, (obj: object) => 'this-error'],
myKey2: (obj: object) => 'my-error',
}
const obj = {
myKey: 'my-value',
myKey2: 'my-value-2',
}
const actual = objectValidator({ keyToValidators })(obj)
const expected = 'myKey: this-error, myKey2: my-error'
assert.equal(actual, expected)
})
it('should return an error in a multi validator situation', () => {
const keyToValidators = {
myKey: [(obj: object) => undefined, (obj: object) => 'this-error'],
myKey2: (obj: object) => undefined,
}
const obj = {
myKey: 'my-value',
myKey2: 'my-value-2',
}
const actual = objectValidator({ required: true, keyToValidators })(obj)
const expected = 'myKey: this-error'
assert.equal(actual, expected)
})
it('should return an error because object is required and is undefined', () => {
const keyToValidators = {
myKey: (obj: object) => undefined,
myKey2: (obj: object) => 'my-error',
}
// @ts-ignore
const actual = objectValidator({ required: true, keyToValidators })(
undefined
)
const expected = 'Must include a value'
assert.equal(actual, expected)
})
it('should return an error if a property fails validation', () => {
const keyToValidators = {
myKey: (obj: object) => undefined,
myKey2: (obj: object) => 'my-error',
}
const obj = {
myKey: 'my-value',
myKey2: 'my-value-2',
}
const actual = objectValidator({ keyToValidators })(obj)
const expected = 'myKey2: my-error'
assert.equal(actual, expected)
})
it('should return an error if actually an array', () => {
const keyToValidators = {
myKey: (obj: object) => undefined,
myKey2: (obj: object) => 'my-error',
}
// @ts-ignore
const actual = objectValidator({ keyToValidators })([])
const expected = 'Must be an object, but got an array'
assert.equal(actual, expected)
})
it('should return undefined if all validation passes', () => {
const keyToValidators = {
myKey: (obj: object) => undefined,
myKey2: (obj: object) => undefined,
}
const obj = {
myKey: 'my-value',
myKey2: 'my-value-2',
myKey3: 'not-actually-checked',
}
const actual = objectValidator({ keyToValidators })(obj)
assert.isUndefined(actual)
})
it('should return undefined if obj is undefined but not required', () => {
const keyToValidators = {
myKey: (obj: object) => 'would-error',
myKey2: (obj: object) => 'would-error-2',
}
//@ts-ignore
const actual = objectValidator({ keyToValidators })(undefined)
assert.isUndefined(actual)
})
})
describe('#isObject()', () => {
it('should return not an object error if its not an object', () => {
// @ts-ignore
const actual = isObject(5)
const expected = 'Must be a object'
assert.equal(actual, expected)
})
it('should return undefined if its an object', () => {
// @ts-ignore
const actual = isObject({})
assert.isUndefined(actual)
})
it('should return array error message because the value is an array', () => {
// @ts-ignore
const actual = isObject([])
const expected = 'Must be an object, but got an array'
assert.equal(actual, expected)
})
})
describe('#multiValidator()', () => {
it('should fail isString check', () => {
const validators = [isRequired, isString, minTextLength(5)]
// @ts-ignore
const actual = multiValidator(validators)(5)
const expected = 'Must be a string'
assert.equal(actual, expected)
})
it('should fail minTextLength check', () => {
const validators = [isRequired, isString, minTextLength(5)]
const actual = multiValidator(validators)('text')
const expected = 'The minimum length is 5'
assert.equal(actual, expected)
})
it('should return undefined when all checks pass', () => {
const validators = [isRequired, isString, minTextLength(5)]
const actual = multiValidator(validators)('text-is-long')
assert.isUndefined(actual)
})
})
describe('#isDate()', () => {
it('should return an error if value is null', () => {
// @ts-ignore
Expand Down

0 comments on commit cd7b998

Please sign in to comment.