From be5fd46005f9a923dd533bfc72d9d23e7b13aa20 Mon Sep 17 00:00:00 2001 From: Sven Slootweg Date: Tue, 30 Jun 2020 23:51:01 +0200 Subject: [PATCH] Add support for rest operator in arguments --- example-arguments.js | 27 ++++ index.js | 3 +- package.json | 1 + src/api/validate-arguments.js | 85 ------------ src/api/validate-arguments/index.js | 122 ++++++++++++++++++ .../remaining-arguments-symbol.js | 3 + src/split-array.js | 22 ++++ 7 files changed, 177 insertions(+), 86 deletions(-) create mode 100644 example-arguments.js delete mode 100644 src/api/validate-arguments.js create mode 100644 src/api/validate-arguments/index.js create mode 100644 src/api/validate-arguments/remaining-arguments-symbol.js create mode 100644 src/split-array.js diff --git a/example-arguments.js b/example-arguments.js new file mode 100644 index 0000000..038d8ab --- /dev/null +++ b/example-arguments.js @@ -0,0 +1,27 @@ +"use strict"; + +const { validateArguments, RemainingArguments } = require("."); +const isString = require("@validatem/is-string"); +const isBoolean = require("@validatem/is-boolean"); +const isNumber = require("@validatem/is-number"); +const required = require("@validatem/required"); +const arrayOf = require("@validatem/array-of"); + +function testFunction(one, two, three, ... rest) { + validateArguments(arguments, { + one: [ required, isString ], + two: [ required, isNumber ], + three: [ required, isBoolean ], + [RemainingArguments]: [ required, arrayOf(isString) ], + }); +} + +testFunction("foo", "bar", null, "baz", 42); + +/* +AggregrateValidationError: One or more validation errors occurred: + - At two: Must be a number + - At three: Required value is missing + - At array -> 1: Must be a string + - At mapping -> d -> (value): Must be a boolean +*/ diff --git a/index.js b/index.js index 1041db9..7cbc344 100644 --- a/index.js +++ b/index.js @@ -8,5 +8,6 @@ module.exports = { validateOptions: require("./src/api/validate-options"), validateValue: require("./src/api/validate-value"), - AggregrateValidationError: require("./src/aggregrate-validation-error") + AggregrateValidationError: require("./src/aggregrate-validation-error"), + RemainingArguments: require("./src/api/validate-arguments/remaining-arguments-symbol") }; diff --git a/package.json b/package.json index 3e7fdc6..8326d1f 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@validatem/normalize-rules": "^0.1.0", "@validatem/required": "^0.1.0", "@validatem/validation-result": "^0.1.1", + "@validatem/virtual-property": "^0.1.0", "as-expression": "^1.0.0", "assure-array": "^1.0.0", "create-error": "^0.3.1", diff --git a/src/api/validate-arguments.js b/src/api/validate-arguments.js deleted file mode 100644 index bc13036..0000000 --- a/src/api/validate-arguments.js +++ /dev/null @@ -1,85 +0,0 @@ -"use strict"; - -const isArguments = require("is-arguments"); -const flatten = require("flatten"); -const asExpression = require("as-expression"); - -const ValidationError = require("@validatem/error"); -const annotateErrors = require("@validatem/annotate-errors"); - -const applyValidators = require("../apply-validators"); -const createValidationMethod = require("./validation-method"); - -// TODO: Simplify below by treating it like an array or object? Or would that introduce too much complexity through specialcasing? -// TODO: Find a way to produce validatem-style errors for the below invocation errors, without introducing recursion - -module.exports = createValidationMethod((args, argumentDefinitions) => { - if (!isArguments(args)) { - throw new Error(`First argument is not an 'arguments' object; maybe you forgot to put it before the validation rules?`); - } else if (argumentDefinitions == null) { - throw new Error(`Validation rules (second argument) are missing; maybe you forgot to specify them?`); - } else { - let usesArrayAPI = (Array.isArray(argumentDefinitions)); - - let definitionCount = (usesArrayAPI) - ? argumentDefinitions.length - : Object.keys(argumentDefinitions).length; - - if (args.length > definitionCount) { - return { - errors: [ new ValidationError(`Got ${args.length} arguments, but only expected ${definitionCount}`) ], - // Cast the below to an array, for consistency with *success* output, in case we ever want to expose the new values to the user in an error case too. - newValue: Array.from(args) - }; - } else { - let normalizedDefinitions = asExpression(() => { - if (usesArrayAPI) { - return argumentDefinitions.map((definition) => { - let [ name, ...rules ] = definition; - - if (typeof name !== "string") { - throw new Error("First item in the argument rules list must be the argument name"); - } else { - return { - name: name, - rules: rules - }; - } - }); - } else { - return Object.entries(argumentDefinitions).map(([ key, value ]) => { - return { - name: key, - rules: value - }; - }); - } - }); - - let results = normalizedDefinitions.map(({ name, rules }, i) => { - let argument = args[i]; - let validatorResult = applyValidators(argument, rules); - - return { - errors: annotateErrors({ - pathSegments: [ name ], - errors: validatorResult.errors - }), - newValue: (validatorResult.newValue !== undefined) - ? validatorResult.newValue - : argument - }; - }); - - let combinedErrors = results.map((result) => result.errors); - let flattenedErrors = flatten(combinedErrors); // TODO: Switch to `Array#flat` once Node 10.x goes EOL (April 2021) - - let newValues = results.map((result) => result.newValue); - - return { - errors: flattenedErrors, - newValue: newValues - }; - } - } -}); diff --git a/src/api/validate-arguments/index.js b/src/api/validate-arguments/index.js new file mode 100644 index 0000000..3c1f569 --- /dev/null +++ b/src/api/validate-arguments/index.js @@ -0,0 +1,122 @@ +"use strict"; + +const isArguments = require("is-arguments"); +const flatten = require("flatten"); +const asExpression = require("as-expression"); + +const ValidationError = require("@validatem/error"); +const annotateErrors = require("@validatem/annotate-errors"); +const virtualProperty = require("@validatem/virtual-property"); + +const splitArray = require("../../split-array"); +const applyValidators = require("../../apply-validators"); +const createValidationMethod = require("../validation-method"); +const RemainingArguments = require("./remaining-arguments-symbol"); + +// TODO: Simplify below by treating it like an array or object? Or would that introduce too much complexity through specialcasing? +// TODO: Find a way to produce validatem-style errors for the below invocation errors, without introducing recursion +// TODO: Maybe create a validatedFunction(rules, (foo, bar) => ...) type abstraction for "pre-processed", more performant validation usecases? At the cost of having to specify the validation rules outside of the function, which you'd need to do anyway in that case. Upside would be that you don't need to specify `arguments` manually anymore. + +function arrayToNormalized(array) { + return array.map((definition) => { + let [ name, ...rules ] = definition; + + if (typeof name !== "string") { + throw new Error("First item in the argument rules list must be the argument name"); + } else { + return { + name: name, + rules: rules + }; + } + }); +} + +function objectToNormalized(object) { + // NOTE: Not using Object.entries because we also want to catch Symbol keys + // NOTE: Reflect.ownKeys is not ordered correctly, Symbols always come last! When object syntax is used, we just assume that the user correctly specified any RemainingArguments symbol at the end. + return Reflect.ownKeys(object).map((key) => { + let value = object[key]; + + return { + name: key, + rules: value + }; + }); +} + +function applyDefinitions(args, definitions, remainingArgumentsIndex) { + let results = definitions.map(({ name, rules }, i) => { + let argument = args[i]; + let validatorResult = applyValidators(argument, rules); + + return { + errors: annotateErrors({ + pathSegments: (name === RemainingArguments) + ? [ virtualProperty(`arguments from ${remainingArgumentsIndex} onwards`) ] // TODO: Better description? + : [ name ], + errors: validatorResult.errors + }), + newValue: (validatorResult.newValue !== undefined) + ? validatorResult.newValue + : argument + }; + }); + + // FIXME: Investigate syncpipe usage here + let combinedErrors = results.map((result) => result.errors); + let flattenedErrors = flatten(combinedErrors); // TODO: Switch to `Array#flat` once Node 10.x goes EOL (April 2021) + + let newValues = results.map((result) => result.newValue); + + return { + errors: flattenedErrors, + newValue: newValues + }; +} + +module.exports = createValidationMethod((args, argumentDefinitions) => { + if (!isArguments(args)) { + throw new Error(`First argument is not an 'arguments' object; maybe you forgot to put it before the validation rules?`); + } else if (argumentDefinitions == null) { + throw new Error(`Validation rules (second argument) are missing; maybe you forgot to specify them?`); + } else { + let normalizedDefinitions = (Array.isArray(argumentDefinitions)) + ? arrayToNormalized(argumentDefinitions) + : objectToNormalized(argumentDefinitions); + + let definitionCount = normalizedDefinitions.length; + let remainingArgumentsIndex = normalizedDefinitions.findIndex(({ name }) => name === RemainingArguments); + let hasRemainingArguments = (remainingArgumentsIndex !== -1); + let remainingArgumentsComeLast = (remainingArgumentsIndex === definitionCount - 1); + + if (hasRemainingArguments && !remainingArgumentsComeLast) { + throw new Error(`A RemainingArguments entry can only be the last item`); + } else if (!hasRemainingArguments && args.length > definitionCount) { + return { + errors: [ new ValidationError(`Got ${args.length} arguments, but only expected ${definitionCount}`) ], + // Cast the below to an array, for consistency with *success* output, in case we ever want to expose the new values to the user in an error case too. + newValue: Array.from(args) + }; + } else { + let normalizedArguments = asExpression(() => { + let argumentsAsArray = Array.from(args); + + if (hasRemainingArguments) { + let [ positionalArguments, remainingArguments ] = splitArray(argumentsAsArray, [ remainingArgumentsIndex ]); + + if (remainingArguments.length > 0) { + // Add all of the remaining arguments as a *single array value*, to make applying validation rules easier + return positionalArguments.concat([ remainingArguments ]); + } else { + return positionalArguments; + } + } else { + return argumentsAsArray; + } + }); + + return applyDefinitions(normalizedArguments, normalizedDefinitions, remainingArgumentsIndex); + } + } +}); diff --git a/src/api/validate-arguments/remaining-arguments-symbol.js b/src/api/validate-arguments/remaining-arguments-symbol.js new file mode 100644 index 0000000..7b279bc --- /dev/null +++ b/src/api/validate-arguments/remaining-arguments-symbol.js @@ -0,0 +1,3 @@ +"use strict"; + +module.exports = Symbol("RemainingArguments"); diff --git a/src/split-array.js b/src/split-array.js new file mode 100644 index 0000000..d1f8e94 --- /dev/null +++ b/src/split-array.js @@ -0,0 +1,22 @@ +"use strict"; + +// FIXME: Move this into its own package + +module.exports = function splitArray(array, indexes) { + let segments = []; + let lastIndex = 0; + + for (let index of indexes) { + if (index < array.length) { + segments.push(array.slice(lastIndex, index)); + lastIndex = index; + } else { + throw new Error(`Attempted to split at index ${index}, but array only has ${array.length} items`); + } + } + + // Final segment, from last specified index to end of array; this is because the indexes only define the *split points*, and so the total amount of segments is always one more than that. + segments.push(array.slice(lastIndex)); + + return segments; +};