diff --git a/src/Schema/Type/Number.php b/src/Schema/Type/Number.php index e52c6ab..978d70a 100644 --- a/src/Schema/Type/Number.php +++ b/src/Schema/Type/Number.php @@ -50,8 +50,16 @@ public function validate(mixed $value, callable $fail): void } } - if ($this->multipleOf !== null && $value % $this->multipleOf !== 0) { - $fail(sprintf('must be a multiple of %d', $this->multipleOf)); + // Divide the value by multipleOf instead of using the modulo operator to avoid bugs when using a multipleOf + // that has decimal places. (Since the modulo operator converts the multipleOf to int) + // Note that dividing two integers returns another integer if the result is a whole number. So to make the + // comparison work at all times we need to cast the result to float. Casting both to integer will not work + // as intended since then the result of the division would also be rounded. + if ( + $this->multipleOf !== null && + (float) ($value / $this->multipleOf) !== round($value / $this->multipleOf) + ) { + $fail(sprintf('must be a multiple of %s', $this->multipleOf)); } } @@ -85,7 +93,7 @@ public function maximum(?float $maximum, bool $exclusive = false): static public function multipleOf(?float $number): static { - if ($number <= 0) { + if ($number !== null && $number <= 0) { throw new InvalidArgumentException('multipleOf must be a positive number'); } diff --git a/tests/unit/NumberTest.php b/tests/unit/NumberTest.php index 72c7755..ef7828a 100644 --- a/tests/unit/NumberTest.php +++ b/tests/unit/NumberTest.php @@ -40,6 +40,10 @@ public static function validationProvider(): array [Number::make()->maximum(10, exclusive: true), 10, false], [Number::make()->multipleOf(2), 1, false], [Number::make()->multipleOf(2), 2, true], + [Number::make()->multipleOf(0.01), 100, true], + [Number::make()->multipleOf(0.01), 100.5, true], + [Number::make()->multipleOf(0.01), 100.56, true], + [Number::make()->multipleOf(0.01), 100.567, false], ]; } @@ -56,4 +60,16 @@ public function test_validation(Type $type, mixed $value, bool $valid) $type->validate($value, $fail); } + + public function test_multipleOf_reset(): void + { + $number = Number::make() + ->multipleOf(2) + ->multipleOf(null); + + $fail = $this->createMock(MockedCaller::class); + $fail->expects($this->never())->method('__invoke'); + + $number->validate(5, $fail); + } }