diff --git a/features/arrayFields.feature b/features/arrayFields.feature new file mode 100644 index 0000000..b8dac91 --- /dev/null +++ b/features/arrayFields.feature @@ -0,0 +1,38 @@ +Feature: Array Fields + + Scenario: A model that has an array field is created holding an array of integers and is checked and validated + Given ArrayModel1 model is used + When ArrayModelData1 data is inserted + Then the getArrayField field is called on the model + Then the array values match + | array | [1,2,3,4,5]| + Then functions.validate is called + Then an array of 0 errors is shown + + Scenario: A model that has an array field but has a non-array inserted into it fails validation + Given ArrayModel1 model is used + When ArrayModelData2 data is inserted + Then the getArrayField field is called on the model + Then functions.validate is called + Then an array of 1 errors is shown + + Scenario: A model that has an array field for integers but an array of strings is inserted into it, it should fail validation + Given ArrayModel1 model is used + When ArrayModelData3 data is inserted + Then the getArrayField field is called on the model + Then functions.validate is called + Then an array of 1 errors is shown + + Scenario: A model that has an array field that has mixed values it should not fail validation. + Given ArrayModel2 model is used + When ArrayModelData4 data is inserted + Then the getArrayField field is called on the model + Then functions.validate is called + Then an array of 0 errors is shown + + Scenario: A model that uses the arrayField field that has mixed values it should not fail validation. + Given ArrayModel3 model is used + When ArrayModelData4 data is inserted + Then the getArrayField field is called on the model + Then functions.validate is called + Then an array of 0 errors is shown diff --git a/features/model.feature b/features/model.feature index 0ee8d52..f4c4031 100644 --- a/features/model.feature +++ b/features/model.feature @@ -1,7 +1,7 @@ Feature: Models Scenario: A Model With a 4 fields - Given TestModel1 is used + Given TestModel1 model is used When TestModel1b data is inserted Then TestModel1b expected fields are found diff --git a/features/stepDefinitions/steps.js b/features/stepDefinitions/steps.js index bed1cb9..b95d4b6 100644 --- a/features/stepDefinitions/steps.js +++ b/features/stepDefinitions/steps.js @@ -1,8 +1,7 @@ const assert = require('chai').assert const flatMap = require('lodash/flatMap') const { Given, When, Then } = require('@cucumber/cucumber') - -const { createModel, field } = require('../../index') +const { createModel, field, arrayField, validation } = require('../../index') const MODEL_DEFINITIONS = { TestModel1: createModel({ @@ -10,6 +9,18 @@ const MODEL_DEFINITIONS = { type: field({ required: true, isString: true }), flag: field({ required: true, isNumber: true }), }), + ArrayModel1: createModel({ + arrayField: field({ + isArray: true, + validators: [validation.arrayType(validation.TYPE_PRIMATIVES.integer)], + }), + }), + ArrayModel2: createModel({ + arrayField: field({ isArray: true }), + }), + ArrayModel3: createModel({ + arrayField: arrayField({}), + }), } const MODEL_INPUT_VALUES = { @@ -23,6 +34,18 @@ const MODEL_INPUT_VALUES = { type: 'a-type', flag: 1, }, + ArrayModelData1: { + arrayField: [1, 2, 3, 4, 5], + }, + ArrayModelData2: { + arrayField: 'a-string', + }, + ArrayModelData3: { + arrayField: ['a-string', 'a-string2'], + }, + ArrayModelData4: { + arrayField: ['a-string', 1, {}, true], + }, } const EXPECTED_FIELDS = { @@ -58,7 +81,7 @@ Then('an array of {int} errors is shown', function (errorCount) { assert.equal(errors.length, errorCount) }) -Given('{word} is used', function (modelDefinition) { +Given('{word} model is used', function (modelDefinition) { const def = MODEL_DEFINITIONS[modelDefinition] if (!def) { throw new Error(`${modelDefinition} did not result in a definition`) @@ -85,3 +108,14 @@ Then('{word} expected fields are found', function (fields) { } }) }) + +Then('the {word} field is called on the model', function (field) { + return this.instance[field]().then(result => { + this.results = result + }) +}) + +Then('the array values match', function (table) { + const expected = JSON.parse(table.rowsHash().array) + assert.deepEqual(this.results, expected) +}) diff --git a/package.json b/package.json index 434b9c8..7c5a092 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "functional-models", - "version": "1.0.5", + "version": "1.0.6", "description": "A library for creating JavaScript function based models.", "main": "index.js", "scripts": { diff --git a/src/fields.js b/src/fields.js index 2e7b59a..c4ec635 100644 --- a/src/fields.js +++ b/src/fields.js @@ -98,9 +98,17 @@ const referenceField = config => { }) } +const arrayField = (config = {}) => + field({ + defaultValue: [], + ...config, + isArray: true, + }) + module.exports = { field, uniqueId, dateField, + arrayField, referenceField, } diff --git a/src/validation.js b/src/validation.js index a459c34..ad2a87a 100644 --- a/src/validation.js +++ b/src/validation.js @@ -2,6 +2,14 @@ const isEmpty = require('lodash/isEmpty') const flatMap = require('lodash/flatMap') const get = require('lodash/get') +const TYPE_PRIMATIVES = { + boolean: 'boolean', + string: 'string', + object: 'object', + number: 'number', + integer: 'integer', +} + const _trueOrError = (method, error) => value => { if (method(value) === false) { return error @@ -30,6 +38,28 @@ const isInteger = _trueOrError(v => { const isBoolean = isType('boolean') const isString = isType('string') +const isArray = _trueOrError(v => Array.isArray(v), 'Value is not an array') + +const PRIMATIVE_TO_SPECIAL_TYPE_VALIDATOR = { + [TYPE_PRIMATIVES.boolean]: isBoolean, + [TYPE_PRIMATIVES.string]: isString, + [TYPE_PRIMATIVES.integer]: isInteger, + [TYPE_PRIMATIVES.number]: isNumber, +} + +const arrayType = type => value => { + const arrayError = isArray(value) + if (arrayError) { + return arrayError + } + const validator = PRIMATIVE_TO_SPECIAL_TYPE_VALIDATOR[type] || isType(type) + return value.reduce((acc, v) => { + if (acc) { + return acc + } + return validator(v) + }, undefined) +} const meetsRegex = (regex, flags, errorMessage = 'Format was invalid') => @@ -122,6 +152,7 @@ const CONFIG_TO_VALIDATE_METHOD = { isInteger: _boolChoice(isInteger), isNumber: _boolChoice(isNumber), isString: _boolChoice(isString), + isArray: _boolChoice(isArray), } const createFieldValidator = config => { @@ -135,7 +166,7 @@ const createFieldValidator = config => { validators.length > 0 ? aggregateValidator(validators) : emptyValidator return async value => { const errors = await validator(value) - return flatMap(errors) + return [...new Set(flatMap(errors))] } } @@ -159,6 +190,7 @@ module.exports = { isString, isInteger, isType, + isArray, isRequired, maxNumber, minNumber, @@ -170,4 +202,6 @@ module.exports = { emptyValidator, createFieldValidator, createModelValidator, + arrayType, + TYPE_PRIMATIVES, } diff --git a/test/src/fields.test.js b/test/src/fields.test.js index 9a173e7..9849e8d 100644 --- a/test/src/fields.test.js +++ b/test/src/fields.test.js @@ -4,10 +4,53 @@ const { field, dateField, referenceField, + arrayField, } = require('../../src/fields') const { createModel } = require('../../src/models') describe('/src/fields.js', () => { + describe('#arrayField()', () => { + describe('#createGetter()', () => { + it('should return an array passed in without issue', async () => { + const theField = arrayField({}) + const getter = theField.createGetter([1, 2, 3]) + const actual = await getter() + const expected = [1, 2, 3] + assert.deepEqual(actual, expected) + }) + it('should return an array passed in without issue, even if no config is passed', async () => { + const theField = arrayField() + const getter = theField.createGetter([1, 2, 3]) + const actual = await getter() + const expected = [1, 2, 3] + assert.deepEqual(actual, expected) + }) + it('should return an empty array if defaultValue is not changed in config and null is passed', async () => { + const theField = arrayField() + const getter = theField.createGetter(null) + const actual = await getter() + const expected = [] + assert.deepEqual(actual, expected) + }) + it('should return the passed in defaultValue if set in config and null is passed', async () => { + const theField = arrayField({ defaultValue: [1, 2, 3] }) + const getter = theField.createGetter(null) + const actual = await getter() + const expected = [1, 2, 3] + assert.deepEqual(actual, expected) + }) + }) + describe('#getValidator()', () => { + it('should validate an array passed in without issue', async () => { + const theField = arrayField({}) + const getter = theField.createGetter([1, 2, 3]) + const validator = theField.getValidator(getter) + const actual = await validator() + const expected = [] + assert.deepEqual(actual, expected) + }) + }) + }) describe('#field()', () => { it('should throw an exception if config.valueSelector is not a function but is set', () => { assert.throws(() => { diff --git a/test/src/validation.test.js b/test/src/validation.test.js index 7554cca..643cdbd 100644 --- a/test/src/validation.test.js +++ b/test/src/validation.test.js @@ -5,6 +5,8 @@ const { isBoolean, isInteger, isString, + isArray, + arrayType, isRequired, maxNumber, minNumber, @@ -16,6 +18,7 @@ const { emptyValidator, createModelValidator, createFieldValidator, + TYPE_PRIMATIVES, } = require('../../src/validation') describe('/src/validation.js', () => { @@ -315,4 +318,72 @@ describe('/src/validation.js', () => { assert.equal(actual.length, expected) }) }) + describe('#isArray()', () => { + it('should return an error for null', () => { + const actual = isArray(null) + assert.isOk(actual) + }) + it('should return an error for undefined', () => { + const actual = isArray(undefined) + assert.isOk(actual) + }) + it('should return an error for 1', () => { + const actual = isArray(1) + assert.isOk(actual) + }) + it('should return an error for "1"', () => { + const actual = isArray('1') + assert.isOk(actual) + }) + it('should return undefined for [1,2,3]', () => { + const actual = isArray([1, 2, 3]) + assert.isUndefined(actual) + }) + it('should return undefined for []', () => { + const actual = isArray([]) + assert.isUndefined(actual) + }) + }) + describe('#arrayType()', () => { + describe('#(object)()', () => { + it('should return an error for null, even though its an object, its not an array', () => { + const actual = arrayType('object')(null) + assert.isOk(actual) + }) + it('should return an error for 1', () => { + const actual = arrayType('object')(1) + assert.isOk(actual) + }) + it('should return undefined for [{}]', () => { + const actual = arrayType('object')([{}]) + assert.isUndefined(actual) + }) + }) + describe('#(integer)()', () => { + it('should return an error for null', () => { + const actual = arrayType(TYPE_PRIMATIVES.integer)(null) + assert.isOk(actual) + }) + it('should return an error for undefined', () => { + const actual = arrayType(TYPE_PRIMATIVES.integer)(undefined) + assert.isOk(actual) + }) + it('should return an error for 1', () => { + const actual = arrayType(TYPE_PRIMATIVES.integer)(1) + assert.isOk(actual) + }) + it('should return an error for "1"', () => { + const actual = arrayType(TYPE_PRIMATIVES.integer)('1') + assert.isOk(actual) + }) + it('should return undefined for [1,2,3]', () => { + const actual = arrayType(TYPE_PRIMATIVES.integer)([1, 2, 3]) + assert.isUndefined(actual) + }) + it('should return an error for [1,"2",3]', () => { + const actual = arrayType(TYPE_PRIMATIVES.integer)([1, '2', 3]) + assert.isOk(actual) + }) + }) + }) })