Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion src/validation/number.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,65 @@ import type { ValidationError, ValidationErrorPath } from '../errors'
import type { NonBooleanJsfSchema, SchemaValue } from '../types'
import { getSchemaType } from './schema'

interface DecimalParts {
digits: bigint
exponent: number
}

/**
* Decompose a finite number into its exact decimal digits and exponent.
* @param value - The number to decompose
* @returns The digits (as a bigint) and the power of ten they must be multiplied by
* @description
* The decomposition is based on `toString()`, which returns the shortest decimal representation
* that round-trips to the same double - in other words, the number as it was written in the schema
* or typed by the user. So `0.0001` decomposes to `1 * 10 ** -4`, not to the slightly larger binary
* value the double actually holds.
*/
function toDecimalParts(value: number): DecimalParts {
const [mantissa, exponent] = value.toString().split('e')
const [integerDigits, fractionDigits = ''] = mantissa.split('.')

return {
digits: BigInt(integerDigits + fractionDigits),
exponent: (exponent === undefined ? 0 : Number(exponent)) - fractionDigits.length,
}
}

/**
* Check whether a value is an exact multiple of `multipleOf`.
* @param value - The number being validated
* @param multipleOf - The divisor the value must be a multiple of
* @returns `true` if the value is an exact multiple of `multipleOf`
* @description
* A plain `value % multipleOf` check is unreliable whenever `multipleOf` is fractional, because a
* value like `0.0001` has no exact binary representation: `3.025 % 0.0001` yields `0.00009999...`
* instead of `0`, so unambiguously valid values get rejected.
*
* To avoid this, both numbers are scaled by the same power of ten until they are integers, and the
* remainder is taken with bigint arithmetic, which is exact at any magnitude.
*/
function isMultipleOf(value: number, multipleOf: number): boolean {
if (Number.isInteger(value) && Number.isInteger(multipleOf)) {
return value % multipleOf === 0
}

// NaN, Infinity and a zero divisor can never produce a valid multiple.
if (!Number.isFinite(value) || !Number.isFinite(multipleOf) || multipleOf === 0) {
return false
}

const dividend = toDecimalParts(value)
const divisor = toDecimalParts(multipleOf)

// Line both numbers up on the smaller exponent so that they are both whole numbers
const exponent = Math.min(dividend.exponent, divisor.exponent)
const scaledValue = dividend.digits * 10n ** BigInt(dividend.exponent - exponent)
const scaledMultipleOf = divisor.digits * 10n ** BigInt(divisor.exponent - exponent)

return scaledValue % scaledMultipleOf === 0n
}

/**
* Validate a number against a schema
* @param value - The value to validate
Expand Down Expand Up @@ -33,7 +92,7 @@ export function validateNumber(
}

// MultipleOf validation - dividing value by multipleOf must have no remainder
if (schema.multipleOf !== undefined && value % schema.multipleOf !== 0) {
if (schema.multipleOf !== undefined && !isMultipleOf(value, schema.multipleOf)) {
errors.push({ path, validation: 'multipleOf', schema, value })
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,6 @@
"minProperties validation with a decimal": [
"too short is invalid"
],
"by small number": [
"0.0075 is multiple of 0.0001"
],
"small multiple of large integer": [
"any integer is a multiple of 1e-8"
],
"collect annotations inside a 'not', even if collection is disabled": [
"unevaluated property"
],
Expand Down
67 changes: 67 additions & 0 deletions test/validation/number.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { JsfObjectSchema } from '../../src/types'
import { describe, expect, it } from '@jest/globals'
import { createHeadlessForm } from '../../src'
import { validateSchema } from '../../src/validation/schema'
import { errorLike } from '../test-utils'

Expand Down Expand Up @@ -163,4 +165,69 @@ describe('number validation', () => {
}),
])
})

describe('multipleOf with fractional values', () => {
it('accepts values that are true multiples of a fractional multipleOf', () => {
expect(validateSchema(3.025, { type: 'number', multipleOf: 0.0001 })).toEqual([])
expect(validateSchema(3.4, { type: 'number', multipleOf: 0.0001 })).toEqual([])
expect(validateSchema(0, { type: 'number', multipleOf: 0.0001 })).toEqual([])
expect(validateSchema(0.0075, { type: 'number', multipleOf: 0.0001 })).toEqual([])
expect(validateSchema(100, { type: 'number', multipleOf: 0.01 })).toEqual([])
expect(validateSchema(12.34, { type: 'number', multipleOf: 0.01 })).toEqual([])
expect(validateSchema(250.5, { type: 'number', multipleOf: 0.01 })).toEqual([])
expect(validateSchema(4.5, { type: 'number', multipleOf: 1.5 })).toEqual([])
expect(validateSchema(-3.4, { type: 'number', multipleOf: 0.0001 })).toEqual([])
})

it('still rejects values that are not multiples of a fractional multipleOf', () => {
const multipleOfError = [errorLike({ path: [], validation: 'multipleOf' })]

expect(validateSchema(3.12345, { type: 'number', multipleOf: 0.0001 })).toEqual(multipleOfError)
expect(validateSchema(0.00751, { type: 'number', multipleOf: 0.0001 })).toEqual(multipleOfError)
expect(validateSchema(12.345, { type: 'number', multipleOf: 0.01 })).toEqual(multipleOfError)
expect(validateSchema(35, { type: 'number', multipleOf: 1.5 })).toEqual(multipleOfError)
expect(validateSchema(-3.12345, { type: 'number', multipleOf: 0.0001 })).toEqual(multipleOfError)
})

it('validates a percentage field nested in a fieldset', () => {
const schema: JsfObjectSchema = {
'type': 'object',
'additionalProperties': false,
'properties': {
foo: {
'type': 'object',
'additionalProperties': false,
'title': 'Working title',
'properties': {
problematic_field: {
'type': 'number',
'title': 'Should accept 3.025',
'description': 'Enter a number between 0 and 99.9999',
'minimum': 0,
'maximum': 99.9999,
'multipleOf': 0.0001,
'x-jsf-errorMessage': {
maximum: 'Please enter a number as a percentage value with up to 4 decimal places',
},
'x-jsf-presentation': { inputType: 'number' },
},
},
'required': ['problematic_field'],
'x-jsf-order': ['problematic_field'],
'x-jsf-presentation': { inputType: 'fieldset' },
},
},
'required': ['foo'],
'x-jsf-order': ['foo'],
}

const form = createHeadlessForm(schema)

expect(form.handleValidation({ foo: { problematic_field: 3.025 } }).formErrors).toBeUndefined()

expect(form.handleValidation({ foo: { problematic_field: 3.02555 } }).formErrors).toEqual({
foo: { problematic_field: 'Must be a multiple of 0.0001' },
})
})
})
})
Loading