diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a4f88f..f6fb6428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). All notable c ### Added - [#323](https://github.com/Kashoo/synctos/issues/323): Option to ignore item validation errors when value is unchanged - [#324](https://github.com/Kashoo/synctos/issues/324): Validation type that accepts any type of value +- [#86](https://github.com/Kashoo/synctos/issues/86): Conditional validation type ## [2.5.0] - 2018-05-30 ### Added diff --git a/README.md b/README.md index 8bb6087b..8f7fbd8f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ For validation of documents in Apache CouchDB, see the [couchster](https://githu - [Content validation](#content-validation) - [Simple type validation](#simple-type-validation) - [Complex type validation](#complex-type-validation) - - [Universal constraint validation](#universal-constraint-validation) + - [Multi-type validation](#multi-type-validation) + - [Universal validation constraints](#universal-validation-constraints) - [Predefined validators](#predefined-validators) - [Dynamic constraint validation](#dynamic-constraint-validation) - [Definition file](#definition-file) @@ -473,7 +474,6 @@ Validation for simple data types (e.g. integers, floating point numbers, strings * `supportedContentTypes`: An array of content/MIME types that are allowed for the attachment's contents (e.g. "image/png", "text/html", "application/xml"). Takes precedence over the document-wide `supportedContentTypes` constraint for the referenced attachment. No restriction by default. * `maximumSize`: The maximum file size, in bytes, of the attachment. May not be greater than 20MB (20,971,520 bytes), as Couchbase Server/Sync Gateway sets that as the hard limit per document or attachment. Takes precedence over the document-wide `maximumIndividualSize` constraint for the referenced attachment. Unlimited by default. * `regexPattern`: A regular expression pattern that must be satisfied by the value. Takes precedence over the document-wide `attachmentConstraints.filenameRegexPattern` constraint for the referenced attachment. No restriction by default. -* `any`: The value may be any JSON data type: number, string, boolean, array or object. No additional parameters. ##### Complex type validation @@ -543,7 +543,64 @@ myHash1: { } ``` -##### Universal constraint validation +##### Multi-type validation + +These validation types support more than a single data type: + +* `any`: The value may be any JSON data type: number, string, boolean, array or object. No additional parameters. +* `conditional`: The value must match any one of some number of candidate validators. Each validator is accompanied by a condition that determines whether that validator should be applied to the value. Additional parameters: + * `validationCandidates`: A list of candidates to act as the property or element's validator if their conditions are satisfied. Each condition is defined as a function that returns a boolean and accepts as parameters (1) the new document, (2) the old document that is being replaced/deleted (if any; it will be `null` if it has been deleted or does not exist), (3) an object that contains metadata about the current item to validate and (4) a stack of the items (e.g. object properties, array elements, hashtable element values) that have gone through validation, where the last/top element contains metadata for the direct parent of the item currently being validated and the first/bottom element is metadata for the root (i.e. the document). Conditions are tested in the order they are defined; if two or more candidates' conditions would evaluate to `true`, only the first candidate's validator will be applied to the property or element value. When a matching validation candidate declares the same constraint as the containing `conditional` validator, the candidate validator's constraint takes precedence. An example: + +```javascript +entries: { + type: 'hashtable', + hashtableValuesValidator: { + type: 'object', + required: true, + propertyValidators: { + entryType: { + type: 'enum', + required: true, + predefinedValues: [ 'name', 'codes' ] + }, + entryValue: { + type: 'conditional', + required: true, + validationCandidates: [ + { + condition: function(doc, oldDoc, currentItemEntry, validationItemStack) { + var parentEntry = validationItemStack[validationItemStack.length - 1]; + + return parentEntry.itemValue.entryType === 'name'; + }, + validator: { + type: 'string', + mustNotBeEmpty: true + } + }, + { + condition: function(doc, oldDoc, currentItemEntry, validationItemStack) { + var parentEntry = validationItemStack[validationItemStack.length - 1]; + + return parentEntry.itemValue.entryType === 'codes'; + }, + validator: { + type: 'array', + arrayElementsValidator: { + type: 'integer', + required: true, + minimumValue: 1 + } + } + } + ] + } + } + } +} +``` + +##### Universal validation constraints Validation for all simple and complex data types support the following additional parameters: @@ -558,7 +615,7 @@ Validation for all simple and complex data types support the following additiona * `mustEqualStrict`: The value of the property or element must be strictly equal to the specified value. Differs from `mustEqual` in that specialized string validation types (e.g. `date`, `datetime`, `time`, `timezone`, `uuid`) are not compared semantically; for example, the two `timezone` values of "Z" and "+00:00" are _not_ considered equal because the strings are not strictly equal. No constraint by default. * `skipValidationWhenValueUnchanged`: When set to `true`, the property or element is not validated if the document is being replaced and its value is _semantically_ equal to the same property or element value from the previous document revision. Useful if a change that is not backward compatible must be introduced to a property/element validator and existing values from documents that are already stored in the database should be preserved as they are. Differs from `skipValidationWhenValueUnchangedStrict` in that it checks for semantic equality of specialized string validation types (e.g. `date`, `datetime`, `time`, `timezone`, `uuid`); for example, the two `date` values of "2018" and "2018-01-01" are considered equal with this constraint since they represent the same date. Defaults to `false`. * `skipValidationWhenValueUnchangedStrict`: When set to `true`, the property or element is not validated if the document is being replaced and its value is _strictly_ equal to the same property or element value from the previous document revision. Useful if a change that is not backward compatible must be introduced to a property/element validator and existing values from documents that are already stored in the database should be preserved as they are. Differs from `skipValidationWhenValueUnchanged` in that specialized string validation types (e.g. `date`, `datetime`, `time`, `timezone`, `uuid`) are not compared semantically; for example, the two `datetime` values of "2018-06-23T14:30:00.000Z" and "2018-06-23T14:30+00:00" are _not_ considered equal because the strings are not strictly equal. Defaults to `false`. -* `customValidation`: A function that accepts as parameters (1) the new document, (2) the old document that is being replaced/deleted (if any), (3) an object that contains metadata about the current item to validate and (4) a stack of the items (e.g. object properties, array elements, hashtable element values) that have gone through validation, where the last/top element contains metadata for the direct parent of the item currently being validated and the first/bottom element is metadata for the root (i.e. the document). In cases where the document is in the process of being deleted, the first parameter's `_deleted` property will be `true`, so be sure to account for such cases. If the document does not yet exist, the second parameter will be `null`. And, in some cases where the document previously existed (i.e. it was deleted), the second parameter _may_ be non-null and its `_deleted` property will be `true`. Generally, custom validation should not throw exceptions; it's recommended to return an array/list of error descriptions so the sync function can compile a list of all validation errors that were encountered once full validation is complete. A return value of `null`, `undefined` or an empty array indicate there were no validation errors. An example: +* `customValidation`: A function that accepts as parameters (1) the new document, (2) the old document that is being replaced/deleted (if any), (3) an object that contains metadata about the current item to validate and (4) a stack of the items (e.g. object properties, array elements, hashtable element values) that have gone through validation, where the last/top element contains metadata for the direct parent of the item currently being validated and the first/bottom element is metadata for the root (i.e. the document). If the document does not yet exist, the second parameter will be `null`. And, in some cases where the document previously existed (i.e. it was deleted), the second parameter _may_ be non-null and its `_deleted` property will be `true`. Generally, custom validation should not throw exceptions; it's recommended to return an array/list of error descriptions so the sync function can compile a list of all validation errors that were encountered once full validation is complete. A return value of `null`, `undefined` or an empty array indicate there were no validation errors. An example: ``` propertyValidators: { diff --git a/samples/fragment-notification.js b/samples/fragment-notification.js index 68c850d3..103ea43f 100644 --- a/samples/fragment-notification.js +++ b/samples/fragment-notification.js @@ -101,22 +101,42 @@ type: 'array', immutable: true, arrayElementsValidator: { - type: 'object', + type: 'conditional', required: true, - propertyValidators: { - url: { - // The URL of the action - type: 'string', - required: true, - mustNotBeEmpty: true + validationCandidates: [ + { + condition: function(doc, oldDoc, currentItemEntry, validationItemStack) { + return typeof currentItemEntry.itemValue === 'object'; + }, + validator: { + type: 'object', + propertyValidators: { + url: { + // The URL of the action + type: 'string', + required: true, + mustNotBeEmpty: true + }, + label: { + // A plain text label for the action + type: 'string', + required: true, + mustNotBeEmpty: true + } + } + } }, - label: { - // A plain text label for the action - type: 'string', - required: true, - mustNotBeEmpty: true + { + condition: function(doc, oldDoc, currentItemEntry, validationItemStack) { + return typeof currentItemEntry.itemValue === 'string'; + }, + validator: { + // The URL of the action + type: 'string', + mustNotBeEmpty: true + } } - } + ] } } } diff --git a/samples/fragment-notifications-config.js b/samples/fragment-notifications-config.js index 578fae53..47fee30b 100644 --- a/samples/fragment-notifications-config.js +++ b/samples/fragment-notifications-config.js @@ -22,16 +22,36 @@ // The list of notification transports that are enabled for the notification type type: 'array', arrayElementsValidator: { - type: 'object', + type: 'conditional', required: true, - propertyValidators: { - transportId: { - // The ID of the notification transport - type: 'string', - required: true, - mustNotBeEmpty: true + validationCandidates: [ + { + condition: function(doc, oldDoc, currentItemEntry, validationItemStack) { + return typeof currentItemEntry.itemValue === 'object'; + }, + validator: { + type: 'object', + propertyValidators: { + transportId: { + // The ID of the notification transport + type: 'string', + required: true, + mustNotBeEmpty: true + } + } + } + }, + { + condition: function(doc, oldDoc, currentItemEntry, validationItemStack) { + return typeof currentItemEntry.itemValue === 'string'; + }, + validator: { + // The ID of the notification transport + type: 'string', + mustNotBeEmpty: true + } } - } + ] } } } diff --git a/src/testing/validation-error-formatter.js b/src/testing/validation-error-formatter.js index d725d4e1..293b8580 100644 --- a/src/testing/validation-error-formatter.js +++ b/src/testing/validation-error-formatter.js @@ -369,6 +369,15 @@ exports.unsupportedProperty = (propertyPath) => `property "${propertyPath}" is n */ exports.uuidFormatInvalid = (itemPath) => `item "${itemPath}" must be ${getTypeDescription('uuid')}`; +/** + * Formats a message for the error that occurs when a value does not satisfy any of the candidate validators for + * conditional validation. + * + * @param {string} itemPath The full path of the property or element in which the error occurs (e.g. "hashtableProp[my-key]") + */ +exports.validationConditionsViolation = + (itemPath) => `item "${itemPath}" does not satisfy any candidate validation conditions`; + function getTypeDescription(type) { switch (type) { case 'array': diff --git a/src/testing/validation-error-formatter.spec.js b/src/testing/validation-error-formatter.spec.js index d4de8fb8..c8bc5b0f 100644 --- a/src/testing/validation-error-formatter.spec.js +++ b/src/testing/validation-error-formatter.spec.js @@ -239,6 +239,11 @@ describe('Validation error formatter', () => { expect(errorFormatter.uuidFormatInvalid(fakeItemPath)).to.equal(`item "${fakeItemPath}" must be a UUID string`); }); + it('produces validation conditions violation messages', () => { + expect(errorFormatter.validationConditionsViolation(fakeItemPath)) + .to.equal(`item "${fakeItemPath}" does not satisfy any candidate validation conditions`); + }); + describe('type constraint violations', () => { it('formats messages for general types', () => { const typeDescriptions = { diff --git a/src/validation/document-definitions-validator.spec.js b/src/validation/document-definitions-validator.spec.js index a5a8e0bf..1406342f 100644 --- a/src/validation/document-definitions-validator.spec.js +++ b/src/validation/document-definitions-validator.spec.js @@ -97,6 +97,32 @@ describe('Document definitions validator:', () => { accessAssignments: (a, b, extraParam) => extraParam, // Too many parameters customActions: { }, // Must have at least one property propertyValidators: { + conditionalTypeProperty: { + type: 'conditional', + immutableWhenSetStrict: true, + minimumValue: -15, // Unsupported constraint for this validation type + validationCandidates: [ + { + condition: (a, b, c, d, extra) => extra, // Too many parameters and must have a "validator" property + foobar: 'baz' // Unsupported property + }, + { + condition: true, // Must be a function + validator: { + type: 'float', + maximumLength: 3, // Unsupported constraint for this validation type + mustEqual: (a, b, c, d) => d + } + }, + { + condition: () => true, + validator: { + type: 'object', + allowUnknownProperties: 0 // Must be a boolean + } + } + ] + }, timeProperty: { type: 'time', immutable: 1, // Must be a boolean @@ -261,6 +287,13 @@ describe('Document definitions validator:', () => { 'myDoc1.attachmentConstraints.filenameRegexPattern: \"filenameRegexPattern\" must be an instance of \"RegExp\"', 'myDoc1.accessAssignments: \"accessAssignments\" must have an arity lesser or equal to 2', 'myDoc1.customActions: \"customActions\" must have at least 1 children', + 'myDoc1.propertyValidators.conditionalTypeProperty.minimumValue: \"minimumValue\" is not allowed', + 'myDoc1.propertyValidators.conditionalTypeProperty.validationCandidates.0.condition: \"condition\" must have an arity lesser or equal to 4', + 'myDoc1.propertyValidators.conditionalTypeProperty.validationCandidates.0.foobar: \"foobar\" is not allowed', + 'myDoc1.propertyValidators.conditionalTypeProperty.validationCandidates.0.validator: \"validator\" is required', + 'myDoc1.propertyValidators.conditionalTypeProperty.validationCandidates.1.condition: \"condition\" must be a Function', + 'myDoc1.propertyValidators.conditionalTypeProperty.validationCandidates.1.validator.maximumLength: \"maximumLength\" is not allowed', + 'myDoc1.propertyValidators.conditionalTypeProperty.validationCandidates.2.validator.allowUnknownProperties: \"allowUnknownProperties\" must be a boolean', 'myDoc1.propertyValidators.timeProperty.immutable: \"immutable\" must be a boolean', 'myDoc1.propertyValidators.timeProperty.minimumValue: \"minimumValue\" with value \"15\" fails to match the required pattern: /^((([01]\\d|2[0-3])(:[0-5]\\d)(:[0-5]\\d(\\.\\d{1,3})?)?)|(24:00(:00(\\.0{1,3})?)?))$/', 'myDoc1.propertyValidators.timeProperty.maximumValue: \"maximumValue\" with value \"23:49:52.1234\" fails to match the required pattern: /^((([01]\\d|2[0-3])(:[0-5]\\d)(:[0-5]\\d(\\.\\d{1,3})?)?)|(24:00(:00(\\.0{1,3})?)?))$/', @@ -319,7 +352,7 @@ describe('Document definitions validator:', () => { 'myDoc1.propertyValidators.nestedObject.propertyValidators.arrayProperty.arrayElementsValidator.propertyValidators.anyProperty.minimumValue: \"minimumValue\" is not allowed', 'myDoc1.propertyValidators.nestedObject.propertyValidators.arrayProperty.arrayElementsValidator.propertyValidators.anyProperty.mustNotBeEmpty: \"mustNotBeEmpty\" is not allowed', 'myDoc1.propertyValidators.nestedObject.propertyValidators.arrayProperty.arrayElementsValidator.propertyValidators.anyProperty.regexPattern: \"regexPattern\" is not allowed', - 'myDoc1.propertyValidators.nestedObject.propertyValidators.unrecognizedTypeProperty.type: \"type\" must be one of [any, array, attachmentReference, boolean, date, datetime, enum, float, hashtable, integer, object, string, time, timezone, uuid]', + 'myDoc1.propertyValidators.nestedObject.propertyValidators.unrecognizedTypeProperty.type: \"type\" must be one of [any, array, attachmentReference, boolean, conditional, date, datetime, enum, float, hashtable, integer, object, string, time, timezone, uuid]', 'myDoc1.expiry: \"expiry\" with value \"20180415T1357-0700\" fails to match the required pattern: /^\\d{4}-(((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01]))|((0[469]|11)-(0[1-9]|[12]\\d|30))|(02-(0[1-9]|[12]\\d)))T([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(Z|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/', ]); }); diff --git a/src/validation/property-validator-schema.js b/src/validation/property-validator-schema.js index ec6ec725..5bc915e8 100644 --- a/src/validation/property-validator-schema.js +++ b/src/validation/property-validator-schema.js @@ -44,7 +44,8 @@ const typeEqualitySchemas = { attachmentReference: joi.string(), array: joi.array(), object: joi.object().unknown(), - hashtable: joi.object().unknown() + hashtable: joi.object().unknown(), + conditional: joi.any() }; const validPropertyTypes = Object.keys(typeEqualitySchemas).sort(); @@ -97,6 +98,9 @@ const schema = joi.object().keys({ .when( joi.object().unknown().keys({ type: 'hashtable' }), { then: makeTypeConstraintsSchema('hashtable') }) + .when( + joi.object().unknown().keys({ type: 'conditional' }), + { then: makeTypeConstraintsSchema('conditional') }) .when( joi.object().unknown().keys({ type: joi.func() }), { then: joi.object().unknown() }); @@ -194,6 +198,9 @@ function typeSpecificConstraintSchemas() { regexPattern: dynamicConstraintSchema(regexSchema) })), hashtableValuesValidator: dynamicConstraintSchema(joi.lazy(() => schema)) + }, + conditional: { + validationCandidates: dynamicConstraintSchema(conditionalValidationCandidatesSchema()).required() } }; } @@ -296,6 +303,15 @@ function maximumValueExclusiveNumberConstraintSchema(numberType) { }); } +function conditionalValidationCandidatesSchema() { + return joi.array().min(1).items([ + joi.object().keys({ + condition: joi.func().maxArity(4).required(), + validator: joi.lazy(() => schema).required() + }) + ]); +} + // Generates a schema that can be used for property validator constraints function dynamicConstraintSchema(wrappedSchema) { // The function schema this creates will support no more than four parameters (doc, oldDoc, value, oldValue) diff --git a/templates/sync-function/document-properties-validation-module.js b/templates/sync-function/document-properties-validation-module.js index d6dc22e4..f74314b6 100644 --- a/templates/sync-function/document-properties-validation-module.js +++ b/templates/sync-function/document-properties-validation-module.js @@ -93,7 +93,9 @@ function documentPropertiesValidationModule(utils, simpleTypeFilter, typeIdValid var itemValue = currentItemEntry.itemValue; var validatorType = resolveItemConstraint(validator.type); - if (shouldSkipItemValidation(validator, validatorType)) { + if (validatorType === 'conditional') { + return performConditionalValidation(validator); + } else if (shouldSkipItemValidation(validator, validatorType)) { return; } @@ -103,7 +105,7 @@ function documentPropertiesValidationModule(utils, simpleTypeFilter, typeIdValid if (!utils.isDocumentMissingOrDeleted(oldDoc)) { if (resolveItemConstraint(validator.immutable)) { - storeOptionalValidationErrors(comparisonModule.validateImmutable(itemStack, false, validator.type)); + storeOptionalValidationErrors(comparisonModule.validateImmutable(itemStack, false, validatorType)); } if (resolveItemConstraint(validator.immutableStrict)) { @@ -113,7 +115,7 @@ function documentPropertiesValidationModule(utils, simpleTypeFilter, typeIdValid } if (resolveItemConstraint(validator.immutableWhenSet)) { - storeOptionalValidationErrors(comparisonModule.validateImmutable(itemStack, true, validator.type)); + storeOptionalValidationErrors(comparisonModule.validateImmutable(itemStack, true, validatorType)); } if (resolveItemConstraint(validator.immutableWhenSetStrict)) { @@ -125,7 +127,7 @@ function documentPropertiesValidationModule(utils, simpleTypeFilter, typeIdValid var expectedEqualValue = resolveItemConstraint(validator.mustEqual); if (expectedEqualValue !== void 0) { - storeOptionalValidationErrors(comparisonModule.validateEquality(itemStack, expectedEqualValue, validator.type)); + storeOptionalValidationErrors(comparisonModule.validateEquality(itemStack, expectedEqualValue, validatorType)); } var expectedStrictEqualValue = resolveItemConstraint(validator.mustEqualStrict); @@ -391,6 +393,42 @@ function documentPropertiesValidationModule(utils, simpleTypeFilter, typeIdValid } } + function performConditionalValidation(validator) { + var currentItemEntry = itemStack[itemStack.length - 1]; + + // Copy all but the last element so that the item's parent is at the top of the stack for condition functions + var conditionalValidationItemStack = itemStack.slice(0, -1); + + var resolvedOldDoc = utils.resolveOldDoc(oldDoc); + var validationCandidates = resolveItemConstraint(validator.validationCandidates) || [ ]; + for (var candidateIndex = 0; candidateIndex < validationCandidates.length; candidateIndex++) { + var candidate = validationCandidates[candidateIndex]; + if (typeof candidate.condition === 'function' && + candidate.condition(doc, resolvedOldDoc, currentItemEntry, conditionalValidationItemStack)) { + + // Create a new validator that merges the universal constraints from the conditional validator and the more + // specific constraints from the candidate validator + var combinedValidator = + assignProperties({ }, [ validator, candidate.validator ], [ 'validationCandidates' ]); + + return validateItemValue(combinedValidator); + } + } + + // If we got here, then none of the candidate validator conditions were satisfied + if (shouldSkipItemValidation(validator, resolveItemConstraint('conditional'))) { + return; + } else if (utils.isValueNullOrUndefined(currentItemEntry.itemValue)) { + // Ensure that a null/missing value does not violate any of the universal constraints specified by the + // conditional validator (e.g. required, immutable) + var nullValidator = assignProperties({ }, [ validator, { type: 'any' } ], [ 'validationCandidates' ]); + + validateItemValue(nullValidator); + } else { + validationErrors.push('item "' + buildItemPath(itemStack) + '" does not satisfy any candidate validation conditions'); + } + } + function performCustomValidation(validator) { var currentItemEntry = itemStack[itemStack.length - 1]; @@ -479,4 +517,18 @@ function documentPropertiesValidationModule(utils, simpleTypeFilter, typeIdValid return nameComponents.join(''); } + + function assignProperties(target, sources, skipPropertyNames) { + var actualSkipPropertyNames = skipPropertyNames || [ ]; + for (var sourceIndex = 0; sourceIndex < sources.length; sourceIndex++) { + var source = sources[sourceIndex]; + for (var propertyName in source) { + if (source.hasOwnProperty(propertyName) && actualSkipPropertyNames.indexOf(propertyName) < 0) { + target[propertyName] = source[propertyName]; + } + } + } + + return target; + } } diff --git a/test/conditional-validation.spec.js b/test/conditional-validation.spec.js new file mode 100644 index 00000000..efd0f561 --- /dev/null +++ b/test/conditional-validation.spec.js @@ -0,0 +1,417 @@ +const testFixtureMaker = require('../src/testing/test-fixture-maker'); +const errorFormatter = require('../src/testing/validation-error-formatter'); + +describe('Conditional validation type:', () => { + const testFixture = + testFixtureMaker.initFromSyncFunction('build/sync-functions/test-conditional-validation-sync-function.js'); + + afterEach(() => { + testFixture.resetTestEnvironment(); + }); + + describe('with static validation', () => { + it('allows creation when a condition is satisifed and the contents are valid', () => { + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: 1534026439173 + } + }; + + testFixture.verifyDocumentCreated(doc); + }); + + it('allows replacement when a condition is satisifed and the contents are valid', () => { + const oldDoc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: '2018-08-11T15:25:00.0-07:00' + } + }; + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: '2018-08-11T15:25-07:00' // Semantically equal to the old value + } + }; + + testFixture.verifyDocumentReplaced(doc, oldDoc); + }); + + it('allows creation when no conditions are satisfied but the vale is null', () => { + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: null + } + }; + + testFixture.verifyDocumentCreated(doc); + }); + + it('allows replacement when no conditions are satisfied but the value is missing', () => { + const oldDoc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: null + } + }; + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { } + }; + + testFixture.verifyDocumentReplaced(doc, oldDoc); + }); + + it('allows replacement when no conditions are satisfied but the value is unchanged', () => { + const oldDoc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: { foo: 'bar' } + } + }; + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: { foo: 'bar' } + } + }; + + testFixture.verifyDocumentReplaced(doc, oldDoc); + }); + + it('rejects creation when no conditions are satisfied', () => { + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: [ ] + } + }; + + testFixture.verifyDocumentNotCreated( + doc, + 'conditionalValidationDoc', + [ errorFormatter.validationConditionsViolation('staticParentObjectProp.conditionalValidationProp') ]); + }); + + it('rejects replacement when no conditions are satisfied', () => { + const oldDoc = { + _id: 'my-doc', + type: 'conditionalValidationDoc' + }; + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: true + } + }; + + testFixture.verifyDocumentNotReplaced( + doc, + oldDoc, + 'conditionalValidationDoc', + [ errorFormatter.validationConditionsViolation('staticParentObjectProp.conditionalValidationProp') ]); + }); + + it('rejects creation when a condition is satisfied but an inner constraint is violated', () => { + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: '2018-08-10T16:59:59.999-07:00' + } + }; + + testFixture.verifyDocumentNotCreated( + doc, + 'conditionalValidationDoc', + [ + errorFormatter.minimumValueViolation( + 'staticParentObjectProp.conditionalValidationProp', + '2018-08-10T24:00:00.000Z') + ]); + }); + + it('rejects replacement when a condition is satisfied but an inner constraint is violated', () => { + const oldDoc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { } + }; + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: 1533945599999 + } + }; + + testFixture.verifyDocumentNotReplaced( + doc, + oldDoc, + 'conditionalValidationDoc', + [ errorFormatter.minimumValueViolation('staticParentObjectProp.conditionalValidationProp', 1533945600000) ]); + }); + + it('allows replacement when a condition is satisfied and an inner constraint overrides an outer constraint', () => { + const oldDoc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: 1 + } + }; + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: 2533945600000 + } + }; + + // The outer (conditional) validator specifies that the property is immutable, but that is overridden by the inner + // (datetime) validator + testFixture.verifyDocumentReplaced(doc, oldDoc); + }); + + it('rejects replacement when a condition is satisfied but an outer constraint is violated', () => { + const oldDoc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: '2018-08-11T20:11:33-07:00' + } + }; + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: '2018-08-11T20:21:02.13-07:00' + } + }; + + testFixture.verifyDocumentNotReplaced( + doc, + oldDoc, + 'conditionalValidationDoc', + [ errorFormatter.immutableItemViolation('staticParentObjectProp.conditionalValidationProp') ]); + }); + + it('rejects replacement when the condition specifies that the same validator must be used as for the old doc', () => { + const oldDoc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: 1534019296900 + } + }; + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + staticParentObjectProp: { + conditionalValidationProp: '2018-08-11T20:28:16.9-07:00' + } + }; + + testFixture.verifyDocumentNotReplaced( + doc, + oldDoc, + 'conditionalValidationDoc', + [ + errorFormatter.typeConstraintViolation('staticParentObjectProp.conditionalValidationProp', 'integer') ]); + }); + }); + + describe('with dynamic validation', () => { + it('allows creation of a valid array when arrays are allowed', () => { + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + dynamicConfig: { excludeArrayValidator: false }, + dynamicConditionalValidationProp: [ 53, 45.9 ] + }; + + testFixture.verifyDocumentCreated(doc); + }); + + it('allows creation of a valid object when objects are allowed', () => { + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + dynamicConfig: { + excludeObjectValidator: false, + excludeHashtableValidator: true + }, + dynamicConditionalValidationProp: { stringProp: 'foobar' } + }; + + testFixture.verifyDocumentCreated(doc); + }); + + it('allows creation of a valid hashtable when hashtables are allowed', () => { + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + dynamicConfig: { + excludeObjectValidator: true, + excludeHashtableValidator: false + }, + dynamicConditionalValidationProp: { 'foo-bar': '1a7072c4-116a-4552-865d-74a4206d7695' } + }; + + testFixture.verifyDocumentCreated(doc); + }); + + it('allows creation of a valid value with a dynamic validator', () => { + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + dynamicConfig: { dynamicConditionType: 'boolean' }, + dynamicConditionalValidationProp: true + }; + + testFixture.verifyDocumentCreated(doc); + }); + + it('allows creation of an object because object comes before hashtable in the candidate list', () => { + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + dynamicConfig: { + excludeObjectValidator: false, + excludeHashtableValidator: false + }, + dynamicConditionalValidationProp: { stringProp: 'barbaz' } + }; + + testFixture.verifyDocumentCreated(doc); + }); + + it('rejects creation of an array when arrays are NOT allowed', () => { + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + dynamicConfig: { excludeArrayValidator: true }, + dynamicConditionalValidationProp: [ ] + }; + + testFixture.verifyDocumentNotCreated( + doc, + 'conditionalValidationDoc', + [ errorFormatter.validationConditionsViolation('dynamicConditionalValidationProp') ]); + }); + + it('rejects creation of an object/hashtable when they are NOT allowed', () => { + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + dynamicConfig: { + excludeObjectValidator: true, + excludeHashtableValidator: true + }, + dynamicConditionalValidationProp: { foo: 'bar' } + }; + + testFixture.verifyDocumentNotCreated( + doc, + 'conditionalValidationDoc', + [ errorFormatter.validationConditionsViolation('dynamicConditionalValidationProp') ]); + }); + + it('rejects creation of an array when the value is invalid', () => { + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + dynamicConditionalValidationProp: [ ] + }; + + testFixture.verifyDocumentNotCreated( + doc, + 'conditionalValidationDoc', + [ errorFormatter.mustNotBeEmptyViolation('dynamicConditionalValidationProp') ]); + }); + + it('rejects creation of an object when the value is invalid', () => { + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + dynamicConfig: { + excludeObjectValidator: false, + excludeHashtableValidator: true + }, + dynamicConditionalValidationProp: { + stringProp: 2847 + } + }; + + testFixture.verifyDocumentNotCreated( + doc, + 'conditionalValidationDoc', + [ errorFormatter.typeConstraintViolation('dynamicConditionalValidationProp.stringProp', 'string') ]); + }); + + it('rejects creation of a hashtable when the value is invalid', () => { + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + dynamicConfig: { + excludeObjectValidator: true, + excludeHashtableValidator: false + }, + dynamicConditionalValidationProp: { + 'my-value': 'not-a-uuid' + } + }; + + testFixture.verifyDocumentNotCreated( + doc, + 'conditionalValidationDoc', + [ errorFormatter.typeConstraintViolation('dynamicConditionalValidationProp[my-value]', 'uuid') ]); + }); + + it('rejects creation when the value does not match the type expected by the dynamic validator', () => { + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + dynamicConfig: { dynamicConditionType: 'boolean' }, + dynamicConditionalValidationProp: 1 + }; + + testFixture.verifyDocumentNotCreated( + doc, + 'conditionalValidationDoc', + [ errorFormatter.validationConditionsViolation('dynamicConditionalValidationProp') ]); + }); + + it('rejects creation of a hashtable because object comes before hashtable in the candidate list', () => { + const doc = { + _id: 'my-doc', + type: 'conditionalValidationDoc', + dynamicConfig: { + excludeObjectValidator: false, + excludeHashtableValidator: false + }, + dynamicConditionalValidationProp: { 'my-uuid': '6417e336-a9fc-4d2c-965b-6fb5a49a26f6' } + }; + + testFixture.verifyDocumentNotCreated( + doc, + 'conditionalValidationDoc', + [ + errorFormatter.requiredValueViolation('dynamicConditionalValidationProp.stringProp'), + errorFormatter.unsupportedProperty('dynamicConditionalValidationProp.my-uuid') + ]); + }); + }); +}); diff --git a/test/resources/conditional-validation-doc-definitions.js b/test/resources/conditional-validation-doc-definitions.js new file mode 100644 index 00000000..2259fc26 --- /dev/null +++ b/test/resources/conditional-validation-doc-definitions.js @@ -0,0 +1,130 @@ +function() { + return { + conditionalValidationDoc: { + typeFilter: simpleTypeFilter, + channels: { write: 'write' }, + propertyValidators: { + staticParentObjectProp: { + type: 'object', + propertyValidators: { + conditionalValidationProp: { + type: 'conditional', + immutableWhenSet: true, + skipValidationWhenValueUnchanged: true, + validationCandidates: [ + { + condition: function(doc, oldDoc, currentItemEntry, validationItemStack) { + var useOldValue = oldDoc && !isValueNullOrUndefined(currentItemEntry.oldItemValue); + var itemValue = useOldValue ? currentItemEntry.oldItemValue : currentItemEntry.itemValue; + + return typeof(itemValue) === 'string'; + }, + validator: { + type: 'datetime', + minimumValue: '2018-08-10T24:00:00.000Z' + } + }, + { + condition: function(doc, oldDoc, currentItemEntry, validationItemStack) { + var useOldValue = oldDoc && !isValueNullOrUndefined(currentItemEntry.oldItemValue); + var itemValue = useOldValue ? currentItemEntry.oldItemValue : currentItemEntry.itemValue; + + return typeof(itemValue) === 'number'; + }, + validator: { + type: 'integer', + minimumValue: 1533945600000, // Equivalent to 2018-08-10T24:00:00.000Z + immutableWhenSet: false // Overrides immutableWhenSet from the outer validator + } + } + ] + } + } + }, + dynamicConfig: { + type: 'object', + propertyValidators: { + excludeArrayValidator: { + type: 'boolean' + }, + excludeObjectValidator: { + type: 'boolean' + }, + excludeHashtableValidator: { + type: 'boolean' + }, + dynamicConditionType: { + type: 'string' + } + } + }, + dynamicConditionalValidationProp: { + type: 'conditional', + validationCandidates: function(doc, oldDoc, value, oldValue) { + var candidates = [ ]; + + var config = doc.dynamicConfig || { }; + + if (!config.excludeArrayValidator) { + candidates.push({ + condition: function(doc, oldDoc, currentItemEntry, validationItemStack) { + return Array.isArray(currentItemEntry.itemValue); + }, + validator: { + type: 'array', + mustNotBeEmpty: true, + arrayElementsValidator: { + type: 'float' + } + } + }); + } + if (!config.excludeObjectValidator) { + candidates.push({ + condition: function(doc, oldDoc, currentItemEntry, validationItemStack) { + return typeof currentItemEntry.itemValue === 'object' && !Array.isArray(currentItemEntry.itemValue); + }, + validator: { + type: 'object', + propertyValidators: { + stringProp: { + type: 'string', + required: true + } + } + } + }); + } + if (!config.excludeHashtableValidator) { + candidates.push({ + condition: function(doc, oldDoc, currentItemEntry, validationItemStack) { + return typeof currentItemEntry.itemValue === 'object' && !Array.isArray(currentItemEntry.itemValue); + }, + validator: { + type: 'hashtable', + hashtableValuesValidator: { + type: 'uuid' + } + } + }); + } + if (config.dynamicConditionType) { + candidates.push({ + condition: function(doc, oldDoc, currentItemEntry, validationItemStack) { + var type = validationItemStack[0].itemValue.dynamicConfig.dynamicConditionType; + + return typeof(currentItemEntry.itemValue) === type; + }, + validator: { + type: config.dynamicConditionType + } + }); + } + + return candidates; + } + } + } + } + }; +} diff --git a/test/sample-notification.spec.js b/test/sample-notification.spec.js index a36b911f..8d48ae50 100644 --- a/test/sample-notification.spec.js +++ b/test/sample-notification.spec.js @@ -70,7 +70,7 @@ describe('Sample business notification doc definition', () => { subject: 'pay up!', message: 'you best pay up now, or else...', createdAt: '2016-02-29T17:13:43.666Z', - actions: [ { url: 'http://foobar.baz', label: 'pay up here'} ], + actions: [ 'http://foobar.baz', 'http://bazbar.foo' ], users: [ 'foobar', 'baz' ] }; diff --git a/test/sample-notifications-config.spec.js b/test/sample-notifications-config.spec.js index dfbfab56..7e2e9828 100644 --- a/test/sample-notifications-config.spec.js +++ b/test/sample-notifications-config.spec.js @@ -20,7 +20,9 @@ describe('Sample business notifications config doc definition', () => { invoicePayments: { enabledTransports: [ { transportId: 'ET1' }, - { transportId: 'ET2' } + { transportId: 'ET2' }, + 'ET3', + 'ET4' ] } } @@ -36,7 +38,8 @@ describe('Sample business notifications config doc definition', () => { invoicePayments: { enabledTransports: [ { 'invalid-property': 'blah' }, - { transportId: '' } + { transportId: '' }, + '' ] }, 'Invalid-Type': { @@ -56,6 +59,7 @@ describe('Sample business notifications config doc definition', () => { errorFormatter.unsupportedProperty('notificationTypes[invoicePayments].enabledTransports[0].invalid-property'), errorFormatter.requiredValueViolation('notificationTypes[invoicePayments].enabledTransports[0].transportId'), errorFormatter.mustNotBeEmptyViolation('notificationTypes[invoicePayments].enabledTransports[1].transportId'), + errorFormatter.mustNotBeEmptyViolation('notificationTypes[invoicePayments].enabledTransports[2]'), errorFormatter.regexPatternHashtableKeyViolation('notificationTypes[Invalid-Type]', /^[a-zA-Z]+$/), errorFormatter.hashtableKeyEmpty('notificationTypes'), errorFormatter.regexPatternHashtableKeyViolation('notificationTypes[]', /^[a-zA-Z]+$/),