diff --git a/CHANGELOG.md b/CHANGELOG.md index 53f7399..b100b5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +- Allows to disable coercion + ## 2.0.0-alpha.4 - 2020-03-21 - Publish only src folder for jest-structure diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 9ed36ae..3961669 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -10,7 +10,7 @@ - [Arrays coercion](coercion/arrays-and-array-subclasses.md) - [Generic coercion](coercion/generic-coercion.md) - [Recursive coercion](coercion/recursive-coercion.md) - - [Observations](coercion/observations.md) + - [Disabling coercion](coercion/disabling-coercion.md) - [Validation](validation/README.md) - [String validations](validation/string-validations.md) - [Number validations](validation/number-validations.md) diff --git a/docs/coercion/README.md b/docs/coercion/README.md index 478c057..a480cfb 100644 --- a/docs/coercion/README.md +++ b/docs/coercion/README.md @@ -1,9 +1,36 @@ # Coercion -Structure does type coercion based on the declared [schema](../schema-concept/README.md). It's important to note that it __never__ coerces the following scenarios: +Structure does type coercion based on the declared [schema](../schema-concept/README.md), let's break it into 3 categories: -- `undefined`; -- `null` when `nullable` option is enabled; +- [Primitive type coercion](primitive-type-coercion.md) +- [Arrays coercion](arrays-and-array-subclasses.md) +- [Generic coercion](generic-coercion.md) + +## Observations + +Structure **never** coerces the following scenarios: + +- value is `undefined`; +- value is `null` when `nullable` option is enabled; - value is already of the declared type (except for arrays, we'll talk more about this soon). -Let's break the coercion into 3 categories. +Also, Structure only does **array items coercion** during instantiation, so mutating an array (using push, for example) won't coerce the new item: + +```javascript +const Library = attributes({ + books: { + type: Array, + itemType: String, + }, +})(class Library {}); + +const library = new Library({ + books: [1984], +}); + +library.books; // ['1984'] => coerced number to string + +library.books.push(42); + +library.books; // ['1984', 42] => new item was not coerced +``` diff --git a/docs/coercion/disabling-coercion.md b/docs/coercion/disabling-coercion.md new file mode 100644 index 0000000..1b56c99 --- /dev/null +++ b/docs/coercion/disabling-coercion.md @@ -0,0 +1,85 @@ +# Disabling coercion + +You can disable coercion for a whole structure or for attributes individually using the `coercion` option in the schema and attribute options, respectively. Notice that it will cause validation to fail when the passed value is not of the expected value: + +## Disabling for the whole structure + +```js +const User = attributes( + { + name: String, + age: Number, + }, + { + coercion: false, + } +)(class User {}); + +const user = new User({ name: 123, age: '42' }); + +user.name; // 123 +user.age; // '42' + +const { valid, errors } = user.validate(); + +valid; // false +errors; /* +[ + { message: '"name" must be a string', path: ['name'] }, + { message: '"age" must be a number', path: ['age'] } +] +*/ +``` + +## Disabling for specific attributes + +```js +const User = attributes({ + name: { type: String, coercion: false }, + age: Number, +})(class User {}); + +const user = new User({ name: 123, age: '42' }); + +user.name; // 123 +user.age; // 42 + +const { valid, errors } = user.validate(); + +valid; // false +errors; /* +[ + { message: '"name" must be a string', path: ['name'] } +] +*/ +``` + +## Overwritting structure option with attribute option + +If you define the `coercion` option both for the structure _and_ for an attribute, the structure one will apply for the whole schema except the specific attributes that overwrite it: + +```js +const User = attributes( + { + name: { type: String, coercion: true }, + age: Number, + }, + { + coercion: false, + } +)(class User {}); + +const user = new User({ name: 123, age: '42' }); + +user.name; // '123' +user.age; // '42' + +const { valid, errors } = user.validate(); + +valid; // false +errors; /* +[ + { message: '"age" must be a number', path: ['age'] } +] +*/ +``` diff --git a/docs/coercion/observations.md b/docs/coercion/observations.md deleted file mode 100644 index 19558ea..0000000 --- a/docs/coercion/observations.md +++ /dev/null @@ -1,23 +0,0 @@ -# Observations - -__Important: Structure only does coercion during object creation, so mutating an array (using push, for example) won't coerce the new item:__ - -```javascript -const Library = attributes({ - books: { - type: Array, - itemType: String - } -})(class Library { }); - -const library = new Library({ - books: [1984] -}); - -library.books; // ['1984'] => coerced number to string - -library.books.push(42); - -library.books; // ['1984', 42] => new item was not coerced - -``` diff --git a/packages/structure/src/coercion/coercion.js b/packages/structure/src/coercion/coercion.js index 0df9f1f..e9d433f 100644 --- a/packages/structure/src/coercion/coercion.js +++ b/packages/structure/src/coercion/coercion.js @@ -20,6 +20,10 @@ exports.create = function createCoercionFor(coercion, attributeDefinition) { }; }; +exports.disabled = { + coerce: (value) => value, +}; + const getNullableValue = (coercion, attributeDefinition) => needsNullableInitialization(attributeDefinition) ? getNullValue(coercion) : null; diff --git a/packages/structure/src/coercion/index.js b/packages/structure/src/coercion/index.js index a286dbd..9d54813 100644 --- a/packages/structure/src/coercion/index.js +++ b/packages/structure/src/coercion/index.js @@ -10,6 +10,10 @@ const types = [ ]; exports.for = function coercionFor(attributeDefinition) { + if (!attributeDefinition.options.coercion) { + return Coercion.disabled; + } + const coercion = getCoercion(attributeDefinition); return Coercion.create(coercion, attributeDefinition); diff --git a/packages/structure/src/schema/AttributeDefinitions/AttributeDefinition.js b/packages/structure/src/schema/AttributeDefinitions/AttributeDefinition.js index 1cf962d..2a98e9c 100644 --- a/packages/structure/src/schema/AttributeDefinitions/AttributeDefinition.js +++ b/packages/structure/src/schema/AttributeDefinitions/AttributeDefinition.js @@ -1,4 +1,4 @@ -const { isFunction, isString } = require('lodash'); +const { isFunction, isString, isUndefined } = require('lodash'); const Coercion = require('../../coercion'); const Validation = require('../../validation'); const Errors = require('../../errors'); @@ -61,7 +61,7 @@ class AttributeDefinition { this.__isAttributeDefinition = true; this.name = name; - this.options = options; + options = this.options = applyDefaultOptions(options, schema); this.hasDefault = 'default' in options; this.isDynamicDefault = isFunction(options.default); this.hasDynamicType = hasDynamicType(options); @@ -140,6 +140,16 @@ const makeComplete = (options) => { return { type: options }; }; +const applyDefaultOptions = (options, schema) => { + return { + ...options, + coercion: inheritOptionFromSchema(options.coercion, schema.options.coercion), + }; +}; + +const inheritOptionFromSchema = (option, schemaOption) => + !isUndefined(option) ? option : schemaOption; + const isShorthand = (options) => isFunction(options) || isString(options); const hasStaticType = (options) => isFunction(options.type); diff --git a/packages/structure/src/schema/index.js b/packages/structure/src/schema/index.js index de56913..7817fb7 100644 --- a/packages/structure/src/schema/index.js +++ b/packages/structure/src/schema/index.js @@ -48,7 +48,7 @@ class Schema { } constructor({ attributeDefinitions, wrappedClass, options }) { - this.options = options; + this.options = applyDefaultOptions(options); this.attributeDefinitions = AttributeDefinitions.for(attributeDefinitions, { schema: this }); this.wrappedClass = wrappedClass; this.identifier = options.identifier || wrappedClass.name; @@ -97,4 +97,13 @@ class Schema { } } +const defaultOptions = { + coercion: true, +}; + +const applyDefaultOptions = (options) => ({ + ...defaultOptions, + ...options, +}); + module.exports = Schema; diff --git a/packages/structure/test/unit/featureSwitches/coercion.spec.js b/packages/structure/test/unit/featureSwitches/coercion.spec.js new file mode 100644 index 0000000..26bf849 --- /dev/null +++ b/packages/structure/test/unit/featureSwitches/coercion.spec.js @@ -0,0 +1,171 @@ +const { attributes } = require('../../../src'); + +describe('coercion feature switch', () => { + describe('when using for the whole structure', () => { + describe('explicitly enabled', () => { + let User; + + beforeEach(() => { + User = attributes( + { + name: String, + }, + { + coercion: true, + } + )(class User {}); + }); + + it('coerces attribute', () => { + const user = new User({ name: 42 }); + + expect(user.name).toEqual('42'); + }); + }); + + describe('enabled by default', () => { + let User; + + beforeEach(() => { + User = attributes({ + name: String, + })(class User {}); + }); + + it('coerces attribute', () => { + const user = new User({ name: 42 }); + + expect(user.name).toEqual('42'); + }); + }); + + describe('disabled', () => { + let User; + + beforeEach(() => { + User = attributes( + { + name: String, + }, + { + coercion: false, + } + )(class User {}); + }); + + it('does not coerce attribute', () => { + const user = new User({ name: 42 }); + + expect(user.name).toEqual(42); + }); + + it('fails validation because of wrong type', () => { + const user = new User({ name: 42 }); + + expect(user).toHaveInvalidAttribute(['name'], ['"name" must be a string']); + }); + }); + }); + + describe('when using for a single attribute', () => { + describe('enabled', () => { + let User; + + beforeEach(() => { + User = attributes({ + name: { type: String, coercion: true }, + age: Number, + })(class User {}); + }); + + it('coerces attribute', () => { + const user = new User({ name: 42 }); + + expect(user.name).toEqual('42'); + }); + }); + + describe('disabled', () => { + let User; + + beforeEach(() => { + User = attributes({ + name: { type: String, coercion: false }, + age: Number, + })(class User {}); + }); + + it('does not coerce attribute', () => { + const user = new User({ name: 42 }); + + expect(user.name).toEqual(42); + }); + + it('fails validation because of wrong type', () => { + const user = new User({ name: 42 }); + + expect(user).toHaveInvalidAttribute(['name'], ['"name" must be a string']); + }); + }); + + describe('overrides the schema', () => { + describe('schema: disabled, attribute: enabled', () => { + let User; + + beforeEach(() => { + User = attributes( + { + name: { type: String, coercion: true }, + age: Number, + }, + { + coercion: false, + } + )(class User {}); + }); + + it('coerces the attribute but not the others', () => { + const user = new User({ name: 42, age: '1' }); + + expect(user.name).toEqual('42'); + expect(user.age).toEqual('1'); + }); + + it('fails validation because of wrong type of other attributes', () => { + const user = new User({ name: 42, age: '1' }); + + expect(user).toHaveInvalidAttribute(['age'], ['"age" must be a number']); + }); + }); + + describe('schema: enabled, attribute: disabled', () => { + let User; + + beforeEach(() => { + User = attributes( + { + name: { type: String, coercion: false }, + age: Number, + }, + { + coercion: true, + } + )(class User {}); + }); + + it('does not coerce the attribute but coerces the others', () => { + const user = new User({ name: 42, age: '1' }); + + expect(user.name).toEqual(42); + expect(user.age).toEqual(1); + }); + + it('fails validation because of wrong type', () => { + const user = new User({ name: 42, age: '1' }); + + expect(user).toHaveInvalidAttribute(['name'], ['"name" must be a string']); + }); + }); + }); + }); +});