From e14a83cb6043373b2aabcc1939873254e8285fd7 Mon Sep 17 00:00:00 2001 From: Jan Klan Date: Wed, 7 May 2025 11:35:04 +0930 Subject: [PATCH] Add NumRange type --- .../Doctrine/DBAL/Types/NumRange.php | 54 ++++++ src/MartinGeorgiev/Model/ArithmeticRange.php | 74 +++++++++ src/MartinGeorgiev/Model/BaseRange.php | 38 +++++ src/MartinGeorgiev/Model/RangeInterface.php | 28 ++++ src/MartinGeorgiev/Utils/MathUtils.php | 56 +++++++ .../MartinGeorgiev/Utils/MathUtilsTest.php | 156 ++++++++++++++++++ 6 files changed, 406 insertions(+) create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/NumRange.php create mode 100644 src/MartinGeorgiev/Model/ArithmeticRange.php create mode 100644 src/MartinGeorgiev/Model/BaseRange.php create mode 100644 src/MartinGeorgiev/Model/RangeInterface.php create mode 100644 src/MartinGeorgiev/Utils/MathUtils.php create mode 100644 tests/Unit/MartinGeorgiev/Utils/MathUtilsTest.php diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/NumRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/NumRange.php new file mode 100644 index 00000000..c521526e --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/NumRange.php @@ -0,0 +1,54 @@ + + */ +class NumRange extends BaseType +{ + protected const TYPE_NAME = 'numrange'; + + public function convertToPHPValue($value, AbstractPlatform $platform): ?ArithmeticRange + { + if (null === $value || 'empty' === $value) { + return null; + } + + if (!\is_string($value)) { + throw new \RuntimeException('NumRange expects only string. Unexpected value from DB: '.$value); + } + + if (!\preg_match('/(\[|\()(.*)\,(.*)(\]|\))/', $value, $matches)) { + throw new \RuntimeException('unexpected value from DB: '.$value); + } + + return ArithmeticRange::createFromString($value); + } + + public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string + { + if (empty($value)) { + return null; + } + + $stringValue = (string) $value; + + if ('(,)' === $stringValue) { + return null; + } + + return $stringValue; + } +} diff --git a/src/MartinGeorgiev/Model/ArithmeticRange.php b/src/MartinGeorgiev/Model/ArithmeticRange.php new file mode 100644 index 00000000..c2d616cd --- /dev/null +++ b/src/MartinGeorgiev/Model/ArithmeticRange.php @@ -0,0 +1,74 @@ + + */ +class ArithmeticRange extends BaseRange +{ + public function __construct( + public null|float|int $lower, + public null|float|int $upper, + public bool $lowerInclusive = true, + public bool $upperInclusive = false, + ) { + // Void + } + + public function __toString(): string + { + if (null !== $this->lower && $this->lower === $this->upper && !$this->lowerInclusive && !$this->upperInclusive) { + return 'empty'; + } + + return \sprintf( + '%s%s,%s%s', + $this->lowerInclusive ? '[' : '(', + $this->lower, + $this->upper, + $this->upperInclusive ? ']' : ')', + ); + } + + public function contains(mixed $target): bool + { + return MathUtils::inRange($target, $this->lower, $this->upper, $this->lowerInclusive, $this->upperInclusive); + } + + /** + * @see https://www.postgresql.org/docs/current/rangetypes.html#RANGETYPES-INFINITE + */ + public static function createFromString(string $value): self + { + if (!\preg_match('/([\[(])(.*),(.*)([])])/', $value, $matches)) { + throw new \RuntimeException('Unexpected value: '.$value); + } + + $startParenthesis = $matches[1]; + $startsAtString = \trim($matches[2], '"'); + $endsAtString = \trim($matches[3], '"'); + $endParenthesis = $matches[4]; + + if (\in_array($startsAtString, ['infinity', '-infinity', ''], true)) { + $startsAt = null; + } else { + $startsAt = MathUtils::stringToNumber($startsAtString); + } + + if (\in_array($endsAtString, ['infinity', '-infinity', ''], true)) { + $endsAt = null; + } else { + $endsAt = MathUtils::stringToNumber($endsAtString); + } + + $startInclusive = '[' === $startParenthesis; + $endInclusive = ']' === $endParenthesis; + + return new NumRange($startsAt, $endsAt, $startInclusive, $endInclusive); + } +} diff --git a/src/MartinGeorgiev/Model/BaseRange.php b/src/MartinGeorgiev/Model/BaseRange.php new file mode 100644 index 00000000..0a8fb1b5 --- /dev/null +++ b/src/MartinGeorgiev/Model/BaseRange.php @@ -0,0 +1,38 @@ +lower; + } + + public function isUpperInfinite(): bool + { + return null === $this->upper; + } + + public function isEmpty(): bool + { + return null !== $this->lower && $this->lower === $this->upper; + } + + public function isInfinite(): bool + { + return $this->isLowerInfinite() && $this->isUpperInfinite(); + } + + public function hasSingleBoundary(): bool + { + return !$this->isLowerInfinite() xor !$this->isUpperInfinite(); + } + + public function hasBothBoundaries(): bool + { + return !$this->isLowerInfinite() && !$this->isUpperInfinite(); + } +} diff --git a/src/MartinGeorgiev/Model/RangeInterface.php b/src/MartinGeorgiev/Model/RangeInterface.php new file mode 100644 index 00000000..8f6ecaba --- /dev/null +++ b/src/MartinGeorgiev/Model/RangeInterface.php @@ -0,0 +1,28 @@ +range[Start/End]Inclusive, we will use (>= or >) and (<= or <) to work out where the value is + $isGreater = $startInclusive ? $value >= $start : $value > $start; + $isLesser = $endInclusive ? $value <= $end : $value < $end; + + return + (null === $start || $isGreater) + && (null === $end || $isLesser); + } + + public static function stringToNumber(?string $number): null|float|int + { + if (!\is_numeric($number)) { + return null; + } + + return ((float) $number == (int) $number) ? (int) $number : (float) $number; + } +} diff --git a/tests/Unit/MartinGeorgiev/Utils/MathUtilsTest.php b/tests/Unit/MartinGeorgiev/Utils/MathUtilsTest.php new file mode 100644 index 00000000..47b979f9 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Utils/MathUtilsTest.php @@ -0,0 +1,156 @@ + false, + 1 => true, + 2 => true, + 3 => true, + 4 => false, + ] as $value => $expected) { + yield [ + 'value' => $value, + 'start' => 1, + 'end' => 3, + 'startInclusive' => true, + 'endInclusive' => true, + 'expected' => $expected, + ]; + } + + foreach ([ + 0 => false, + 1 => false, + 2 => true, + 3 => true, + 4 => false, + ] as $value => $expected) { + yield [ + 'value' => $value, + 'start' => 1, + 'end' => 3, + 'startInclusive' => false, // <-- this + 'endInclusive' => true, + 'expected' => $expected, + ]; + } + + foreach ([ + 0 => false, + 1 => true, + 2 => true, + 3 => false, + 4 => false, + ] as $value => $expected) { + yield [ + 'value' => $value, + 'start' => 1, + 'end' => 3, + 'startInclusive' => true, + 'endInclusive' => false, // <-- this + 'expected' => $expected, + ]; + } + + foreach ([ + 0 => false, + 1 => false, + 2 => true, + 3 => false, + 4 => false, + ] as $value => $expected) { + yield [ + 'value' => $value, + 'start' => 1, + 'end' => 3, + 'startInclusive' => false, // <-- this + 'endInclusive' => false, // <-- this + 'expected' => $expected, + ]; + } + } + + /** + * @dataProvider providerStringToNumber + */ + public function test_string_to_number(?string $input, null|float|int $expected): void + { + self::assertEquals($expected, MathUtils::stringToNumber($input)); + } + + public static function providerStringToNumber(): \Generator + { + yield [null, null]; + + yield ['foo', null]; + + yield ['1+1', null]; + + yield ['+1', 1]; + + yield ['-1', -1]; + + yield ['1', 1]; + + yield ['1.0', 1.0]; + + yield ['1.1', 1.1]; + + yield ['2.2E+1', 22]; + } +}