From c4c8ff13229cbf19fe90f8dd1c110a589b8fba64 Mon Sep 17 00:00:00 2001 From: Ben Avery Date: Thu, 17 Aug 2017 18:02:14 +0200 Subject: [PATCH] Refactor multipleOf and divisibleBy to perform integer calculations to avoid IEEE-754 float innacuracies. This was causing, e.g. 2.4 not to be a multipleOf 0.1. The code multiplies by the minimum factor of 10 so that the modulo calculation is performed on integers. All credit goes to @manahga for coming up with this solution. --- lib/attribute.js | 59 +++++++++++++++++++++++----------------------- lib/helpers.js | 35 +++++++++++++++++++++++++++ test/attributes.js | 28 +++++++++++++++++++++- 3 files changed, 92 insertions(+), 30 deletions(-) diff --git a/lib/attribute.js b/lib/attribute.js index 54c998a..52de2b7 100644 --- a/lib/attribute.js +++ b/lib/attribute.js @@ -412,59 +412,60 @@ validators.maximum = function validateMaximum (instance, schema, options, ctx) { }; /** - * Validates divisibleBy when the type of the instance value is a number. - * Of course, this is susceptible to floating point error since it compares the floating points - * and not the JSON byte sequences to arbitrary precision. + * Perform validation for multipleOf and divisibleBy, which are essentially the same. * @param instance * @param schema - * @return {String|null} + * @param validationType + * @param errorMessage + * @returns {String|null} */ -validators.divisibleBy = function validateDivisibleBy (instance, schema, options, ctx) { +var validateMultipleOfOrDivisbleBy = function validateMultipleOfOrDivisbleBy (instance, schema, options, ctx, validationType, errorMessage) { if (typeof instance !== 'number') { return null; } - if (schema.divisibleBy == 0) { - throw new SchemaError("divisibleBy cannot be zero"); + var validationArgument = schema[validationType]; + if (validationArgument == 0) { + throw new SchemaError(validationType + " cannot be zero"); } var result = new ValidatorResult(instance, schema, options, ctx); - if (instance / schema.divisibleBy % 1) { + + var instanceDecimals = helpers.getDecimalPlaces(instance); + var divisorDecimals = helpers.getDecimalPlaces(validationArgument); + + var maxDecimals = Math.max(instanceDecimals , divisorDecimals); + var multiplier = Math.pow(10, maxDecimals); + + if (Math.round(instance * multiplier) % Math.round(validationArgument * multiplier) !== 0) { result.addError({ - name: 'divisibleBy', - argument: schema.divisibleBy, - message: "is not divisible by (multiple of) " + JSON.stringify(schema.divisibleBy), + name: validationType, + argument: validationArgument, + message: errorMessage + JSON.stringify(validationArgument) }); } + return result; }; /** * Validates divisibleBy when the type of the instance value is a number. - * Of course, this is susceptible to floating point error since it compares the floating points - * and not the JSON byte sequences to arbitrary precision. * @param instance * @param schema * @return {String|null} */ validators.multipleOf = function validateMultipleOf (instance, schema, options, ctx) { - if (typeof instance !== 'number') { - return null; - } - - if (schema.multipleOf == 0) { - throw new SchemaError("multipleOf cannot be zero"); - } + return validateMultipleOfOrDivisbleBy(instance, schema, options, ctx, "multipleOf", "is not a multiple of (divisible by) "); +}; - var result = new ValidatorResult(instance, schema, options, ctx); - if (instance / schema.multipleOf % 1) { - result.addError({ - name: 'multipleOf', - argument: schema.multipleOf, - message: "is not a multiple of (divisible by) " + JSON.stringify(schema.multipleOf), - }); - } - return result; +/** + * Validates multipleOf when the type of the instance value is a number. + * @param instance + * @param schema + * @return {String|null} + */ +validators.divisibleBy = function validateDivisibleBy (instance, schema, options, ctx) { + return validateMultipleOfOrDivisbleBy(instance, schema, options, ctx, "divisibleBy", "is not divisible by (multiple of) "); }; /** diff --git a/lib/helpers.js b/lib/helpers.js index 422abd2..4b28543 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -288,3 +288,38 @@ exports.encodePath = function encodePointer(a){ // the slash is encoded by encodeURIComponent return a.map(pathEncoder).join(''); }; + + +/** + * Calculate the number of decimal places a number uses + * We need this to get correct results out of multipleOf and divisibleBy + * when either figure is has decimal places, due to IEEE-754 float issues. + * @param number + * @returns {number} + */ +exports.getDecimalPlaces = function getDecimalPlaces(number) { + + var decimalPlaces = 0; + if (isNaN(number)) return decimalPlaces; + + if (typeof number !== 'number') { + number = Number(number); + } + + var parts = number.toString().split('e'); + if (parts.length === 2) { + if (parts[1][0] !== '-') { + return decimalPlaces; + } else { + decimalPlaces = Number(parts[1].slice(1)); + } + } + + var decimalParts = parts[0].split('.'); + if (decimalParts.length === 2) { + decimalPlaces += decimalParts[1].length; + } + + return decimalPlaces; +}; + diff --git a/test/attributes.js b/test/attributes.js index b29154b..2824255 100644 --- a/test/attributes.js +++ b/test/attributes.js @@ -190,7 +190,7 @@ describe('Attributes', function () { }); }); - describe('dividibleBy', function () { + describe('divisibleBy', function () { beforeEach(function () { this.validator = new Validator(); }); @@ -206,6 +206,32 @@ describe('Attributes', function () { it('should not validate 1 is even', function () { return this.validator.validate(1, {'type': 'number', 'divisibleBy': 2}).valid.should.be.false; }); + + it('should validate divisibleBy with decimals', function () { + return this.validator.validate(2.4, {'type': 'number', 'divisibleBy': 0.1}).valid.should.be.true; + }); + }); + + describe('multipleOf', function () { + beforeEach(function () { + this.validator = new Validator(); + }); + + it('should validate if 0 is even', function () { + return this.validator.validate(2, {'type': 'number', 'multipleOf': 2}).valid.should.be.true; + }); + + it('should validate if -2 is even', function () { + return this.validator.validate(-2, {'type': 'number', 'multipleOf': 2}).valid.should.be.true; + }); + + it('should not validate 1 is even', function () { + return this.validator.validate(1, {'type': 'number', 'multipleOf': 2}).valid.should.be.false; + }); + + it('should validate mutlipleOf with decimals', function () { + return this.validator.validate(2.4, {'type': 'number', 'multipleOf': 0.1}).valid.should.be.true; + }); }); describe('pattern', function () {