From b971ee044cbd0baadf5833bd8dcda5085109367b Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Tue, 1 Jul 2025 01:58:19 +0300 Subject: [PATCH 01/16] feat: add support for range types --- .../Doctrine/Entity/ContainsRanges.php | 56 +++++++ .../Doctrine/DBAL/Types/BaseRangeType.php | 71 +++++++++ .../Doctrine/DBAL/Types/DateRange.php | 27 ++++ .../InvalidRangeForDatabaseException.php | 32 ++++ .../Doctrine/DBAL/Types/Int4Range.php | 27 ++++ .../Doctrine/DBAL/Types/Int8Range.php | 27 ++++ .../Doctrine/DBAL/Types/NumRange.php | 27 ++++ .../Doctrine/DBAL/Types/TsRange.php | 27 ++++ .../Doctrine/DBAL/Types/TstzRange.php | 27 ++++ .../Types/ValueObject/BaseIntegerRange.php | 93 +++++++++++ .../Types/ValueObject/BaseTimestampRange.php | 120 +++++++++++++++ .../DBAL/Types/ValueObject/DateRange.php | 103 +++++++++++++ .../DBAL/Types/ValueObject/Int4Range.php | 59 +++++++ .../DBAL/Types/ValueObject/Int8Range.php | 33 ++++ .../DBAL/Types/ValueObject/NumericRange.php | 85 ++++++++++ .../Doctrine/DBAL/Types/ValueObject/Range.php | 133 ++++++++++++++++ .../DBAL/Types/ValueObject/TsRange.php | 37 +++++ .../DBAL/Types/ValueObject/TstzRange.php | 37 +++++ .../DBAL/Types/ContainsRangesEntityTest.php | 98 ++++++++++++ .../Types/PostgreSQLEmptyRangeOutputTest.php | 134 ++++++++++++++++ .../DBAL/Types/RangeTypesIntegrationTest.php | 124 +++++++++++++++ .../Doctrine/DBAL/Types/DateRangeTest.php | 109 +++++++++++++ .../Doctrine/DBAL/Types/Int4RangeTest.php | 145 ++++++++++++++++++ .../Doctrine/DBAL/Types/Int8RangeTest.php | 105 +++++++++++++ .../Doctrine/DBAL/Types/NumRangeTest.php | 140 +++++++++++++++++ .../Doctrine/DBAL/Types/TsRangeTest.php | 111 ++++++++++++++ .../Doctrine/DBAL/Types/TstzRangeTest.php | 107 +++++++++++++ .../DBAL/Types/ValueObject/DateRangeTest.php | 144 +++++++++++++++++ .../DBAL/Types/ValueObject/Int4RangeTest.php | 117 ++++++++++++++ .../DBAL/Types/ValueObject/Int8RangeTest.php | 101 ++++++++++++ .../Types/ValueObject/NumericRangeTest.php | 100 ++++++++++++ .../DBAL/Types/ValueObject/TsRangeTest.php | 129 ++++++++++++++++ .../DBAL/Types/ValueObject/TstzRangeTest.php | 139 +++++++++++++++++ 33 files changed, 2824 insertions(+) create mode 100644 fixtures/MartinGeorgiev/Doctrine/Entity/ContainsRanges.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/DateRange.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidRangeForDatabaseException.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/Int4Range.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/Int8Range.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/NumRange.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/TsRange.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/TstzRange.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRange.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4Range.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8Range.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRange.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRange.php create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRange.php create mode 100644 tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/ContainsRangesEntityTest.php create mode 100644 tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/PostgreSQLEmptyRangeOutputTest.php create mode 100644 tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/RangeTypesIntegrationTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php diff --git a/fixtures/MartinGeorgiev/Doctrine/Entity/ContainsRanges.php b/fixtures/MartinGeorgiev/Doctrine/Entity/ContainsRanges.php new file mode 100644 index 00000000..ccc891a1 --- /dev/null +++ b/fixtures/MartinGeorgiev/Doctrine/Entity/ContainsRanges.php @@ -0,0 +1,56 @@ +int4Range1 = new Int4Range(1, 1000); + $this->int4Range2 = new Int4Range(0, 2147483647); + + $this->int8Range1 = new Int8Range(1, PHP_INT_MAX); + $this->int8Range2 = new Int8Range(PHP_INT_MIN, 0); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php new file mode 100644 index 00000000..76474c73 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php @@ -0,0 +1,71 @@ + + */ +abstract class BaseRangeType extends Type +{ + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + { + return static::TYPE_NAME; + } + + public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string + { + if ($value === null) { + return null; + } + + if (!$value instanceof Range) { + throw InvalidRangeForDatabaseException::forInvalidType($value); + } + + return (string) $value; + } + + /** + * @param mixed $value + * + * @return T|null + */ + public function convertToPHPValue($value, AbstractPlatform $platform): ?Range + { + if ($value === null) { + return null; + } + + if (!\is_string($value)) { + throw InvalidRangeForDatabaseException::forInvalidType($value); + } + + if ($value === '') { + return null; + } + + try { + return $this->createFromString($value); + } catch (\InvalidArgumentException) { + throw InvalidRangeForDatabaseException::forInvalidFormat($value); + } + } + + /** + * @return T + */ + abstract protected function createFromString(string $value): Range; +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/DateRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/DateRange.php new file mode 100644 index 00000000..dca9ea2a --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/DateRange.php @@ -0,0 +1,27 @@ + + * + * @since 3.3 + * + * @author Martin Georgiev + */ +class DateRange extends BaseRangeType +{ + protected const TYPE_NAME = 'daterange'; + + protected function createFromString(string $value): Range + { + return DateRangeValueObject::fromString($value); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidRangeForDatabaseException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidRangeForDatabaseException.php new file mode 100644 index 00000000..daeb9868 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidRangeForDatabaseException.php @@ -0,0 +1,32 @@ + + */ +final class InvalidRangeForDatabaseException extends \InvalidArgumentException +{ + public static function forInvalidType(mixed $value): self + { + return new self( + \sprintf( + 'Invalid type for range. Expected Range object or string, got %s', + \get_debug_type($value) + ) + ); + } + + public static function forInvalidFormat(string $value): self + { + return new self( + \sprintf('Invalid range format: %s', $value) + ); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Int4Range.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Int4Range.php new file mode 100644 index 00000000..0f84da25 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Int4Range.php @@ -0,0 +1,27 @@ + + * + * @since 3.3 + * + * @author Martin Georgiev + */ +class Int4Range extends BaseRangeType +{ + protected const TYPE_NAME = 'int4range'; + + protected function createFromString(string $value): Range + { + return Int4RangeValueObject::fromString($value); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Int8Range.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Int8Range.php new file mode 100644 index 00000000..2ca01b0e --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Int8Range.php @@ -0,0 +1,27 @@ + + * + * @since 3.3 + * + * @author Martin Georgiev + */ +class Int8Range extends BaseRangeType +{ + protected const TYPE_NAME = 'int8range'; + + protected function createFromString(string $value): Range + { + return Int8RangeValueObject::fromString($value); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/NumRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/NumRange.php new file mode 100644 index 00000000..ae2cedeb --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/NumRange.php @@ -0,0 +1,27 @@ + + * + * @since 3.3 + * + * @author Martin Georgiev + */ +class NumRange extends BaseRangeType +{ + protected const TYPE_NAME = 'numrange'; + + protected function createFromString(string $value): Range + { + return NumericRange::fromString($value); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/TsRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/TsRange.php new file mode 100644 index 00000000..78b0dddc --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/TsRange.php @@ -0,0 +1,27 @@ + + * + * @since 3.3 + * + * @author Martin Georgiev + */ +class TsRange extends BaseRangeType +{ + protected const TYPE_NAME = 'tsrange'; + + protected function createFromString(string $value): Range + { + return TsRangeValueObject::fromString($value); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/TstzRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/TstzRange.php new file mode 100644 index 00000000..886e7022 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/TstzRange.php @@ -0,0 +1,27 @@ + + * + * @since 3.3 + * + * @author Martin Georgiev + */ +class TstzRange extends BaseRangeType +{ + protected const TYPE_NAME = 'tstzrange'; + + protected function createFromString(string $value): Range + { + return TstzRangeValueObject::fromString($value); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php new file mode 100644 index 00000000..d695c78a --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php @@ -0,0 +1,93 @@ + + */ +abstract class BaseIntegerRange extends Range +{ + public function __construct( + ?int $lower, + ?int $upper, + bool $isLowerBracketInclusive = true, + bool $isUpperBracketInclusive = false, + bool $isExplicitlyEmpty = false, + ) { + parent::__construct($lower, $upper, $isLowerBracketInclusive, $isUpperBracketInclusive, $isExplicitlyEmpty); + } + + /** + * Uses PostgreSQL's explicit empty state rather than mathematical tricks. + */ + public static function empty(): static + { + return new static(null, null, true, false, true); + } + + public static function infinite(): static + { + return new static(null, null, false, false); + } + + public static function inclusive(?int $lower, ?int $upper): static + { + return new static($lower, $upper, true, true); + } + + public static function fromString(string $rangeString): static + { + $rangeString = \trim($rangeString); + + if ($rangeString === parent::EMPTY_RANGE_STRING) { + return static::empty(); + } + + if (!\preg_match('/^(\[|\()("?[^",]*"?),("?[^",]*"?)(\]|\))$/', $rangeString, $matches)) { + throw new \InvalidArgumentException( + \sprintf('Invalid range format: %s', $rangeString) + ); + } + + $isLowerBracketInclusive = $matches[1] === parent::BRACKET_LOWER_INCLUSIVE; + $isUpperBracketInclusive = $matches[4] === parent::BRACKET_UPPER_INCLUSIVE; + $lowerBoundValue = $matches[2] === '' ? null : static::parseValue(\trim($matches[2], '"')); + $upperBoundValue = $matches[3] === '' ? null : static::parseValue(\trim($matches[3], '"')); + + return new static($lowerBoundValue, $upperBoundValue, $isLowerBracketInclusive, $isUpperBracketInclusive); + } + + protected function compareBounds(mixed $a, mixed $b): int + { + return $a <=> $b; + } + + protected function formatValue(mixed $value): string + { + return (string) $value; + } + + protected static function parseValue(string $value): int + { + if (!\is_numeric($value)) { + throw new \InvalidArgumentException( + \sprintf('Invalid integer value: %s', $value) + ); + } + + $intValue = (int) $value; + if ((string) $intValue !== $value) { + throw new \InvalidArgumentException( + \sprintf('Value %s is not a valid integer', $value) + ); + } + + return $intValue; + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php new file mode 100644 index 00000000..e75498c2 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php @@ -0,0 +1,120 @@ + + */ +abstract class BaseTimestampRange extends Range +{ + public function __construct( + ?\DateTimeInterface $lower, + ?\DateTimeInterface $upper, + bool $isLowerBracketInclusive = true, + bool $isUpperBracketInclusive = false, + bool $isExplicitlyEmpty = false, + ) { + parent::__construct($lower, $upper, $isLowerBracketInclusive, $isUpperBracketInclusive, $isExplicitlyEmpty); + } + + /** + * Uses PostgreSQL's explicit empty state rather than mathematical tricks. + */ + public static function empty(): static + { + return new static(null, null, true, false, true); + } + + public static function infinite(): static + { + return new static(null, null, false, false); + } + + public static function inclusive(?\DateTimeInterface $lower, ?\DateTimeInterface $upper): static + { + return new static($lower, $upper, true, true); + } + + public static function hour(\DateTimeInterface $dateTime): static + { + $start = \DateTimeImmutable::createFromInterface($dateTime)->setTime( + (int) $dateTime->format('H'), + 0, + 0, + 0 + ); + $end = $start->modify('+1 hour'); + + return new static($start, $end, true, false); + } + + public static function fromString(string $rangeString): static + { + $rangeString = \trim($rangeString); + + if ($rangeString === parent::EMPTY_RANGE_STRING) { + return static::empty(); + } + + if (!\preg_match('/^(\[|\()("?[^",]*"?),("?[^",]*"?)(\]|\))$/', $rangeString, $matches)) { + throw new \InvalidArgumentException( + \sprintf('Invalid range format: %s', $rangeString) + ); + } + + $isLowerBracketInclusive = $matches[1] === parent::BRACKET_LOWER_INCLUSIVE; + $isUpperBracketInclusive = $matches[4] === parent::BRACKET_UPPER_INCLUSIVE; + $lowerBoundValue = $matches[2] === '' ? null : static::parseTimestampValue(\trim($matches[2], '"')); + $upperBoundValue = $matches[3] === '' ? null : static::parseTimestampValue(\trim($matches[3], '"')); + + return new static($lowerBoundValue, $upperBoundValue, $isLowerBracketInclusive, $isUpperBracketInclusive); + } + + /** + * Compare timestamps with microsecond precision. + * PHP's getTimestamp() only returns seconds, so we need separate microsecond comparison. + */ + protected function compareBounds(mixed $a, mixed $b): int + { + $timestampComparison = $a->getTimestamp() <=> $b->getTimestamp(); + + if ($timestampComparison !== 0) { + return $timestampComparison; + } + + return (int) $a->format('u') <=> (int) $b->format('u'); + } + + protected function formatValue(mixed $value): string + { + if (!$value instanceof \DateTimeInterface) { + throw new \InvalidArgumentException('Value must be a DateTimeInterface'); + } + + return $value->format('Y-m-d H:i:s.u'); + } + + protected static function parseTimestampValue(string $value): \DateTimeImmutable + { + try { + return new \DateTimeImmutable($value); + } catch (\Exception $exception) { + throw new \InvalidArgumentException( + \sprintf('Invalid timestamp value: %s. Error: %s', $value, $exception->getMessage()), + 0, + $exception + ); + } + } + + protected static function parseValue(string $value): \DateTimeImmutable + { + return static::parseTimestampValue($value); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRange.php new file mode 100644 index 00000000..8018a54f --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRange.php @@ -0,0 +1,103 @@ + + * + * @since 3.3 + * + * @author Martin Georgiev + */ +final class DateRange extends Range +{ + public function __construct( + mixed $lower, + mixed $upper, + bool $isLowerBracketInclusive = true, + bool $isUpperBracketInclusive = false, + bool $isExplicitlyEmpty = false, + ) { + if ($lower !== null && !$lower instanceof \DateTimeInterface) { + throw new \InvalidArgumentException( + \sprintf('Lower bound must be DateTimeInterface, %s given', \gettype($lower)) + ); + } + + if ($upper !== null && !$upper instanceof \DateTimeInterface) { + throw new \InvalidArgumentException( + \sprintf('Upper bound must be DateTimeInterface, %s given', \gettype($upper)) + ); + } + + parent::__construct($lower, $upper, $isLowerBracketInclusive, $isUpperBracketInclusive, $isExplicitlyEmpty); + } + + protected function compareBounds(mixed $a, mixed $b): int + { + return $a->getTimestamp() <=> $b->getTimestamp(); + } + + protected function formatValue(mixed $value): string + { + if (!$value instanceof \DateTimeInterface) { + throw new \InvalidArgumentException('Value must be a DateTimeInterface'); + } + + return $value->format('Y-m-d'); + } + + protected static function parseValue(string $value): \DateTimeImmutable + { + try { + return new \DateTimeImmutable($value); + } catch (\Exception $exception) { + throw new \InvalidArgumentException( + \sprintf('Invalid date value: %s. Error: %s', $value, $exception->getMessage()), + 0, + $exception + ); + } + } + + /** + * Uses PostgreSQL's explicit empty state rather than mathematical tricks. + */ + public static function empty(): self + { + return new self(null, null, true, false, true); + } + + public static function infinite(): self + { + return new self(null, null, true, true); + } + + public static function singleDay(\DateTimeInterface $date): self + { + $startOfDay = \DateTimeImmutable::createFromInterface($date)->setTime(0, 0, 0); + $endOfDay = $startOfDay->modify('+1 day'); + + return new self($startOfDay, $endOfDay, true, false); + } + + public static function year(int $year): self + { + $startOfYear = new \DateTimeImmutable(\sprintf('%d-01-01', $year)); + $endOfYear = $startOfYear->modify('+1 year'); + + return new self($startOfYear, $endOfYear, true, false); + } + + public static function month(int $year, int $month): self + { + $startOfMonth = new \DateTimeImmutable(\sprintf('%d-%02d-01', $year, $month)); + $endOfMonth = $startOfMonth->modify('+1 month'); + + return new self($startOfMonth, $endOfMonth, true, false); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4Range.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4Range.php new file mode 100644 index 00000000..9aa07183 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4Range.php @@ -0,0 +1,59 @@ + + */ +final class Int4Range extends BaseIntegerRange +{ + private const MIN_INT4_VALUE = -2147483648; + + private const MAX_INT4_VALUE = 2147483647; + + public function __construct( + ?int $lower, + ?int $upper, + bool $isLowerBracketInclusive = true, + bool $isUpperBracketInclusive = false, + bool $isExplicitlyEmpty = false, + ) { + if ($lower !== null && ($lower < self::MIN_INT4_VALUE || $lower > self::MAX_INT4_VALUE)) { + throw new \InvalidArgumentException( + \sprintf('Lower bound %d is outside INT4 range [%d, %d]', $lower, self::MIN_INT4_VALUE, self::MAX_INT4_VALUE) + ); + } + + if ($upper !== null && ($upper < self::MIN_INT4_VALUE || $upper > self::MAX_INT4_VALUE)) { + throw new \InvalidArgumentException( + \sprintf('Upper bound %d is outside INT4 range [%d, %d]', $upper, self::MIN_INT4_VALUE, self::MAX_INT4_VALUE) + ); + } + + parent::__construct($lower, $upper, $isLowerBracketInclusive, $isUpperBracketInclusive, $isExplicitlyEmpty); + } + + protected static function parseValue(string $value): int + { + if (!\is_numeric($value)) { + throw new \InvalidArgumentException( + \sprintf('Invalid integer value: %s', $value) + ); + } + + $intValue = (int) $value; + if ((string) $intValue !== $value) { + throw new \InvalidArgumentException( + \sprintf('Value %s is not a valid integer', $value) + ); + } + + return $intValue; + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8Range.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8Range.php new file mode 100644 index 00000000..24917fef --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8Range.php @@ -0,0 +1,33 @@ + + */ +final class Int8Range extends BaseIntegerRange +{ + protected static function parseValue(string $value): int + { + if (!\is_numeric($value)) { + throw new \InvalidArgumentException( + \sprintf('Invalid integer value: %s', $value) + ); + } + + $intValue = (int) $value; + if ((string) $intValue !== $value) { + throw new \InvalidArgumentException( + \sprintf('Value %s is not a valid integer', $value) + ); + } + + return $intValue; + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRange.php new file mode 100644 index 00000000..1b96447b --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRange.php @@ -0,0 +1,85 @@ + + * + * @since 3.3 + * + * @author Martin Georgiev + */ +final class NumericRange extends Range +{ + public function __construct( + mixed $lower, + mixed $upper, + bool $isLowerBracketInclusive = true, + bool $isUpperBracketInclusive = false, + bool $isExplicitlyEmpty = false, + ) { + if ($lower !== null && !\is_numeric($lower)) { + throw new \InvalidArgumentException( + \sprintf('Lower bound must be numeric, %s given', \gettype($lower)) + ); + } + + if ($upper !== null && !\is_numeric($upper)) { + throw new \InvalidArgumentException( + \sprintf('Upper bound must be numeric, %s given', \gettype($upper)) + ); + } + + parent::__construct($lower, $upper, $isLowerBracketInclusive, $isUpperBracketInclusive, $isExplicitlyEmpty); + } + + protected function compareBounds(mixed $a, mixed $b): int + { + return (float) $a <=> (float) $b; + } + + protected function formatValue(mixed $value): string + { + if (!\is_numeric($value)) { + throw new \InvalidArgumentException('Value must be numeric'); + } + + return (string) $value; + } + + protected static function parseValue(string $value): float|int + { + if (!\is_numeric($value)) { + throw new \InvalidArgumentException( + \sprintf('Invalid numeric value: %s', $value) + ); + } + + $floatValue = (float) $value; + $intValue = (int) $floatValue; + + return $floatValue === (float) $intValue ? $intValue : $floatValue; + } + + public static function withLowerBoundInclusive(mixed $lower, mixed $upper): self + { + return new self($lower, $upper, true, false); + } + + /** + * Uses PostgreSQL's explicit empty state rather than mathematical tricks. + */ + public static function empty(): self + { + return new self(null, null, true, false, true); + } + + public static function infinite(): self + { + return new self(null, null, false, false); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php new file mode 100644 index 00000000..f9bd9ded --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php @@ -0,0 +1,133 @@ + + */ +abstract class Range implements \Stringable +{ + protected const BRACKET_LOWER_INCLUSIVE = '['; + + protected const BRACKET_LOWER_EXCLUSIVE = '('; + + protected const BRACKET_UPPER_INCLUSIVE = ']'; + + protected const BRACKET_UPPER_EXCLUSIVE = ')'; + + protected const EMPTY_RANGE_STRING = 'empty'; + + public function __construct( + protected readonly mixed $lower, + protected readonly mixed $upper, + protected readonly bool $isLowerBracketInclusive = true, + protected readonly bool $isUpperBracketInclusive = false, + protected readonly bool $isExplicitlyEmpty = false, + ) {} + + public function __toString(): string + { + if ($this->isEmpty()) { + return self::EMPTY_RANGE_STRING; + } + + $lowerBracketChar = $this->isLowerBracketInclusive ? self::BRACKET_LOWER_INCLUSIVE : self::BRACKET_LOWER_EXCLUSIVE; + $upperBracketChar = $this->isUpperBracketInclusive ? self::BRACKET_UPPER_INCLUSIVE : self::BRACKET_UPPER_EXCLUSIVE; + + $formattedLowerBound = $this->lower === null ? '' : $this->formatValue($this->lower); + $formattedUpperBound = $this->upper === null ? '' : $this->formatValue($this->upper); + + return $lowerBracketChar.$formattedLowerBound.','.$formattedUpperBound.$upperBracketChar; + } + + public function contains(mixed $target): bool + { + if ($target === null) { + return false; + } + + if ($this->isEmpty()) { + return false; + } + + // Check lower bound + if ($this->lower !== null) { + $comparison = $this->compareBounds($target, $this->lower); + if ($comparison < 0 || ($comparison === 0 && !$this->isLowerBracketInclusive)) { + return false; + } + } + + // Check upper bound + if ($this->upper !== null) { + $comparison = $this->compareBounds($target, $this->upper); + if ($comparison > 0 || ($comparison === 0 && !$this->isUpperBracketInclusive)) { + return false; + } + } + + return true; + } + + public static function fromString(string $rangeString): static + { + $rangeString = \trim($rangeString); + + if ($rangeString === self::EMPTY_RANGE_STRING) { + // PostgreSQL's explicit empty state rather than mathematical tricks + return new static(null, null, true, false, true); + } + + $pattern = '/^('.\preg_quote(self::BRACKET_LOWER_INCLUSIVE, '/').'|'.\preg_quote(self::BRACKET_LOWER_EXCLUSIVE, '/').')("?[^",]*"?),("?[^",]*"?)('.\preg_quote(self::BRACKET_UPPER_INCLUSIVE, '/').'|'.\preg_quote(self::BRACKET_UPPER_EXCLUSIVE, '/').')$/'; + if (!\preg_match($pattern, $rangeString, $matches)) { + throw new \InvalidArgumentException( + \sprintf('Invalid range format: %s', $rangeString) + ); + } + + $isLowerBracketInclusive = $matches[1] === self::BRACKET_LOWER_INCLUSIVE; + $isUpperBracketInclusive = $matches[4] === self::BRACKET_UPPER_INCLUSIVE; + $lowerBoundValue = $matches[2] === '' ? null : static::parseValue(\trim($matches[2], '"')); + $upperBoundValue = $matches[3] === '' ? null : static::parseValue(\trim($matches[3], '"')); + + return new static($lowerBoundValue, $upperBoundValue, $isLowerBracketInclusive, $isUpperBracketInclusive); + } + + abstract protected function compareBounds(mixed $a, mixed $b): int; + + abstract protected function formatValue(mixed $value): string; + + abstract protected static function parseValue(string $value): mixed; + + /** + * Following PostgreSQL's design philosophy, a range can be empty in two ways: + * 1. Explicitly marked as empty (isExplicitlyEmpty flag = true) + * 2. Mathematically empty due to bounds (lower > upper, or equal bounds with exclusive brackets) + */ + public function isEmpty(): bool + { + if ($this->isExplicitlyEmpty) { + return true; + } + + if ($this->lower === null || $this->upper === null) { + return false; + } + + $comparison = $this->compareBounds($this->lower, $this->upper); + + if ($comparison > 0) { + return true; + } + + return $comparison === 0 && (!$this->isLowerBracketInclusive || !$this->isUpperBracketInclusive); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRange.php new file mode 100644 index 00000000..54b67c6e --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRange.php @@ -0,0 +1,37 @@ + + */ +final class TsRange extends BaseTimestampRange +{ + protected function formatValue(mixed $value): string + { + if (!$value instanceof \DateTimeInterface) { + throw new \InvalidArgumentException('Value must be a DateTimeInterface'); + } + + return $value->format('Y-m-d H:i:s.u'); + } + + protected static function parseValue(string $value): \DateTimeImmutable + { + try { + return new \DateTimeImmutable($value); + } catch (\Exception $exception) { + throw new \InvalidArgumentException( + \sprintf('Invalid timestamp value: %s. Error: %s', $value, $exception->getMessage()), + 0, + $exception + ); + } + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRange.php new file mode 100644 index 00000000..ee37093f --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRange.php @@ -0,0 +1,37 @@ + + */ +final class TstzRange extends BaseTimestampRange +{ + protected function formatValue(mixed $value): string + { + if (!$value instanceof \DateTimeInterface) { + throw new \InvalidArgumentException('Value must be a DateTimeInterface'); + } + + return $value->format('Y-m-d H:i:s.uP'); + } + + protected static function parseValue(string $value): \DateTimeImmutable + { + try { + return new \DateTimeImmutable($value); + } catch (\Exception $exception) { + throw new \InvalidArgumentException( + \sprintf('Invalid timestamp value: %s. Error: %s', $value, $exception->getMessage()), + 0, + $exception + ); + } + } +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/ContainsRangesEntityTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/ContainsRangesEntityTest.php new file mode 100644 index 00000000..2dadfe0a --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/ContainsRangesEntityTest.php @@ -0,0 +1,98 @@ +entityManager = $this->createEntityManager(); + + $schemaTool = new SchemaTool($this->entityManager); + $schemaTool->createSchema([$this->entityManager->getClassMetadata(ContainsRanges::class)]); + } + + protected function tearDown(): void + { + $schemaTool = new SchemaTool($this->entityManager); + $schemaTool->dropSchema([$this->entityManager->getClassMetadata(ContainsRanges::class)]); + + parent::tearDown(); + } + + #[Test] + public function can_persist_and_retrieve_entity_with_ranges(): void + { + $containsRanges = new ContainsRanges(); + + $this->entityManager->persist($containsRanges); + $this->entityManager->flush(); + $this->entityManager->clear(); + + $retrievedEntity = $this->entityManager->find(ContainsRanges::class, $containsRanges->id); + + self::assertInstanceOf(ContainsRanges::class, $retrievedEntity); + self::assertEquals('[1,1000)', (string) $retrievedEntity->int4Range1); + self::assertEquals('[0,2147483647)', (string) $retrievedEntity->int4Range2); + self::assertEquals('[1,'.PHP_INT_MAX.')', (string) $retrievedEntity->int8Range1); + self::assertEquals('['.PHP_INT_MIN.',0)', (string) $retrievedEntity->int8Range2); + } + + #[Test] + public function can_update_range_values(): void + { + $containsRanges = new ContainsRanges(); + + $this->entityManager->persist($containsRanges); + $this->entityManager->flush(); + + // Update the range + $containsRanges->int4Range1 = new Int4RangeValueObject(100, 200); + + $this->entityManager->flush(); + $this->entityManager->clear(); + + $retrievedEntity = $this->entityManager->find(ContainsRanges::class, $containsRanges->id); + + self::assertEquals('[100,200)', (string) $retrievedEntity->int4Range1); + } + + #[Test] + public function can_handle_null_ranges(): void + { + $containsRanges = new ContainsRanges(); + $containsRanges->int4Range1 = null; + + $this->entityManager->persist($containsRanges); + $this->entityManager->flush(); + $this->entityManager->clear(); + + $retrievedEntity = $this->entityManager->find(ContainsRanges::class, $containsRanges->id); + + self::assertNull($retrievedEntity->int4Range1); + } +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/PostgreSQLEmptyRangeOutputTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/PostgreSQLEmptyRangeOutputTest.php new file mode 100644 index 00000000..0f9b8166 --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/PostgreSQLEmptyRangeOutputTest.php @@ -0,0 +1,134 @@ +connection->fetchAssociative($sql); + $actualOutput = $result['range_output']; + + self::assertSame( + 'empty', + $actualOutput, + \sprintf("PostgreSQL outputs '%s' for empty ranges, but our constant expects 'empty'", $actualOutput) + ); + } + + #[Test] + public function postgres_outputs_empty_for_int4range_with_same_bounds_exclusive_upper(): void + { + $sql = "SELECT '[5,5)'::int4range AS range_output"; + $result = $this->connection->fetchAssociative($sql); + $actualOutput = $result['range_output']; + + self::assertSame( + 'empty', + $actualOutput, + \sprintf("PostgreSQL outputs '%s' for empty INT4RANGE, but our constant expects 'empty'", $actualOutput) + ); + } + + #[Test] + public function postgres_outputs_empty_for_daterange_with_same_date_exclusive_upper(): void + { + $sql = "SELECT '[2023-01-01,2023-01-01)'::daterange AS range_output"; + $result = $this->connection->fetchAssociative($sql); + $actualOutput = $result['range_output']; + + self::assertSame( + 'empty', + $actualOutput, + \sprintf("PostgreSQL outputs '%s' for empty DATERANGE, but our constant expects 'empty'", $actualOutput) + ); + } + + #[Test] + public function postgres_outputs_empty_for_explicit_empty_input(): void + { + $sql = "SELECT 'empty'::numrange AS range_output"; + $result = $this->connection->fetchAssociative($sql); + $actualOutput = $result['range_output']; + + self::assertSame( + 'empty', + $actualOutput, + \sprintf("PostgreSQL outputs '%s' for explicit 'empty' input, but our constant expects 'empty'", $actualOutput) + ); + } + + #[Test] + public function postgres_isempty_function_correctly_identifies_empty_ranges(): void + { + $sql = " + SELECT + '[4,4)'::numrange AS range1_text, + isempty('[4,4)'::numrange) AS range1_is_empty, + '[5,5)'::int4range AS range2_text, + isempty('[5,5)'::int4range) AS range2_is_empty, + 'empty'::numrange AS range3_text, + isempty('empty'::numrange) AS range3_is_empty + "; + $row = $this->connection->fetchAssociative($sql); + + self::assertSame('empty', $row['range1_text']); + self::assertTrue($row['range1_is_empty']); + + self::assertSame('empty', $row['range2_text']); + self::assertTrue($row['range2_is_empty']); + + self::assertSame('empty', $row['range3_text']); + self::assertTrue($row['range3_is_empty']); + } + + #[Test] + public function postgres_handles_lower_greater_than_upper_as_empty(): void + { + $sql = "SELECT '[10,5)'::numrange AS range_output, isempty('[10,5)'::numrange) AS is_empty"; + $result = $this->connection->fetchAssociative($sql); + + self::assertSame('empty', $result['range_output']); + self::assertTrue($result['is_empty']); + } + + #[Test] + public function postgres_handles_equal_bounds_with_exclusive_brackets_as_empty(): void + { + $sql = "SELECT '(5,5)'::numrange AS range_output, isempty('(5,5)'::numrange) AS is_empty"; + $result = $this->connection->fetchAssociative($sql); + + self::assertSame('empty', $result['range_output']); + self::assertTrue($result['is_empty']); + } + + #[Test] + public function postgres_handles_equal_bounds_with_mixed_brackets_as_empty(): void + { + $sql = "SELECT '(5,5]'::numrange AS range_output, isempty('(5,5]'::numrange) AS is_empty"; + $result = $this->connection->fetchAssociative($sql); + + self::assertSame('empty', $result['range_output']); + self::assertTrue($result['is_empty']); + } + + #[Test] + public function postgres_does_not_treat_equal_bounds_with_inclusive_brackets_as_empty(): void + { + $sql = "SELECT '[5,5]'::numrange AS range_output, isempty('[5,5]'::numrange) AS is_empty"; + $result = $this->connection->fetchAssociative($sql); + + self::assertSame('[5,5]', $result['range_output']); + self::assertFalse($result['is_empty']); + } +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/RangeTypesIntegrationTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/RangeTypesIntegrationTest.php new file mode 100644 index 00000000..9b397e92 --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/RangeTypesIntegrationTest.php @@ -0,0 +1,124 @@ +createTestTableForDataType($tableName, $columnName, $columnType); + + // Insert the value + $sql = \sprintf( + 'INSERT INTO %s.%s ("%s") VALUES (?)', + self::DATABASE_SCHEMA, + $tableName, + $columnName + ); + $this->connection->executeStatement($sql, [$value], [$typeName]); + + // Retrieve the value + $sql = \sprintf( + 'SELECT "%s" FROM %s.%s WHERE id = 1', + $columnName, + self::DATABASE_SCHEMA, + $tableName + ); + $result = $this->connection->fetchOne($sql); + + $type = Type::getType($typeName); + $convertedResult = $type->convertToPHPValue($result, $this->connection->getDatabasePlatform()); + + self::assertEquals($value, $convertedResult); + } + + public static function providesRangeTypesAndValues(): \Generator + { + yield 'numrange simple' => ['numrange', 'NUMRANGE', new NumRangeValueObject(1.5, 10.7)]; + yield 'numrange infinite' => ['numrange', 'NUMRANGE', new NumRangeValueObject(null, 1000, false, false)]; + yield 'numrange empty' => ['numrange', 'NUMRANGE', NumRangeValueObject::empty()]; + + yield 'int4range simple' => ['int4range', 'INT4RANGE', new Int4RangeValueObject(1, 1000)]; + yield 'int4range infinite' => ['int4range', 'INT4RANGE', new Int4RangeValueObject(null, 1000, false, false)]; + yield 'int8range simple' => ['int8range', 'INT8RANGE', new Int8RangeValueObject(PHP_INT_MIN, PHP_INT_MAX)]; + + yield 'daterange simple' => ['daterange', 'DATERANGE', new DateRangeValueObject(new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-12-31'))]; + yield 'tsrange simple' => ['tsrange', 'TSRANGE', new TsRangeValueObject(new \DateTimeImmutable('2023-01-01 10:00:00'), new \DateTimeImmutable('2023-01-01 18:00:00'))]; + yield 'tstzrange simple' => ['tstzrange', 'TSTZRANGE', new TstzRangeValueObject(new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), new \DateTimeImmutable('2023-01-01 18:00:00+00:00'))]; + } + + #[Test] + public function can_handle_null_values(): void + { + $this->createTestTableForDataType('test_null_range', 'range_value', 'NUMRANGE'); + + $sql = \sprintf( + 'INSERT INTO %s.test_null_range (range_value) VALUES (?)', + self::DATABASE_SCHEMA + ); + $this->connection->executeStatement($sql, [null], ['numrange']); + + $sql = \sprintf( + 'SELECT range_value FROM %s.test_null_range WHERE id = 1', + self::DATABASE_SCHEMA + ); + $result = $this->connection->fetchOne($sql); + + $type = Type::getType('numrange'); + $convertedResult = $type->convertToPHPValue($result, $this->connection->getDatabasePlatform()); + + self::assertNull($convertedResult); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php new file mode 100644 index 00000000..13fe6205 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php @@ -0,0 +1,109 @@ +fixture = new DateRange(); + $this->platform = new PostgreSQLPlatform(); + } + + #[Test] + public function can_get_sql_declaration(): void + { + $result = $this->fixture->getSQLDeclaration([], $this->platform); + + self::assertEquals('daterange', $result); + } + + #[Test] + #[DataProvider('providesValidPHPValues')] + public function can_transform_from_php_value(DateRangeValueObject $dateRangeValueObject, string $expectedSqlValue): void + { + $result = $this->fixture->convertToDatabaseValue($dateRangeValueObject, $this->platform); + + self::assertEquals($expectedSqlValue, $result); + } + + #[Test] + public function can_transform_null_from_php_value(): void + { + $result = $this->fixture->convertToDatabaseValue(null, $this->platform); + + self::assertNull($result); + } + + #[Test] + #[DataProvider('providesValidSqlValues')] + public function can_transform_from_sql_value(string $sqlValue, DateRangeValueObject $dateRangeValueObject): void + { + $result = $this->fixture->convertToPHPValue($sqlValue, $this->platform); + + self::assertEquals($dateRangeValueObject, $result); + } + + #[Test] + public function can_transform_null_from_sql_value(): void + { + $result = $this->fixture->convertToPHPValue(null, $this->platform); + + self::assertNull($result); + } + + public static function providesValidPHPValues(): \Generator + { + yield 'simple range' => [ + new DateRangeValueObject(new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-12-31')), + '[2023-01-01,2023-12-31)', + ]; + yield 'year range' => [ + DateRangeValueObject::year(2023), + '[2023-01-01,2024-01-01)', + ]; + yield 'single day' => [ + DateRangeValueObject::singleDay(new \DateTimeImmutable('2023-06-15')), + '[2023-06-15,2023-06-16)', + ]; + yield 'empty range' => [ + DateRangeValueObject::empty(), + 'empty', + ]; + } + + public static function providesValidSqlValues(): \Generator + { + yield 'simple range' => [ + '[2023-01-01,2023-12-31)', + new DateRangeValueObject(new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-12-31')), + ]; + yield 'empty range' => [ + 'empty', + DateRangeValueObject::empty(), + ]; + } + + #[Test] + public function can_handle_postgres_empty_range(): void + { + $result = $this->fixture->convertToPHPValue('empty', $this->platform); + + self::assertInstanceOf(DateRangeValueObject::class, $result); + self::assertEquals('empty', (string) $result); + self::assertTrue($result->isEmpty()); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php new file mode 100644 index 00000000..c96d23d3 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php @@ -0,0 +1,145 @@ +fixture = new Int4Range(); + $this->platform = new PostgreSQLPlatform(); + } + + #[Test] + public function can_get_sql_declaration(): void + { + $result = $this->fixture->getSQLDeclaration([], $this->platform); + + self::assertEquals('int4range', $result); + } + + #[Test] + #[DataProvider('providesValidPHPValues')] + public function can_transform_from_php_value(Int4RangeValueObject $int4RangeValueObject, string $expectedSqlValue): void + { + $result = $this->fixture->convertToDatabaseValue($int4RangeValueObject, $this->platform); + + self::assertEquals($expectedSqlValue, $result); + } + + #[Test] + public function can_transform_null_from_php_value(): void + { + $result = $this->fixture->convertToDatabaseValue(null, $this->platform); + + self::assertNull($result); + } + + #[Test] + #[DataProvider('providesValidSqlValues')] + public function can_transform_from_sql_value(string $sqlValue, Int4RangeValueObject $int4RangeValueObject): void + { + $result = $this->fixture->convertToPHPValue($sqlValue, $this->platform); + + self::assertEquals($int4RangeValueObject, $result); + } + + #[Test] + public function can_transform_null_from_sql_value(): void + { + $result = $this->fixture->convertToPHPValue(null, $this->platform); + + self::assertNull($result); + } + + #[Test] + public function can_transform_empty_string_from_sql_value(): void + { + $result = $this->fixture->convertToPHPValue('', $this->platform); + + self::assertNull($result); + } + + public static function providesValidPHPValues(): \Generator + { + yield 'simple range' => [ + new Int4RangeValueObject(1, 1000), + '[1,1000)', + ]; + yield 'inclusive range' => [ + new Int4RangeValueObject(1, 10, true, true), + '[1,10]', + ]; + yield 'exclusive range' => [ + new Int4RangeValueObject(1, 10, false, false), + '(1,10)', + ]; + yield 'lower infinite' => [ + new Int4RangeValueObject(null, 10), + '[,10)', + ]; + yield 'upper infinite' => [ + new Int4RangeValueObject(1, null), + '[1,)', + ]; + yield 'empty range' => [ + Int4RangeValueObject::empty(), + 'empty', + ]; + yield 'max int4 values' => [ + new Int4RangeValueObject(-2147483648, 2147483647), + '[-2147483648,2147483647)', + ]; + } + + public static function providesValidSqlValues(): \Generator + { + yield 'simple range' => [ + '[1,1000)', + new Int4RangeValueObject(1, 1000), + ]; + yield 'inclusive range' => [ + '[1,10]', + new Int4RangeValueObject(1, 10, true, true), + ]; + yield 'exclusive range' => [ + '(1,10)', + new Int4RangeValueObject(1, 10, false, false), + ]; + yield 'lower infinite' => [ + '[,10)', + new Int4RangeValueObject(null, 10), + ]; + yield 'upper infinite' => [ + '[1,)', + new Int4RangeValueObject(1, null), + ]; + yield 'empty range' => [ + 'empty', + Int4RangeValueObject::empty(), + ]; + } + + #[Test] + public function can_handle_postgres_empty_range(): void + { + $result = $this->fixture->convertToPHPValue('empty', $this->platform); + + self::assertInstanceOf(Int4RangeValueObject::class, $result); + self::assertEquals('empty', (string) $result); + self::assertTrue($result->isEmpty()); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php new file mode 100644 index 00000000..a4b726c4 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php @@ -0,0 +1,105 @@ +fixture = new Int8Range(); + $this->platform = new PostgreSQLPlatform(); + } + + #[Test] + public function can_get_sql_declaration(): void + { + $result = $this->fixture->getSQLDeclaration([], $this->platform); + + self::assertEquals('int8range', $result); + } + + #[Test] + #[DataProvider('providesValidPHPValues')] + public function can_transform_from_php_value(Int8RangeValueObject $int8RangeValueObject, string $expectedSqlValue): void + { + $result = $this->fixture->convertToDatabaseValue($int8RangeValueObject, $this->platform); + + self::assertEquals($expectedSqlValue, $result); + } + + #[Test] + public function can_transform_null_from_php_value(): void + { + $result = $this->fixture->convertToDatabaseValue(null, $this->platform); + + self::assertNull($result); + } + + #[Test] + #[DataProvider('providesValidSqlValues')] + public function can_transform_from_sql_value(string $sqlValue, Int8RangeValueObject $int8RangeValueObject): void + { + $result = $this->fixture->convertToPHPValue($sqlValue, $this->platform); + + self::assertEquals($int8RangeValueObject, $result); + } + + #[Test] + public function can_transform_null_from_sql_value(): void + { + $result = $this->fixture->convertToPHPValue(null, $this->platform); + + self::assertNull($result); + } + + public static function providesValidPHPValues(): \Generator + { + yield 'simple range' => [ + new Int8RangeValueObject(1, 1000), + '[1,1000)', + ]; + yield 'large range' => [ + new Int8RangeValueObject(PHP_INT_MIN, PHP_INT_MAX), + '['.PHP_INT_MIN.','.PHP_INT_MAX.')', + ]; + yield 'empty range' => [ + Int8RangeValueObject::empty(), + 'empty', + ]; + } + + public static function providesValidSqlValues(): \Generator + { + yield 'simple range' => [ + '[1,1000)', + new Int8RangeValueObject(1, 1000), + ]; + yield 'empty range' => [ + 'empty', + Int8RangeValueObject::empty(), + ]; + } + + #[Test] + public function can_handle_postgres_empty_range(): void + { + $result = $this->fixture->convertToPHPValue('empty', $this->platform); + + self::assertInstanceOf(Int8RangeValueObject::class, $result); + self::assertEquals('empty', (string) $result); + self::assertTrue($result->isEmpty()); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php new file mode 100644 index 00000000..921864ad --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php @@ -0,0 +1,140 @@ +fixture = new NumRange(); + $this->platform = new PostgreSQLPlatform(); + } + + #[Test] + public function can_get_sql_declaration(): void + { + $result = $this->fixture->getSQLDeclaration([], $this->platform); + + self::assertEquals('numrange', $result); + } + + #[Test] + #[DataProvider('providesValidPHPValues')] + public function can_transform_from_php_value(NumericRange $numericRange, string $expectedSqlValue): void + { + $result = $this->fixture->convertToDatabaseValue($numericRange, $this->platform); + + self::assertEquals($expectedSqlValue, $result); + } + + #[Test] + public function can_transform_null_from_php_value(): void + { + $result = $this->fixture->convertToDatabaseValue(null, $this->platform); + + self::assertNull($result); + } + + #[Test] + #[DataProvider('providesValidSqlValues')] + public function can_transform_from_sql_value(string $sqlValue, NumericRange $numericRange): void + { + $result = $this->fixture->convertToPHPValue($sqlValue, $this->platform); + + self::assertEquals($numericRange, $result); + } + + #[Test] + public function can_transform_null_from_sql_value(): void + { + $result = $this->fixture->convertToPHPValue(null, $this->platform); + + self::assertNull($result); + } + + #[Test] + public function can_transform_empty_string_from_sql_value(): void + { + $result = $this->fixture->convertToPHPValue('', $this->platform); + + self::assertNull($result); + } + + public static function providesValidPHPValues(): \Generator + { + yield 'simple range' => [ + new NumericRange(1.5, 10.7), + '[1.5,10.7)', + ]; + yield 'inclusive range' => [ + new NumericRange(1, 10, true, true), + '[1,10]', + ]; + yield 'exclusive range' => [ + new NumericRange(1, 10, false, false), + '(1,10)', + ]; + yield 'lower infinite' => [ + new NumericRange(null, 10), + '[,10)', + ]; + yield 'upper infinite' => [ + new NumericRange(1, null), + '[1,)', + ]; + yield 'empty range' => [ + NumericRange::empty(), + 'empty', + ]; + } + + public static function providesValidSqlValues(): \Generator + { + yield 'simple range' => [ + '[1.5,10.7)', + new NumericRange(1.5, 10.7), + ]; + yield 'inclusive range' => [ + '[1,10]', + new NumericRange(1, 10, true, true), + ]; + yield 'exclusive range' => [ + '(1,10)', + new NumericRange(1, 10, false, false), + ]; + yield 'lower infinite' => [ + '[,10)', + new NumericRange(null, 10), + ]; + yield 'upper infinite' => [ + '[1,)', + new NumericRange(1, null), + ]; + yield 'empty range' => [ + 'empty', + NumericRange::empty(), + ]; + } + + #[Test] + public function can_handle_postgres_empty_range(): void + { + $result = $this->fixture->convertToPHPValue('empty', $this->platform); + + self::assertInstanceOf(NumericRange::class, $result); + self::assertEquals('empty', (string) $result); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php new file mode 100644 index 00000000..75d46fe5 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php @@ -0,0 +1,111 @@ +fixture = new TsRange(); + $this->platform = new PostgreSQLPlatform(); + } + + #[Test] + public function can_get_sql_declaration(): void + { + $result = $this->fixture->getSQLDeclaration([], $this->platform); + + self::assertEquals('tsrange', $result); + } + + #[Test] + #[DataProvider('providesValidPHPValues')] + public function can_transform_from_php_value(TsRangeValueObject $tsRangeValueObject, string $expectedSqlValue): void + { + $result = $this->fixture->convertToDatabaseValue($tsRangeValueObject, $this->platform); + + self::assertEquals($expectedSqlValue, $result); + } + + #[Test] + public function can_transform_null_from_php_value(): void + { + $result = $this->fixture->convertToDatabaseValue(null, $this->platform); + + self::assertNull($result); + } + + #[Test] + #[DataProvider('providesValidSqlValues')] + public function can_transform_from_sql_value(string $sqlValue, TsRangeValueObject $tsRangeValueObject): void + { + $result = $this->fixture->convertToPHPValue($sqlValue, $this->platform); + + self::assertEquals($tsRangeValueObject, $result); + } + + #[Test] + public function can_transform_null_from_sql_value(): void + { + $result = $this->fixture->convertToPHPValue(null, $this->platform); + + self::assertNull($result); + } + + public static function providesValidPHPValues(): \Generator + { + yield 'simple range' => [ + new TsRangeValueObject( + new \DateTimeImmutable('2023-01-01 10:00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00') + ), + '[2023-01-01 10:00:00.000000,2023-01-01 18:00:00.000000)', + ]; + yield 'hour range' => [ + TsRangeValueObject::hour(new \DateTimeImmutable('2023-01-01 14:30:00')), + '[2023-01-01 14:00:00.000000,2023-01-01 15:00:00.000000)', + ]; + yield 'empty range' => [ + TsRangeValueObject::empty(), + 'empty', + ]; + } + + public static function providesValidSqlValues(): \Generator + { + yield 'simple range' => [ + '[2023-01-01 10:00:00,2023-01-01 18:00:00)', + new TsRangeValueObject( + new \DateTimeImmutable('2023-01-01 10:00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00') + ), + ]; + yield 'empty range' => [ + 'empty', + TsRangeValueObject::empty(), + ]; + } + + #[Test] + public function can_handle_postgres_empty_range(): void + { + $result = $this->fixture->convertToPHPValue('empty', $this->platform); + + self::assertInstanceOf(TsRangeValueObject::class, $result); + self::assertEquals('empty', (string) $result); + self::assertTrue($result->isEmpty()); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php new file mode 100644 index 00000000..696b6d40 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php @@ -0,0 +1,107 @@ +fixture = new TstzRange(); + $this->platform = new PostgreSQLPlatform(); + } + + #[Test] + public function can_get_sql_declaration(): void + { + $result = $this->fixture->getSQLDeclaration([], $this->platform); + + self::assertEquals('tstzrange', $result); + } + + #[Test] + #[DataProvider('providesValidPHPValues')] + public function can_transform_from_php_value(TstzRangeValueObject $tstzRangeValueObject, string $expectedSqlValue): void + { + $result = $this->fixture->convertToDatabaseValue($tstzRangeValueObject, $this->platform); + + self::assertEquals($expectedSqlValue, $result); + } + + #[Test] + public function can_transform_null_from_php_value(): void + { + $result = $this->fixture->convertToDatabaseValue(null, $this->platform); + + self::assertNull($result); + } + + #[Test] + #[DataProvider('providesValidSqlValues')] + public function can_transform_from_sql_value(string $sqlValue, TstzRangeValueObject $tstzRangeValueObject): void + { + $result = $this->fixture->convertToPHPValue($sqlValue, $this->platform); + + self::assertEquals($tstzRangeValueObject, $result); + } + + #[Test] + public function can_transform_null_from_sql_value(): void + { + $result = $this->fixture->convertToPHPValue(null, $this->platform); + + self::assertNull($result); + } + + public static function providesValidPHPValues(): \Generator + { + yield 'simple range with timezone' => [ + new TstzRangeValueObject( + new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00+00:00') + ), + '[2023-01-01 10:00:00.000000+00:00,2023-01-01 18:00:00.000000+00:00)', + ]; + yield 'empty range' => [ + TstzRangeValueObject::empty(), + 'empty', + ]; + } + + public static function providesValidSqlValues(): \Generator + { + yield 'simple range with timezone' => [ + '[2023-01-01 10:00:00+00:00,2023-01-01 18:00:00+00:00)', + new TstzRangeValueObject( + new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00+00:00') + ), + ]; + yield 'empty range' => [ + 'empty', + TstzRangeValueObject::empty(), + ]; + } + + #[Test] + public function can_handle_postgres_empty_range(): void + { + $result = $this->fixture->convertToPHPValue('empty', $this->platform); + + self::assertInstanceOf(TstzRangeValueObject::class, $result); + self::assertEquals('empty', (string) $result); + self::assertTrue($result->isEmpty()); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php new file mode 100644 index 00000000..7a49b282 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php @@ -0,0 +1,144 @@ +isEmpty()); + } + + #[Test] + public function can_create_empty_range(): void + { + $dateRange = DateRange::empty(); + + self::assertEquals('empty', (string) $dateRange); + self::assertTrue($dateRange->isEmpty()); + } + + #[Test] + public function can_create_infinite_range(): void + { + $dateRange = DateRange::infinite(); + + self::assertEquals('[,]', (string) $dateRange); + self::assertFalse($dateRange->isEmpty()); + } + + #[Test] + public function can_create_single_day_range(): void + { + $date = new \DateTimeImmutable('2023-06-15'); + $dateRange = DateRange::singleDay($date); + + self::assertEquals('[2023-06-15,2023-06-16)', (string) $dateRange); + self::assertFalse($dateRange->isEmpty()); + } + + #[Test] + public function can_create_year_range(): void + { + $dateRange = DateRange::year(2023); + + self::assertEquals('[2023-01-01,2024-01-01)', (string) $dateRange); + self::assertFalse($dateRange->isEmpty()); + } + + #[Test] + public function can_create_month_range(): void + { + $dateRange = DateRange::month(2023, 6); + + self::assertEquals('[2023-06-01,2023-07-01)', (string) $dateRange); + self::assertFalse($dateRange->isEmpty()); + } + + #[Test] + #[DataProvider('providesContainsTestCases')] + public function can_check_contains(DateRange $dateRange, mixed $value, bool $expected): void + { + self::assertEquals($expected, $dateRange->contains($value)); + } + + #[Test] + #[DataProvider('providesFromStringTestCases')] + public function can_parse_from_string(string $input, DateRange $dateRange): void + { + $result = DateRange::fromString($input); + + self::assertEquals($dateRange->__toString(), $result->__toString()); + self::assertEquals($dateRange->isEmpty(), $result->isEmpty()); + } + + #[Test] + public function throws_exception_for_invalid_lower_bound(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Lower bound must be DateTimeInterface'); + + new DateRange('invalid', new \DateTimeImmutable('2023-12-31')); + } + + #[Test] + public function throws_exception_for_invalid_upper_bound(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Upper bound must be DateTimeInterface'); + + new DateRange(new \DateTimeImmutable('2023-01-01'), 'invalid'); + } + + public static function providesContainsTestCases(): \Generator + { + $dateRange = new DateRange( + new \DateTimeImmutable('2023-01-01'), + new \DateTimeImmutable('2023-12-31') + ); + + yield 'contains date in range' => [$dateRange, new \DateTimeImmutable('2023-06-15'), true]; + yield 'contains lower bound (inclusive)' => [$dateRange, new \DateTimeImmutable('2023-01-01'), true]; + yield 'does not contain upper bound (exclusive)' => [$dateRange, new \DateTimeImmutable('2023-12-31'), false]; + yield 'does not contain date before range' => [$dateRange, new \DateTimeImmutable('2022-12-31'), false]; + yield 'does not contain date after range' => [$dateRange, new \DateTimeImmutable('2024-01-01'), false]; + yield 'does not contain null' => [$dateRange, null, false]; + + $emptyRange = DateRange::empty(); + yield 'empty range contains nothing' => [$emptyRange, new \DateTimeImmutable('2023-06-15'), false]; + } + + public static function providesFromStringTestCases(): \Generator + { + yield 'simple range' => [ + '[2023-01-01,2023-12-31)', + new DateRange(new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-12-31')), + ]; + yield 'inclusive range' => [ + '[2023-01-01,2023-12-31]', + new DateRange(new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-12-31'), true, true), + ]; + yield 'infinite lower' => [ + '[,2023-12-31)', + new DateRange(null, new \DateTimeImmutable('2023-12-31')), + ]; + yield 'infinite upper' => [ + '[2023-01-01,)', + new DateRange(new \DateTimeImmutable('2023-01-01'), null), + ]; + yield 'empty range' => ['empty', DateRange::empty()]; + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php new file mode 100644 index 00000000..b6ccdf25 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php @@ -0,0 +1,117 @@ +isEmpty()); + } + + #[Test] + public function can_create_empty_range(): void + { + $int4Range = Int4Range::empty(); + + self::assertEquals('empty', (string) $int4Range); + self::assertTrue($int4Range->isEmpty()); + } + + #[Test] + public function can_create_infinite_range(): void + { + $int4Range = Int4Range::infinite(); + + self::assertEquals('(,)', (string) $int4Range); + self::assertFalse($int4Range->isEmpty()); + } + + #[Test] + public function can_create_inclusive_range(): void + { + $int4Range = Int4Range::inclusive(1, 10); + + self::assertEquals('[1,10]', (string) $int4Range); + self::assertFalse($int4Range->isEmpty()); + } + + #[Test] + #[DataProvider('providesContainsTestCases')] + public function can_check_contains(Int4Range $int4Range, mixed $value, bool $expected): void + { + self::assertEquals($expected, $int4Range->contains($value)); + } + + #[Test] + #[DataProvider('providesFromStringTestCases')] + public function can_parse_from_string(string $input, Int4Range $int4Range): void + { + $result = Int4Range::fromString($input); + + self::assertEquals($int4Range->__toString(), $result->__toString()); + self::assertEquals($int4Range->isEmpty(), $result->isEmpty()); + } + + #[Test] + public function validates_int4_bounds_for_lower(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Lower bound -2147483649 is outside INT4 range'); + + new Int4Range(-2147483649, 100); + } + + #[Test] + public function validates_int4_bounds_for_upper(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Upper bound 2147483648 is outside INT4 range'); + + new Int4Range(100, 2147483648); + } + + #[Test] + public function allows_max_int4_values(): void + { + $int4Range = new Int4Range(-2147483648, 2147483647); + + self::assertEquals('[-2147483648,2147483647)', (string) $int4Range); + } + + public static function providesContainsTestCases(): \Generator + { + $int4Range = new Int4Range(1, 10); + + yield 'contains value in range' => [$int4Range, 5, true]; + yield 'contains lower bound (inclusive)' => [$int4Range, 1, true]; + yield 'does not contain upper bound (exclusive)' => [$int4Range, 10, false]; + yield 'does not contain value below range' => [$int4Range, 0, false]; + yield 'does not contain value above range' => [$int4Range, 11, false]; + yield 'does not contain null' => [$int4Range, null, false]; + + $emptyRange = Int4Range::empty(); + yield 'empty range contains nothing' => [$emptyRange, 5, false]; + } + + public static function providesFromStringTestCases(): \Generator + { + yield 'simple range' => ['[1,1000)', new Int4Range(1, 1000)]; + yield 'inclusive range' => ['[1,10]', new Int4Range(1, 10, true, true)]; + yield 'exclusive range' => ['(1,10)', new Int4Range(1, 10, false, false)]; + yield 'infinite lower' => ['[,10)', new Int4Range(null, 10)]; + yield 'infinite upper' => ['[1,)', new Int4Range(1, null)]; + yield 'empty range' => ['empty', Int4Range::empty()]; + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php new file mode 100644 index 00000000..2c43b122 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php @@ -0,0 +1,101 @@ +isEmpty()); + } + + #[Test] + public function can_create_empty_range(): void + { + $int8Range = Int8Range::empty(); + + self::assertEquals('empty', (string) $int8Range); + self::assertTrue($int8Range->isEmpty()); + } + + #[Test] + public function can_create_infinite_range(): void + { + $int8Range = Int8Range::infinite(); + + self::assertEquals('(,)', (string) $int8Range); + self::assertFalse($int8Range->isEmpty()); + } + + #[Test] + public function can_create_inclusive_range(): void + { + $int8Range = Int8Range::inclusive(1, 10); + + self::assertEquals('[1,10]', (string) $int8Range); + self::assertFalse($int8Range->isEmpty()); + } + + #[Test] + public function can_handle_large_values(): void + { + $int8Range = new Int8Range(PHP_INT_MIN, PHP_INT_MAX); + + self::assertEquals('['.PHP_INT_MIN.','.PHP_INT_MAX.')', (string) $int8Range); + self::assertFalse($int8Range->isEmpty()); + } + + #[Test] + #[DataProvider('providesContainsTestCases')] + public function can_check_contains(Int8Range $int8Range, mixed $value, bool $expected): void + { + self::assertEquals($expected, $int8Range->contains($value)); + } + + #[Test] + #[DataProvider('providesFromStringTestCases')] + public function can_parse_from_string(string $input, Int8Range $int8Range): void + { + $result = Int8Range::fromString($input); + + self::assertEquals($int8Range->__toString(), $result->__toString()); + self::assertEquals($int8Range->isEmpty(), $result->isEmpty()); + } + + public static function providesContainsTestCases(): \Generator + { + $int8Range = new Int8Range(1, 10); + + yield 'contains value in range' => [$int8Range, 5, true]; + yield 'contains lower bound (inclusive)' => [$int8Range, 1, true]; + yield 'does not contain upper bound (exclusive)' => [$int8Range, 10, false]; + yield 'does not contain value below range' => [$int8Range, 0, false]; + yield 'does not contain value above range' => [$int8Range, 11, false]; + yield 'does not contain null' => [$int8Range, null, false]; + + $emptyRange = Int8Range::empty(); + yield 'empty range contains nothing' => [$emptyRange, 5, false]; + } + + public static function providesFromStringTestCases(): \Generator + { + yield 'simple range' => ['[1,1000)', new Int8Range(1, 1000)]; + yield 'inclusive range' => ['[1,10]', new Int8Range(1, 10, true, true)]; + yield 'exclusive range' => ['(1,10)', new Int8Range(1, 10, false, false)]; + yield 'infinite lower' => ['[,10)', new Int8Range(null, 10)]; + yield 'infinite upper' => ['[1,)', new Int8Range(1, null)]; + yield 'empty range' => ['empty', Int8Range::empty()]; + yield 'large values' => ['['.PHP_INT_MIN.','.PHP_INT_MAX.')', new Int8Range(PHP_INT_MIN, PHP_INT_MAX)]; + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php new file mode 100644 index 00000000..2e2fcdb2 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php @@ -0,0 +1,100 @@ +isEmpty()); + } + + #[Test] + public function can_create_empty_range(): void + { + $numericRange = NumericRange::empty(); + + self::assertEquals('empty', (string) $numericRange); + self::assertTrue($numericRange->isEmpty()); + } + + #[Test] + public function can_create_infinite_range(): void + { + $numericRange = NumericRange::infinite(); + + self::assertEquals('(,)', (string) $numericRange); + self::assertFalse($numericRange->isEmpty()); + } + + #[Test] + #[DataProvider('providesContainsTestCases')] + public function can_check_contains(NumericRange $numericRange, mixed $value, bool $expected): void + { + self::assertEquals($expected, $numericRange->contains($value)); + } + + #[Test] + #[DataProvider('providesFromStringTestCases')] + public function can_parse_from_string(string $input, NumericRange $numericRange): void + { + $result = NumericRange::fromString($input); + + self::assertEquals($numericRange->__toString(), $result->__toString()); + self::assertEquals($numericRange->isEmpty(), $result->isEmpty()); + } + + public static function providesContainsTestCases(): \Generator + { + $numericRange = new NumericRange(1, 10); + + yield 'contains value in range' => [$numericRange, 5, true]; + yield 'contains lower bound (inclusive)' => [$numericRange, 1, true]; + yield 'does not contain upper bound (exclusive)' => [$numericRange, 10, false]; + yield 'does not contain value below range' => [$numericRange, 0, false]; + yield 'does not contain value above range' => [$numericRange, 11, false]; + yield 'does not contain null' => [$numericRange, null, false]; + + $emptyRange = NumericRange::empty(); + yield 'empty range contains nothing' => [$emptyRange, 5, false]; + } + + public static function providesFromStringTestCases(): \Generator + { + yield 'simple range' => ['[1.5,10.7)', new NumericRange(1.5, 10.7)]; + yield 'inclusive range' => ['[1,10]', new NumericRange(1, 10, true, true)]; + yield 'exclusive range' => ['(1,10)', new NumericRange(1, 10, false, false)]; + yield 'infinite lower' => ['[,10)', new NumericRange(null, 10)]; + yield 'infinite upper' => ['[1,)', new NumericRange(1, null)]; + yield 'empty range' => ['empty', NumericRange::empty()]; + } + + #[Test] + public function throws_exception_for_invalid_lower_bound(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Lower bound must be numeric'); + + new NumericRange('invalid', 10); + } + + #[Test] + public function throws_exception_for_invalid_upper_bound(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Upper bound must be numeric'); + + new NumericRange(1, 'invalid'); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php new file mode 100644 index 00000000..5973fd03 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php @@ -0,0 +1,129 @@ +isEmpty()); + } + + #[Test] + public function can_create_empty_range(): void + { + $tsRange = TsRange::empty(); + + self::assertEquals('empty', (string) $tsRange); + self::assertTrue($tsRange->isEmpty()); + } + + #[Test] + public function can_create_infinite_range(): void + { + $tsRange = TsRange::infinite(); + + self::assertEquals('(,)', (string) $tsRange); + self::assertFalse($tsRange->isEmpty()); + } + + #[Test] + public function can_create_inclusive_range(): void + { + $start = new \DateTimeImmutable('2023-01-01 10:00:00'); + $end = new \DateTimeImmutable('2023-01-01 18:00:00'); + $tsRange = TsRange::inclusive($start, $end); + + self::assertEquals('[2023-01-01 10:00:00.000000,2023-01-01 18:00:00.000000]', (string) $tsRange); + self::assertFalse($tsRange->isEmpty()); + } + + #[Test] + public function can_create_hour_range(): void + { + $dateTime = new \DateTimeImmutable('2023-01-01 14:30:00'); + $tsRange = TsRange::hour($dateTime); + + self::assertEquals('[2023-01-01 14:00:00.000000,2023-01-01 15:00:00.000000)', (string) $tsRange); + self::assertFalse($tsRange->isEmpty()); + } + + #[Test] + #[DataProvider('providesContainsTestCases')] + public function can_check_contains(TsRange $tsRange, mixed $value, bool $expected): void + { + self::assertEquals($expected, $tsRange->contains($value)); + } + + #[Test] + #[DataProvider('providesFromStringTestCases')] + public function can_parse_from_string(string $input, TsRange $tsRange): void + { + $result = TsRange::fromString($input); + + self::assertEquals($tsRange->__toString(), $result->__toString()); + self::assertEquals($tsRange->isEmpty(), $result->isEmpty()); + } + + #[Test] + public function handles_microseconds_correctly(): void + { + $start = new \DateTimeImmutable('2023-01-01 10:00:00.123456'); + $end = new \DateTimeImmutable('2023-01-01 18:00:00.654321'); + $tsRange = new TsRange($start, $end); + + self::assertEquals('[2023-01-01 10:00:00.123456,2023-01-01 18:00:00.654321)', (string) $tsRange); + } + + public static function providesContainsTestCases(): \Generator + { + $tsRange = new TsRange( + new \DateTimeImmutable('2023-01-01 10:00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00') + ); + + yield 'contains timestamp in range' => [$tsRange, new \DateTimeImmutable('2023-01-01 14:00:00'), true]; + yield 'contains lower bound (inclusive)' => [$tsRange, new \DateTimeImmutable('2023-01-01 10:00:00'), true]; + yield 'does not contain upper bound (exclusive)' => [$tsRange, new \DateTimeImmutable('2023-01-01 18:00:00'), false]; + yield 'does not contain timestamp before range' => [$tsRange, new \DateTimeImmutable('2023-01-01 09:00:00'), false]; + yield 'does not contain timestamp after range' => [$tsRange, new \DateTimeImmutable('2023-01-01 19:00:00'), false]; + yield 'does not contain null' => [$tsRange, null, false]; + + $emptyRange = TsRange::empty(); + yield 'empty range contains nothing' => [$emptyRange, new \DateTimeImmutable('2023-01-01 14:00:00'), false]; + } + + public static function providesFromStringTestCases(): \Generator + { + yield 'simple range' => [ + '[2023-01-01 10:00:00,2023-01-01 18:00:00)', + new TsRange(new \DateTimeImmutable('2023-01-01 10:00:00'), new \DateTimeImmutable('2023-01-01 18:00:00')), + ]; + yield 'inclusive range' => [ + '[2023-01-01 10:00:00,2023-01-01 18:00:00]', + new TsRange(new \DateTimeImmutable('2023-01-01 10:00:00'), new \DateTimeImmutable('2023-01-01 18:00:00'), true, true), + ]; + yield 'infinite lower' => [ + '[,2023-01-01 18:00:00)', + new TsRange(null, new \DateTimeImmutable('2023-01-01 18:00:00')), + ]; + yield 'infinite upper' => [ + '[2023-01-01 10:00:00,)', + new TsRange(new \DateTimeImmutable('2023-01-01 10:00:00'), null), + ]; + yield 'empty range' => ['empty', TsRange::empty()]; + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php new file mode 100644 index 00000000..8e6019c1 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php @@ -0,0 +1,139 @@ +isEmpty()); + } + + #[Test] + public function can_create_empty_range(): void + { + $tstzRange = TstzRange::empty(); + + self::assertEquals('empty', (string) $tstzRange); + self::assertTrue($tstzRange->isEmpty()); + } + + #[Test] + public function can_create_infinite_range(): void + { + $tstzRange = TstzRange::infinite(); + + self::assertEquals('(,)', (string) $tstzRange); + self::assertFalse($tstzRange->isEmpty()); + } + + #[Test] + public function can_create_inclusive_range(): void + { + $start = new \DateTimeImmutable('2023-01-01 10:00:00+00:00'); + $end = new \DateTimeImmutable('2023-01-01 18:00:00+00:00'); + $tstzRange = TstzRange::inclusive($start, $end); + + self::assertEquals('[2023-01-01 10:00:00.000000+00:00,2023-01-01 18:00:00.000000+00:00]', (string) $tstzRange); + self::assertFalse($tstzRange->isEmpty()); + } + + #[Test] + public function can_create_hour_range(): void + { + $dateTime = new \DateTimeImmutable('2023-01-01 14:30:00+02:00'); + $tstzRange = TstzRange::hour($dateTime); + + self::assertEquals('[2023-01-01 14:00:00.000000+02:00,2023-01-01 15:00:00.000000+02:00)', (string) $tstzRange); + self::assertFalse($tstzRange->isEmpty()); + } + + #[Test] + #[DataProvider('providesContainsTestCases')] + public function can_check_contains(TstzRange $tstzRange, mixed $value, bool $expected): void + { + self::assertEquals($expected, $tstzRange->contains($value)); + } + + #[Test] + #[DataProvider('providesFromStringTestCases')] + public function can_parse_from_string(string $input, TstzRange $tstzRange): void + { + $result = TstzRange::fromString($input); + + self::assertEquals($tstzRange->__toString(), $result->__toString()); + self::assertEquals($tstzRange->isEmpty(), $result->isEmpty()); + } + + #[Test] + public function handles_different_timezones(): void + { + $start = new \DateTimeImmutable('2023-01-01 10:00:00+02:00'); + $end = new \DateTimeImmutable('2023-01-01 18:00:00-05:00'); + $tstzRange = new TstzRange($start, $end); + + self::assertEquals('[2023-01-01 10:00:00.000000+02:00,2023-01-01 18:00:00.000000-05:00)', (string) $tstzRange); + } + + #[Test] + public function handles_microseconds_with_timezone(): void + { + $start = new \DateTimeImmutable('2023-01-01 10:00:00.123456+00:00'); + $end = new \DateTimeImmutable('2023-01-01 18:00:00.654321+00:00'); + $tstzRange = new TstzRange($start, $end); + + self::assertEquals('[2023-01-01 10:00:00.123456+00:00,2023-01-01 18:00:00.654321+00:00)', (string) $tstzRange); + } + + public static function providesContainsTestCases(): \Generator + { + $tstzRange = new TstzRange( + new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00+00:00') + ); + + yield 'contains timestamp in range' => [$tstzRange, new \DateTimeImmutable('2023-01-01 14:00:00+00:00'), true]; + yield 'contains lower bound (inclusive)' => [$tstzRange, new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), true]; + yield 'does not contain upper bound (exclusive)' => [$tstzRange, new \DateTimeImmutable('2023-01-01 18:00:00+00:00'), false]; + yield 'does not contain timestamp before range' => [$tstzRange, new \DateTimeImmutable('2023-01-01 09:00:00+00:00'), false]; + yield 'does not contain timestamp after range' => [$tstzRange, new \DateTimeImmutable('2023-01-01 19:00:00+00:00'), false]; + yield 'does not contain null' => [$tstzRange, null, false]; + + $emptyRange = TstzRange::empty(); + yield 'empty range contains nothing' => [$emptyRange, new \DateTimeImmutable('2023-01-01 14:00:00+00:00'), false]; + } + + public static function providesFromStringTestCases(): \Generator + { + yield 'simple range with timezone' => [ + '[2023-01-01 10:00:00+00:00,2023-01-01 18:00:00+00:00)', + new TstzRange(new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), new \DateTimeImmutable('2023-01-01 18:00:00+00:00')), + ]; + yield 'inclusive range with timezone' => [ + '[2023-01-01 10:00:00+02:00,2023-01-01 18:00:00+02:00]', + new TstzRange(new \DateTimeImmutable('2023-01-01 10:00:00+02:00'), new \DateTimeImmutable('2023-01-01 18:00:00+02:00'), true, true), + ]; + yield 'infinite lower' => [ + '[,2023-01-01 18:00:00+00:00)', + new TstzRange(null, new \DateTimeImmutable('2023-01-01 18:00:00+00:00')), + ]; + yield 'infinite upper' => [ + '[2023-01-01 10:00:00+00:00,)', + new TstzRange(new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), null), + ]; + yield 'empty range' => ['empty', TstzRange::empty()]; + } +} From 31c9707331922824af0966130b3f482831010f3a Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Wed, 2 Jul 2025 01:08:24 +0300 Subject: [PATCH 02/16] Drop AI noise that is not strictly required. --- .../Doctrine/DBAL/Types/DateRange.php | 2 +- .../Doctrine/DBAL/Types/Int4Range.php | 2 +- .../Doctrine/DBAL/Types/Int8Range.php | 2 +- .../Doctrine/DBAL/Types/NumRange.php | 2 +- .../Doctrine/DBAL/Types/TsRange.php | 2 +- .../Doctrine/DBAL/Types/TstzRange.php | 2 +- .../Types/ValueObject/BaseIntegerRange.php | 18 ---- .../Types/ValueObject/BaseTimestampRange.php | 18 ---- .../DBAL/Types/ValueObject/DateRange.php | 13 --- .../DBAL/Types/ValueObject/NumericRange.php | 18 ---- .../Doctrine/DBAL/Types/ValueObject/Range.php | 90 +++++++++++-------- .../DBAL/Types/ValueObject/Int4RangeTest.php | 2 +- .../DBAL/Types/ValueObject/Int8RangeTest.php | 2 +- .../DBAL/Types/ValueObject/TsRangeTest.php | 2 +- .../DBAL/Types/ValueObject/TstzRangeTest.php | 2 +- 15 files changed, 61 insertions(+), 116 deletions(-) diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/DateRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/DateRange.php index dca9ea2a..8d2c9442 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/DateRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/DateRange.php @@ -8,7 +8,7 @@ use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; /** - * PostgreSQL DATERANGE type. + * Implementation of PostgreSQL DATERANGE type. * * @extends BaseRangeType * diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Int4Range.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Int4Range.php index 0f84da25..8aaf7108 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/Int4Range.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Int4Range.php @@ -8,7 +8,7 @@ use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; /** - * PostgreSQL INT4RANGE type. + * Implementation of PostgreSQL INT4RANGE type. * * @extends BaseRangeType * diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Int8Range.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Int8Range.php index 2ca01b0e..78095925 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/Int8Range.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Int8Range.php @@ -8,7 +8,7 @@ use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; /** - * PostgreSQL INT8RANGE type. + * Implementation of PostgreSQL INT8RANGE type. * * @extends BaseRangeType * diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/NumRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/NumRange.php index ae2cedeb..3bc7cb9f 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/NumRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/NumRange.php @@ -8,7 +8,7 @@ use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; /** - * PostgreSQL NUMRANGE type. + * Implementation of PostgreSQL NUMRANGE type. * * @extends BaseRangeType * diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/TsRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/TsRange.php index 78b0dddc..51dcebb4 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/TsRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/TsRange.php @@ -8,7 +8,7 @@ use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\TsRange as TsRangeValueObject; /** - * PostgreSQL TSRANGE type. + * Implementation of PostgreSQL TSRANGE type. * * @extends BaseRangeType * diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/TstzRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/TstzRange.php index 886e7022..343f8f5e 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/TstzRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/TstzRange.php @@ -8,7 +8,7 @@ use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\TstzRange as TstzRangeValueObject; /** - * PostgreSQL TSTZRANGE type. + * Implementation of PostgreSQL TSTZRANGE type. * * @extends BaseRangeType * diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php index d695c78a..962af509 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php @@ -23,24 +23,6 @@ public function __construct( parent::__construct($lower, $upper, $isLowerBracketInclusive, $isUpperBracketInclusive, $isExplicitlyEmpty); } - /** - * Uses PostgreSQL's explicit empty state rather than mathematical tricks. - */ - public static function empty(): static - { - return new static(null, null, true, false, true); - } - - public static function infinite(): static - { - return new static(null, null, false, false); - } - - public static function inclusive(?int $lower, ?int $upper): static - { - return new static($lower, $upper, true, true); - } - public static function fromString(string $rangeString): static { $rangeString = \trim($rangeString); diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php index e75498c2..67475d98 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php @@ -23,24 +23,6 @@ public function __construct( parent::__construct($lower, $upper, $isLowerBracketInclusive, $isUpperBracketInclusive, $isExplicitlyEmpty); } - /** - * Uses PostgreSQL's explicit empty state rather than mathematical tricks. - */ - public static function empty(): static - { - return new static(null, null, true, false, true); - } - - public static function infinite(): static - { - return new static(null, null, false, false); - } - - public static function inclusive(?\DateTimeInterface $lower, ?\DateTimeInterface $upper): static - { - return new static($lower, $upper, true, true); - } - public static function hour(\DateTimeInterface $dateTime): static { $start = \DateTimeImmutable::createFromInterface($dateTime)->setTime( diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRange.php index 8018a54f..73f9635b 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRange.php @@ -64,19 +64,6 @@ protected static function parseValue(string $value): \DateTimeImmutable } } - /** - * Uses PostgreSQL's explicit empty state rather than mathematical tricks. - */ - public static function empty(): self - { - return new self(null, null, true, false, true); - } - - public static function infinite(): self - { - return new self(null, null, true, true); - } - public static function singleDay(\DateTimeInterface $date): self { $startOfDay = \DateTimeImmutable::createFromInterface($date)->setTime(0, 0, 0); diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRange.php index 1b96447b..91320333 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRange.php @@ -64,22 +64,4 @@ protected static function parseValue(string $value): float|int return $floatValue === (float) $intValue ? $intValue : $floatValue; } - - public static function withLowerBoundInclusive(mixed $lower, mixed $upper): self - { - return new self($lower, $upper, true, false); - } - - /** - * Uses PostgreSQL's explicit empty state rather than mathematical tricks. - */ - public static function empty(): self - { - return new self(null, null, true, false, true); - } - - public static function infinite(): self - { - return new self(null, null, false, false); - } } diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php index f9bd9ded..045a736f 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php @@ -39,44 +39,42 @@ public function __toString(): string return self::EMPTY_RANGE_STRING; } - $lowerBracketChar = $this->isLowerBracketInclusive ? self::BRACKET_LOWER_INCLUSIVE : self::BRACKET_LOWER_EXCLUSIVE; - $upperBracketChar = $this->isUpperBracketInclusive ? self::BRACKET_UPPER_INCLUSIVE : self::BRACKET_UPPER_EXCLUSIVE; + $lowerBracket = $this->isLowerBracketInclusive ? self::BRACKET_LOWER_INCLUSIVE : self::BRACKET_LOWER_EXCLUSIVE; + $upperBracket = $this->isUpperBracketInclusive ? self::BRACKET_UPPER_INCLUSIVE : self::BRACKET_UPPER_EXCLUSIVE; $formattedLowerBound = $this->lower === null ? '' : $this->formatValue($this->lower); $formattedUpperBound = $this->upper === null ? '' : $this->formatValue($this->upper); - return $lowerBracketChar.$formattedLowerBound.','.$formattedUpperBound.$upperBracketChar; + return $lowerBracket.$formattedLowerBound.','.$formattedUpperBound.$upperBracket; } - public function contains(mixed $target): bool + /** + * Following PostgreSQL's design philosophy, a range can be empty in two ways: + * 1. Explicitly marked as empty (isExplicitlyEmpty flag = true) + * 2. Mathematically empty due to bounds (lower > upper, or equal bounds with exclusive brackets) + */ + public function isEmpty(): bool { - if ($target === null) { - return false; + if ($this->isExplicitlyEmpty) { + return true; } - if ($this->isEmpty()) { + if ($this->lower === null || $this->upper === null) { return false; } - // Check lower bound - if ($this->lower !== null) { - $comparison = $this->compareBounds($target, $this->lower); - if ($comparison < 0 || ($comparison === 0 && !$this->isLowerBracketInclusive)) { - return false; - } - } - - // Check upper bound - if ($this->upper !== null) { - $comparison = $this->compareBounds($target, $this->upper); - if ($comparison > 0 || ($comparison === 0 && !$this->isUpperBracketInclusive)) { - return false; - } + $comparison = $this->compareBounds($this->lower, $this->upper); + if ($comparison > 0) { + return true; } - return true; + return $comparison === 0 && (!$this->isLowerBracketInclusive || !$this->isUpperBracketInclusive); } + abstract protected function compareBounds(mixed $a, mixed $b): int; + + abstract protected function formatValue(mixed $value): string; + public static function fromString(string $rangeString): static { $rangeString = \trim($rangeString); @@ -101,33 +99,47 @@ public static function fromString(string $rangeString): static return new static($lowerBoundValue, $upperBoundValue, $isLowerBracketInclusive, $isUpperBracketInclusive); } - abstract protected function compareBounds(mixed $a, mixed $b): int; - - abstract protected function formatValue(mixed $value): string; - abstract protected static function parseValue(string $value): mixed; - /** - * Following PostgreSQL's design philosophy, a range can be empty in two ways: - * 1. Explicitly marked as empty (isExplicitlyEmpty flag = true) - * 2. Mathematically empty due to bounds (lower > upper, or equal bounds with exclusive brackets) - */ - public function isEmpty(): bool + public function contains(mixed $target): bool { - if ($this->isExplicitlyEmpty) { - return true; + if ($target === null) { + return false; } - if ($this->lower === null || $this->upper === null) { + if ($this->isEmpty()) { return false; } - $comparison = $this->compareBounds($this->lower, $this->upper); + // Check lower bound + if ($this->lower !== null) { + $comparison = $this->compareBounds($target, $this->lower); + if ($comparison < 0 || ($comparison === 0 && !$this->isLowerBracketInclusive)) { + return false; + } + } - if ($comparison > 0) { - return true; + // Check upper bound + if ($this->upper !== null) { + $comparison = $this->compareBounds($target, $this->upper); + if ($comparison > 0 || ($comparison === 0 && !$this->isUpperBracketInclusive)) { + return false; + } } - return $comparison === 0 && (!$this->isLowerBracketInclusive || !$this->isUpperBracketInclusive); + return true; + } + + /** + * Uses PostgreSQL's explicit empty state rather than mathematical tricks. + */ + public static function empty(): static + { + return new static(null, null, true, false, true); + } + + public static function infinite(): static + { + return new static(null, null, true, true); } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php index b6ccdf25..10347856 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php @@ -41,7 +41,7 @@ public function can_create_infinite_range(): void #[Test] public function can_create_inclusive_range(): void { - $int4Range = Int4Range::inclusive(1, 10); + $int4Range = new Int4Range(1, 10, true, true); self::assertEquals('[1,10]', (string) $int4Range); self::assertFalse($int4Range->isEmpty()); diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php index 2c43b122..14e75e4b 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php @@ -41,7 +41,7 @@ public function can_create_infinite_range(): void #[Test] public function can_create_inclusive_range(): void { - $int8Range = Int8Range::inclusive(1, 10); + $int8Range = new Int8Range(1, 10, true, true); self::assertEquals('[1,10]', (string) $int8Range); self::assertFalse($int8Range->isEmpty()); diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php index 5973fd03..f17e5d3c 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php @@ -45,7 +45,7 @@ public function can_create_inclusive_range(): void { $start = new \DateTimeImmutable('2023-01-01 10:00:00'); $end = new \DateTimeImmutable('2023-01-01 18:00:00'); - $tsRange = TsRange::inclusive($start, $end); + $tsRange = new TsRange($start, $end, true, true); self::assertEquals('[2023-01-01 10:00:00.000000,2023-01-01 18:00:00.000000]', (string) $tsRange); self::assertFalse($tsRange->isEmpty()); diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php index 8e6019c1..969d8703 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php @@ -45,7 +45,7 @@ public function can_create_inclusive_range(): void { $start = new \DateTimeImmutable('2023-01-01 10:00:00+00:00'); $end = new \DateTimeImmutable('2023-01-01 18:00:00+00:00'); - $tstzRange = TstzRange::inclusive($start, $end); + $tstzRange = new TstzRange($start, $end, true, true); self::assertEquals('[2023-01-01 10:00:00.000000+00:00,2023-01-01 18:00:00.000000+00:00]', (string) $tstzRange); self::assertFalse($tstzRange->isEmpty()); From 978996e8dc51700cf36cc74fdce0790a801eff85 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Wed, 2 Jul 2025 01:31:38 +0300 Subject: [PATCH 03/16] Drop AI noise that is not strictly required. --- .../Types/ValueObject/BaseIntegerRange.php | 28 ----------- .../Types/ValueObject/BaseTimestampRange.php | 48 +------------------ .../Doctrine/DBAL/Types/ValueObject/Range.php | 2 +- .../Doctrine/DBAL/Types/TsRangeTest.php | 5 +- .../DBAL/Types/ValueObject/DateRangeTest.php | 2 +- .../DBAL/Types/ValueObject/TsRangeTest.php | 5 +- .../DBAL/Types/ValueObject/TstzRangeTest.php | 5 +- 7 files changed, 14 insertions(+), 81 deletions(-) diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php index 962af509..41311f60 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php @@ -23,28 +23,6 @@ public function __construct( parent::__construct($lower, $upper, $isLowerBracketInclusive, $isUpperBracketInclusive, $isExplicitlyEmpty); } - public static function fromString(string $rangeString): static - { - $rangeString = \trim($rangeString); - - if ($rangeString === parent::EMPTY_RANGE_STRING) { - return static::empty(); - } - - if (!\preg_match('/^(\[|\()("?[^",]*"?),("?[^",]*"?)(\]|\))$/', $rangeString, $matches)) { - throw new \InvalidArgumentException( - \sprintf('Invalid range format: %s', $rangeString) - ); - } - - $isLowerBracketInclusive = $matches[1] === parent::BRACKET_LOWER_INCLUSIVE; - $isUpperBracketInclusive = $matches[4] === parent::BRACKET_UPPER_INCLUSIVE; - $lowerBoundValue = $matches[2] === '' ? null : static::parseValue(\trim($matches[2], '"')); - $upperBoundValue = $matches[3] === '' ? null : static::parseValue(\trim($matches[3], '"')); - - return new static($lowerBoundValue, $upperBoundValue, $isLowerBracketInclusive, $isUpperBracketInclusive); - } - protected function compareBounds(mixed $a, mixed $b): int { return $a <=> $b; @@ -57,12 +35,6 @@ protected function formatValue(mixed $value): string protected static function parseValue(string $value): int { - if (!\is_numeric($value)) { - throw new \InvalidArgumentException( - \sprintf('Invalid integer value: %s', $value) - ); - } - $intValue = (int) $value; if ((string) $intValue !== $value) { throw new \InvalidArgumentException( diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php index 67475d98..fd895022 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php @@ -23,53 +23,14 @@ public function __construct( parent::__construct($lower, $upper, $isLowerBracketInclusive, $isUpperBracketInclusive, $isExplicitlyEmpty); } - public static function hour(\DateTimeInterface $dateTime): static - { - $start = \DateTimeImmutable::createFromInterface($dateTime)->setTime( - (int) $dateTime->format('H'), - 0, - 0, - 0 - ); - $end = $start->modify('+1 hour'); - - return new static($start, $end, true, false); - } - - public static function fromString(string $rangeString): static - { - $rangeString = \trim($rangeString); - - if ($rangeString === parent::EMPTY_RANGE_STRING) { - return static::empty(); - } - - if (!\preg_match('/^(\[|\()("?[^",]*"?),("?[^",]*"?)(\]|\))$/', $rangeString, $matches)) { - throw new \InvalidArgumentException( - \sprintf('Invalid range format: %s', $rangeString) - ); - } - - $isLowerBracketInclusive = $matches[1] === parent::BRACKET_LOWER_INCLUSIVE; - $isUpperBracketInclusive = $matches[4] === parent::BRACKET_UPPER_INCLUSIVE; - $lowerBoundValue = $matches[2] === '' ? null : static::parseTimestampValue(\trim($matches[2], '"')); - $upperBoundValue = $matches[3] === '' ? null : static::parseTimestampValue(\trim($matches[3], '"')); - - return new static($lowerBoundValue, $upperBoundValue, $isLowerBracketInclusive, $isUpperBracketInclusive); - } - - /** - * Compare timestamps with microsecond precision. - * PHP's getTimestamp() only returns seconds, so we need separate microsecond comparison. - */ protected function compareBounds(mixed $a, mixed $b): int { $timestampComparison = $a->getTimestamp() <=> $b->getTimestamp(); - if ($timestampComparison !== 0) { return $timestampComparison; } + // PHP's getTimestamp() only returns seconds, so we need separate microsecond comparison. return (int) $a->format('u') <=> (int) $b->format('u'); } @@ -82,7 +43,7 @@ protected function formatValue(mixed $value): string return $value->format('Y-m-d H:i:s.u'); } - protected static function parseTimestampValue(string $value): \DateTimeImmutable + protected static function parseValue(string $value): \DateTimeImmutable { try { return new \DateTimeImmutable($value); @@ -94,9 +55,4 @@ protected static function parseTimestampValue(string $value): \DateTimeImmutable ); } } - - protected static function parseValue(string $value): \DateTimeImmutable - { - return static::parseTimestampValue($value); - } } diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php index 045a736f..54f221a6 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php @@ -140,6 +140,6 @@ public static function empty(): static public static function infinite(): static { - return new static(null, null, true, true); + return new static(null, null, false, false); } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php index 75d46fe5..12fe9111 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php @@ -75,7 +75,10 @@ public static function providesValidPHPValues(): \Generator '[2023-01-01 10:00:00.000000,2023-01-01 18:00:00.000000)', ]; yield 'hour range' => [ - TsRangeValueObject::hour(new \DateTimeImmutable('2023-01-01 14:30:00')), + new TsRangeValueObject( + new \DateTimeImmutable('2023-01-01 14:00:00'), + new \DateTimeImmutable('2023-01-01 15:00:00') + ), '[2023-01-01 14:00:00.000000,2023-01-01 15:00:00.000000)', ]; yield 'empty range' => [ diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php index 7a49b282..0e867ac0 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php @@ -36,7 +36,7 @@ public function can_create_infinite_range(): void { $dateRange = DateRange::infinite(); - self::assertEquals('[,]', (string) $dateRange); + self::assertEquals('(,)', (string) $dateRange); self::assertFalse($dateRange->isEmpty()); } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php index f17e5d3c..7ece4eb7 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php @@ -54,8 +54,9 @@ public function can_create_inclusive_range(): void #[Test] public function can_create_hour_range(): void { - $dateTime = new \DateTimeImmutable('2023-01-01 14:30:00'); - $tsRange = TsRange::hour($dateTime); + $start = new \DateTimeImmutable('2023-01-01 14:00:00'); + $end = $start->modify('+1 hour'); + $tsRange = new TsRange($start, $end); self::assertEquals('[2023-01-01 14:00:00.000000,2023-01-01 15:00:00.000000)', (string) $tsRange); self::assertFalse($tsRange->isEmpty()); diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php index 969d8703..58f36bcc 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php @@ -54,8 +54,9 @@ public function can_create_inclusive_range(): void #[Test] public function can_create_hour_range(): void { - $dateTime = new \DateTimeImmutable('2023-01-01 14:30:00+02:00'); - $tstzRange = TstzRange::hour($dateTime); + $start = new \DateTimeImmutable('2023-01-01 14:00:00+02:00'); + $end = $start->modify('+1 hour'); + $tstzRange = new TstzRange($start, $end); self::assertEquals('[2023-01-01 14:00:00.000000+02:00,2023-01-01 15:00:00.000000+02:00)', (string) $tstzRange); self::assertFalse($tstzRange->isEmpty()); From 036661f7ffd4baf709f4e876eb3938a5d03c23b6 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sat, 5 Jul 2025 02:20:01 +0300 Subject: [PATCH 04/16] Address code duplication --- .../Types/ValueObject/BaseTimestampRange.php | 11 +--------- .../DBAL/Types/ValueObject/Int4Range.php | 18 ---------------- .../DBAL/Types/ValueObject/Int8Range.php | 21 +------------------ .../DBAL/Types/ValueObject/TsRange.php | 13 ------------ .../DBAL/Types/ValueObject/TstzRange.php | 13 ------------ 5 files changed, 2 insertions(+), 74 deletions(-) diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php index fd895022..78e6e988 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php @@ -30,19 +30,10 @@ protected function compareBounds(mixed $a, mixed $b): int return $timestampComparison; } - // PHP's getTimestamp() only returns seconds, so we need separate microsecond comparison. + // PHP's getTimestamp() only returns seconds, so we need to separate the microsecond comparison. return (int) $a->format('u') <=> (int) $b->format('u'); } - protected function formatValue(mixed $value): string - { - if (!$value instanceof \DateTimeInterface) { - throw new \InvalidArgumentException('Value must be a DateTimeInterface'); - } - - return $value->format('Y-m-d H:i:s.u'); - } - protected static function parseValue(string $value): \DateTimeImmutable { try { diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4Range.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4Range.php index 9aa07183..c51d7f86 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4Range.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4Range.php @@ -38,22 +38,4 @@ public function __construct( parent::__construct($lower, $upper, $isLowerBracketInclusive, $isUpperBracketInclusive, $isExplicitlyEmpty); } - - protected static function parseValue(string $value): int - { - if (!\is_numeric($value)) { - throw new \InvalidArgumentException( - \sprintf('Invalid integer value: %s', $value) - ); - } - - $intValue = (int) $value; - if ((string) $intValue !== $value) { - throw new \InvalidArgumentException( - \sprintf('Value %s is not a valid integer', $value) - ); - } - - return $intValue; - } } diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8Range.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8Range.php index 24917fef..dcfa8419 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8Range.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8Range.php @@ -11,23 +11,4 @@ * * @author Martin Georgiev */ -final class Int8Range extends BaseIntegerRange -{ - protected static function parseValue(string $value): int - { - if (!\is_numeric($value)) { - throw new \InvalidArgumentException( - \sprintf('Invalid integer value: %s', $value) - ); - } - - $intValue = (int) $value; - if ((string) $intValue !== $value) { - throw new \InvalidArgumentException( - \sprintf('Value %s is not a valid integer', $value) - ); - } - - return $intValue; - } -} +final class Int8Range extends BaseIntegerRange {} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRange.php index 54b67c6e..0ddef85f 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRange.php @@ -21,17 +21,4 @@ protected function formatValue(mixed $value): string return $value->format('Y-m-d H:i:s.u'); } - - protected static function parseValue(string $value): \DateTimeImmutable - { - try { - return new \DateTimeImmutable($value); - } catch (\Exception $exception) { - throw new \InvalidArgumentException( - \sprintf('Invalid timestamp value: %s. Error: %s', $value, $exception->getMessage()), - 0, - $exception - ); - } - } } diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRange.php index ee37093f..e9f15dff 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRange.php @@ -21,17 +21,4 @@ protected function formatValue(mixed $value): string return $value->format('Y-m-d H:i:s.uP'); } - - protected static function parseValue(string $value): \DateTimeImmutable - { - try { - return new \DateTimeImmutable($value); - } catch (\Exception $exception) { - throw new \InvalidArgumentException( - \sprintf('Invalid timestamp value: %s. Error: %s', $value, $exception->getMessage()), - 0, - $exception - ); - } - } } From 22da0b0be00b44cbb503776e0b18a474e3f8ec6b Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sat, 5 Jul 2025 02:34:25 +0300 Subject: [PATCH 05/16] Add the correct integration tests --- .../Doctrine/Entity/ContainsRanges.php | 56 -------- .../DBAL/Types/ContainsRangesEntityTest.php | 98 ------------- .../Doctrine/DBAL/Types/DBALTypesTest.php | 36 +++++ .../Types/PostgreSQLEmptyRangeOutputTest.php | 134 ------------------ .../DBAL/Types/RangeTypesIntegrationTest.php | 124 ---------------- tests/Integration/MartinGeorgiev/TestCase.php | 12 ++ 6 files changed, 48 insertions(+), 412 deletions(-) delete mode 100644 fixtures/MartinGeorgiev/Doctrine/Entity/ContainsRanges.php delete mode 100644 tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/ContainsRangesEntityTest.php delete mode 100644 tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/PostgreSQLEmptyRangeOutputTest.php delete mode 100644 tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/RangeTypesIntegrationTest.php diff --git a/fixtures/MartinGeorgiev/Doctrine/Entity/ContainsRanges.php b/fixtures/MartinGeorgiev/Doctrine/Entity/ContainsRanges.php deleted file mode 100644 index ccc891a1..00000000 --- a/fixtures/MartinGeorgiev/Doctrine/Entity/ContainsRanges.php +++ /dev/null @@ -1,56 +0,0 @@ -int4Range1 = new Int4Range(1, 1000); - $this->int4Range2 = new Int4Range(0, 2147483647); - - $this->int8Range1 = new Int8Range(1, PHP_INT_MAX); - $this->int8Range2 = new Int8Range(PHP_INT_MIN, 0); - } -} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/ContainsRangesEntityTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/ContainsRangesEntityTest.php deleted file mode 100644 index 2dadfe0a..00000000 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/ContainsRangesEntityTest.php +++ /dev/null @@ -1,98 +0,0 @@ -entityManager = $this->createEntityManager(); - - $schemaTool = new SchemaTool($this->entityManager); - $schemaTool->createSchema([$this->entityManager->getClassMetadata(ContainsRanges::class)]); - } - - protected function tearDown(): void - { - $schemaTool = new SchemaTool($this->entityManager); - $schemaTool->dropSchema([$this->entityManager->getClassMetadata(ContainsRanges::class)]); - - parent::tearDown(); - } - - #[Test] - public function can_persist_and_retrieve_entity_with_ranges(): void - { - $containsRanges = new ContainsRanges(); - - $this->entityManager->persist($containsRanges); - $this->entityManager->flush(); - $this->entityManager->clear(); - - $retrievedEntity = $this->entityManager->find(ContainsRanges::class, $containsRanges->id); - - self::assertInstanceOf(ContainsRanges::class, $retrievedEntity); - self::assertEquals('[1,1000)', (string) $retrievedEntity->int4Range1); - self::assertEquals('[0,2147483647)', (string) $retrievedEntity->int4Range2); - self::assertEquals('[1,'.PHP_INT_MAX.')', (string) $retrievedEntity->int8Range1); - self::assertEquals('['.PHP_INT_MIN.',0)', (string) $retrievedEntity->int8Range2); - } - - #[Test] - public function can_update_range_values(): void - { - $containsRanges = new ContainsRanges(); - - $this->entityManager->persist($containsRanges); - $this->entityManager->flush(); - - // Update the range - $containsRanges->int4Range1 = new Int4RangeValueObject(100, 200); - - $this->entityManager->flush(); - $this->entityManager->clear(); - - $retrievedEntity = $this->entityManager->find(ContainsRanges::class, $containsRanges->id); - - self::assertEquals('[100,200)', (string) $retrievedEntity->int4Range1); - } - - #[Test] - public function can_handle_null_ranges(): void - { - $containsRanges = new ContainsRanges(); - $containsRanges->int4Range1 = null; - - $this->entityManager->persist($containsRanges); - $this->entityManager->flush(); - $this->entityManager->clear(); - - $retrievedEntity = $this->entityManager->find(ContainsRanges::class, $containsRanges->id); - - self::assertNull($retrievedEntity->int4Range1); - } -} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/DBALTypesTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/DBALTypesTest.php index 66c4c723..3980f849 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/DBALTypesTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/DBALTypesTest.php @@ -5,7 +5,14 @@ namespace Tests\Integration\MartinGeorgiev\Doctrine\DBAL\Types; use Doctrine\DBAL\Types\Type; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DateRange as DateRangeValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Int4Range as Int4RangeValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Int8Range as Int8RangeValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\NumericRange as NumRangeValueObject; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Point as PointValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range as RangeValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\TsRange as TsRangeValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\TstzRange as TstzRangeValueObject; use PHPUnit\Framework\Attributes\DataProvider; class DBALTypesTest extends TestCase @@ -97,6 +104,27 @@ public static function providePointTypeTestCases(): array ]; } + #[DataProvider('provideRangeTypeTestCases')] + public function test_range_type(string $typeName, string $columnType, RangeValueObject $rangeValueObject): void + { + $this->runTypeTest($typeName, $columnType, $rangeValueObject); + } + + public static function provideRangeTypeTestCases(): array + { + return [ + 'numrange simple' => ['numrange', 'NUMRANGE', new NumRangeValueObject(1.5, 10.7)], + 'numrange infinite' => ['numrange', 'NUMRANGE', new NumRangeValueObject(null, 1000, false, false)], + 'numrange empty' => ['numrange', 'NUMRANGE', NumRangeValueObject::empty()], + 'int4range simple' => ['int4range', 'INT4RANGE', new Int4RangeValueObject(1, 1000)], + 'int4range infinite' => ['int4range', 'INT4RANGE', new Int4RangeValueObject(null, 1000, false, false)], + 'int8range simple' => ['int8range', 'INT8RANGE', new Int8RangeValueObject(PHP_INT_MIN, PHP_INT_MAX)], + 'daterange simple' => ['daterange', 'DATERANGE', new DateRangeValueObject(new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-12-31'))], + 'tsrange simple' => ['tsrange', 'TSRANGE', new TsRangeValueObject(new \DateTimeImmutable('2023-01-01 10:00:00'), new \DateTimeImmutable('2023-01-01 18:00:00'))], + 'tstzrange simple' => ['tstzrange', 'TSTZRANGE', new TstzRangeValueObject(new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), new \DateTimeImmutable('2023-01-01 18:00:00+00:00'))], + ]; + } + /** * Generic test method that handles all types of tests. */ @@ -142,6 +170,7 @@ private function assertDatabaseRoundtripEquals(mixed $expected, mixed $actual, s { match (true) { $expected instanceof PointValueObject => $this->assertPointEquals($expected, $actual, $typeName), + $expected instanceof RangeValueObject => $this->assertRangeEquals($expected, $actual, $typeName), \is_array($expected) => $this->assertEquals($expected, $actual, 'Failed asserting that array values are equal for type '.$typeName), default => $this->assertSame($expected, $actual, 'Failed asserting that values are identical for type '.$typeName) }; @@ -153,4 +182,11 @@ private function assertPointEquals(PointValueObject $pointValueObject, mixed $ac $this->assertEquals($pointValueObject->getX(), $actual->getX(), 'Failed asserting that X coordinates are equal for type '.$typeName); $this->assertEquals($pointValueObject->getY(), $actual->getY(), 'Failed asserting that Y coordinates are equal for type '.$typeName); } + + private function assertRangeEquals(RangeValueObject $rangeValueObject, mixed $actual, string $typeName): void + { + $this->assertInstanceOf(RangeValueObject::class, $actual, 'Failed asserting that value is a Range object for type '.$typeName); + $this->assertEquals($rangeValueObject->__toString(), $actual->__toString(), 'Failed asserting that range string representations are equal for type '.$typeName); + $this->assertEquals($rangeValueObject->isEmpty(), $actual->isEmpty(), 'Failed asserting that range empty states are equal for type '.$typeName); + } } diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/PostgreSQLEmptyRangeOutputTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/PostgreSQLEmptyRangeOutputTest.php deleted file mode 100644 index 0f9b8166..00000000 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/PostgreSQLEmptyRangeOutputTest.php +++ /dev/null @@ -1,134 +0,0 @@ -connection->fetchAssociative($sql); - $actualOutput = $result['range_output']; - - self::assertSame( - 'empty', - $actualOutput, - \sprintf("PostgreSQL outputs '%s' for empty ranges, but our constant expects 'empty'", $actualOutput) - ); - } - - #[Test] - public function postgres_outputs_empty_for_int4range_with_same_bounds_exclusive_upper(): void - { - $sql = "SELECT '[5,5)'::int4range AS range_output"; - $result = $this->connection->fetchAssociative($sql); - $actualOutput = $result['range_output']; - - self::assertSame( - 'empty', - $actualOutput, - \sprintf("PostgreSQL outputs '%s' for empty INT4RANGE, but our constant expects 'empty'", $actualOutput) - ); - } - - #[Test] - public function postgres_outputs_empty_for_daterange_with_same_date_exclusive_upper(): void - { - $sql = "SELECT '[2023-01-01,2023-01-01)'::daterange AS range_output"; - $result = $this->connection->fetchAssociative($sql); - $actualOutput = $result['range_output']; - - self::assertSame( - 'empty', - $actualOutput, - \sprintf("PostgreSQL outputs '%s' for empty DATERANGE, but our constant expects 'empty'", $actualOutput) - ); - } - - #[Test] - public function postgres_outputs_empty_for_explicit_empty_input(): void - { - $sql = "SELECT 'empty'::numrange AS range_output"; - $result = $this->connection->fetchAssociative($sql); - $actualOutput = $result['range_output']; - - self::assertSame( - 'empty', - $actualOutput, - \sprintf("PostgreSQL outputs '%s' for explicit 'empty' input, but our constant expects 'empty'", $actualOutput) - ); - } - - #[Test] - public function postgres_isempty_function_correctly_identifies_empty_ranges(): void - { - $sql = " - SELECT - '[4,4)'::numrange AS range1_text, - isempty('[4,4)'::numrange) AS range1_is_empty, - '[5,5)'::int4range AS range2_text, - isempty('[5,5)'::int4range) AS range2_is_empty, - 'empty'::numrange AS range3_text, - isempty('empty'::numrange) AS range3_is_empty - "; - $row = $this->connection->fetchAssociative($sql); - - self::assertSame('empty', $row['range1_text']); - self::assertTrue($row['range1_is_empty']); - - self::assertSame('empty', $row['range2_text']); - self::assertTrue($row['range2_is_empty']); - - self::assertSame('empty', $row['range3_text']); - self::assertTrue($row['range3_is_empty']); - } - - #[Test] - public function postgres_handles_lower_greater_than_upper_as_empty(): void - { - $sql = "SELECT '[10,5)'::numrange AS range_output, isempty('[10,5)'::numrange) AS is_empty"; - $result = $this->connection->fetchAssociative($sql); - - self::assertSame('empty', $result['range_output']); - self::assertTrue($result['is_empty']); - } - - #[Test] - public function postgres_handles_equal_bounds_with_exclusive_brackets_as_empty(): void - { - $sql = "SELECT '(5,5)'::numrange AS range_output, isempty('(5,5)'::numrange) AS is_empty"; - $result = $this->connection->fetchAssociative($sql); - - self::assertSame('empty', $result['range_output']); - self::assertTrue($result['is_empty']); - } - - #[Test] - public function postgres_handles_equal_bounds_with_mixed_brackets_as_empty(): void - { - $sql = "SELECT '(5,5]'::numrange AS range_output, isempty('(5,5]'::numrange) AS is_empty"; - $result = $this->connection->fetchAssociative($sql); - - self::assertSame('empty', $result['range_output']); - self::assertTrue($result['is_empty']); - } - - #[Test] - public function postgres_does_not_treat_equal_bounds_with_inclusive_brackets_as_empty(): void - { - $sql = "SELECT '[5,5]'::numrange AS range_output, isempty('[5,5]'::numrange) AS is_empty"; - $result = $this->connection->fetchAssociative($sql); - - self::assertSame('[5,5]', $result['range_output']); - self::assertFalse($result['is_empty']); - } -} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/RangeTypesIntegrationTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/RangeTypesIntegrationTest.php deleted file mode 100644 index 9b397e92..00000000 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/RangeTypesIntegrationTest.php +++ /dev/null @@ -1,124 +0,0 @@ -createTestTableForDataType($tableName, $columnName, $columnType); - - // Insert the value - $sql = \sprintf( - 'INSERT INTO %s.%s ("%s") VALUES (?)', - self::DATABASE_SCHEMA, - $tableName, - $columnName - ); - $this->connection->executeStatement($sql, [$value], [$typeName]); - - // Retrieve the value - $sql = \sprintf( - 'SELECT "%s" FROM %s.%s WHERE id = 1', - $columnName, - self::DATABASE_SCHEMA, - $tableName - ); - $result = $this->connection->fetchOne($sql); - - $type = Type::getType($typeName); - $convertedResult = $type->convertToPHPValue($result, $this->connection->getDatabasePlatform()); - - self::assertEquals($value, $convertedResult); - } - - public static function providesRangeTypesAndValues(): \Generator - { - yield 'numrange simple' => ['numrange', 'NUMRANGE', new NumRangeValueObject(1.5, 10.7)]; - yield 'numrange infinite' => ['numrange', 'NUMRANGE', new NumRangeValueObject(null, 1000, false, false)]; - yield 'numrange empty' => ['numrange', 'NUMRANGE', NumRangeValueObject::empty()]; - - yield 'int4range simple' => ['int4range', 'INT4RANGE', new Int4RangeValueObject(1, 1000)]; - yield 'int4range infinite' => ['int4range', 'INT4RANGE', new Int4RangeValueObject(null, 1000, false, false)]; - yield 'int8range simple' => ['int8range', 'INT8RANGE', new Int8RangeValueObject(PHP_INT_MIN, PHP_INT_MAX)]; - - yield 'daterange simple' => ['daterange', 'DATERANGE', new DateRangeValueObject(new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-12-31'))]; - yield 'tsrange simple' => ['tsrange', 'TSRANGE', new TsRangeValueObject(new \DateTimeImmutable('2023-01-01 10:00:00'), new \DateTimeImmutable('2023-01-01 18:00:00'))]; - yield 'tstzrange simple' => ['tstzrange', 'TSTZRANGE', new TstzRangeValueObject(new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), new \DateTimeImmutable('2023-01-01 18:00:00+00:00'))]; - } - - #[Test] - public function can_handle_null_values(): void - { - $this->createTestTableForDataType('test_null_range', 'range_value', 'NUMRANGE'); - - $sql = \sprintf( - 'INSERT INTO %s.test_null_range (range_value) VALUES (?)', - self::DATABASE_SCHEMA - ); - $this->connection->executeStatement($sql, [null], ['numrange']); - - $sql = \sprintf( - 'SELECT range_value FROM %s.test_null_range WHERE id = 1', - self::DATABASE_SCHEMA - ); - $result = $this->connection->fetchOne($sql); - - $type = Type::getType('numrange'); - $convertedResult = $type->convertToPHPValue($result, $this->connection->getDatabasePlatform()); - - self::assertNull($convertedResult); - } -} diff --git a/tests/Integration/MartinGeorgiev/TestCase.php b/tests/Integration/MartinGeorgiev/TestCase.php index 5668a5c9..c70253c9 100644 --- a/tests/Integration/MartinGeorgiev/TestCase.php +++ b/tests/Integration/MartinGeorgiev/TestCase.php @@ -17,19 +17,25 @@ use MartinGeorgiev\Doctrine\DBAL\Types\BooleanArray; use MartinGeorgiev\Doctrine\DBAL\Types\Cidr; use MartinGeorgiev\Doctrine\DBAL\Types\CidrArray; +use MartinGeorgiev\Doctrine\DBAL\Types\DateRange; use MartinGeorgiev\Doctrine\DBAL\Types\DoublePrecisionArray; use MartinGeorgiev\Doctrine\DBAL\Types\Inet; use MartinGeorgiev\Doctrine\DBAL\Types\InetArray; +use MartinGeorgiev\Doctrine\DBAL\Types\Int4Range; +use MartinGeorgiev\Doctrine\DBAL\Types\Int8Range; use MartinGeorgiev\Doctrine\DBAL\Types\IntegerArray; use MartinGeorgiev\Doctrine\DBAL\Types\Jsonb; use MartinGeorgiev\Doctrine\DBAL\Types\JsonbArray; use MartinGeorgiev\Doctrine\DBAL\Types\Macaddr; use MartinGeorgiev\Doctrine\DBAL\Types\MacaddrArray; +use MartinGeorgiev\Doctrine\DBAL\Types\NumRange; use MartinGeorgiev\Doctrine\DBAL\Types\Point; use MartinGeorgiev\Doctrine\DBAL\Types\PointArray; use MartinGeorgiev\Doctrine\DBAL\Types\RealArray; use MartinGeorgiev\Doctrine\DBAL\Types\SmallIntArray; use MartinGeorgiev\Doctrine\DBAL\Types\TextArray; +use MartinGeorgiev\Doctrine\DBAL\Types\TsRange; +use MartinGeorgiev\Doctrine\DBAL\Types\TstzRange; use MartinGeorgiev\Utils\PHPArrayToPostgresValueTransformer; use MartinGeorgiev\Utils\PostgresArrayToPHPArrayTransformer; use PHPUnit\Framework\TestCase as BaseTestCase; @@ -160,19 +166,25 @@ protected function registerCustomTypes(): void 'bool[]' => BooleanArray::class, 'cidr' => Cidr::class, 'cidr[]' => CidrArray::class, + 'daterange' => DateRange::class, 'double precision[]' => DoublePrecisionArray::class, 'inet' => Inet::class, 'inet[]' => InetArray::class, + 'int4range' => Int4Range::class, + 'int8range' => Int8Range::class, 'integer[]' => IntegerArray::class, 'jsonb' => Jsonb::class, 'jsonb[]' => JsonbArray::class, 'macaddr' => Macaddr::class, 'macaddr[]' => MacaddrArray::class, + 'numrange' => NumRange::class, 'point' => Point::class, 'point[]' => PointArray::class, 'real[]' => RealArray::class, 'smallint[]' => SmallIntArray::class, 'text[]' => TextArray::class, + 'tsrange' => TsRange::class, + 'tstzrange' => TstzRange::class, ]; foreach ($typesMap as $typeName => $typeClass) { From 7c94c0e15a0e2704bf4a4d29e223e5cf9116a8b8 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sat, 5 Jul 2025 02:49:18 +0300 Subject: [PATCH 06/16] Simplify unit tests --- .../Doctrine/DBAL/Types/BaseRangeType.php | 5 + .../Doctrine/DBAL/Types/BaseRangeTestCase.php | 110 ++++++++++++++++ .../Doctrine/DBAL/Types/DateRangeTest.php | 101 +++++---------- .../Doctrine/DBAL/Types/Int4RangeTest.php | 119 ++++-------------- .../Doctrine/DBAL/Types/Int8RangeTest.php | 99 +++++---------- .../Doctrine/DBAL/Types/NumRangeTest.php | 112 +++-------------- .../Doctrine/DBAL/Types/TsRangeTest.php | 110 +++++----------- .../Doctrine/DBAL/Types/TstzRangeTest.php | 107 +++++----------- 8 files changed, 275 insertions(+), 488 deletions(-) create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php index 76474c73..5474e925 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php @@ -20,6 +20,11 @@ */ abstract class BaseRangeType extends Type { + public function getName(): string + { + return static::TYPE_NAME; + } + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string { return static::TYPE_NAME; diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php new file mode 100644 index 00000000..7eda8247 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php @@ -0,0 +1,110 @@ +platform = $this->createMock(AbstractPlatform::class); + $this->fixture = $this->createRangeType(); + } + + #[Test] + public function has_name(): void + { + self::assertEquals($this->getExpectedTypeName(), $this->fixture->getName()); + } + + #[Test] + public function can_get_sql_declaration(): void + { + $result = $this->fixture->getSQLDeclaration([], $this->platform); + + self::assertEquals($this->getExpectedSqlDeclaration(), $result); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_transform_from_php_value(?Range $range, ?string $postgresValue): void + { + self::assertEquals($postgresValue, $this->fixture->convertToDatabaseValue($range, $this->platform)); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_transform_to_php_value(?Range $range, ?string $postgresValue): void + { + $result = $this->fixture->convertToPHPValue($postgresValue, $this->platform); + + if (!$range instanceof Range) { + self::assertNull($result); + } else { + self::assertInstanceOf($this->getExpectedValueObjectClass(), $result); + self::assertEquals($range->__toString(), $result->__toString()); + self::assertEquals($range->isEmpty(), $result->isEmpty()); + } + } + + #[Test] + public function can_transform_null_from_php_value(): void + { + $result = $this->fixture->convertToDatabaseValue(null, $this->platform); + + self::assertNull($result); + } + + #[Test] + public function can_transform_null_from_sql_value(): void + { + $result = $this->fixture->convertToPHPValue(null, $this->platform); + + self::assertNull($result); + } + + #[Test] + public function can_handle_postgres_empty_range(): void + { + $result = $this->fixture->convertToPHPValue('empty', $this->platform); + + self::assertInstanceOf($this->getExpectedValueObjectClass(), $result); + self::assertEquals('empty', (string) $result); + self::assertTrue($result->isEmpty()); + } + + /** + * Each array contains [phpValue, postgresValue] pairs. + */ + abstract public static function provideValidTransformations(): \Generator; + + abstract protected function createRangeType(): Type; + + /** + * Returns the expected type name (e.g., 'numrange', 'int4range'). + */ + abstract protected function getExpectedTypeName(): string; + + /** + * Returns the expected SQL declaration (e.g., 'NUMRANGE', 'INT4RANGE'). + */ + abstract protected function getExpectedSqlDeclaration(): string; + + abstract protected function getExpectedValueObjectClass(): string; +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php index 13fe6205..ce692bd2 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php @@ -4,106 +4,61 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types; -use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Types\Type; use MartinGeorgiev\Doctrine\DBAL\Types\DateRange; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DateRange as DateRangeValueObject; -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; -final class DateRangeTest extends TestCase +final class DateRangeTest extends BaseRangeTestCase { - private DateRange $fixture; - - private PostgreSQLPlatform $platform; - - protected function setUp(): void + protected function createRangeType(): Type { - $this->fixture = new DateRange(); - $this->platform = new PostgreSQLPlatform(); + return new DateRange(); } - #[Test] - public function can_get_sql_declaration(): void + protected function getExpectedTypeName(): string { - $result = $this->fixture->getSQLDeclaration([], $this->platform); - - self::assertEquals('daterange', $result); + return 'daterange'; } - #[Test] - #[DataProvider('providesValidPHPValues')] - public function can_transform_from_php_value(DateRangeValueObject $dateRangeValueObject, string $expectedSqlValue): void + protected function getExpectedSqlDeclaration(): string { - $result = $this->fixture->convertToDatabaseValue($dateRangeValueObject, $this->platform); - - self::assertEquals($expectedSqlValue, $result); + return 'daterange'; } - #[Test] - public function can_transform_null_from_php_value(): void + protected function getExpectedValueObjectClass(): string { - $result = $this->fixture->convertToDatabaseValue(null, $this->platform); - - self::assertNull($result); + return DateRangeValueObject::class; } - #[Test] - #[DataProvider('providesValidSqlValues')] - public function can_transform_from_sql_value(string $sqlValue, DateRangeValueObject $dateRangeValueObject): void - { - $result = $this->fixture->convertToPHPValue($sqlValue, $this->platform); - - self::assertEquals($dateRangeValueObject, $result); - } - - #[Test] - public function can_transform_null_from_sql_value(): void - { - $result = $this->fixture->convertToPHPValue(null, $this->platform); - - self::assertNull($result); - } - - public static function providesValidPHPValues(): \Generator + public static function provideValidTransformations(): \Generator { yield 'simple range' => [ new DateRangeValueObject(new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-12-31')), '[2023-01-01,2023-12-31)', ]; - yield 'year range' => [ - DateRangeValueObject::year(2023), - '[2023-01-01,2024-01-01)', + yield 'inclusive range' => [ + new DateRangeValueObject(new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-01-10'), true, true), + '[2023-01-01,2023-01-10]', ]; - yield 'single day' => [ - DateRangeValueObject::singleDay(new \DateTimeImmutable('2023-06-15')), - '[2023-06-15,2023-06-16)', + yield 'exclusive range' => [ + new DateRangeValueObject(new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-01-10'), false, false), + '(2023-01-01,2023-01-10)', ]; - yield 'empty range' => [ - DateRangeValueObject::empty(), - 'empty', + yield 'infinite lower' => [ + new DateRangeValueObject(null, new \DateTimeImmutable('2023-12-31')), + '[,2023-12-31)', ]; - } - - public static function providesValidSqlValues(): \Generator - { - yield 'simple range' => [ - '[2023-01-01,2023-12-31)', - new DateRangeValueObject(new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-12-31')), + yield 'infinite upper' => [ + new DateRangeValueObject(new \DateTimeImmutable('2023-01-01'), null), + '[2023-01-01,)', ]; yield 'empty range' => [ - 'empty', DateRangeValueObject::empty(), + 'empty', + ]; + yield 'null value' => [ + null, + null, ]; - } - - #[Test] - public function can_handle_postgres_empty_range(): void - { - $result = $this->fixture->convertToPHPValue('empty', $this->platform); - - self::assertInstanceOf(DateRangeValueObject::class, $result); - self::assertEquals('empty', (string) $result); - self::assertTrue($result->isEmpty()); } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php index c96d23d3..0310f31b 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php @@ -4,76 +4,33 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types; -use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Types\Type; use MartinGeorgiev\Doctrine\DBAL\Types\Int4Range; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Int4Range as Int4RangeValueObject; -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; -final class Int4RangeTest extends TestCase +final class Int4RangeTest extends BaseRangeTestCase { - private Int4Range $fixture; - - private PostgreSQLPlatform $platform; - - protected function setUp(): void - { - $this->fixture = new Int4Range(); - $this->platform = new PostgreSQLPlatform(); - } - - #[Test] - public function can_get_sql_declaration(): void - { - $result = $this->fixture->getSQLDeclaration([], $this->platform); - - self::assertEquals('int4range', $result); - } - - #[Test] - #[DataProvider('providesValidPHPValues')] - public function can_transform_from_php_value(Int4RangeValueObject $int4RangeValueObject, string $expectedSqlValue): void - { - $result = $this->fixture->convertToDatabaseValue($int4RangeValueObject, $this->platform); - - self::assertEquals($expectedSqlValue, $result); - } - - #[Test] - public function can_transform_null_from_php_value(): void + protected function createRangeType(): Type { - $result = $this->fixture->convertToDatabaseValue(null, $this->platform); - - self::assertNull($result); + return new Int4Range(); } - #[Test] - #[DataProvider('providesValidSqlValues')] - public function can_transform_from_sql_value(string $sqlValue, Int4RangeValueObject $int4RangeValueObject): void + protected function getExpectedTypeName(): string { - $result = $this->fixture->convertToPHPValue($sqlValue, $this->platform); - - self::assertEquals($int4RangeValueObject, $result); + return 'int4range'; } - #[Test] - public function can_transform_null_from_sql_value(): void + protected function getExpectedSqlDeclaration(): string { - $result = $this->fixture->convertToPHPValue(null, $this->platform); - - self::assertNull($result); + return 'int4range'; } - #[Test] - public function can_transform_empty_string_from_sql_value(): void + protected function getExpectedValueObjectClass(): string { - $result = $this->fixture->convertToPHPValue('', $this->platform); - - self::assertNull($result); + return Int4RangeValueObject::class; } - public static function providesValidPHPValues(): \Generator + public static function provideValidTransformations(): \Generator { yield 'simple range' => [ new Int4RangeValueObject(1, 1000), @@ -87,59 +44,25 @@ public static function providesValidPHPValues(): \Generator new Int4RangeValueObject(1, 10, false, false), '(1,10)', ]; - yield 'lower infinite' => [ - new Int4RangeValueObject(null, 10), - '[,10)', + yield 'infinite lower' => [ + new Int4RangeValueObject(null, 100), + '[,100)', ]; - yield 'upper infinite' => [ + yield 'infinite upper' => [ new Int4RangeValueObject(1, null), '[1,)', ]; - yield 'empty range' => [ - Int4RangeValueObject::empty(), - 'empty', - ]; - yield 'max int4 values' => [ + yield 'max bounds' => [ new Int4RangeValueObject(-2147483648, 2147483647), '[-2147483648,2147483647)', ]; - } - - public static function providesValidSqlValues(): \Generator - { - yield 'simple range' => [ - '[1,1000)', - new Int4RangeValueObject(1, 1000), - ]; - yield 'inclusive range' => [ - '[1,10]', - new Int4RangeValueObject(1, 10, true, true), - ]; - yield 'exclusive range' => [ - '(1,10)', - new Int4RangeValueObject(1, 10, false, false), - ]; - yield 'lower infinite' => [ - '[,10)', - new Int4RangeValueObject(null, 10), - ]; - yield 'upper infinite' => [ - '[1,)', - new Int4RangeValueObject(1, null), - ]; yield 'empty range' => [ - 'empty', Int4RangeValueObject::empty(), + 'empty', + ]; + yield 'null value' => [ + null, + null, ]; - } - - #[Test] - public function can_handle_postgres_empty_range(): void - { - $result = $this->fixture->convertToPHPValue('empty', $this->platform); - - self::assertInstanceOf(Int4RangeValueObject::class, $result); - self::assertEquals('empty', (string) $result); - self::assertTrue($result->isEmpty()); } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php index a4b726c4..b6bda8e4 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php @@ -4,74 +4,55 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types; -use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Types\Type; use MartinGeorgiev\Doctrine\DBAL\Types\Int8Range; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Int8Range as Int8RangeValueObject; -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; -final class Int8RangeTest extends TestCase +final class Int8RangeTest extends BaseRangeTestCase { - private Int8Range $fixture; - - private PostgreSQLPlatform $platform; - - protected function setUp(): void - { - $this->fixture = new Int8Range(); - $this->platform = new PostgreSQLPlatform(); - } - - #[Test] - public function can_get_sql_declaration(): void - { - $result = $this->fixture->getSQLDeclaration([], $this->platform); - - self::assertEquals('int8range', $result); - } - - #[Test] - #[DataProvider('providesValidPHPValues')] - public function can_transform_from_php_value(Int8RangeValueObject $int8RangeValueObject, string $expectedSqlValue): void + protected function createRangeType(): Type { - $result = $this->fixture->convertToDatabaseValue($int8RangeValueObject, $this->platform); - - self::assertEquals($expectedSqlValue, $result); + return new Int8Range(); } - #[Test] - public function can_transform_null_from_php_value(): void + protected function getExpectedTypeName(): string { - $result = $this->fixture->convertToDatabaseValue(null, $this->platform); - - self::assertNull($result); + return 'int8range'; } - #[Test] - #[DataProvider('providesValidSqlValues')] - public function can_transform_from_sql_value(string $sqlValue, Int8RangeValueObject $int8RangeValueObject): void + protected function getExpectedSqlDeclaration(): string { - $result = $this->fixture->convertToPHPValue($sqlValue, $this->platform); - - self::assertEquals($int8RangeValueObject, $result); + return 'int8range'; } - #[Test] - public function can_transform_null_from_sql_value(): void + protected function getExpectedValueObjectClass(): string { - $result = $this->fixture->convertToPHPValue(null, $this->platform); - - self::assertNull($result); + return Int8RangeValueObject::class; } - public static function providesValidPHPValues(): \Generator + public static function provideValidTransformations(): \Generator { yield 'simple range' => [ new Int8RangeValueObject(1, 1000), '[1,1000)', ]; - yield 'large range' => [ + yield 'inclusive range' => [ + new Int8RangeValueObject(1, 10, true, true), + '[1,10]', + ]; + yield 'exclusive range' => [ + new Int8RangeValueObject(1, 10, false, false), + '(1,10)', + ]; + yield 'infinite lower' => [ + new Int8RangeValueObject(null, 100), + '[,100)', + ]; + yield 'infinite upper' => [ + new Int8RangeValueObject(1, null), + '[1,)', + ]; + yield 'max bounds' => [ new Int8RangeValueObject(PHP_INT_MIN, PHP_INT_MAX), '['.PHP_INT_MIN.','.PHP_INT_MAX.')', ]; @@ -79,27 +60,9 @@ public static function providesValidPHPValues(): \Generator Int8RangeValueObject::empty(), 'empty', ]; - } - - public static function providesValidSqlValues(): \Generator - { - yield 'simple range' => [ - '[1,1000)', - new Int8RangeValueObject(1, 1000), + yield 'null value' => [ + null, + null, ]; - yield 'empty range' => [ - 'empty', - Int8RangeValueObject::empty(), - ]; - } - - #[Test] - public function can_handle_postgres_empty_range(): void - { - $result = $this->fixture->convertToPHPValue('empty', $this->platform); - - self::assertInstanceOf(Int8RangeValueObject::class, $result); - self::assertEquals('empty', (string) $result); - self::assertTrue($result->isEmpty()); } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php index 921864ad..c2e480cf 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php @@ -4,76 +4,33 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types; -use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Types\Type; use MartinGeorgiev\Doctrine\DBAL\Types\NumRange; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\NumericRange; -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; -final class NumRangeTest extends TestCase +final class NumRangeTest extends BaseRangeTestCase { - private NumRange $fixture; - - private PostgreSQLPlatform $platform; - - protected function setUp(): void - { - $this->fixture = new NumRange(); - $this->platform = new PostgreSQLPlatform(); - } - - #[Test] - public function can_get_sql_declaration(): void - { - $result = $this->fixture->getSQLDeclaration([], $this->platform); - - self::assertEquals('numrange', $result); - } - - #[Test] - #[DataProvider('providesValidPHPValues')] - public function can_transform_from_php_value(NumericRange $numericRange, string $expectedSqlValue): void - { - $result = $this->fixture->convertToDatabaseValue($numericRange, $this->platform); - - self::assertEquals($expectedSqlValue, $result); - } - - #[Test] - public function can_transform_null_from_php_value(): void + protected function createRangeType(): Type { - $result = $this->fixture->convertToDatabaseValue(null, $this->platform); - - self::assertNull($result); + return new NumRange(); } - #[Test] - #[DataProvider('providesValidSqlValues')] - public function can_transform_from_sql_value(string $sqlValue, NumericRange $numericRange): void + protected function getExpectedTypeName(): string { - $result = $this->fixture->convertToPHPValue($sqlValue, $this->platform); - - self::assertEquals($numericRange, $result); + return 'numrange'; } - #[Test] - public function can_transform_null_from_sql_value(): void + protected function getExpectedSqlDeclaration(): string { - $result = $this->fixture->convertToPHPValue(null, $this->platform); - - self::assertNull($result); + return 'numrange'; } - #[Test] - public function can_transform_empty_string_from_sql_value(): void + protected function getExpectedValueObjectClass(): string { - $result = $this->fixture->convertToPHPValue('', $this->platform); - - self::assertNull($result); + return NumericRange::class; } - public static function providesValidPHPValues(): \Generator + public static function provideValidTransformations(): \Generator { yield 'simple range' => [ new NumericRange(1.5, 10.7), @@ -87,11 +44,11 @@ public static function providesValidPHPValues(): \Generator new NumericRange(1, 10, false, false), '(1,10)', ]; - yield 'lower infinite' => [ - new NumericRange(null, 10), - '[,10)', + yield 'infinite lower' => [ + new NumericRange(null, 100), + '[,100)', ]; - yield 'upper infinite' => [ + yield 'infinite upper' => [ new NumericRange(1, null), '[1,)', ]; @@ -99,42 +56,9 @@ public static function providesValidPHPValues(): \Generator NumericRange::empty(), 'empty', ]; - } - - public static function providesValidSqlValues(): \Generator - { - yield 'simple range' => [ - '[1.5,10.7)', - new NumericRange(1.5, 10.7), - ]; - yield 'inclusive range' => [ - '[1,10]', - new NumericRange(1, 10, true, true), + yield 'null value' => [ + null, + null, ]; - yield 'exclusive range' => [ - '(1,10)', - new NumericRange(1, 10, false, false), - ]; - yield 'lower infinite' => [ - '[,10)', - new NumericRange(null, 10), - ]; - yield 'upper infinite' => [ - '[1,)', - new NumericRange(1, null), - ]; - yield 'empty range' => [ - 'empty', - NumericRange::empty(), - ]; - } - - #[Test] - public function can_handle_postgres_empty_range(): void - { - $result = $this->fixture->convertToPHPValue('empty', $this->platform); - - self::assertInstanceOf(NumericRange::class, $result); - self::assertEquals('empty', (string) $result); } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php index 12fe9111..095a96ef 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php @@ -4,111 +4,61 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types; -use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Types\Type; use MartinGeorgiev\Doctrine\DBAL\Types\TsRange; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\TsRange as TsRangeValueObject; -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; -final class TsRangeTest extends TestCase +final class TsRangeTest extends BaseRangeTestCase { - private TsRange $fixture; - - private PostgreSQLPlatform $platform; - - protected function setUp(): void - { - $this->fixture = new TsRange(); - $this->platform = new PostgreSQLPlatform(); - } - - #[Test] - public function can_get_sql_declaration(): void - { - $result = $this->fixture->getSQLDeclaration([], $this->platform); - - self::assertEquals('tsrange', $result); - } - - #[Test] - #[DataProvider('providesValidPHPValues')] - public function can_transform_from_php_value(TsRangeValueObject $tsRangeValueObject, string $expectedSqlValue): void + protected function createRangeType(): Type { - $result = $this->fixture->convertToDatabaseValue($tsRangeValueObject, $this->platform); - - self::assertEquals($expectedSqlValue, $result); + return new TsRange(); } - #[Test] - public function can_transform_null_from_php_value(): void + protected function getExpectedTypeName(): string { - $result = $this->fixture->convertToDatabaseValue(null, $this->platform); - - self::assertNull($result); + return 'tsrange'; } - #[Test] - #[DataProvider('providesValidSqlValues')] - public function can_transform_from_sql_value(string $sqlValue, TsRangeValueObject $tsRangeValueObject): void + protected function getExpectedSqlDeclaration(): string { - $result = $this->fixture->convertToPHPValue($sqlValue, $this->platform); - - self::assertEquals($tsRangeValueObject, $result); + return 'tsrange'; } - #[Test] - public function can_transform_null_from_sql_value(): void + protected function getExpectedValueObjectClass(): string { - $result = $this->fixture->convertToPHPValue(null, $this->platform); - - self::assertNull($result); + return TsRangeValueObject::class; } - public static function providesValidPHPValues(): \Generator + public static function provideValidTransformations(): \Generator { yield 'simple range' => [ - new TsRangeValueObject( - new \DateTimeImmutable('2023-01-01 10:00:00'), - new \DateTimeImmutable('2023-01-01 18:00:00') - ), + new TsRangeValueObject(new \DateTimeImmutable('2023-01-01 10:00:00'), new \DateTimeImmutable('2023-01-01 18:00:00')), '[2023-01-01 10:00:00.000000,2023-01-01 18:00:00.000000)', ]; - yield 'hour range' => [ - new TsRangeValueObject( - new \DateTimeImmutable('2023-01-01 14:00:00'), - new \DateTimeImmutable('2023-01-01 15:00:00') - ), - '[2023-01-01 14:00:00.000000,2023-01-01 15:00:00.000000)', + yield 'inclusive range' => [ + new TsRangeValueObject(new \DateTimeImmutable('2023-01-01 10:00:00'), new \DateTimeImmutable('2023-01-01 18:00:00'), true, true), + '[2023-01-01 10:00:00.000000,2023-01-01 18:00:00.000000]', ]; - yield 'empty range' => [ - TsRangeValueObject::empty(), - 'empty', + yield 'exclusive range' => [ + new TsRangeValueObject(new \DateTimeImmutable('2023-01-01 10:00:00'), new \DateTimeImmutable('2023-01-01 18:00:00'), false, false), + '(2023-01-01 10:00:00.000000,2023-01-01 18:00:00.000000)', ]; - } - - public static function providesValidSqlValues(): \Generator - { - yield 'simple range' => [ - '[2023-01-01 10:00:00,2023-01-01 18:00:00)', - new TsRangeValueObject( - new \DateTimeImmutable('2023-01-01 10:00:00'), - new \DateTimeImmutable('2023-01-01 18:00:00') - ), + yield 'infinite lower' => [ + new TsRangeValueObject(null, new \DateTimeImmutable('2023-01-01 18:00:00')), + '[,2023-01-01 18:00:00.000000)', + ]; + yield 'infinite upper' => [ + new TsRangeValueObject(new \DateTimeImmutable('2023-01-01 10:00:00'), null), + '[2023-01-01 10:00:00.000000,)', ]; yield 'empty range' => [ - 'empty', TsRangeValueObject::empty(), + 'empty', + ]; + yield 'null value' => [ + null, + null, ]; - } - - #[Test] - public function can_handle_postgres_empty_range(): void - { - $result = $this->fixture->convertToPHPValue('empty', $this->platform); - - self::assertInstanceOf(TsRangeValueObject::class, $result); - self::assertEquals('empty', (string) $result); - self::assertTrue($result->isEmpty()); } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php index 696b6d40..9e70ec29 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php @@ -4,104 +4,61 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types; -use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Types\Type; use MartinGeorgiev\Doctrine\DBAL\Types\TstzRange; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\TstzRange as TstzRangeValueObject; -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; -final class TstzRangeTest extends TestCase +final class TstzRangeTest extends BaseRangeTestCase { - private TstzRange $fixture; - - private PostgreSQLPlatform $platform; - - protected function setUp(): void - { - $this->fixture = new TstzRange(); - $this->platform = new PostgreSQLPlatform(); - } - - #[Test] - public function can_get_sql_declaration(): void - { - $result = $this->fixture->getSQLDeclaration([], $this->platform); - - self::assertEquals('tstzrange', $result); - } - - #[Test] - #[DataProvider('providesValidPHPValues')] - public function can_transform_from_php_value(TstzRangeValueObject $tstzRangeValueObject, string $expectedSqlValue): void + protected function createRangeType(): Type { - $result = $this->fixture->convertToDatabaseValue($tstzRangeValueObject, $this->platform); - - self::assertEquals($expectedSqlValue, $result); + return new TstzRange(); } - #[Test] - public function can_transform_null_from_php_value(): void + protected function getExpectedTypeName(): string { - $result = $this->fixture->convertToDatabaseValue(null, $this->platform); - - self::assertNull($result); + return 'tstzrange'; } - #[Test] - #[DataProvider('providesValidSqlValues')] - public function can_transform_from_sql_value(string $sqlValue, TstzRangeValueObject $tstzRangeValueObject): void + protected function getExpectedSqlDeclaration(): string { - $result = $this->fixture->convertToPHPValue($sqlValue, $this->platform); - - self::assertEquals($tstzRangeValueObject, $result); + return 'tstzrange'; } - #[Test] - public function can_transform_null_from_sql_value(): void + protected function getExpectedValueObjectClass(): string { - $result = $this->fixture->convertToPHPValue(null, $this->platform); - - self::assertNull($result); + return TstzRangeValueObject::class; } - public static function providesValidPHPValues(): \Generator + public static function provideValidTransformations(): \Generator { - yield 'simple range with timezone' => [ - new TstzRangeValueObject( - new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), - new \DateTimeImmutable('2023-01-01 18:00:00+00:00') - ), + yield 'simple range' => [ + new TstzRangeValueObject(new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), new \DateTimeImmutable('2023-01-01 18:00:00+00:00')), '[2023-01-01 10:00:00.000000+00:00,2023-01-01 18:00:00.000000+00:00)', ]; - yield 'empty range' => [ - TstzRangeValueObject::empty(), - 'empty', + yield 'inclusive range' => [ + new TstzRangeValueObject(new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), new \DateTimeImmutable('2023-01-01 18:00:00+00:00'), true, true), + '[2023-01-01 10:00:00.000000+00:00,2023-01-01 18:00:00.000000+00:00]', ]; - } - - public static function providesValidSqlValues(): \Generator - { - yield 'simple range with timezone' => [ - '[2023-01-01 10:00:00+00:00,2023-01-01 18:00:00+00:00)', - new TstzRangeValueObject( - new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), - new \DateTimeImmutable('2023-01-01 18:00:00+00:00') - ), + yield 'exclusive range' => [ + new TstzRangeValueObject(new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), new \DateTimeImmutable('2023-01-01 18:00:00+00:00'), false, false), + '(2023-01-01 10:00:00.000000+00:00,2023-01-01 18:00:00.000000+00:00)', + ]; + yield 'infinite lower' => [ + new TstzRangeValueObject(null, new \DateTimeImmutable('2023-01-01 18:00:00+00:00')), + '[,2023-01-01 18:00:00.000000+00:00)', + ]; + yield 'infinite upper' => [ + new TstzRangeValueObject(new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), null), + '[2023-01-01 10:00:00.000000+00:00,)', ]; yield 'empty range' => [ - 'empty', TstzRangeValueObject::empty(), + 'empty', + ]; + yield 'null value' => [ + null, + null, ]; - } - - #[Test] - public function can_handle_postgres_empty_range(): void - { - $result = $this->fixture->convertToPHPValue('empty', $this->platform); - - self::assertInstanceOf(TstzRangeValueObject::class, $result); - self::assertEquals('empty', (string) $result); - self::assertTrue($result->isEmpty()); } } From d937631a14af8e9db020c484fbe9c541181874a7 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sat, 5 Jul 2025 12:55:58 +0300 Subject: [PATCH 07/16] Improve error handling --- .../Doctrine/DBAL/Types/BaseRangeType.php | 18 ++----- .../InvalidRangeForPHPException.php | 47 +++++++++++++++++++ .../Types/ValueObject/BaseIntegerRange.php | 8 ++++ .../Types/ValueObject/BaseTimestampRange.php | 12 +++++ .../DBAL/Types/ValueObject/DateRange.php | 10 ++++ .../DBAL/Types/ValueObject/NumericRange.php | 10 ++++ .../Doctrine/DBAL/Types/ValueObject/Range.php | 6 +-- .../Doctrine/DBAL/Types/BaseRangeTestCase.php | 8 ---- 8 files changed, 94 insertions(+), 25 deletions(-) create mode 100644 src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidRangeForPHPException.php diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php index 5474e925..9ae9837d 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php @@ -5,8 +5,8 @@ namespace MartinGeorgiev\Doctrine\DBAL\Types; use Doctrine\DBAL\Platforms\AbstractPlatform; -use Doctrine\DBAL\Types\Type; use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidRangeForDatabaseException; +use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidRangeForPHPException; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; /** @@ -18,18 +18,8 @@ * * @author Martin Georgiev */ -abstract class BaseRangeType extends Type +abstract class BaseRangeType extends BaseType { - public function getName(): string - { - return static::TYPE_NAME; - } - - public function getSQLDeclaration(array $column, AbstractPlatform $platform): string - { - return static::TYPE_NAME; - } - public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string { if ($value === null) { @@ -55,7 +45,7 @@ public function convertToPHPValue($value, AbstractPlatform $platform): ?Range } if (!\is_string($value)) { - throw InvalidRangeForDatabaseException::forInvalidType($value); + throw InvalidRangeForPHPException::forInvalidType($value); } if ($value === '') { @@ -65,7 +55,7 @@ public function convertToPHPValue($value, AbstractPlatform $platform): ?Range try { return $this->createFromString($value); } catch (\InvalidArgumentException) { - throw InvalidRangeForDatabaseException::forInvalidFormat($value); + throw InvalidRangeForPHPException::forInvalidFormat($value); } } diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidRangeForPHPException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidRangeForPHPException.php new file mode 100644 index 00000000..0a2b835f --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidRangeForPHPException.php @@ -0,0 +1,47 @@ + + */ +class InvalidRangeForPHPException extends ConversionException +{ + private static function create(string $message, mixed $value): self + { + return new self(\sprintf($message, \var_export($value, true))); + } + + public static function forInvalidNumericBound(mixed $value): self + { + return self::create('Range bound must be numeric, %s given', $value); + } + + public static function forInvalidIntegerBound(mixed $value): self + { + return self::create('Range bound must be an integer, %s given', $value); + } + + public static function forInvalidDateTimeBound(mixed $value): self + { + return self::create('Range bound must be a DateTimeInterface instance, %s given', $value); + } + + public static function forInvalidType(mixed $value): self + { + return self::create('Invalid database value type for range conversion. Expected string, %s given', $value); + } + + public static function forInvalidFormat(string $value): self + { + return new self(\sprintf('Invalid range format from database: %s', $value)); + } +} diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php index 41311f60..19555e39 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php @@ -4,9 +4,13 @@ namespace MartinGeorgiev\Doctrine\DBAL\Types\ValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidRangeForPHPException; + /** * Base class for PostgreSQL integer range types. * + * @extends Range + * * @since 3.3 * * @author Martin Georgiev @@ -30,6 +34,10 @@ protected function compareBounds(mixed $a, mixed $b): int protected function formatValue(mixed $value): string { + if (!\is_int($value)) { + throw InvalidRangeForPHPException::forInvalidIntegerBound($value); + } + return (string) $value; } diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php index 78e6e988..fd1f364b 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php @@ -4,9 +4,13 @@ namespace MartinGeorgiev\Doctrine\DBAL\Types\ValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidRangeForPHPException; + /** * Base class for PostgreSQL timestamp range types. * + * @extends Range<\DateTimeInterface> + * * @since 3.3 * * @author Martin Georgiev @@ -25,6 +29,14 @@ public function __construct( protected function compareBounds(mixed $a, mixed $b): int { + if (!$a instanceof \DateTimeInterface) { + throw InvalidRangeForPHPException::forInvalidDateTimeBound($a); + } + + if (!$b instanceof \DateTimeInterface) { + throw InvalidRangeForPHPException::forInvalidDateTimeBound($b); + } + $timestampComparison = $a->getTimestamp() <=> $b->getTimestamp(); if ($timestampComparison !== 0) { return $timestampComparison; diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRange.php index 73f9635b..913ac898 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRange.php @@ -4,6 +4,8 @@ namespace MartinGeorgiev\Doctrine\DBAL\Types\ValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidRangeForPHPException; + /** * Represents a PostgreSQL DATERANGE (date range). * @@ -39,6 +41,14 @@ public function __construct( protected function compareBounds(mixed $a, mixed $b): int { + if (!$a instanceof \DateTimeInterface) { + throw InvalidRangeForPHPException::forInvalidDateTimeBound($a); + } + + if (!$b instanceof \DateTimeInterface) { + throw InvalidRangeForPHPException::forInvalidDateTimeBound($b); + } + return $a->getTimestamp() <=> $b->getTimestamp(); } diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRange.php index 91320333..72b073a9 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRange.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRange.php @@ -4,6 +4,8 @@ namespace MartinGeorgiev\Doctrine\DBAL\Types\ValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidRangeForPHPException; + /** * Represents a PostgreSQL NUMRANGE (numeric range). * @@ -39,6 +41,14 @@ public function __construct( protected function compareBounds(mixed $a, mixed $b): int { + if (!\is_numeric($a)) { + throw InvalidRangeForPHPException::forInvalidNumericBound($a); + } + + if (!\is_numeric($b)) { + throw InvalidRangeForPHPException::forInvalidNumericBound($b); + } + return (float) $a <=> (float) $b; } diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php index 54f221a6..f99fac7d 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php @@ -75,6 +75,9 @@ abstract protected function compareBounds(mixed $a, mixed $b): int; abstract protected function formatValue(mixed $value): string; + /** + * @param string $rangeString The PostgreSQL range string (e.g., '[1,10)', 'empty') + */ public static function fromString(string $rangeString): static { $rangeString = \trim($rangeString); @@ -130,9 +133,6 @@ public function contains(mixed $target): bool return true; } - /** - * Uses PostgreSQL's explicit empty state rather than mathematical tricks. - */ public static function empty(): static { return new static(null, null, true, false, true); diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php index 7eda8247..3f6644e8 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php @@ -33,14 +33,6 @@ public function has_name(): void self::assertEquals($this->getExpectedTypeName(), $this->fixture->getName()); } - #[Test] - public function can_get_sql_declaration(): void - { - $result = $this->fixture->getSQLDeclaration([], $this->platform); - - self::assertEquals($this->getExpectedSqlDeclaration(), $result); - } - #[DataProvider('provideValidTransformations')] #[Test] public function can_transform_from_php_value(?Range $range, ?string $postgresValue): void From 6a4ab3591ecf151e88b5b05e9009244762dbce8f Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sat, 5 Jul 2025 13:25:30 +0300 Subject: [PATCH 08/16] Narrow down interfaced method --- .../MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php | 5 +++-- .../MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php | 3 +-- .../MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php | 3 +-- .../MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php | 3 +-- .../Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php | 3 +-- .../Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php | 3 +-- .../MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php | 3 +-- 7 files changed, 9 insertions(+), 14 deletions(-) diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php index 3f6644e8..ac699e7b 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\Type; +use MartinGeorgiev\Doctrine\DBAL\Types\BaseRangeType; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -19,7 +20,7 @@ abstract class BaseRangeTestCase extends TestCase */ protected MockObject $platform; - protected Type $fixture; + protected BaseRangeType $fixture; protected function setUp(): void { @@ -86,7 +87,7 @@ public function can_handle_postgres_empty_range(): void */ abstract public static function provideValidTransformations(): \Generator; - abstract protected function createRangeType(): Type; + abstract protected function createRangeType(): BaseRangeType; /** * Returns the expected type name (e.g., 'numrange', 'int4range'). diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php index ce692bd2..7e353551 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php @@ -4,13 +4,12 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types; -use Doctrine\DBAL\Types\Type; use MartinGeorgiev\Doctrine\DBAL\Types\DateRange; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DateRange as DateRangeValueObject; final class DateRangeTest extends BaseRangeTestCase { - protected function createRangeType(): Type + protected function createRangeType(): DateRange { return new DateRange(); } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php index 0310f31b..744ae6ac 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php @@ -4,13 +4,12 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types; -use Doctrine\DBAL\Types\Type; use MartinGeorgiev\Doctrine\DBAL\Types\Int4Range; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Int4Range as Int4RangeValueObject; final class Int4RangeTest extends BaseRangeTestCase { - protected function createRangeType(): Type + protected function createRangeType(): Int4Range { return new Int4Range(); } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php index b6bda8e4..edb67a2a 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php @@ -4,13 +4,12 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types; -use Doctrine\DBAL\Types\Type; use MartinGeorgiev\Doctrine\DBAL\Types\Int8Range; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Int8Range as Int8RangeValueObject; final class Int8RangeTest extends BaseRangeTestCase { - protected function createRangeType(): Type + protected function createRangeType(): Int8Range { return new Int8Range(); } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php index c2e480cf..873ab90d 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php @@ -4,13 +4,12 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types; -use Doctrine\DBAL\Types\Type; use MartinGeorgiev\Doctrine\DBAL\Types\NumRange; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\NumericRange; final class NumRangeTest extends BaseRangeTestCase { - protected function createRangeType(): Type + protected function createRangeType(): NumRange { return new NumRange(); } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php index 095a96ef..6b4114b1 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php @@ -4,13 +4,12 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types; -use Doctrine\DBAL\Types\Type; use MartinGeorgiev\Doctrine\DBAL\Types\TsRange; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\TsRange as TsRangeValueObject; final class TsRangeTest extends BaseRangeTestCase { - protected function createRangeType(): Type + protected function createRangeType(): TsRange { return new TsRange(); } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php index 9e70ec29..bf5e7adf 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php @@ -4,13 +4,12 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types; -use Doctrine\DBAL\Types\Type; use MartinGeorgiev\Doctrine\DBAL\Types\TstzRange; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\TstzRange as TstzRangeValueObject; final class TstzRangeTest extends BaseRangeTestCase { - protected function createRangeType(): Type + protected function createRangeType(): TstzRange { return new TstzRange(); } From ecb779ed81c234a40af3a8cebcb3c463c6a75cdb Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sun, 6 Jul 2025 01:53:12 +0300 Subject: [PATCH 09/16] Use template for BaseRangeTestCase --- .../Doctrine/DBAL/Types/BaseRangeType.php | 11 ++++++---- .../Doctrine/DBAL/Types/BaseRangeTestCase.php | 20 +++++++++++++++---- .../Doctrine/DBAL/Types/DateRangeTest.php | 8 +++----- .../Doctrine/DBAL/Types/Int4RangeTest.php | 8 +++----- .../Doctrine/DBAL/Types/Int8RangeTest.php | 8 +++----- .../Doctrine/DBAL/Types/NumRangeTest.php | 8 +++----- .../Doctrine/DBAL/Types/TsRangeTest.php | 8 +++----- .../Doctrine/DBAL/Types/TstzRangeTest.php | 8 +++----- 8 files changed, 41 insertions(+), 38 deletions(-) diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php index 9ae9837d..9827126f 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php @@ -12,7 +12,7 @@ /** * Base class for PostgreSQL range types. * - * @template T of Range + * @template R of Range * * @since 3.3 * @@ -20,6 +20,9 @@ */ abstract class BaseRangeType extends BaseType { + /** + * @param R|null $value + */ public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string { if ($value === null) { @@ -34,9 +37,9 @@ public function convertToDatabaseValue($value, AbstractPlatform $platform): ?str } /** - * @param mixed $value + * @param string|null $value * - * @return T|null + * @return R|null */ public function convertToPHPValue($value, AbstractPlatform $platform): ?Range { @@ -60,7 +63,7 @@ public function convertToPHPValue($value, AbstractPlatform $platform): ?Range } /** - * @return T + * @return R */ abstract protected function createFromString(string $value): Range; } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php index ac699e7b..673c3d00 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php @@ -5,7 +5,6 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types; use Doctrine\DBAL\Platforms\AbstractPlatform; -use Doctrine\DBAL\Types\Type; use MartinGeorgiev\Doctrine\DBAL\Types\BaseRangeType; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; use PHPUnit\Framework\Attributes\DataProvider; @@ -13,6 +12,9 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +/** + * @template R of Range + */ abstract class BaseRangeTestCase extends TestCase { /** @@ -20,6 +22,9 @@ abstract class BaseRangeTestCase extends TestCase */ protected MockObject $platform; + /** + * @var BaseRangeType + */ protected BaseRangeType $fixture; protected function setUp(): void @@ -34,6 +39,9 @@ public function has_name(): void self::assertEquals($this->getExpectedTypeName(), $this->fixture->getName()); } + /** + * @param R|null $range + */ #[DataProvider('provideValidTransformations')] #[Test] public function can_transform_from_php_value(?Range $range, ?string $postgresValue): void @@ -41,6 +49,9 @@ public function can_transform_from_php_value(?Range $range, ?string $postgresVal self::assertEquals($postgresValue, $this->fixture->convertToDatabaseValue($range, $this->platform)); } + /** + * @param R|null $range + */ #[DataProvider('provideValidTransformations')] #[Test] public function can_transform_to_php_value(?Range $range, ?string $postgresValue): void @@ -87,6 +98,9 @@ public function can_handle_postgres_empty_range(): void */ abstract public static function provideValidTransformations(): \Generator; + /** + * @return BaseRangeType + */ abstract protected function createRangeType(): BaseRangeType; /** @@ -95,9 +109,7 @@ abstract protected function createRangeType(): BaseRangeType; abstract protected function getExpectedTypeName(): string; /** - * Returns the expected SQL declaration (e.g., 'NUMRANGE', 'INT4RANGE'). + * @return class-string */ - abstract protected function getExpectedSqlDeclaration(): string; - abstract protected function getExpectedValueObjectClass(): string; } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php index 7e353551..dae37b11 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php @@ -7,6 +7,9 @@ use MartinGeorgiev\Doctrine\DBAL\Types\DateRange; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DateRange as DateRangeValueObject; +/** + * @extends BaseRangeTestCase + */ final class DateRangeTest extends BaseRangeTestCase { protected function createRangeType(): DateRange @@ -19,11 +22,6 @@ protected function getExpectedTypeName(): string return 'daterange'; } - protected function getExpectedSqlDeclaration(): string - { - return 'daterange'; - } - protected function getExpectedValueObjectClass(): string { return DateRangeValueObject::class; diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php index 744ae6ac..2087b8ca 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php @@ -7,6 +7,9 @@ use MartinGeorgiev\Doctrine\DBAL\Types\Int4Range; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Int4Range as Int4RangeValueObject; +/** + * @extends BaseRangeTestCase + */ final class Int4RangeTest extends BaseRangeTestCase { protected function createRangeType(): Int4Range @@ -19,11 +22,6 @@ protected function getExpectedTypeName(): string return 'int4range'; } - protected function getExpectedSqlDeclaration(): string - { - return 'int4range'; - } - protected function getExpectedValueObjectClass(): string { return Int4RangeValueObject::class; diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php index edb67a2a..2d2ebad6 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php @@ -7,6 +7,9 @@ use MartinGeorgiev\Doctrine\DBAL\Types\Int8Range; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Int8Range as Int8RangeValueObject; +/** + * @extends BaseRangeTestCase + */ final class Int8RangeTest extends BaseRangeTestCase { protected function createRangeType(): Int8Range @@ -19,11 +22,6 @@ protected function getExpectedTypeName(): string return 'int8range'; } - protected function getExpectedSqlDeclaration(): string - { - return 'int8range'; - } - protected function getExpectedValueObjectClass(): string { return Int8RangeValueObject::class; diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php index 873ab90d..81458373 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php @@ -7,6 +7,9 @@ use MartinGeorgiev\Doctrine\DBAL\Types\NumRange; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\NumericRange; +/** + * @extends BaseRangeTestCase + */ final class NumRangeTest extends BaseRangeTestCase { protected function createRangeType(): NumRange @@ -19,11 +22,6 @@ protected function getExpectedTypeName(): string return 'numrange'; } - protected function getExpectedSqlDeclaration(): string - { - return 'numrange'; - } - protected function getExpectedValueObjectClass(): string { return NumericRange::class; diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php index 6b4114b1..7e6f5b8e 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php @@ -7,6 +7,9 @@ use MartinGeorgiev\Doctrine\DBAL\Types\TsRange; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\TsRange as TsRangeValueObject; +/** + * @extends BaseRangeTestCase + */ final class TsRangeTest extends BaseRangeTestCase { protected function createRangeType(): TsRange @@ -19,11 +22,6 @@ protected function getExpectedTypeName(): string return 'tsrange'; } - protected function getExpectedSqlDeclaration(): string - { - return 'tsrange'; - } - protected function getExpectedValueObjectClass(): string { return TsRangeValueObject::class; diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php index bf5e7adf..6c74f92a 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php @@ -7,6 +7,9 @@ use MartinGeorgiev\Doctrine\DBAL\Types\TstzRange; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\TstzRange as TstzRangeValueObject; +/** + * @extends BaseRangeTestCase + */ final class TstzRangeTest extends BaseRangeTestCase { protected function createRangeType(): TstzRange @@ -19,11 +22,6 @@ protected function getExpectedTypeName(): string return 'tstzrange'; } - protected function getExpectedSqlDeclaration(): string - { - return 'tstzrange'; - } - protected function getExpectedValueObjectClass(): string { return TstzRangeValueObject::class; From c1a439da8960a8b0849ea6e5d45b002feb3fbdd7 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sun, 6 Jul 2025 02:05:20 +0300 Subject: [PATCH 10/16] Accept errors as a known PHPStan limitation rather than worked around with complex annotations that don't provide real value --- ci/phpstan/baselines/range-baseline.neon | 25 +++++++++++++++++ ci/phpstan/config.neon | 1 + .../Doctrine/DBAL/Types/ValueObject/Range.php | 2 +- .../Doctrine/DBAL/Types/DBALTypesTest.php | 27 +++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 ci/phpstan/baselines/range-baseline.neon diff --git a/ci/phpstan/baselines/range-baseline.neon b/ci/phpstan/baselines/range-baseline.neon new file mode 100644 index 00000000..f78a867a --- /dev/null +++ b/ci/phpstan/baselines/range-baseline.neon @@ -0,0 +1,25 @@ +parameters: + ignoreErrors: + - + message: '#^Method MartinGeorgiev\\Doctrine\\DBAL\\Types\\ValueObject\\Range\:\:empty\(\) should return static\(MartinGeorgiev\\Doctrine\\DBAL\\Types\\ValueObject\\Range\\) but returns static\(MartinGeorgiev\\Doctrine\\DBAL\\Types\\ValueObject\\Range\\)\.$#' + identifier: return.type + count: 1 + path: ../../../src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php + + - + message: '#^Method MartinGeorgiev\\Doctrine\\DBAL\\Types\\ValueObject\\Range\:\:fromString\(\) should return static\(MartinGeorgiev\\Doctrine\\DBAL\\Types\\ValueObject\\Range\\) but returns static\(MartinGeorgiev\\Doctrine\\DBAL\\Types\\ValueObject\\Range\\)\.$#' + identifier: return.type + count: 2 + path: ../../../src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php + + - + message: '#^Method MartinGeorgiev\\Doctrine\\DBAL\\Types\\ValueObject\\Range\:\:infinite\(\) should return static\(MartinGeorgiev\\Doctrine\\DBAL\\Types\\ValueObject\\Range\\) but returns static\(MartinGeorgiev\\Doctrine\\DBAL\\Types\\ValueObject\\Range\\)\.$#' + identifier: return.type + count: 1 + path: ../../../src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 4 + path: ../../../src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php diff --git a/ci/phpstan/config.neon b/ci/phpstan/config.neon index 88974d2e..87755b96 100644 --- a/ci/phpstan/config.neon +++ b/ci/phpstan/config.neon @@ -6,6 +6,7 @@ includes: - ./baselines/deprecated-methods.neon - ./baselines/lexer-variations.neon - ./baselines/phpstan-identifiers.neon + - ./baselines/range-baseline.neon - ./baselines/type-mismatches.neon parameters: diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php index f99fac7d..d5caaac8 100644 --- a/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php @@ -7,7 +7,7 @@ /** * Abstract base class for PostgreSQL range types. * - * @template T of int|float|\DateTimeInterface + * @template R of int|float|\DateTimeInterface * * @since 3.3 * diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/DBALTypesTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/DBALTypesTest.php index 3980f849..fd3ee9de 100644 --- a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/DBALTypesTest.php +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/DBALTypesTest.php @@ -23,6 +23,9 @@ public function test_scalar_type(string $typeName, string $columnType, mixed $te $this->runTypeTest($typeName, $columnType, $testValue); } + /** + * @return array + */ public static function provideScalarTypeTestCases(): array { return [ @@ -35,12 +38,18 @@ public static function provideScalarTypeTestCases(): array ]; } + /** + * @param array $testValue + */ #[DataProvider('provideArrayTypeTestCases')] public function test_array_type(string $typeName, string $columnType, array $testValue): void { $this->runTypeTest($typeName, $columnType, $testValue); } + /** + * @return array}> + */ public static function provideArrayTypeTestCases(): array { return [ @@ -63,12 +72,18 @@ public static function provideArrayTypeTestCases(): array ]; } + /** + * @param array $testValue + */ #[DataProvider('provideJsonTypeTestCases')] public function test_json_type(string $typeName, string $columnType, array $testValue): void { $this->runTypeTest($typeName, $columnType, $testValue); } + /** + * @return array}> + */ public static function provideJsonTypeTestCases(): array { return [ @@ -94,6 +109,9 @@ public function test_point_type(string $typeName, string $columnType, PointValue $this->runTypeTest($typeName, $columnType, $pointValueObject); } + /** + * @return array + */ public static function providePointTypeTestCases(): array { return [ @@ -104,12 +122,18 @@ public static function providePointTypeTestCases(): array ]; } + /** + * @param DateRangeValueObject|Int4RangeValueObject|Int8RangeValueObject|NumRangeValueObject|TsRangeValueObject|TstzRangeValueObject $rangeValueObject + */ #[DataProvider('provideRangeTypeTestCases')] public function test_range_type(string $typeName, string $columnType, RangeValueObject $rangeValueObject): void { $this->runTypeTest($typeName, $columnType, $rangeValueObject); } + /** + * @return array + */ public static function provideRangeTypeTestCases(): array { return [ @@ -183,6 +207,9 @@ private function assertPointEquals(PointValueObject $pointValueObject, mixed $ac $this->assertEquals($pointValueObject->getY(), $actual->getY(), 'Failed asserting that Y coordinates are equal for type '.$typeName); } + /** + * @param RangeValueObject<\DateTimeInterface|float|int> $rangeValueObject + */ private function assertRangeEquals(RangeValueObject $rangeValueObject, mixed $actual, string $typeName): void { $this->assertInstanceOf(RangeValueObject::class, $actual, 'Failed asserting that value is a Range object for type '.$typeName); From 75bd932a9d18290e3e94c932eabe3cf90ea337e3 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sun, 6 Jul 2025 02:41:45 +0300 Subject: [PATCH 11/16] Add basic documentation --- docs/AVAILABLE-TYPES.md | 6 ++++++ docs/INTEGRATING-WITH-DOCTRINE.md | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/docs/AVAILABLE-TYPES.md b/docs/AVAILABLE-TYPES.md index 7e82a289..02ef6551 100644 --- a/docs/AVAILABLE-TYPES.md +++ b/docs/AVAILABLE-TYPES.md @@ -19,3 +19,9 @@ | macaddr[] | _macaddr | `MartinGeorgiev\Doctrine\DBAL\Types\MacaddrArray` | | point | point | `MartinGeorgiev\Doctrine\DBAL\Types\Point` | | point[] | _point | `MartinGeorgiev\Doctrine\DBAL\Types\PointArray` | +| daterange | daterange | `MartinGeorgiev\Doctrine\DBAL\Types\DateRange` | +| int4range | int4range | `MartinGeorgiev\Doctrine\DBAL\Types\Int4Range` | +| int8range | int8range | `MartinGeorgiev\Doctrine\DBAL\Types\Int8Range` | +| numrange | numrange | `MartinGeorgiev\Doctrine\DBAL\Types\NumRange` | +| tsrange | tsrange | `MartinGeorgiev\Doctrine\DBAL\Types\TsRange` | +| tstzrange | tstzrange | `MartinGeorgiev\Doctrine\DBAL\Types\TstzRange` | diff --git a/docs/INTEGRATING-WITH-DOCTRINE.md b/docs/INTEGRATING-WITH-DOCTRINE.md index 0f1cd782..538fe2f7 100644 --- a/docs/INTEGRATING-WITH-DOCTRINE.md +++ b/docs/INTEGRATING-WITH-DOCTRINE.md @@ -31,6 +31,13 @@ Type::addType('macaddr[]', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\MacaddrArray" Type::addType('point', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Point"); Type::addType('point[]', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\PointArray"); + +Type::addType('daterange', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\DateRange"); +Type::addType('int4range', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Int4Range"); +Type::addType('int8range', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Int8Range"); +Type::addType('numrange', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\NumRange"); +Type::addType('tsrange', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\TsRange"); +Type::addType('tstzrange', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\TstzRange"); ``` @@ -237,6 +244,13 @@ $platform->registerDoctrineTypeMapping('_macaddr','macaddr[]'); $platform->registerDoctrineTypeMapping('point','point'); $platform->registerDoctrineTypeMapping('point[]','point[]'); $platform->registerDoctrineTypeMapping('_point','point[]'); + +$platform->registerDoctrineTypeMapping('daterange','daterange'); +$platform->registerDoctrineTypeMapping('int4range','int4range'); +$platform->registerDoctrineTypeMapping('int8range','int8range'); +$platform->registerDoctrineTypeMapping('numrange','numrange'); +$platform->registerDoctrineTypeMapping('tsrange','tsrange'); +$platform->registerDoctrineTypeMapping('tstzrange','tstzrange'); ... ``` From b70f8c3a4958709b5c9721937ecb983e4306ca4b Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sun, 6 Jul 2025 16:58:48 +0300 Subject: [PATCH 12/16] Add examples for range's VO and DT usage --- README.md | 11 ++ docs/RANGE-TYPES.md | 330 +++++++++++++++++++++++++++++++++ docs/USE-CASES-AND-EXAMPLES.md | 42 +++++ 3 files changed, 383 insertions(+) create mode 100644 docs/RANGE-TYPES.md diff --git a/README.md b/README.md index 65152e60..1703c74c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Enhances Doctrine with PostgreSQL-specific features and functions. Supports Post // Register types with Doctrine Type::addType('jsonb', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Jsonb"); Type::addType('text[]', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\TextArray"); +Type::addType('numrange', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\NumRange"); // Use in your Doctrine entities #[ORM\Column(type: 'jsonb')] @@ -21,6 +22,9 @@ private array $data; #[ORM\Column(type: 'text[]')] private array $tags; +#[ORM\Column(type: 'numrange')] +private NumericRange $priceRange; + // Use in DQL $query = $em->createQuery(' SELECT e @@ -51,6 +55,9 @@ This package provides comprehensive Doctrine support for PostgreSQL features: - MAC addresses (`macaddr`, `macaddr[]`) - **Geometric Types** - Point (`point`, `point[]`) +- **Range Types** + - Date and time ranges (`daterange`, `tsrange`, `tstzrange`) + - Numeric ranges (`numrange`, `int4range`, `int8range`) ### PostgreSQL Operators - **Array Operations** @@ -62,6 +69,9 @@ This package provides comprehensive Doctrine support for PostgreSQL features: - Field access (`->`, `->>`) - Path operations (`#>`, `#>>`) - JSON containment and existence operators +- **Range Operations** + - Containment checks (in PHP value objects and for DQL queries with `@>`) + - Overlaps (`&&`) ### Functions - **Text Search** @@ -85,6 +95,7 @@ This package provides comprehensive Doctrine support for PostgreSQL features: Full documentation: - [Available Types](docs/AVAILABLE-TYPES.md) +- [Value Objects for Range Types](docs/RANGE-TYPES.md) - [Available Functions and Operators](docs/AVAILABLE-FUNCTIONS-AND-OPERATORS.md) - [Common Use Cases and Examples](docs/USE-CASES-AND-EXAMPLES.md) diff --git a/docs/RANGE-TYPES.md b/docs/RANGE-TYPES.md new file mode 100644 index 00000000..8656370a --- /dev/null +++ b/docs/RANGE-TYPES.md @@ -0,0 +1,330 @@ +# PostgreSQL Range Types + +PostgreSQL range types represent ranges of values of some element type (called the range's subtype). This library provides support for all PostgreSQL built-in range types. + +## Available Range Types + +| Range Type | PostgreSQL Type | Value Type | Description | +|---|---|---|---| +| DateRange | DATERANGE | DateTimeInterface | Date ranges (without time) | +| Int4Range | INT4RANGE | int | 4-byte integer ranges | +| Int8Range | INT8RANGE | int | 8-byte integer ranges | +| NumRange | NUMRANGE | int/float | Numeric ranges with arbitrary precision | +| TsRange | TSRANGE | DateTimeInterface | Timestamp ranges without timezone | +| TstzRange | TSTZRANGE | DateTimeInterface | Timestamp ranges with timezone | + +## Basic Usage + +### Registration + +First, register the range types you need: + +```php +use Doctrine\DBAL\Types\Type; + +Type::addType('daterange', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\DateRange"); +Type::addType('int4range', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Int4Range"); +Type::addType('int8range', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Int8Range"); +Type::addType('numrange', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\NumRange"); +Type::addType('tsrange', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\TsRange"); +Type::addType('tstzrange', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\TstzRange"); +``` + +### Entity Usage + +```php +use Doctrine\ORM\Mapping as ORM; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DateRange; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\NumericRange; + +#[ORM\Entity] +class Product +{ + #[ORM\Column(type: 'numrange')] + private ?NumericRange $priceRange = null; + + #[ORM\Column(type: 'daterange')] + private ?DateRange $availabilityPeriod = null; + + public function setPriceRange(float $min, float $max): void + { + $this->priceRange = new NumericRange($min, $max); + } + + public function setAvailabilityPeriod(\DateTimeInterface $start, \DateTimeInterface $end): void + { + $this->availabilityPeriod = new DateRange($start, $end); + } +} +``` + +## Range Construction + +### Inclusive vs Exclusive Bounds + +Ranges support both inclusive `[` and exclusive `(` bounds: + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\NumericRange; + +// [1.0, 10.0) - includes 1.0, excludes 10.0 +$range = new NumericRange(1.0, 10.0, true, false); + +// (0, 100] - excludes 0, includes 100 +$range = new NumericRange(0, 100, false, true); + +// [5, 15] - includes both bounds +$range = new NumericRange(5, 15, true, true); +``` + +### Infinite Ranges + +Ranges can be unbounded on either side: + +```php +// [10, ∞) - from 10 to infinity +$range = new NumericRange(10, null, true, false); + +// (-∞, 100] - from negative infinity to 100 +$range = new NumericRange(null, 100, false, true); + +// (-∞, ∞) - infinite range +$range = NumericRange::infinite(); +``` + +### Empty Ranges + +```php +// Create an explicitly empty range +$range = NumericRange::empty(); + +// Check if a range is empty +if ($range->isEmpty()) { + // Handle empty range +} +``` + +## Numeric Ranges (NUMRANGE) + +For arbitrary precision numeric values: + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\NumericRange; + +// Price range from €10.50 to €99.99 +$priceRange = new NumericRange(10.50, 99.99); + +// Check if a price is in range +if ($priceRange->contains(25.00)) { + echo "Price is in range"; +} + +// Create from PostgreSQL string +$range = NumericRange::fromString('[10.5,99.99)'); +``` + +## Integer Ranges + +### Int4Range (4-byte integers) + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Int4Range; + +// Age range +$ageRange = new Int4Range(18, 65); + +// Check if age is valid +if ($ageRange->contains(25)) { + echo "Age is valid"; +} +``` + +### Int8Range (8-byte integers) + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Int8Range; + +// Large number range +$range = new Int8Range(PHP_INT_MIN, PHP_INT_MAX); +``` + +## Date Ranges (DATERANGE) + +For date-only ranges without time components: + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DateRange; + +// Event period +$eventPeriod = new DateRange( + new \DateTimeImmutable('2024-01-01'), + new \DateTimeImmutable('2024-12-31') +); + +// Convenience methods +$singleDay = DateRange::singleDay(new \DateTimeImmutable('2024-06-15')); +$year2024 = DateRange::year(2024); +$june2024 = DateRange::month(2024, 6); + +// Check if a date falls within the range +$checkDate = new \DateTimeImmutable('2024-06-15'); +if ($eventPeriod->contains($checkDate)) { + echo "Date is within event period"; +} +``` + +## Timestamp Ranges + +### TsRange (without timezone) + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\TsRange; + +// Working hours +$workingHours = new TsRange( + new \DateTimeImmutable('2024-01-01 09:00:00'), + new \DateTimeImmutable('2024-01-01 17:00:00') +); +``` + +### TstzRange (with timezone) + +```php +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\TstzRange; + +// Meeting time across UTC timezone +$meetingTime = new TstzRange( + new \DateTimeImmutable('2024-01-01 14:00:00+00:00'), + new \DateTimeImmutable('2024-01-01 15:00:00+00:00') +); +``` + +## Range Operations + +### Contains Check + +```php +$range = new NumericRange(1, 10); + +if ($range->contains(5)) { + echo "5 is in the range [1, 10)"; +} +``` + +### String Representation + +```php +$range = new NumericRange(1.5, 10.7); +echo $range; // Outputs: [1.5,10.7) + +$range = new DateRange( + new \DateTimeImmutable('2024-01-01'), + new \DateTimeImmutable('2024-12-31') +); +echo $range; // Outputs: [2024-01-01,2024-12-31) +``` + +### Parsing from String Values + +```php +// Parse PostgreSQL range strings +$numRange = NumericRange::fromString('[1.5,10.7)'); +$dateRange = DateRange::fromString('[2024-01-01,2024-12-31)'); +$emptyRange = NumericRange::fromString('empty'); +``` + +## DQL Usage with Range Functions + +Register range functions for DQL queries: + +```php +$configuration->addCustomStringFunction('DATERANGE', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Daterange::class); +$configuration->addCustomStringFunction('INT4RANGE', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Int4range::class); +$configuration->addCustomStringFunction('INT8RANGE', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Int8range::class); +$configuration->addCustomStringFunction('NUMRANGE', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Numrange::class); +$configuration->addCustomStringFunction('TSRANGE', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Tsrange::class); +$configuration->addCustomStringFunction('TSTZRANGE', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Tstzrange::class); +``` + +Use in DQL: + +```php +// Find products with overlapping price ranges +$dql = " + SELECT p + FROM Product p + WHERE OVERLAPS(p.priceRange, NUMRANGE(20, 50)) = TRUE +"; + +// Find events in a date range +$dql = " + SELECT e + FROM Event e + WHERE CONTAINS(e.period, DATERANGE('2024-06-01', '2024-06-30')) = TRUE +"; +``` + +## Common Use Cases + +### Price Ranges + +```php +#[ORM\Entity] +class Product +{ + #[ORM\Column(type: 'numrange')] + private ?NumericRange $priceRange = null; + + public function setPriceRange(float $min, float $max): void + { + $this->priceRange = new NumericRange($min, $max, true, false); + } + + public function isInPriceRange(float $price): bool + { + return $this->priceRange?->contains($price) ?? false; + } +} +``` + +### Availability Periods + +```php +#[ORM\Entity] +class Room +{ + #[ORM\Column(type: 'tstzrange')] + private ?TstzRange $availabilityWindow = null; + + public function setAvailability(\DateTimeInterface $start, \DateTimeInterface $end): void + { + $this->availabilityWindow = new TstzRange($start, $end); + } + + public function isAvailableAt(\DateTimeInterface $time): bool + { + return $this->availabilityWindow?->contains($time) ?? false; + } +} +``` + +### Age Restrictions + +```php +#[ORM\Entity] +class Event +{ + #[ORM\Column(type: 'int4range')] + private ?Int4Range $ageRestriction = null; + + public function setAgeRestriction(int $minAge, int $maxAge): void + { + $this->ageRestriction = new Int4Range($minAge, $maxAge, true, true); + } + + public function isAgeAllowed(int $age): bool + { + return $this->ageRestriction?->contains($age) ?? true; + } +} +``` diff --git a/docs/USE-CASES-AND-EXAMPLES.md b/docs/USE-CASES-AND-EXAMPLES.md index c4a47ff0..bfd62451 100644 --- a/docs/USE-CASES-AND-EXAMPLES.md +++ b/docs/USE-CASES-AND-EXAMPLES.md @@ -97,3 +97,45 @@ SELECT DATE_ADD(e.timestampWithTz, '1 day', 'Europe/London') FROM Entity e SELECT DATE_SUBTRACT(e.timestampWithTz, '2 hours') FROM Entity e SELECT DATE_SUBTRACT(e.timestampWithTz, '2 hours', 'UTC') FROM Entity e ``` + +Using Range Types +--- + +PostgreSQL range types allow you to work with ranges of values efficiently. Here are practical examples: + +```php +// Entity with range fields +#[ORM\Entity] +class Product +{ + #[ORM\Column(type: 'numrange')] + private ?NumericRange $priceRange = null; + + #[ORM\Column(type: 'daterange')] + private ?DateRange $availabilityPeriod = null; +} + +// Create ranges +$product = new Product(); +$product->setPriceRange(new NumericRange(10.50, 99.99)); +$product->setAvailabilityPeriod(new DateRange( + new \DateTimeImmutable('2024-01-01'), + new \DateTimeImmutable('2024-12-31') +)); + +// Check if values are in range +if ($product->getPriceRange()->contains(25.00)) { + echo "Price is in range"; +} +``` + +```sql +-- Find products with overlapping price ranges +SELECT p FROM Product p WHERE OVERLAPS(p.priceRange, NUMRANGE(20, 50)) = TRUE + +-- Find products available in a specific period +SELECT p FROM Product p WHERE CONTAINS(p.availabilityPeriod, DATERANGE('2024-06-01', '2024-06-30')) = TRUE + +-- Find products with prices in a specific range +SELECT p FROM Product p WHERE p.priceRange @> 25.0 +``` From 2f55bcb78c713aca316547d881def2b3243023ed Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Tue, 8 Jul 2025 01:47:47 +0300 Subject: [PATCH 13/16] Add more tests --- .../Doctrine/DBAL/Types/BaseRangeTestCase.php | 37 +++ .../Types/ValueObject/BaseRangeTestCase.php | 288 +++++++++++++++++ .../BaseTimestampRangeTestCase.php | 169 ++++++++++ .../DBAL/Types/ValueObject/DateRangeTest.php | 148 +++++++++ .../DBAL/Types/ValueObject/Int4RangeTest.php | 133 ++++---- .../DBAL/Types/ValueObject/Int8RangeTest.php | 123 ++++---- .../Types/ValueObject/NumericRangeTest.php | 189 ++++++++--- .../DBAL/Types/ValueObject/TsRangeTest.php | 294 ++++++++++++++---- .../DBAL/Types/ValueObject/TstzRangeTest.php | 229 +++++++++++--- 9 files changed, 1355 insertions(+), 255 deletions(-) create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseRangeTestCase.php create mode 100644 tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRangeTestCase.php diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php index 673c3d00..66416938 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php @@ -6,6 +6,8 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use MartinGeorgiev\Doctrine\DBAL\Types\BaseRangeType; +use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidRangeForDatabaseException; +use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidRangeForPHPException; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -93,6 +95,41 @@ public function can_handle_postgres_empty_range(): void self::assertTrue($result->isEmpty()); } + #[Test] + public function can_handle_empty_string_from_sql(): void + { + $result = $this->fixture->convertToPHPValue('', $this->platform); + + self::assertNull($result); + } + + #[Test] + public function throws_exception_for_invalid_php_value_type(): void + { + $this->expectException(InvalidRangeForDatabaseException::class); + $this->expectExceptionMessage('Invalid type for range'); + + $this->fixture->convertToDatabaseValue('invalid', $this->platform); // @phpstan-ignore-line argument.type + } + + #[Test] + public function throws_exception_for_invalid_sql_value_type(): void + { + $this->expectException(InvalidRangeForPHPException::class); + $this->expectExceptionMessage('Invalid database value type for range conversion'); + + $this->fixture->convertToPHPValue([1, 2], $this->platform); // @phpstan-ignore-line argument.type + } + + #[Test] + public function throws_exception_for_invalid_range_format(): void + { + $this->expectException(InvalidRangeForPHPException::class); + $this->expectExceptionMessage('Invalid range format from database'); + + $this->fixture->convertToPHPValue('{1,2}', $this->platform); + } + /** * Each array contains [phpValue, postgresValue] pairs. */ diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseRangeTestCase.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseRangeTestCase.php new file mode 100644 index 00000000..a4905f78 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseRangeTestCase.php @@ -0,0 +1,288 @@ +createSimpleRange(); + $expectedString = $this->getExpectedSimpleRangeString(); + + self::assertEquals($expectedString, (string) $range); + self::assertFalse($range->isEmpty()); + } + + #[Test] + public function can_create_empty_range(): void + { + $range = $this->createEmptyRange(); + + self::assertEquals('empty', (string) $range); + self::assertTrue($range->isEmpty()); + } + + #[Test] + public function can_create_infinite_range(): void + { + $range = $this->createInfiniteRange(); + + self::assertEquals('(,)', (string) $range); + self::assertFalse($range->isEmpty()); + } + + #[Test] + public function can_create_inclusive_range(): void + { + $range = $this->createInclusiveRange(); + $expectedString = $this->getExpectedInclusiveRangeString(); + + self::assertEquals($expectedString, (string) $range); + self::assertFalse($range->isEmpty()); + } + + #[Test] + #[DataProvider('provideContainsTestCases')] + public function can_check_contains(Range $range, mixed $value, bool $expected): void + { + self::assertEquals($expected, $range->contains($value)); + } + + #[Test] + #[DataProvider('provideFromStringTestCases')] + public function can_parse_from_string(string $input, Range $expectedRange): void + { + $range = $this->parseFromString($input); + $this->assertRangeEquals($expectedRange, $range); + } + + #[Test] + public function can_handle_boundary_conditions(): void + { + $range = $this->createBoundaryTestRange(); + $testCases = $this->getBoundaryTestCases(); + + $this->assertBoundaryConditions($range, $testCases); + } + + #[Test] + public function can_handle_comparison_via_is_empty(): void + { + $testCases = $this->getComparisonTestCases(); + + foreach ($testCases as $description => $testCase) { + self::assertEquals( + $testCase['expectedEmpty'], + $testCase['range']->isEmpty(), + 'Comparison test failed: '.$description + ); + } + } + + /** + * Create a simple range for basic testing. + */ + abstract protected function createSimpleRange(): Range; + + /** + * Get expected string representation of simple range. + */ + abstract protected function getExpectedSimpleRangeString(): string; + + /** + * Create an empty range. + */ + abstract protected function createEmptyRange(): Range; + + /** + * Create an infinite range. + */ + abstract protected function createInfiniteRange(): Range; + + /** + * Create an inclusive range for testing. + */ + abstract protected function createInclusiveRange(): Range; + + /** + * Get expected string representation of inclusive range. + */ + abstract protected function getExpectedInclusiveRangeString(): string; + + /** + * Parse range from string. + */ + abstract protected function parseFromString(string $input): Range; + + /** + * Create range for boundary testing. + */ + abstract protected function createBoundaryTestRange(): Range; + + /** + * Get boundary test cases. + * + * @return array + */ + abstract protected function getBoundaryTestCases(): array; + + /** + * Get comparison test cases. + * + * @return array + */ + abstract protected function getComparisonTestCases(): array; + + /** + * @return \Generator + */ + abstract public static function provideContainsTestCases(): \Generator; + + /** + * @return \Generator + */ + abstract public static function provideFromStringTestCases(): \Generator; + + /** + * Assert that a range equals another range by comparing string representation and isEmpty state. + */ + protected function assertRangeEquals(Range $expected, Range $actual, string $message = ''): void + { + self::assertEquals($expected->__toString(), $actual->__toString(), $message.' (string representation)'); + self::assertEquals($expected->isEmpty(), $actual->isEmpty(), $message.' (isEmpty state)'); + } + + /** + * Assert that a range contains all the given values. + * + * @param array $values + */ + protected function assertRangeContainsAll(Range $range, array $values, string $message = ''): void + { + foreach ($values as $value) { + self::assertTrue( + $range->contains($value), + $message.' - Range should contain value: '.\var_export($value, true) + ); + } + } + + /** + * Assert that a range does not contain any of the given values. + * + * @param array $values + */ + protected function assertRangeContainsNone(Range $range, array $values, string $message = ''): void + { + foreach ($values as $value) { + self::assertFalse( + $range->contains($value), + $message.' - Range should not contain value: '.\var_export($value, true) + ); + } + } + + /** + * Assert that a range has the expected string representation. + */ + protected function assertRangeStringEquals(string $expected, Range $range, string $message = ''): void + { + self::assertEquals($expected, (string) $range, $message); + } + + /** + * Assert that a range is empty. + */ + protected function assertRangeIsEmpty(Range $range, string $message = ''): void + { + self::assertTrue($range->isEmpty(), $message.' - Range should be empty'); + self::assertEquals('empty', (string) $range, $message.' - Empty range should have "empty" string representation'); + } + + /** + * Assert that a range is not empty. + */ + protected function assertRangeIsNotEmpty(Range $range, string $message = ''): void + { + self::assertFalse($range->isEmpty(), $message.' - Range should not be empty'); + self::assertNotEquals('empty', (string) $range, $message.' - Non-empty range should not have "empty" string representation'); + } + + /** + * Test boundary conditions for a range with known bounds. + * + * @param array $testCases + */ + protected function assertBoundaryConditions(Range $range, array $testCases, string $message = ''): void + { + foreach ($testCases as $description => $testCase) { + self::assertEquals( + $testCase['expected'], + $range->contains($testCase['value']), + $message.' - Boundary test failed: '.$description + ); + } + } + + /** + * Generate common boundary test cases for a range [lower, upper). + * + * @return array + */ + protected function generateBoundaryTestCases( + mixed $lower, + mixed $upper, + mixed $belowLower, + mixed $aboveUpper, + mixed $middle + ): array { + return [ + 'contains lower bound (inclusive)' => ['value' => $lower, 'expected' => true], + 'does not contain value below range' => ['value' => $belowLower, 'expected' => false], + 'does not contain upper bound (exclusive)' => ['value' => $upper, 'expected' => false], + 'does not contain value above range' => ['value' => $aboveUpper, 'expected' => false], + 'contains middle value' => ['value' => $middle, 'expected' => true], + 'does not contain null' => ['value' => null, 'expected' => false], + ]; + } + + /** + * Test that a range correctly handles equal bounds with different bracket combinations. + * + * @param callable $rangeFactory Function that creates a range: fn($lower, $upper, $lowerInc, $upperInc) => Range + */ + protected function assertEqualBoundsHandling(callable $rangeFactory, mixed $value): void + { + $inclusiveEqual = $rangeFactory($value, $value, true, true); + self::assertFalse($inclusiveEqual->isEmpty(), 'Equal bounds with inclusive brackets should not be empty'); + self::assertTrue($inclusiveEqual->contains($value), 'Equal bounds with inclusive brackets should contain the value'); + + $exclusiveExclusive = $rangeFactory($value, $value, false, false); + self::assertTrue($exclusiveExclusive->isEmpty(), 'Equal bounds with exclusive brackets should be empty'); + + $inclusiveExclusive = $rangeFactory($value, $value, true, false); + self::assertTrue($inclusiveExclusive->isEmpty(), 'Equal bounds with mixed brackets should be empty'); + + $exclusiveInclusive = $rangeFactory($value, $value, false, true); + self::assertTrue($exclusiveInclusive->isEmpty(), 'Equal bounds with mixed brackets should be empty'); + } + + /** + * Test that a range correctly handles reverse bounds (lower > upper). + * + * @param callable $rangeFactory Function that creates a range: fn($lower, $upper, $lowerInc, $upperInc) => Range + */ + protected function assertReverseBoundsHandling(callable $rangeFactory, mixed $lower, mixed $upper): void + { + $reverseRange = $rangeFactory($lower, $upper, true, false); + self::assertTrue($reverseRange->isEmpty(), 'Range with lower > upper should be empty'); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRangeTestCase.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRangeTestCase.php new file mode 100644 index 00000000..eaecedf5 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRangeTestCase.php @@ -0,0 +1,169 @@ + [ + 'value' => $this->getTestStartTime(), + 'expected' => true, + ], + 'does not contain value before range' => [ + 'value' => $this->getTimeBeforeRange(), + 'expected' => false, + ], + 'does not contain upper bound (exclusive)' => [ + 'value' => $this->getTestEndTime(), + 'expected' => false, + ], + 'contains value in middle' => [ + 'value' => $this->getTimeInMiddle(), + 'expected' => true, + ], + ]; + } + + protected function getComparisonTestCases(): array + { + return [ + 'reverse range should be empty' => [ + 'range' => $this->createRangeWithTimes($this->getTestEndTime(), $this->getTestStartTime()), + 'expectedEmpty' => true, + ], + 'normal range should not be empty' => [ + 'range' => $this->createRangeWithTimes($this->getTestStartTime(), $this->getTestEndTime()), + 'expectedEmpty' => false, + ], + 'equal bounds exclusive should be empty' => [ + 'range' => $this->createRangeWithTimes( + $this->getTestStartTime(), + $this->getTestStartTime(), + false, + false + ), + 'expectedEmpty' => true, + ], + 'equal bounds inclusive should not be empty' => [ + 'range' => $this->createRangeWithTimes( + $this->getTestStartTime(), + $this->getTestStartTime(), + true, + true + ), + 'expectedEmpty' => false, + ], + ]; + } + + #[Test] + public function throws_exception_for_invalid_constructor_input(): void + { + $this->expectException(\TypeError::class); + + $this->createRangeWithTimes('invalid', $this->getTestEndTime()); + } + + #[Test] + public function throws_exception_for_invalid_parse_input(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid timestamp value'); + + $this->parseFromString('[invalid_timestamp,2023-01-01 18:00:00)'); + } + + #[Test] + public function throws_exception_for_invalid_contains_input(): void + { + $range = $this->createBoundaryTestRange(); + + $this->expectException(InvalidRangeForPHPException::class); + $this->expectExceptionMessage('Range bound must be a DateTimeInterface instance'); + + $range->contains('invalid'); + } + + #[Test] + public function can_handle_microsecond_precision(): void + { + $earlier = $this->createTimeWithMicroseconds('2023-01-01 10:00:00.123456'); + $later = $this->createTimeWithMicroseconds('2023-01-01 10:00:00.654321'); + + $range = $this->createRangeWithTimes($earlier, $later); + + self::assertTrue($range->contains($this->createTimeWithMicroseconds('2023-01-01 10:00:00.400000'))); + self::assertFalse($range->contains($this->createTimeWithMicroseconds('2023-01-01 10:00:00.100000'))); + self::assertFalse($range->contains($this->createTimeWithMicroseconds('2023-01-01 10:00:00.700000'))); + } + + #[Test] + public function can_handle_different_datetime_implementations(): void + { + $dateTime = new \DateTimeImmutable($this->getTestStartTimeString()); + $dateTimeImmutable = new \DateTimeImmutable($this->getTestEndTimeString()); + + $range = $this->createRangeWithTimes($dateTime, $dateTimeImmutable); + $formatted = (string) $range; + + self::assertStringContainsString('2023-01-01 10:00:00', $formatted); + self::assertStringContainsString('2023-01-01 18:00:00', $formatted); + } + + /** + * Create a range with specific DateTimeInterface objects. + */ + abstract protected function createRangeWithTimes( + ?\DateTimeInterface $start, + ?\DateTimeInterface $end, + bool $lowerInclusive = true, + bool $upperInclusive = false + ): Range; + + /** + * Get test start time. + */ + abstract protected function getTestStartTime(): \DateTimeInterface; + + /** + * Get test end time. + */ + abstract protected function getTestEndTime(): \DateTimeInterface; + + /** + * Get time before the test range. + */ + abstract protected function getTimeBeforeRange(): \DateTimeInterface; + + /** + * Get time in the middle of the test range. + */ + abstract protected function getTimeInMiddle(): \DateTimeInterface; + + /** + * Create time with microseconds for precision testing. + */ + abstract protected function createTimeWithMicroseconds(string $timeString): \DateTimeInterface; + + /** + * Get test start time as string. + */ + abstract protected function getTestStartTimeString(): string; + + /** + * Get test end time as string. + */ + abstract protected function getTestEndTimeString(): string; +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php index 0e867ac0..163c1471 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types\ValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidRangeForPHPException; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DateRange; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -141,4 +142,151 @@ public static function providesFromStringTestCases(): \Generator ]; yield 'empty range' => ['empty', DateRange::empty()]; } + + #[Test] + public function throws_exception_for_invalid_lower_bound_type(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Lower bound must be DateTimeInterface'); + + new DateRange('invalid', new \DateTimeImmutable('2023-12-31')); + } + + #[Test] + public function throws_exception_for_invalid_upper_bound_type(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Upper bound must be DateTimeInterface'); + + new DateRange(new \DateTimeImmutable('2023-01-01'), 'invalid'); + } + + #[Test] + public function throws_exception_for_invalid_datetime_in_comparison_via_contains(): void + { + $dateRange = new DateRange( + new \DateTimeImmutable('2023-01-01'), + new \DateTimeImmutable('2023-12-31') + ); + + $this->expectException(InvalidRangeForPHPException::class); + $this->expectExceptionMessage('Range bound must be a DateTimeInterface instance'); + + $dateRange->contains('invalid'); + } + + #[Test] + public function throws_exception_for_invalid_value_in_constructor(): void + { + $this->expectException(\TypeError::class); + + new DateRange('invalid', new \DateTimeImmutable('2023-12-31')); + } + + #[Test] + public function throws_exception_for_invalid_date_string_in_parse_via_from_string(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid range format'); + + DateRange::fromString('[invalid_date,2023-12-31)'); + } + + #[Test] + public function can_parse_various_date_formats_via_from_string(): void + { + $dateRange = DateRange::fromString('[2023-01-01,2023-12-31)'); + self::assertStringContainsString('2023-01-01', (string) $dateRange); + + $range2 = DateRange::fromString('[2023-12-31,2024-01-01)'); + self::assertStringContainsString('2023-12-31', (string) $range2); + } + + #[Test] + public function can_format_date_values_via_to_string(): void + { + // Time should be ignored in date formatting + $dateRange = new DateRange( + new \DateTimeImmutable('2023-06-15 14:30:00'), + new \DateTimeImmutable('2023-06-16 20:45:00') + ); + + $formatted = (string) $dateRange; + self::assertStringContainsString('2023-06-15', $formatted); + self::assertStringContainsString('2023-06-16', $formatted); + // Should not contain time information + self::assertStringNotContainsString('14:30:00', $formatted); + self::assertStringNotContainsString('20:45:00', $formatted); + } + + #[Test] + public function can_compare_dates_with_different_times_via_is_empty(): void + { + $date1 = new \DateTimeImmutable('2023-06-15 10:00:00'); + $date2 = new \DateTimeImmutable('2023-06-15 20:00:00'); + + // Test comparison logic through isEmpty() - more natural than reflection + // When lower > upper, range should be empty + $reverseRange = new DateRange($date2, $date1); // 20:00 to 10:00 + self::assertTrue($reverseRange->isEmpty()); + + // When lower < upper, range should not be empty + $normalRange = new DateRange($date1, $date2); // 10:00 to 20:00 + self::assertFalse($normalRange->isEmpty()); + + // When lower == upper with exclusive bounds, should be empty + $equalExclusive = new DateRange($date1, $date1, false, false); + self::assertTrue($equalExclusive->isEmpty()); + + // When lower == upper with inclusive bounds, should not be empty + $equalInclusive = new DateRange($date1, $date1, true, true); + self::assertFalse($equalInclusive->isEmpty()); + } + + #[Test] + #[DataProvider('provideLeapYearTestCases')] + public function can_handle_leap_years(DateRange $dateRange, string $expectedString, string $description): void + { + self::assertEquals($expectedString, (string) $dateRange, $description); + } + + #[Test] + #[DataProvider('provideEdgeCaseMonthTestCases')] + public function can_handle_edge_case_months(DateRange $dateRange, string $expectedString, string $description): void + { + self::assertEquals($expectedString, (string) $dateRange, $description); + } + + public static function provideLeapYearTestCases(): \Generator + { + yield 'leap year 2024' => [ + DateRange::year(2024), + '[2024-01-01,2025-01-01)', + 'Leap year should span full year correctly', + ]; + yield 'leap year february 2024' => [ + DateRange::month(2024, 2), + '[2024-02-01,2024-03-01)', + 'February in leap year should span correctly', + ]; + yield 'non-leap year february 2023' => [ + DateRange::month(2023, 2), + '[2023-02-01,2023-03-01)', + 'February in non-leap year should span correctly', + ]; + } + + public static function provideEdgeCaseMonthTestCases(): \Generator + { + yield 'december crosses year boundary' => [ + DateRange::month(2023, 12), + '[2023-12-01,2024-01-01)', + 'December should cross year boundary correctly', + ]; + yield 'january starts year' => [ + DateRange::month(2023, 1), + '[2023-01-01,2023-02-01)', + 'January should start year correctly', + ]; + } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php index 10347856..94b494f6 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php @@ -5,63 +5,105 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types\ValueObject; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Int4Range; -use PHPUnit\Framework\Attributes\DataProvider; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; -final class Int4RangeTest extends TestCase +final class Int4RangeTest extends BaseRangeTestCase { - #[Test] - public function can_create_simple_range(): void + protected function createSimpleRange(): Range { - $int4Range = new Int4Range(1, 1000); + return new Int4Range(1, 1000); + } - self::assertEquals('[1,1000)', (string) $int4Range); - self::assertFalse($int4Range->isEmpty()); + protected function getExpectedSimpleRangeString(): string + { + return '[1,1000)'; } - #[Test] - public function can_create_empty_range(): void + protected function createEmptyRange(): Range { - $int4Range = Int4Range::empty(); + return Int4Range::empty(); + } - self::assertEquals('empty', (string) $int4Range); - self::assertTrue($int4Range->isEmpty()); + protected function createInfiniteRange(): Range + { + return Int4Range::infinite(); } - #[Test] - public function can_create_infinite_range(): void + protected function createInclusiveRange(): Range { - $int4Range = Int4Range::infinite(); + return new Int4Range(1, 10, true, true); + } - self::assertEquals('(,)', (string) $int4Range); - self::assertFalse($int4Range->isEmpty()); + protected function getExpectedInclusiveRangeString(): string + { + return '[1,10]'; } - #[Test] - public function can_create_inclusive_range(): void + protected function parseFromString(string $input): Range { - $int4Range = new Int4Range(1, 10, true, true); + return Int4Range::fromString($input); + } - self::assertEquals('[1,10]', (string) $int4Range); - self::assertFalse($int4Range->isEmpty()); + protected function createBoundaryTestRange(): Range + { + return new Int4Range(1, 10, true, false); // [1, 10) } - #[Test] - #[DataProvider('providesContainsTestCases')] - public function can_check_contains(Int4Range $int4Range, mixed $value, bool $expected): void + protected function getBoundaryTestCases(): array { - self::assertEquals($expected, $int4Range->contains($value)); + return [ + 'contains lower bound (inclusive)' => ['value' => 1, 'expected' => true], + 'does not contain value below range' => ['value' => 0, 'expected' => false], + 'does not contain upper bound (exclusive)' => ['value' => 10, 'expected' => false], + 'contains value just below upper' => ['value' => 9, 'expected' => true], + 'does not contain value above range' => ['value' => 11, 'expected' => false], + 'contains middle value' => ['value' => 5, 'expected' => true], + ]; } - #[Test] - #[DataProvider('providesFromStringTestCases')] - public function can_parse_from_string(string $input, Int4Range $int4Range): void + protected function getComparisonTestCases(): array + { + return [ + 'reverse range should be empty' => [ + 'range' => new Int4Range(10, 5), + 'expectedEmpty' => true, + ], + 'normal range should not be empty' => [ + 'range' => new Int4Range(5, 10), + 'expectedEmpty' => false, + ], + 'equal bounds exclusive should be empty' => [ + 'range' => new Int4Range(5, 5, false, false), + 'expectedEmpty' => true, + ], + 'equal bounds inclusive should not be empty' => [ + 'range' => new Int4Range(5, 5, true, true), + 'expectedEmpty' => false, + ], + ]; + } + + public static function provideContainsTestCases(): \Generator { - $result = Int4Range::fromString($input); + $int4Range = new Int4Range(1, 10); - self::assertEquals($int4Range->__toString(), $result->__toString()); - self::assertEquals($int4Range->isEmpty(), $result->isEmpty()); + yield 'contains middle value' => [$int4Range, 5, true]; + yield 'contains lower bound' => [$int4Range, 1, true]; + yield 'excludes upper bound' => [$int4Range, 10, false]; + yield 'excludes below range' => [$int4Range, 0, false]; + yield 'excludes above range' => [$int4Range, 11, false]; + yield 'excludes null' => [$int4Range, null, false]; + } + + public static function provideFromStringTestCases(): \Generator + { + yield 'basic range' => ['[1,10)', new Int4Range(1, 10)]; + yield 'inclusive' => ['[1,10]', new Int4Range(1, 10, true, true)]; + yield 'exclusive' => ['(1,10)', new Int4Range(1, 10, false, false)]; + yield 'infinite lower' => ['[,10)', new Int4Range(null, 10)]; + yield 'infinite upper' => ['[1,)', new Int4Range(1, null)]; + yield 'empty' => ['empty', Int4Range::empty()]; } #[Test] @@ -89,29 +131,4 @@ public function allows_max_int4_values(): void self::assertEquals('[-2147483648,2147483647)', (string) $int4Range); } - - public static function providesContainsTestCases(): \Generator - { - $int4Range = new Int4Range(1, 10); - - yield 'contains value in range' => [$int4Range, 5, true]; - yield 'contains lower bound (inclusive)' => [$int4Range, 1, true]; - yield 'does not contain upper bound (exclusive)' => [$int4Range, 10, false]; - yield 'does not contain value below range' => [$int4Range, 0, false]; - yield 'does not contain value above range' => [$int4Range, 11, false]; - yield 'does not contain null' => [$int4Range, null, false]; - - $emptyRange = Int4Range::empty(); - yield 'empty range contains nothing' => [$emptyRange, 5, false]; - } - - public static function providesFromStringTestCases(): \Generator - { - yield 'simple range' => ['[1,1000)', new Int4Range(1, 1000)]; - yield 'inclusive range' => ['[1,10]', new Int4Range(1, 10, true, true)]; - yield 'exclusive range' => ['(1,10)', new Int4Range(1, 10, false, false)]; - yield 'infinite lower' => ['[,10)', new Int4Range(null, 10)]; - yield 'infinite upper' => ['[1,)', new Int4Range(1, null)]; - yield 'empty range' => ['empty', Int4Range::empty()]; - } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php index 14e75e4b..b4ebba37 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php @@ -5,97 +5,114 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types\ValueObject; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Int8Range; -use PHPUnit\Framework\Attributes\DataProvider; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; -final class Int8RangeTest extends TestCase +final class Int8RangeTest extends BaseRangeTestCase { - #[Test] - public function can_create_simple_range(): void + protected function createSimpleRange(): Range { - $int8Range = new Int8Range(1, 1000); - - self::assertEquals('[1,1000)', (string) $int8Range); - self::assertFalse($int8Range->isEmpty()); + return new Int8Range(1, 1000); } - #[Test] - public function can_create_empty_range(): void + protected function getExpectedSimpleRangeString(): string { - $int8Range = Int8Range::empty(); + return '[1,1000)'; + } - self::assertEquals('empty', (string) $int8Range); - self::assertTrue($int8Range->isEmpty()); + protected function createEmptyRange(): Range + { + return Int8Range::empty(); } - #[Test] - public function can_create_infinite_range(): void + protected function createInfiniteRange(): Range { - $int8Range = Int8Range::infinite(); + return Int8Range::infinite(); + } - self::assertEquals('(,)', (string) $int8Range); - self::assertFalse($int8Range->isEmpty()); + protected function createInclusiveRange(): Range + { + return new Int8Range(1, 10, true, true); } - #[Test] - public function can_create_inclusive_range(): void + protected function getExpectedInclusiveRangeString(): string { - $int8Range = new Int8Range(1, 10, true, true); + return '[1,10]'; + } - self::assertEquals('[1,10]', (string) $int8Range); - self::assertFalse($int8Range->isEmpty()); + protected function parseFromString(string $input): Range + { + return Int8Range::fromString($input); } - #[Test] - public function can_handle_large_values(): void + protected function createBoundaryTestRange(): Range { - $int8Range = new Int8Range(PHP_INT_MIN, PHP_INT_MAX); + return new Int8Range(1, 10, true, false); // [1, 10) + } - self::assertEquals('['.PHP_INT_MIN.','.PHP_INT_MAX.')', (string) $int8Range); - self::assertFalse($int8Range->isEmpty()); + protected function getBoundaryTestCases(): array + { + return [ + 'contains lower bound (inclusive)' => ['value' => 1, 'expected' => true], + 'does not contain value below range' => ['value' => 0, 'expected' => false], + 'does not contain upper bound (exclusive)' => ['value' => 10, 'expected' => false], + 'contains value just below upper' => ['value' => 9, 'expected' => true], + 'does not contain value above range' => ['value' => 11, 'expected' => false], + 'contains middle value' => ['value' => 5, 'expected' => true], + ]; } - #[Test] - #[DataProvider('providesContainsTestCases')] - public function can_check_contains(Int8Range $int8Range, mixed $value, bool $expected): void + protected function getComparisonTestCases(): array { - self::assertEquals($expected, $int8Range->contains($value)); + return [ + 'reverse range should be empty' => [ + 'range' => new Int8Range(10, 5), + 'expectedEmpty' => true, + ], + 'normal range should not be empty' => [ + 'range' => new Int8Range(5, 10), + 'expectedEmpty' => false, + ], + 'equal bounds exclusive should be empty' => [ + 'range' => new Int8Range(5, 5, false, false), + 'expectedEmpty' => true, + ], + 'equal bounds inclusive should not be empty' => [ + 'range' => new Int8Range(5, 5, true, true), + 'expectedEmpty' => false, + ], + ]; } #[Test] - #[DataProvider('providesFromStringTestCases')] - public function can_parse_from_string(string $input, Int8Range $int8Range): void + public function can_handle_large_values(): void { - $result = Int8Range::fromString($input); + $int8Range = new Int8Range(PHP_INT_MIN, PHP_INT_MAX); - self::assertEquals($int8Range->__toString(), $result->__toString()); - self::assertEquals($int8Range->isEmpty(), $result->isEmpty()); + self::assertEquals('['.PHP_INT_MIN.','.PHP_INT_MAX.')', (string) $int8Range); + self::assertFalse($int8Range->isEmpty()); } - public static function providesContainsTestCases(): \Generator + public static function provideContainsTestCases(): \Generator { $int8Range = new Int8Range(1, 10); - yield 'contains value in range' => [$int8Range, 5, true]; - yield 'contains lower bound (inclusive)' => [$int8Range, 1, true]; - yield 'does not contain upper bound (exclusive)' => [$int8Range, 10, false]; - yield 'does not contain value below range' => [$int8Range, 0, false]; - yield 'does not contain value above range' => [$int8Range, 11, false]; - yield 'does not contain null' => [$int8Range, null, false]; - - $emptyRange = Int8Range::empty(); - yield 'empty range contains nothing' => [$emptyRange, 5, false]; + yield 'contains middle value' => [$int8Range, 5, true]; + yield 'contains lower bound' => [$int8Range, 1, true]; + yield 'excludes upper bound' => [$int8Range, 10, false]; + yield 'excludes below range' => [$int8Range, 0, false]; + yield 'excludes above range' => [$int8Range, 11, false]; + yield 'excludes null' => [$int8Range, null, false]; } - public static function providesFromStringTestCases(): \Generator + public static function provideFromStringTestCases(): \Generator { - yield 'simple range' => ['[1,1000)', new Int8Range(1, 1000)]; - yield 'inclusive range' => ['[1,10]', new Int8Range(1, 10, true, true)]; - yield 'exclusive range' => ['(1,10)', new Int8Range(1, 10, false, false)]; + yield 'basic range' => ['[1,10)', new Int8Range(1, 10)]; + yield 'inclusive' => ['[1,10]', new Int8Range(1, 10, true, true)]; + yield 'exclusive' => ['(1,10)', new Int8Range(1, 10, false, false)]; yield 'infinite lower' => ['[,10)', new Int8Range(null, 10)]; yield 'infinite upper' => ['[1,)', new Int8Range(1, null)]; - yield 'empty range' => ['empty', Int8Range::empty()]; + yield 'empty' => ['empty', Int8Range::empty()]; yield 'large values' => ['['.PHP_INT_MIN.','.PHP_INT_MAX.')', new Int8Range(PHP_INT_MIN, PHP_INT_MAX)]; } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php index 2e2fcdb2..67f9c185 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php @@ -4,73 +4,100 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types\ValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidRangeForPHPException; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\NumericRange; -use PHPUnit\Framework\Attributes\DataProvider; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; -final class NumericRangeTest extends TestCase +final class NumericRangeTest extends BaseRangeTestCase { - #[Test] - public function can_create_simple_range(): void + protected function createSimpleRange(): Range { - $numericRange = new NumericRange(1.5, 10.7); + return new NumericRange(1.5, 10.7); + } - self::assertEquals('[1.5,10.7)', (string) $numericRange); - self::assertFalse($numericRange->isEmpty()); + protected function getExpectedSimpleRangeString(): string + { + return '[1.5,10.7)'; } - #[Test] - public function can_create_empty_range(): void + protected function createEmptyRange(): Range { - $numericRange = NumericRange::empty(); + return NumericRange::empty(); + } - self::assertEquals('empty', (string) $numericRange); - self::assertTrue($numericRange->isEmpty()); + protected function createInfiniteRange(): Range + { + return NumericRange::infinite(); } - #[Test] - public function can_create_infinite_range(): void + protected function createInclusiveRange(): Range { - $numericRange = NumericRange::infinite(); + return new NumericRange(1, 10, true, true); + } - self::assertEquals('(,)', (string) $numericRange); - self::assertFalse($numericRange->isEmpty()); + protected function getExpectedInclusiveRangeString(): string + { + return '[1,10]'; } - #[Test] - #[DataProvider('providesContainsTestCases')] - public function can_check_contains(NumericRange $numericRange, mixed $value, bool $expected): void + protected function parseFromString(string $input): Range { - self::assertEquals($expected, $numericRange->contains($value)); + return NumericRange::fromString($input); } - #[Test] - #[DataProvider('providesFromStringTestCases')] - public function can_parse_from_string(string $input, NumericRange $numericRange): void + protected function createBoundaryTestRange(): Range { - $result = NumericRange::fromString($input); + return new NumericRange(1, 10, true, false); // [1, 10) + } - self::assertEquals($numericRange->__toString(), $result->__toString()); - self::assertEquals($numericRange->isEmpty(), $result->isEmpty()); + protected function getBoundaryTestCases(): array + { + return [ + 'contains lower bound (inclusive)' => ['value' => 1, 'expected' => true], + 'does not contain value below range' => ['value' => 0, 'expected' => false], + 'does not contain upper bound (exclusive)' => ['value' => 10, 'expected' => false], + 'contains value just below upper' => ['value' => 9.9, 'expected' => true], + 'does not contain value above range' => ['value' => 11, 'expected' => false], + 'contains middle value' => ['value' => 5.5, 'expected' => true], + ]; } - public static function providesContainsTestCases(): \Generator + protected function getComparisonTestCases(): array { - $numericRange = new NumericRange(1, 10); + return [ + 'reverse range should be empty' => [ + 'range' => new NumericRange(10.5, 5.0), + 'expectedEmpty' => true, + ], + 'normal range should not be empty' => [ + 'range' => new NumericRange(5.0, 10.5), + 'expectedEmpty' => false, + ], + 'equal bounds exclusive should be empty' => [ + 'range' => new NumericRange(5.0, 5.0, false, false), + 'expectedEmpty' => true, + ], + 'equal bounds inclusive should not be empty' => [ + 'range' => new NumericRange(5.0, 5.0, true, true), + 'expectedEmpty' => false, + ], + ]; + } - yield 'contains value in range' => [$numericRange, 5, true]; - yield 'contains lower bound (inclusive)' => [$numericRange, 1, true]; - yield 'does not contain upper bound (exclusive)' => [$numericRange, 10, false]; - yield 'does not contain value below range' => [$numericRange, 0, false]; - yield 'does not contain value above range' => [$numericRange, 11, false]; - yield 'does not contain null' => [$numericRange, null, false]; + public static function provideContainsTestCases(): \Generator + { + $numericRange = new NumericRange(1, 10); - $emptyRange = NumericRange::empty(); - yield 'empty range contains nothing' => [$emptyRange, 5, false]; + yield 'contains middle value' => [$numericRange, 5, true]; + yield 'contains lower bound' => [$numericRange, 1, true]; + yield 'excludes upper bound' => [$numericRange, 10, false]; + yield 'excludes below range' => [$numericRange, 0, false]; + yield 'excludes above range' => [$numericRange, 11, false]; + yield 'excludes null' => [$numericRange, null, false]; } - public static function providesFromStringTestCases(): \Generator + public static function provideFromStringTestCases(): \Generator { yield 'simple range' => ['[1.5,10.7)', new NumericRange(1.5, 10.7)]; yield 'inclusive range' => ['[1,10]', new NumericRange(1, 10, true, true)]; @@ -97,4 +124,88 @@ public function throws_exception_for_invalid_upper_bound(): void new NumericRange(1, 'invalid'); } + + #[Test] + public function throws_exception_for_invalid_numeric_bound_in_comparison_via_contains(): void + { + $numericRange = new NumericRange(1, 10); + + $this->expectException(InvalidRangeForPHPException::class); + $this->expectExceptionMessage('Range bound must be numeric'); + + // Test compareBounds error through contains() - natural public API + $numericRange->contains('invalid'); + } + + #[Test] + public function throws_exception_for_invalid_value_in_constructor(): void + { + $this->expectException(\TypeError::class); + + new NumericRange('invalid', 10); + } + + #[Test] + public function throws_exception_for_invalid_parse_value_via_from_string(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid range format'); + + NumericRange::fromString('[not_numeric,10)'); + } + + #[Test] + public function can_parse_integer_and_float_values_via_from_string(): void + { + $numericRange = NumericRange::fromString('[42,100)'); + self::assertStringContainsString('42', (string) $numericRange); + + $range2 = NumericRange::fromString('[-123,0)'); + self::assertStringContainsString('-123', (string) $range2); + + $range3 = NumericRange::fromString('[3.14,10)'); + self::assertStringContainsString('3.14', (string) $range3); + + $range4 = NumericRange::fromString('[-2.5,0)'); + self::assertStringContainsString('-2.5', (string) $range4); + } + + #[Test] + public function can_handle_mixed_integer_and_float_ranges(): void + { + $range = new NumericRange(1, 10.5); + self::assertEquals('[1,10.5)', (string) $range); + + $range2 = new NumericRange(1.5, 10); + self::assertEquals('[1.5,10)', (string) $range2); + } + + #[Test] + public function can_compare_mixed_numeric_types_via_is_empty(): void + { + $reverseRange = new NumericRange(5.1, 5.0); + self::assertTrue($reverseRange->isEmpty()); + + $normalRange = new NumericRange(5.0, 5.1); + self::assertFalse($normalRange->isEmpty()); + + $equalRange = new NumericRange(5, 5.0, true, true); + self::assertFalse($equalRange->isEmpty()); + + $equalExclusive = new NumericRange(5.0, 5.0, false, false); + self::assertTrue($equalExclusive->isEmpty()); + } + + #[Test] + public function can_format_numeric_values_via_to_string(): void + { + $range1 = new NumericRange(42, 100); + self::assertStringContainsString('42', (string) $range1); + + $range2 = new NumericRange(3.14, 10); + self::assertStringContainsString('3.14', (string) $range2); + + $range3 = new NumericRange(-2.5, 0); + self::assertStringContainsString('-2.5', (string) $range3); + } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php index 7ece4eb7..cf259e1f 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php @@ -4,51 +4,162 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types\ValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\TsRange; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; -final class TsRangeTest extends TestCase +final class TsRangeTest extends BaseTimestampRangeTestCase { - #[Test] - public function can_create_simple_range(): void + protected function createSimpleRange(): Range { - $start = new \DateTimeImmutable('2023-01-01 10:00:00'); - $end = new \DateTimeImmutable('2023-01-01 18:00:00'); - $tsRange = new TsRange($start, $end); + return new TsRange( + new \DateTimeImmutable('2023-01-01 10:00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00') + ); + } - self::assertEquals('[2023-01-01 10:00:00.000000,2023-01-01 18:00:00.000000)', (string) $tsRange); - self::assertFalse($tsRange->isEmpty()); + protected function getExpectedSimpleRangeString(): string + { + return '[2023-01-01 10:00:00.000000,2023-01-01 18:00:00.000000)'; } - #[Test] - public function can_create_empty_range(): void + protected function createEmptyRange(): Range { - $tsRange = TsRange::empty(); + return TsRange::empty(); + } - self::assertEquals('empty', (string) $tsRange); - self::assertTrue($tsRange->isEmpty()); + protected function createInfiniteRange(): Range + { + return TsRange::infinite(); } - #[Test] - public function can_create_infinite_range(): void + protected function createInclusiveRange(): Range { - $tsRange = TsRange::infinite(); + return new TsRange( + new \DateTimeImmutable('2023-01-01 10:00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00'), + true, + true + ); + } - self::assertEquals('(,)', (string) $tsRange); - self::assertFalse($tsRange->isEmpty()); + protected function getExpectedInclusiveRangeString(): string + { + return '[2023-01-01 10:00:00.000000,2023-01-01 18:00:00.000000]'; } - #[Test] - public function can_create_inclusive_range(): void + protected function parseFromString(string $input): Range { - $start = new \DateTimeImmutable('2023-01-01 10:00:00'); - $end = new \DateTimeImmutable('2023-01-01 18:00:00'); - $tsRange = new TsRange($start, $end, true, true); + return TsRange::fromString($input); + } - self::assertEquals('[2023-01-01 10:00:00.000000,2023-01-01 18:00:00.000000]', (string) $tsRange); - self::assertFalse($tsRange->isEmpty()); + protected function createBoundaryTestRange(): Range + { + return new TsRange($this->getTestStartTime(), $this->getTestEndTime(), true, false); + } + + protected function createRangeWithTimes( + ?\DateTimeInterface $start, + ?\DateTimeInterface $end, + bool $lowerInclusive = true, + bool $upperInclusive = false + ): Range { + return new TsRange($start, $end, $lowerInclusive, $upperInclusive); + } + + protected function getTestStartTime(): \DateTimeInterface + { + return new \DateTimeImmutable('2023-01-01 10:00:00'); + } + + protected function getTestEndTime(): \DateTimeInterface + { + return new \DateTimeImmutable('2023-01-01 18:00:00'); + } + + protected function getTimeBeforeRange(): \DateTimeInterface + { + return new \DateTimeImmutable('2023-01-01 09:00:00'); + } + + protected function getTimeInMiddle(): \DateTimeInterface + { + return new \DateTimeImmutable('2023-01-01 14:00:00'); + } + + protected function createTimeWithMicroseconds(string $timeString): \DateTimeInterface + { + return new \DateTimeImmutable($timeString); + } + + protected function getTestStartTimeString(): string + { + return '2023-01-01 10:00:00'; + } + + protected function getTestEndTimeString(): string + { + return '2023-01-01 18:00:00'; + } + + protected function getBoundaryTestCases(): array + { + return [ + 'contains lower bound (inclusive)' => [ + 'value' => new \DateTimeImmutable('2023-01-01 10:00:00'), + 'expected' => true, + ], + 'does not contain value before range' => [ + 'value' => new \DateTimeImmutable('2023-01-01 09:00:00'), + 'expected' => false, + ], + 'does not contain upper bound (exclusive)' => [ + 'value' => new \DateTimeImmutable('2023-01-01 18:00:00'), + 'expected' => false, + ], + 'contains value in middle' => [ + 'value' => new \DateTimeImmutable('2023-01-01 14:00:00'), + 'expected' => true, + ], + ]; + } + + protected function getComparisonTestCases(): array + { + return [ + 'reverse range should be empty' => [ + 'range' => new TsRange( + new \DateTimeImmutable('2023-01-01 18:00:00'), + new \DateTimeImmutable('2023-01-01 10:00:00') + ), + 'expectedEmpty' => true, + ], + 'normal range should not be empty' => [ + 'range' => new TsRange( + new \DateTimeImmutable('2023-01-01 10:00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00') + ), + 'expectedEmpty' => false, + ], + 'equal bounds exclusive should be empty' => [ + 'range' => new TsRange( + new \DateTimeImmutable('2023-01-01 10:00:00'), + new \DateTimeImmutable('2023-01-01 10:00:00'), + false, + false + ), + 'expectedEmpty' => true, + ], + 'equal bounds inclusive should not be empty' => [ + 'range' => new TsRange( + new \DateTimeImmutable('2023-01-01 10:00:00'), + new \DateTimeImmutable('2023-01-01 10:00:00'), + true, + true + ), + 'expectedEmpty' => false, + ], + ]; } #[Test] @@ -62,21 +173,46 @@ public function can_create_hour_range(): void self::assertFalse($tsRange->isEmpty()); } - #[Test] - #[DataProvider('providesContainsTestCases')] - public function can_check_contains(TsRange $tsRange, mixed $value, bool $expected): void + public static function provideContainsTestCases(): \Generator { - self::assertEquals($expected, $tsRange->contains($value)); + $start = new \DateTimeImmutable('2023-01-01 10:00:00'); + $end = new \DateTimeImmutable('2023-01-01 18:00:00'); + $tsRange = new TsRange($start, $end); + + yield 'contains value in range' => [ + $tsRange, + new \DateTimeImmutable('2023-01-01 14:00:00'), + true, + ]; + yield 'contains lower bound (inclusive)' => [$tsRange, $start, true]; + yield 'does not contain upper bound (exclusive)' => [$tsRange, $end, false]; + yield 'does not contain value before range' => [ + $tsRange, + new \DateTimeImmutable('2023-01-01 09:00:00'), + false, + ]; + yield 'does not contain null' => [$tsRange, null, false]; } - #[Test] - #[DataProvider('providesFromStringTestCases')] - public function can_parse_from_string(string $input, TsRange $tsRange): void + public static function provideFromStringTestCases(): \Generator { - $result = TsRange::fromString($input); - - self::assertEquals($tsRange->__toString(), $result->__toString()); - self::assertEquals($tsRange->isEmpty(), $result->isEmpty()); + yield 'simple range' => [ + '[2023-01-01 10:00:00.000000,2023-01-01 18:00:00.000000)', + new TsRange( + new \DateTimeImmutable('2023-01-01 10:00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00') + ), + ]; + yield 'inclusive range' => [ + '[2023-01-01 10:00:00.000000,2023-01-01 18:00:00.000000]', + new TsRange( + new \DateTimeImmutable('2023-01-01 10:00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00'), + true, + true + ), + ]; + yield 'empty range' => ['empty', TsRange::empty()]; } #[Test] @@ -89,42 +225,66 @@ public function handles_microseconds_correctly(): void self::assertEquals('[2023-01-01 10:00:00.123456,2023-01-01 18:00:00.654321)', (string) $tsRange); } - public static function providesContainsTestCases(): \Generator + #[Test] + public function throws_exception_for_invalid_value_in_constructor(): void + { + $this->expectException(\TypeError::class); + + new TsRange('invalid', new \DateTimeImmutable('2023-01-01 18:00:00')); + } + + #[Test] + public function can_format_timestamp_values_via_to_string(): void { $tsRange = new TsRange( - new \DateTimeImmutable('2023-01-01 10:00:00'), - new \DateTimeImmutable('2023-01-01 18:00:00') + new \DateTimeImmutable('2023-06-15 14:30:25.123456'), + new \DateTimeImmutable('2023-06-15 18:45:30.654321') ); - yield 'contains timestamp in range' => [$tsRange, new \DateTimeImmutable('2023-01-01 14:00:00'), true]; - yield 'contains lower bound (inclusive)' => [$tsRange, new \DateTimeImmutable('2023-01-01 10:00:00'), true]; - yield 'does not contain upper bound (exclusive)' => [$tsRange, new \DateTimeImmutable('2023-01-01 18:00:00'), false]; - yield 'does not contain timestamp before range' => [$tsRange, new \DateTimeImmutable('2023-01-01 09:00:00'), false]; - yield 'does not contain timestamp after range' => [$tsRange, new \DateTimeImmutable('2023-01-01 19:00:00'), false]; - yield 'does not contain null' => [$tsRange, null, false]; + $formatted = (string) $tsRange; + self::assertStringContainsString('2023-06-15 14:30:25.123456', $formatted); + self::assertStringContainsString('2023-06-15 18:45:30.654321', $formatted); + } - $emptyRange = TsRange::empty(); - yield 'empty range contains nothing' => [$emptyRange, new \DateTimeImmutable('2023-01-01 14:00:00'), false]; + #[Test] + public function can_handle_microseconds_in_formatting(): void + { + $tsRange = new TsRange( + new \DateTimeImmutable('2023-01-01 10:00:00.123456'), + new \DateTimeImmutable('2023-01-01 18:00:00.654321') + ); + + self::assertEquals('[2023-01-01 10:00:00.123456,2023-01-01 18:00:00.654321)', (string) $tsRange); } - public static function providesFromStringTestCases(): \Generator + #[Test] + public function can_parse_timestamp_strings_via_from_string(): void { - yield 'simple range' => [ - '[2023-01-01 10:00:00,2023-01-01 18:00:00)', - new TsRange(new \DateTimeImmutable('2023-01-01 10:00:00'), new \DateTimeImmutable('2023-01-01 18:00:00')), - ]; - yield 'inclusive range' => [ - '[2023-01-01 10:00:00,2023-01-01 18:00:00]', - new TsRange(new \DateTimeImmutable('2023-01-01 10:00:00'), new \DateTimeImmutable('2023-01-01 18:00:00'), true, true), - ]; - yield 'infinite lower' => [ - '[,2023-01-01 18:00:00)', - new TsRange(null, new \DateTimeImmutable('2023-01-01 18:00:00')), - ]; - yield 'infinite upper' => [ - '[2023-01-01 10:00:00,)', - new TsRange(new \DateTimeImmutable('2023-01-01 10:00:00'), null), - ]; - yield 'empty range' => ['empty', TsRange::empty()]; + $tsRange = TsRange::fromString('[2023-06-15 14:30:25.123456,2023-06-15 18:45:30.654321)'); + + $formatted = (string) $tsRange; + self::assertStringContainsString('2023-06-15 14:30:25.123456', $formatted); + self::assertStringContainsString('2023-06-15 18:45:30.654321', $formatted); + } + + #[Test] + public function throws_exception_for_invalid_timestamp_string_via_from_string(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid timestamp value'); + + TsRange::fromString('[invalid_timestamp,2023-01-01 18:00:00)'); + } + + #[Test] + public function can_handle_timezone_information_in_input(): void + { + // TsRange preserves the original timestamp but formats without timezone info + $timestampWithTz = new \DateTimeImmutable('2023-01-01 10:00:00+02:00'); + $tsRange = new TsRange($timestampWithTz, null); + + // The timestamp should be formatted as-is without timezone conversion + $formatted = (string) $tsRange; + self::assertStringContainsString('2023-01-01 10:00:00.000000', $formatted); } } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php index 58f36bcc..90404e1c 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php @@ -4,51 +4,102 @@ namespace Tests\Unit\MartinGeorgiev\Doctrine\DBAL\Types\ValueObject; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\TstzRange; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; -final class TstzRangeTest extends TestCase +final class TstzRangeTest extends BaseTimestampRangeTestCase { - #[Test] - public function can_create_simple_range(): void + protected function createSimpleRange(): Range { - $start = new \DateTimeImmutable('2023-01-01 10:00:00+00:00'); - $end = new \DateTimeImmutable('2023-01-01 18:00:00+00:00'); - $tstzRange = new TstzRange($start, $end); + return new TstzRange( + new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00+00:00') + ); + } - self::assertEquals('[2023-01-01 10:00:00.000000+00:00,2023-01-01 18:00:00.000000+00:00)', (string) $tstzRange); - self::assertFalse($tstzRange->isEmpty()); + protected function getExpectedSimpleRangeString(): string + { + return '[2023-01-01 10:00:00.000000+00:00,2023-01-01 18:00:00.000000+00:00)'; } - #[Test] - public function can_create_empty_range(): void + protected function createEmptyRange(): Range { - $tstzRange = TstzRange::empty(); + return TstzRange::empty(); + } - self::assertEquals('empty', (string) $tstzRange); - self::assertTrue($tstzRange->isEmpty()); + protected function createInfiniteRange(): Range + { + return TstzRange::infinite(); } - #[Test] - public function can_create_infinite_range(): void + protected function createInclusiveRange(): Range { - $tstzRange = TstzRange::infinite(); + return new TstzRange( + new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00+00:00'), + true, + true + ); + } - self::assertEquals('(,)', (string) $tstzRange); - self::assertFalse($tstzRange->isEmpty()); + protected function getExpectedInclusiveRangeString(): string + { + return '[2023-01-01 10:00:00.000000+00:00,2023-01-01 18:00:00.000000+00:00]'; } - #[Test] - public function can_create_inclusive_range(): void + protected function parseFromString(string $input): Range { - $start = new \DateTimeImmutable('2023-01-01 10:00:00+00:00'); - $end = new \DateTimeImmutable('2023-01-01 18:00:00+00:00'); - $tstzRange = new TstzRange($start, $end, true, true); + return TstzRange::fromString($input); + } - self::assertEquals('[2023-01-01 10:00:00.000000+00:00,2023-01-01 18:00:00.000000+00:00]', (string) $tstzRange); - self::assertFalse($tstzRange->isEmpty()); + protected function createBoundaryTestRange(): Range + { + return new TstzRange($this->getTestStartTime(), $this->getTestEndTime(), true, false); + } + + protected function createRangeWithTimes( + ?\DateTimeInterface $start, + ?\DateTimeInterface $end, + bool $lowerInclusive = true, + bool $upperInclusive = false + ): Range { + return new TstzRange($start, $end, $lowerInclusive, $upperInclusive); + } + + protected function getTestStartTime(): \DateTimeInterface + { + return new \DateTimeImmutable('2023-01-01 10:00:00+00:00'); + } + + protected function getTestEndTime(): \DateTimeInterface + { + return new \DateTimeImmutable('2023-01-01 18:00:00+00:00'); + } + + protected function getTimeBeforeRange(): \DateTimeInterface + { + return new \DateTimeImmutable('2023-01-01 09:00:00+00:00'); + } + + protected function getTimeInMiddle(): \DateTimeInterface + { + return new \DateTimeImmutable('2023-01-01 14:00:00+00:00'); + } + + protected function createTimeWithMicroseconds(string $timeString): \DateTimeInterface + { + return new \DateTimeImmutable($timeString); + } + + protected function getTestStartTimeString(): string + { + return '2023-01-01 10:00:00+00:00'; + } + + protected function getTestEndTimeString(): string + { + return '2023-01-01 18:00:00+00:00'; } #[Test] @@ -62,21 +113,46 @@ public function can_create_hour_range(): void self::assertFalse($tstzRange->isEmpty()); } - #[Test] - #[DataProvider('providesContainsTestCases')] - public function can_check_contains(TstzRange $tstzRange, mixed $value, bool $expected): void + public static function provideContainsTestCases(): \Generator { - self::assertEquals($expected, $tstzRange->contains($value)); + $start = new \DateTimeImmutable('2023-01-01 10:00:00+00:00'); + $end = new \DateTimeImmutable('2023-01-01 18:00:00+00:00'); + $tstzRange = new TstzRange($start, $end); + + yield 'contains value in range' => [ + $tstzRange, + new \DateTimeImmutable('2023-01-01 14:00:00+00:00'), + true, + ]; + yield 'contains lower bound (inclusive)' => [$tstzRange, $start, true]; + yield 'does not contain upper bound (exclusive)' => [$tstzRange, $end, false]; + yield 'does not contain value before range' => [ + $tstzRange, + new \DateTimeImmutable('2023-01-01 09:00:00+00:00'), + false, + ]; + yield 'does not contain null' => [$tstzRange, null, false]; } - #[Test] - #[DataProvider('providesFromStringTestCases')] - public function can_parse_from_string(string $input, TstzRange $tstzRange): void + public static function provideFromStringTestCases(): \Generator { - $result = TstzRange::fromString($input); - - self::assertEquals($tstzRange->__toString(), $result->__toString()); - self::assertEquals($tstzRange->isEmpty(), $result->isEmpty()); + yield 'simple range' => [ + '[2023-01-01 10:00:00.000000+00:00,2023-01-01 18:00:00.000000+00:00)', + new TstzRange( + new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00+00:00') + ), + ]; + yield 'inclusive range' => [ + '[2023-01-01 10:00:00.000000+00:00,2023-01-01 18:00:00.000000+00:00]', + new TstzRange( + new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00+00:00'), + true, + true + ), + ]; + yield 'empty range' => ['empty', TstzRange::empty()]; } #[Test] @@ -137,4 +213,81 @@ public static function providesFromStringTestCases(): \Generator ]; yield 'empty range' => ['empty', TstzRange::empty()]; } + + #[Test] + public function throws_exception_for_invalid_constructor_input(): void + { + $this->expectException(\TypeError::class); + + new TstzRange('invalid', new \DateTimeImmutable('2023-01-01 18:00:00+00:00')); + } + + #[Test] + public function can_format_timestamp_with_timezone_via_to_string(): void + { + $tstzRange = new TstzRange( + new \DateTimeImmutable('2023-06-15 14:30:25.123456+02:00'), + new \DateTimeImmutable('2023-06-15 18:45:30.654321+02:00') + ); + + $formatted = (string) $tstzRange; + self::assertStringContainsString('2023-06-15 14:30:25.123456+02:00', $formatted); + self::assertStringContainsString('2023-06-15 18:45:30.654321+02:00', $formatted); + } + + #[Test] + public function can_handle_timezone_comparison(): void + { + $utc = new \DateTimeImmutable('2023-01-01 10:00:00+00:00'); + $est = new \DateTimeImmutable('2023-01-01 05:00:00-05:00'); // Same moment as UTC + $later = new \DateTimeImmutable('2023-01-01 15:00:00+00:00'); // Later moment + + $tstzRange = new TstzRange($utc, $later); + + // EST represents the same moment as the lower bound, so it should be contained (inclusive lower) + self::assertTrue($tstzRange->contains($est)); + } + + #[Test] + public function can_parse_timestamp_with_timezone_strings_via_from_string(): void + { + $tstzRange = TstzRange::fromString('[2023-06-15 14:30:25.123456+02:00,2023-06-15 18:45:30.654321+02:00)'); + + $formatted = (string) $tstzRange; + self::assertStringContainsString('2023-06-15 14:30:25.123456+02:00', $formatted); + self::assertStringContainsString('2023-06-15 18:45:30.654321+02:00', $formatted); + } + + #[Test] + public function throws_exception_for_invalid_timestamp_string_via_from_string(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid timestamp value'); + + TstzRange::fromString('[invalid_timestamp,2023-01-01 18:00:00+00:00)'); + } + + #[Test] + public function preserves_timezone_information(): void + { + $timestampWithTz = new \DateTimeImmutable('2023-01-01 10:00:00+02:00'); + $tstzRange = new TstzRange($timestampWithTz, null); + + $formatted = (string) $tstzRange; + self::assertStringContainsString('+02:00', $formatted); + self::assertStringContainsString('2023-01-01 10:00:00.000000+02:00', $formatted); + } + + #[Test] + public function can_handle_different_datetime_implementations(): void + { + $dateTime = new \DateTimeImmutable('2023-01-01 10:00:00+02:00'); + $dateTimeImmutable = new \DateTimeImmutable('2023-01-01 18:00:00-05:00'); + + $tstzRange = new TstzRange($dateTime, $dateTimeImmutable); + $formatted = (string) $tstzRange; + + self::assertStringContainsString('+02:00', $formatted); + self::assertStringContainsString('-05:00', $formatted); + } } From bf9b2c7218483e5811330958e611325acb86c1f6 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Tue, 8 Jul 2025 01:59:33 +0300 Subject: [PATCH 14/16] Fix broken tests --- .../DBAL/Types/ValueObject/DateRangeTest.php | 241 +++++++++++++----- .../Types/ValueObject/NumericRangeTest.php | 5 +- 2 files changed, 174 insertions(+), 72 deletions(-) diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php index 163c1471..5275effd 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php @@ -6,39 +6,131 @@ use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidRangeForPHPException; use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DateRange; +use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; -final class DateRangeTest extends TestCase +final class DateRangeTest extends BaseRangeTestCase { - #[Test] - public function can_create_simple_range(): void + protected function createSimpleRange(): Range { - $start = new \DateTimeImmutable('2023-01-01'); - $end = new \DateTimeImmutable('2023-12-31'); - $dateRange = new DateRange($start, $end); + return new DateRange( + new \DateTimeImmutable('2023-01-01'), + new \DateTimeImmutable('2023-12-31') + ); + } - self::assertEquals('[2023-01-01,2023-12-31)', (string) $dateRange); - self::assertFalse($dateRange->isEmpty()); + protected function getExpectedSimpleRangeString(): string + { + return '[2023-01-01,2023-12-31)'; } - #[Test] - public function can_create_empty_range(): void + protected function createEmptyRange(): Range { - $dateRange = DateRange::empty(); + return DateRange::empty(); + } - self::assertEquals('empty', (string) $dateRange); - self::assertTrue($dateRange->isEmpty()); + protected function createInfiniteRange(): Range + { + return DateRange::infinite(); } - #[Test] - public function can_create_infinite_range(): void + protected function createInclusiveRange(): Range { - $dateRange = DateRange::infinite(); + return new DateRange( + new \DateTimeImmutable('2023-01-01'), + new \DateTimeImmutable('2023-12-31'), + true, + true + ); + } - self::assertEquals('(,)', (string) $dateRange); - self::assertFalse($dateRange->isEmpty()); + protected function getExpectedInclusiveRangeString(): string + { + return '[2023-01-01,2023-12-31]'; + } + + protected function parseFromString(string $input): Range + { + return DateRange::fromString($input); + } + + protected function createBoundaryTestRange(): Range + { + return new DateRange( + new \DateTimeImmutable('2023-01-01'), + new \DateTimeImmutable('2023-01-10'), + true, + false + ); + } + + protected function getBoundaryTestCases(): array + { + return [ + 'contains lower bound (inclusive)' => [ + 'value' => new \DateTimeImmutable('2023-01-01'), + 'expected' => true, + ], + 'does not contain value before range' => [ + 'value' => new \DateTimeImmutable('2022-12-31'), + 'expected' => false, + ], + 'does not contain upper bound (exclusive)' => [ + 'value' => new \DateTimeImmutable('2023-01-10'), + 'expected' => false, + ], + 'contains value just before upper' => [ + 'value' => new \DateTimeImmutable('2023-01-09'), + 'expected' => true, + ], + 'does not contain value after range' => [ + 'value' => new \DateTimeImmutable('2023-01-11'), + 'expected' => false, + ], + 'contains middle value' => [ + 'value' => new \DateTimeImmutable('2023-01-05'), + 'expected' => true, + ], + ]; + } + + protected function getComparisonTestCases(): array + { + return [ + 'reverse range should be empty' => [ + 'range' => new DateRange( + new \DateTimeImmutable('2023-12-31'), + new \DateTimeImmutable('2023-01-01') + ), + 'expectedEmpty' => true, + ], + 'normal range should not be empty' => [ + 'range' => new DateRange( + new \DateTimeImmutable('2023-01-01'), + new \DateTimeImmutable('2023-12-31') + ), + 'expectedEmpty' => false, + ], + 'equal bounds exclusive should be empty' => [ + 'range' => new DateRange( + new \DateTimeImmutable('2023-06-15'), + new \DateTimeImmutable('2023-06-15'), + false, + false + ), + 'expectedEmpty' => true, + ], + 'equal bounds inclusive should not be empty' => [ + 'range' => new DateRange( + new \DateTimeImmutable('2023-06-15'), + new \DateTimeImmutable('2023-06-15'), + true, + true + ), + 'expectedEmpty' => false, + ], + ]; } #[Test] @@ -69,68 +161,58 @@ public function can_create_month_range(): void self::assertFalse($dateRange->isEmpty()); } - #[Test] - #[DataProvider('providesContainsTestCases')] - public function can_check_contains(DateRange $dateRange, mixed $value, bool $expected): void - { - self::assertEquals($expected, $dateRange->contains($value)); - } - - #[Test] - #[DataProvider('providesFromStringTestCases')] - public function can_parse_from_string(string $input, DateRange $dateRange): void - { - $result = DateRange::fromString($input); - - self::assertEquals($dateRange->__toString(), $result->__toString()); - self::assertEquals($dateRange->isEmpty(), $result->isEmpty()); - } - - #[Test] - public function throws_exception_for_invalid_lower_bound(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Lower bound must be DateTimeInterface'); - - new DateRange('invalid', new \DateTimeImmutable('2023-12-31')); - } - - #[Test] - public function throws_exception_for_invalid_upper_bound(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Upper bound must be DateTimeInterface'); - - new DateRange(new \DateTimeImmutable('2023-01-01'), 'invalid'); - } - - public static function providesContainsTestCases(): \Generator + public static function provideContainsTestCases(): \Generator { $dateRange = new DateRange( new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-12-31') ); - yield 'contains date in range' => [$dateRange, new \DateTimeImmutable('2023-06-15'), true]; - yield 'contains lower bound (inclusive)' => [$dateRange, new \DateTimeImmutable('2023-01-01'), true]; - yield 'does not contain upper bound (exclusive)' => [$dateRange, new \DateTimeImmutable('2023-12-31'), false]; - yield 'does not contain date before range' => [$dateRange, new \DateTimeImmutable('2022-12-31'), false]; - yield 'does not contain date after range' => [$dateRange, new \DateTimeImmutable('2024-01-01'), false]; + yield 'contains date in range' => [ + $dateRange, + new \DateTimeImmutable('2023-06-15'), + true, + ]; + yield 'contains lower bound (inclusive)' => [ + $dateRange, + new \DateTimeImmutable('2023-01-01'), + true, + ]; + yield 'does not contain upper bound (exclusive)' => [ + $dateRange, + new \DateTimeImmutable('2023-12-31'), + false, + ]; + yield 'does not contain date before range' => [ + $dateRange, + new \DateTimeImmutable('2022-12-31'), + false, + ]; + yield 'does not contain date after range' => [ + $dateRange, + new \DateTimeImmutable('2024-01-01'), + false, + ]; yield 'does not contain null' => [$dateRange, null, false]; - - $emptyRange = DateRange::empty(); - yield 'empty range contains nothing' => [$emptyRange, new \DateTimeImmutable('2023-06-15'), false]; } - public static function providesFromStringTestCases(): \Generator + public static function provideFromStringTestCases(): \Generator { - yield 'simple range' => [ + yield 'simple date range' => [ '[2023-01-01,2023-12-31)', - new DateRange(new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-12-31')), + new DateRange( + new \DateTimeImmutable('2023-01-01'), + new \DateTimeImmutable('2023-12-31') + ), ]; - yield 'inclusive range' => [ + yield 'inclusive date range' => [ '[2023-01-01,2023-12-31]', - new DateRange(new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-12-31'), true, true), + new DateRange( + new \DateTimeImmutable('2023-01-01'), + new \DateTimeImmutable('2023-12-31'), + true, + true + ), ]; yield 'infinite lower' => [ '[,2023-12-31)', @@ -143,6 +225,24 @@ public static function providesFromStringTestCases(): \Generator yield 'empty range' => ['empty', DateRange::empty()]; } + #[Test] + public function throws_exception_for_invalid_lower_bound(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Lower bound must be DateTimeInterface'); + + new DateRange('invalid', new \DateTimeImmutable('2023-12-31')); + } + + #[Test] + public function throws_exception_for_invalid_upper_bound(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Upper bound must be DateTimeInterface'); + + new DateRange(new \DateTimeImmutable('2023-01-01'), 'invalid'); + } + #[Test] public function throws_exception_for_invalid_lower_bound_type(): void { @@ -178,7 +278,8 @@ public function throws_exception_for_invalid_datetime_in_comparison_via_contains #[Test] public function throws_exception_for_invalid_value_in_constructor(): void { - $this->expectException(\TypeError::class); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Lower bound must be DateTimeInterface'); new DateRange('invalid', new \DateTimeImmutable('2023-12-31')); } @@ -187,7 +288,7 @@ public function throws_exception_for_invalid_value_in_constructor(): void public function throws_exception_for_invalid_date_string_in_parse_via_from_string(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid range format'); + $this->expectExceptionMessage('Invalid date value'); DateRange::fromString('[invalid_date,2023-12-31)'); } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php index 67f9c185..48cb0ccd 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php @@ -140,7 +140,8 @@ public function throws_exception_for_invalid_numeric_bound_in_comparison_via_con #[Test] public function throws_exception_for_invalid_value_in_constructor(): void { - $this->expectException(\TypeError::class); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Lower bound must be numeric'); new NumericRange('invalid', 10); } @@ -149,7 +150,7 @@ public function throws_exception_for_invalid_value_in_constructor(): void public function throws_exception_for_invalid_parse_value_via_from_string(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid range format'); + $this->expectExceptionMessage('Invalid numeric value'); NumericRange::fromString('[not_numeric,10)'); } From 7e627268a50d95dc992552b28ef3df457f4e5233 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sun, 13 Jul 2025 02:04:27 +0200 Subject: [PATCH 15/16] YAGNI tests --- .../DBAL/Types/ValueObject/DateRangeTest.php | 27 ------------- .../Types/ValueObject/NumericRangeTest.php | 9 ----- .../DBAL/Types/ValueObject/TstzRangeTest.php | 39 ------------------- 3 files changed, 75 deletions(-) diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php index 5275effd..d6dace81 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php @@ -243,24 +243,6 @@ public function throws_exception_for_invalid_upper_bound(): void new DateRange(new \DateTimeImmutable('2023-01-01'), 'invalid'); } - #[Test] - public function throws_exception_for_invalid_lower_bound_type(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Lower bound must be DateTimeInterface'); - - new DateRange('invalid', new \DateTimeImmutable('2023-12-31')); - } - - #[Test] - public function throws_exception_for_invalid_upper_bound_type(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Upper bound must be DateTimeInterface'); - - new DateRange(new \DateTimeImmutable('2023-01-01'), 'invalid'); - } - #[Test] public function throws_exception_for_invalid_datetime_in_comparison_via_contains(): void { @@ -275,15 +257,6 @@ public function throws_exception_for_invalid_datetime_in_comparison_via_contains $dateRange->contains('invalid'); } - #[Test] - public function throws_exception_for_invalid_value_in_constructor(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Lower bound must be DateTimeInterface'); - - new DateRange('invalid', new \DateTimeImmutable('2023-12-31')); - } - #[Test] public function throws_exception_for_invalid_date_string_in_parse_via_from_string(): void { diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php index 48cb0ccd..6d6033b7 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php @@ -137,15 +137,6 @@ public function throws_exception_for_invalid_numeric_bound_in_comparison_via_con $numericRange->contains('invalid'); } - #[Test] - public function throws_exception_for_invalid_value_in_constructor(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Lower bound must be numeric'); - - new NumericRange('invalid', 10); - } - #[Test] public function throws_exception_for_invalid_parse_value_via_from_string(): void { diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php index 90404e1c..f7882afc 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php @@ -175,45 +175,6 @@ public function handles_microseconds_with_timezone(): void self::assertEquals('[2023-01-01 10:00:00.123456+00:00,2023-01-01 18:00:00.654321+00:00)', (string) $tstzRange); } - public static function providesContainsTestCases(): \Generator - { - $tstzRange = new TstzRange( - new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), - new \DateTimeImmutable('2023-01-01 18:00:00+00:00') - ); - - yield 'contains timestamp in range' => [$tstzRange, new \DateTimeImmutable('2023-01-01 14:00:00+00:00'), true]; - yield 'contains lower bound (inclusive)' => [$tstzRange, new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), true]; - yield 'does not contain upper bound (exclusive)' => [$tstzRange, new \DateTimeImmutable('2023-01-01 18:00:00+00:00'), false]; - yield 'does not contain timestamp before range' => [$tstzRange, new \DateTimeImmutable('2023-01-01 09:00:00+00:00'), false]; - yield 'does not contain timestamp after range' => [$tstzRange, new \DateTimeImmutable('2023-01-01 19:00:00+00:00'), false]; - yield 'does not contain null' => [$tstzRange, null, false]; - - $emptyRange = TstzRange::empty(); - yield 'empty range contains nothing' => [$emptyRange, new \DateTimeImmutable('2023-01-01 14:00:00+00:00'), false]; - } - - public static function providesFromStringTestCases(): \Generator - { - yield 'simple range with timezone' => [ - '[2023-01-01 10:00:00+00:00,2023-01-01 18:00:00+00:00)', - new TstzRange(new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), new \DateTimeImmutable('2023-01-01 18:00:00+00:00')), - ]; - yield 'inclusive range with timezone' => [ - '[2023-01-01 10:00:00+02:00,2023-01-01 18:00:00+02:00]', - new TstzRange(new \DateTimeImmutable('2023-01-01 10:00:00+02:00'), new \DateTimeImmutable('2023-01-01 18:00:00+02:00'), true, true), - ]; - yield 'infinite lower' => [ - '[,2023-01-01 18:00:00+00:00)', - new TstzRange(null, new \DateTimeImmutable('2023-01-01 18:00:00+00:00')), - ]; - yield 'infinite upper' => [ - '[2023-01-01 10:00:00+00:00,)', - new TstzRange(new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), null), - ]; - yield 'empty range' => ['empty', TstzRange::empty()]; - } - #[Test] public function throws_exception_for_invalid_constructor_input(): void { From 3a481233ea5ea53a5b15ef3d701f9148a76a1d71 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sun, 13 Jul 2025 02:19:55 +0200 Subject: [PATCH 16/16] add PHPStan template annotations and suppress covariant generics - Add proper template annotations to test base classes and suppress. - PHPStan errors for lack of covariant generics support. - Maintains DRY test structure while ensuring static analysis passes cleanly. --- ci/phpstan/baselines/range-baseline.neon | 22 +++++++++ .../Types/ValueObject/BaseRangeTestCase.php | 47 +++++++++++++++++-- .../BaseTimestampRangeTestCase.php | 9 ++++ .../DBAL/Types/ValueObject/DateRangeTest.php | 3 ++ .../DBAL/Types/ValueObject/Int4RangeTest.php | 3 ++ .../DBAL/Types/ValueObject/Int8RangeTest.php | 3 ++ .../Types/ValueObject/NumericRangeTest.php | 3 ++ .../DBAL/Types/ValueObject/TsRangeTest.php | 4 ++ .../DBAL/Types/ValueObject/TstzRangeTest.php | 4 ++ 9 files changed, 93 insertions(+), 5 deletions(-) diff --git a/ci/phpstan/baselines/range-baseline.neon b/ci/phpstan/baselines/range-baseline.neon index f78a867a..75a7d90c 100644 --- a/ci/phpstan/baselines/range-baseline.neon +++ b/ci/phpstan/baselines/range-baseline.neon @@ -23,3 +23,25 @@ parameters: identifier: new.static count: 4 path: ../../../src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php + + # Temporary suppression for PHPStan's lack of covariant generics support + # Remove when PHPStan supports covariance (https://github.com/phpstan/phpstan/issues/7427) + - + message: '#.*Template type R on class.*Range is not covariant#' + path: ../../../tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/ + + - + message: '#.*should return.*Range.*but returns.*Range#' + path: ../../../tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/ + + - + message: '#.*Generator expects value type.*Range.*given#' + path: ../../../tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/ + + - + message: '#.*extends generic class.*but does not specify its types#' + path: ../../../tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/ + + - + message: '#.*should be compatible with return type#' + path: ../../../tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/ diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseRangeTestCase.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseRangeTestCase.php index a4905f78..e6975a96 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseRangeTestCase.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseRangeTestCase.php @@ -9,6 +9,13 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +/** + * @template R of int|float|\DateTimeInterface + * + * TODO: Remove PHPStan suppressions when covariant generics are supported + * + * @see https://github.com/phpstan/phpstan/issues/7427 + */ abstract class BaseRangeTestCase extends TestCase { #[Test] @@ -49,6 +56,9 @@ public function can_create_inclusive_range(): void self::assertFalse($range->isEmpty()); } + /** + * @param Range $range + */ #[Test] #[DataProvider('provideContainsTestCases')] public function can_check_contains(Range $range, mixed $value, bool $expected): void @@ -56,6 +66,9 @@ public function can_check_contains(Range $range, mixed $value, bool $expected): self::assertEquals($expected, $range->contains($value)); } + /** + * @param Range $expectedRange + */ #[Test] #[DataProvider('provideFromStringTestCases')] public function can_parse_from_string(string $input, Range $expectedRange): void @@ -89,6 +102,8 @@ public function can_handle_comparison_via_is_empty(): void /** * Create a simple range for basic testing. + * + * @return Range */ abstract protected function createSimpleRange(): Range; @@ -99,16 +114,22 @@ abstract protected function getExpectedSimpleRangeString(): string; /** * Create an empty range. + * + * @return Range */ abstract protected function createEmptyRange(): Range; /** * Create an infinite range. + * + * @return Range */ abstract protected function createInfiniteRange(): Range; /** * Create an inclusive range for testing. + * + * @return Range */ abstract protected function createInclusiveRange(): Range; @@ -119,11 +140,15 @@ abstract protected function getExpectedInclusiveRangeString(): string; /** * Parse range from string. + * + * @return Range */ abstract protected function parseFromString(string $input): Range; /** * Create range for boundary testing. + * + * @return Range */ abstract protected function createBoundaryTestRange(): Range; @@ -137,22 +162,25 @@ abstract protected function getBoundaryTestCases(): array; /** * Get comparison test cases. * - * @return array + * @return array, expectedEmpty: bool}> */ abstract protected function getComparisonTestCases(): array; /** - * @return \Generator + * @return \Generator, mixed, bool}> */ abstract public static function provideContainsTestCases(): \Generator; /** - * @return \Generator + * @return \Generator}> */ abstract public static function provideFromStringTestCases(): \Generator; /** * Assert that a range equals another range by comparing string representation and isEmpty state. + * + * @param Range $expected + * @param Range $actual */ protected function assertRangeEquals(Range $expected, Range $actual, string $message = ''): void { @@ -163,6 +191,7 @@ protected function assertRangeEquals(Range $expected, Range $actual, string $mes /** * Assert that a range contains all the given values. * + * @param Range $range * @param array $values */ protected function assertRangeContainsAll(Range $range, array $values, string $message = ''): void @@ -178,6 +207,7 @@ protected function assertRangeContainsAll(Range $range, array $values, string $m /** * Assert that a range does not contain any of the given values. * + * @param Range $range * @param array $values */ protected function assertRangeContainsNone(Range $range, array $values, string $message = ''): void @@ -192,6 +222,8 @@ protected function assertRangeContainsNone(Range $range, array $values, string $ /** * Assert that a range has the expected string representation. + * + * @param Range $range */ protected function assertRangeStringEquals(string $expected, Range $range, string $message = ''): void { @@ -200,6 +232,8 @@ protected function assertRangeStringEquals(string $expected, Range $range, strin /** * Assert that a range is empty. + * + * @param Range $range */ protected function assertRangeIsEmpty(Range $range, string $message = ''): void { @@ -209,6 +243,8 @@ protected function assertRangeIsEmpty(Range $range, string $message = ''): void /** * Assert that a range is not empty. + * + * @param Range $range */ protected function assertRangeIsNotEmpty(Range $range, string $message = ''): void { @@ -219,6 +255,7 @@ protected function assertRangeIsNotEmpty(Range $range, string $message = ''): vo /** * Test boundary conditions for a range with known bounds. * + * @param Range $range * @param array $testCases */ protected function assertBoundaryConditions(Range $range, array $testCases, string $message = ''): void @@ -257,7 +294,7 @@ protected function generateBoundaryTestCases( /** * Test that a range correctly handles equal bounds with different bracket combinations. * - * @param callable $rangeFactory Function that creates a range: fn($lower, $upper, $lowerInc, $upperInc) => Range + * @param callable(mixed, mixed, bool, bool): Range $rangeFactory Function that creates a range */ protected function assertEqualBoundsHandling(callable $rangeFactory, mixed $value): void { @@ -278,7 +315,7 @@ protected function assertEqualBoundsHandling(callable $rangeFactory, mixed $valu /** * Test that a range correctly handles reverse bounds (lower > upper). * - * @param callable $rangeFactory Function that creates a range: fn($lower, $upper, $lowerInc, $upperInc) => Range + * @param callable(mixed, mixed, bool, bool): Range $rangeFactory Function that creates a range */ protected function assertReverseBoundsHandling(callable $rangeFactory, mixed $lower, mixed $upper): void { diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRangeTestCase.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRangeTestCase.php index eaecedf5..4a893843 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRangeTestCase.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRangeTestCase.php @@ -12,6 +12,9 @@ * Base test case for timestamp range types (TsRange, TstzRange). * Provides common timestamp-specific test patterns. */ +/** + * @template R of \DateTimeInterface + */ abstract class BaseTimestampRangeTestCase extends BaseRangeTestCase { protected function getBoundaryTestCases(): array @@ -36,6 +39,9 @@ protected function getBoundaryTestCases(): array ]; } + /** + * @return array, expectedEmpty: bool}> + */ protected function getComparisonTestCases(): array { return [ @@ -73,6 +79,7 @@ public function throws_exception_for_invalid_constructor_input(): void { $this->expectException(\TypeError::class); + /* @phpstan-ignore-next-line */ $this->createRangeWithTimes('invalid', $this->getTestEndTime()); } @@ -124,6 +131,8 @@ public function can_handle_different_datetime_implementations(): void /** * Create a range with specific DateTimeInterface objects. + * + * @return Range */ abstract protected function createRangeWithTimes( ?\DateTimeInterface $start, diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php index d6dace81..fde5298b 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php @@ -10,6 +10,9 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +/** + * @extends BaseRangeTestCase<\DateTimeInterface> + */ final class DateRangeTest extends BaseRangeTestCase { protected function createSimpleRange(): Range diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php index 94b494f6..ec5c84ed 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php @@ -8,6 +8,9 @@ use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; use PHPUnit\Framework\Attributes\Test; +/** + * @extends BaseRangeTestCase + */ final class Int4RangeTest extends BaseRangeTestCase { protected function createSimpleRange(): Range diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php index b4ebba37..952528d2 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php @@ -8,6 +8,9 @@ use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; use PHPUnit\Framework\Attributes\Test; +/** + * @extends BaseRangeTestCase + */ final class Int8RangeTest extends BaseRangeTestCase { protected function createSimpleRange(): Range diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php index 6d6033b7..564380bc 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php @@ -9,6 +9,9 @@ use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range; use PHPUnit\Framework\Attributes\Test; +/** + * @extends BaseRangeTestCase + */ final class NumericRangeTest extends BaseRangeTestCase { protected function createSimpleRange(): Range diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php index cf259e1f..a52b2361 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php @@ -8,6 +8,9 @@ use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\TsRange; use PHPUnit\Framework\Attributes\Test; +/** + * @extends BaseTimestampRangeTestCase<\DateTimeInterface> + */ final class TsRangeTest extends BaseTimestampRangeTestCase { protected function createSimpleRange(): Range @@ -230,6 +233,7 @@ public function throws_exception_for_invalid_value_in_constructor(): void { $this->expectException(\TypeError::class); + /* @phpstan-ignore-next-line */ new TsRange('invalid', new \DateTimeImmutable('2023-01-01 18:00:00')); } diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php index f7882afc..bdba6e0c 100644 --- a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php @@ -8,6 +8,9 @@ use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\TstzRange; use PHPUnit\Framework\Attributes\Test; +/** + * @extends BaseTimestampRangeTestCase<\DateTimeInterface> + */ final class TstzRangeTest extends BaseTimestampRangeTestCase { protected function createSimpleRange(): Range @@ -180,6 +183,7 @@ public function throws_exception_for_invalid_constructor_input(): void { $this->expectException(\TypeError::class); + /* @phpstan-ignore-next-line */ new TstzRange('invalid', new \DateTimeImmutable('2023-01-01 18:00:00+00:00')); }