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/ci/phpstan/baselines/range-baseline.neon b/ci/phpstan/baselines/range-baseline.neon new file mode 100644 index 00000000..75a7d90c --- /dev/null +++ b/ci/phpstan/baselines/range-baseline.neon @@ -0,0 +1,47 @@ +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 + + # 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/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/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'); ... ``` 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 +``` diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php new file mode 100644 index 00000000..9827126f --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeType.php @@ -0,0 +1,69 @@ + + */ +abstract class BaseRangeType extends BaseType +{ + /** + * @param R|null $value + */ + public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string + { + if ($value === null) { + return null; + } + + if (!$value instanceof Range) { + throw InvalidRangeForDatabaseException::forInvalidType($value); + } + + return (string) $value; + } + + /** + * @param string|null $value + * + * @return R|null + */ + public function convertToPHPValue($value, AbstractPlatform $platform): ?Range + { + if ($value === null) { + return null; + } + + if (!\is_string($value)) { + throw InvalidRangeForPHPException::forInvalidType($value); + } + + if ($value === '') { + return null; + } + + try { + return $this->createFromString($value); + } catch (\InvalidArgumentException) { + throw InvalidRangeForPHPException::forInvalidFormat($value); + } + } + + /** + * @return R + */ + 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..8d2c9442 --- /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/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/Int4Range.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Int4Range.php new file mode 100644 index 00000000..8aaf7108 --- /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..78095925 --- /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..3bc7cb9f --- /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..51dcebb4 --- /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..343f8f5e --- /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..19555e39 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseIntegerRange.php @@ -0,0 +1,55 @@ + + * + * @since 3.3 + * + * @author Martin Georgiev + */ +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); + } + + protected function compareBounds(mixed $a, mixed $b): int + { + return $a <=> $b; + } + + protected function formatValue(mixed $value): string + { + if (!\is_int($value)) { + throw InvalidRangeForPHPException::forInvalidIntegerBound($value); + } + + return (string) $value; + } + + protected static function parseValue(string $value): int + { + $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..fd1f364b --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRange.php @@ -0,0 +1,61 @@ + + * + * @since 3.3 + * + * @author Martin Georgiev + */ +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); + } + + 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; + } + + // PHP's getTimestamp() only returns seconds, so we need to separate the microsecond comparison. + return (int) $a->format('u') <=> (int) $b->format('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/DateRange.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRange.php new file mode 100644 index 00000000..913ac898 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRange.php @@ -0,0 +1,100 @@ + + * + * @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 + { + if (!$a instanceof \DateTimeInterface) { + throw InvalidRangeForPHPException::forInvalidDateTimeBound($a); + } + + if (!$b instanceof \DateTimeInterface) { + throw InvalidRangeForPHPException::forInvalidDateTimeBound($b); + } + + 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 + ); + } + } + + 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..c51d7f86 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4Range.php @@ -0,0 +1,41 @@ + + */ +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); + } +} 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..dcfa8419 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8Range.php @@ -0,0 +1,14 @@ + + */ +final class Int8Range extends BaseIntegerRange {} 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..72b073a9 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRange.php @@ -0,0 +1,77 @@ + + * + * @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 + { + if (!\is_numeric($a)) { + throw InvalidRangeForPHPException::forInvalidNumericBound($a); + } + + if (!\is_numeric($b)) { + throw InvalidRangeForPHPException::forInvalidNumericBound($b); + } + + 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; + } +} 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..d5caaac8 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Range.php @@ -0,0 +1,145 @@ + + */ +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; + } + + $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 $lowerBracket.$formattedLowerBound.','.$formattedUpperBound.$upperBracket; + } + + /** + * 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); + } + + 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); + + 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 static function parseValue(string $value): mixed; + + 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 empty(): static + { + return new static(null, null, true, false, true); + } + + public static function infinite(): static + { + return new static(null, null, false, false); + } +} 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..0ddef85f --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRange.php @@ -0,0 +1,24 @@ + + */ +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'); + } +} 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..e9f15dff --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRange.php @@ -0,0 +1,24 @@ + + */ +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'); + } +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/DBALTypesTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/DBALTypesTest.php index 66c4c723..fd3ee9de 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 @@ -16,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 [ @@ -28,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 [ @@ -56,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 [ @@ -87,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 [ @@ -97,6 +122,33 @@ 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 [ + '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 +194,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 +206,14 @@ 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); } + + /** + * @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); + $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/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) { 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..66416938 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/BaseRangeTestCase.php @@ -0,0 +1,152 @@ + + */ + protected BaseRangeType $fixture; + + protected function setUp(): void + { + $this->platform = $this->createMock(AbstractPlatform::class); + $this->fixture = $this->createRangeType(); + } + + #[Test] + 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 + { + 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 + { + $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()); + } + + #[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. + */ + abstract public static function provideValidTransformations(): \Generator; + + /** + * @return BaseRangeType + */ + abstract protected function createRangeType(): BaseRangeType; + + /** + * Returns the expected type name (e.g., 'numrange', 'int4range'). + */ + abstract protected function getExpectedTypeName(): string; + + /** + * @return class-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 new file mode 100644 index 00000000..dae37b11 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/DateRangeTest.php @@ -0,0 +1,61 @@ + + */ +final class DateRangeTest extends BaseRangeTestCase +{ + protected function createRangeType(): DateRange + { + return new DateRange(); + } + + protected function getExpectedTypeName(): string + { + return 'daterange'; + } + + protected function getExpectedValueObjectClass(): string + { + return DateRangeValueObject::class; + } + + 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 'inclusive range' => [ + new DateRangeValueObject(new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-01-10'), true, true), + '[2023-01-01,2023-01-10]', + ]; + yield 'exclusive range' => [ + new DateRangeValueObject(new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-01-10'), false, false), + '(2023-01-01,2023-01-10)', + ]; + yield 'infinite lower' => [ + new DateRangeValueObject(null, new \DateTimeImmutable('2023-12-31')), + '[,2023-12-31)', + ]; + yield 'infinite upper' => [ + new DateRangeValueObject(new \DateTimeImmutable('2023-01-01'), null), + '[2023-01-01,)', + ]; + yield 'empty range' => [ + DateRangeValueObject::empty(), + 'empty', + ]; + yield 'null value' => [ + null, + null, + ]; + } +} 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..2087b8ca --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int4RangeTest.php @@ -0,0 +1,65 @@ + + */ +final class Int4RangeTest extends BaseRangeTestCase +{ + protected function createRangeType(): Int4Range + { + return new Int4Range(); + } + + protected function getExpectedTypeName(): string + { + return 'int4range'; + } + + protected function getExpectedValueObjectClass(): string + { + return Int4RangeValueObject::class; + } + + public static function provideValidTransformations(): \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 'infinite lower' => [ + new Int4RangeValueObject(null, 100), + '[,100)', + ]; + yield 'infinite upper' => [ + new Int4RangeValueObject(1, null), + '[1,)', + ]; + yield 'max bounds' => [ + new Int4RangeValueObject(-2147483648, 2147483647), + '[-2147483648,2147483647)', + ]; + yield 'empty range' => [ + Int4RangeValueObject::empty(), + 'empty', + ]; + yield 'null value' => [ + null, + null, + ]; + } +} 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..2d2ebad6 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/Int8RangeTest.php @@ -0,0 +1,65 @@ + + */ +final class Int8RangeTest extends BaseRangeTestCase +{ + protected function createRangeType(): Int8Range + { + return new Int8Range(); + } + + protected function getExpectedTypeName(): string + { + return 'int8range'; + } + + protected function getExpectedValueObjectClass(): string + { + return Int8RangeValueObject::class; + } + + public static function provideValidTransformations(): \Generator + { + yield 'simple range' => [ + new Int8RangeValueObject(1, 1000), + '[1,1000)', + ]; + 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.')', + ]; + yield 'empty range' => [ + Int8RangeValueObject::empty(), + 'empty', + ]; + yield 'null value' => [ + null, + null, + ]; + } +} 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..81458373 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/NumRangeTest.php @@ -0,0 +1,61 @@ + + */ +final class NumRangeTest extends BaseRangeTestCase +{ + protected function createRangeType(): NumRange + { + return new NumRange(); + } + + protected function getExpectedTypeName(): string + { + return 'numrange'; + } + + protected function getExpectedValueObjectClass(): string + { + return NumericRange::class; + } + + public static function provideValidTransformations(): \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 'infinite lower' => [ + new NumericRange(null, 100), + '[,100)', + ]; + yield 'infinite upper' => [ + new NumericRange(1, null), + '[1,)', + ]; + yield 'empty range' => [ + NumericRange::empty(), + 'empty', + ]; + yield 'null value' => [ + null, + null, + ]; + } +} 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..7e6f5b8e --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TsRangeTest.php @@ -0,0 +1,61 @@ + + */ +final class TsRangeTest extends BaseRangeTestCase +{ + protected function createRangeType(): TsRange + { + return new TsRange(); + } + + protected function getExpectedTypeName(): string + { + return 'tsrange'; + } + + protected function getExpectedValueObjectClass(): string + { + return TsRangeValueObject::class; + } + + 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')), + '[2023-01-01 10:00:00.000000,2023-01-01 18: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 '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)', + ]; + 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' => [ + TsRangeValueObject::empty(), + 'empty', + ]; + yield 'null value' => [ + null, + null, + ]; + } +} 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..6c74f92a --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/TstzRangeTest.php @@ -0,0 +1,61 @@ + + */ +final class TstzRangeTest extends BaseRangeTestCase +{ + protected function createRangeType(): TstzRange + { + return new TstzRange(); + } + + protected function getExpectedTypeName(): string + { + return 'tstzrange'; + } + + protected function getExpectedValueObjectClass(): string + { + return TstzRangeValueObject::class; + } + + public static function provideValidTransformations(): \Generator + { + 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 '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]', + ]; + 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' => [ + TstzRangeValueObject::empty(), + 'empty', + ]; + yield 'null value' => [ + null, + null, + ]; + } +} 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..e6975a96 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseRangeTestCase.php @@ -0,0 +1,325 @@ +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()); + } + + /** + * @param Range $range + */ + #[Test] + #[DataProvider('provideContainsTestCases')] + public function can_check_contains(Range $range, mixed $value, bool $expected): void + { + self::assertEquals($expected, $range->contains($value)); + } + + /** + * @param Range $expectedRange + */ + #[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. + * + * @return Range + */ + abstract protected function createSimpleRange(): Range; + + /** + * Get expected string representation of simple range. + */ + 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; + + /** + * Get expected string representation of inclusive range. + */ + 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; + + /** + * Get boundary test cases. + * + * @return array + */ + abstract protected function getBoundaryTestCases(): array; + + /** + * Get comparison test cases. + * + * @return array, expectedEmpty: bool}> + */ + abstract protected function getComparisonTestCases(): array; + + /** + * @return \Generator, mixed, bool}> + */ + 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. + * + * @param Range $expected + * @param Range $actual + */ + 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 Range $range + * @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 Range $range + * @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. + * + * @param Range $range + */ + protected function assertRangeStringEquals(string $expected, Range $range, string $message = ''): void + { + self::assertEquals($expected, (string) $range, $message); + } + + /** + * Assert that a range is empty. + * + * @param Range $range + */ + 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. + * + * @param Range $range + */ + 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 Range $range + * @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(mixed, mixed, bool, bool): Range $rangeFactory Function that creates a 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(mixed, mixed, bool, bool): Range $rangeFactory Function that creates a 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..4a893843 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/BaseTimestampRangeTestCase.php @@ -0,0 +1,178 @@ + [ + '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, + ], + ]; + } + + /** + * @return array, expectedEmpty: bool}> + */ + 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); + + /* @phpstan-ignore-next-line */ + $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. + * + * @return Range + */ + 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 new file mode 100644 index 00000000..fde5298b --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/DateRangeTest.php @@ -0,0 +1,369 @@ + + */ +final class DateRangeTest extends BaseRangeTestCase +{ + protected function createSimpleRange(): Range + { + return new DateRange( + new \DateTimeImmutable('2023-01-01'), + new \DateTimeImmutable('2023-12-31') + ); + } + + protected function getExpectedSimpleRangeString(): string + { + return '[2023-01-01,2023-12-31)'; + } + + protected function createEmptyRange(): Range + { + return DateRange::empty(); + } + + protected function createInfiniteRange(): Range + { + return DateRange::infinite(); + } + + protected function createInclusiveRange(): Range + { + return new DateRange( + new \DateTimeImmutable('2023-01-01'), + new \DateTimeImmutable('2023-12-31'), + true, + true + ); + } + + 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] + 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()); + } + + 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 'does not contain null' => [$dateRange, null, false]; + } + + public static function provideFromStringTestCases(): \Generator + { + yield 'simple date range' => [ + '[2023-01-01,2023-12-31)', + new DateRange( + new \DateTimeImmutable('2023-01-01'), + new \DateTimeImmutable('2023-12-31') + ), + ]; + yield 'inclusive date 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()]; + } + + #[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_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_date_string_in_parse_via_from_string(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid date value'); + + 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 new file mode 100644 index 00000000..ec5c84ed --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int4RangeTest.php @@ -0,0 +1,137 @@ + + */ +final class Int4RangeTest extends BaseRangeTestCase +{ + protected function createSimpleRange(): Range + { + return new Int4Range(1, 1000); + } + + protected function getExpectedSimpleRangeString(): string + { + return '[1,1000)'; + } + + protected function createEmptyRange(): Range + { + return Int4Range::empty(); + } + + protected function createInfiniteRange(): Range + { + return Int4Range::infinite(); + } + + protected function createInclusiveRange(): Range + { + return new Int4Range(1, 10, true, true); + } + + protected function getExpectedInclusiveRangeString(): string + { + return '[1,10]'; + } + + protected function parseFromString(string $input): Range + { + return Int4Range::fromString($input); + } + + protected function createBoundaryTestRange(): Range + { + return new Int4Range(1, 10, true, false); // [1, 10) + } + + 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], + ]; + } + + 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 + { + $int4Range = new Int4Range(1, 10); + + 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] + 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); + } +} 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..952528d2 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/Int8RangeTest.php @@ -0,0 +1,121 @@ + + */ +final class Int8RangeTest extends BaseRangeTestCase +{ + protected function createSimpleRange(): Range + { + return new Int8Range(1, 1000); + } + + protected function getExpectedSimpleRangeString(): string + { + return '[1,1000)'; + } + + protected function createEmptyRange(): Range + { + return Int8Range::empty(); + } + + protected function createInfiniteRange(): Range + { + return Int8Range::infinite(); + } + + protected function createInclusiveRange(): Range + { + return new Int8Range(1, 10, true, true); + } + + protected function getExpectedInclusiveRangeString(): string + { + return '[1,10]'; + } + + protected function parseFromString(string $input): Range + { + return Int8Range::fromString($input); + } + + protected function createBoundaryTestRange(): Range + { + return new Int8Range(1, 10, true, false); // [1, 10) + } + + 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], + ]; + } + + protected function getComparisonTestCases(): array + { + 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] + 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()); + } + + public static function provideContainsTestCases(): \Generator + { + $int8Range = new Int8Range(1, 10); + + 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 provideFromStringTestCases(): \Generator + { + 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' => ['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..564380bc --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/NumericRangeTest.php @@ -0,0 +1,206 @@ + + */ +final class NumericRangeTest extends BaseRangeTestCase +{ + protected function createSimpleRange(): Range + { + return new NumericRange(1.5, 10.7); + } + + protected function getExpectedSimpleRangeString(): string + { + return '[1.5,10.7)'; + } + + protected function createEmptyRange(): Range + { + return NumericRange::empty(); + } + + protected function createInfiniteRange(): Range + { + return NumericRange::infinite(); + } + + protected function createInclusiveRange(): Range + { + return new NumericRange(1, 10, true, true); + } + + protected function getExpectedInclusiveRangeString(): string + { + return '[1,10]'; + } + + protected function parseFromString(string $input): Range + { + return NumericRange::fromString($input); + } + + protected function createBoundaryTestRange(): Range + { + return new NumericRange(1, 10, true, false); // [1, 10) + } + + 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], + ]; + } + + protected function getComparisonTestCases(): array + { + 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, + ], + ]; + } + + public static function provideContainsTestCases(): \Generator + { + $numericRange = new NumericRange(1, 10); + + 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 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)]; + 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'); + } + + #[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_parse_value_via_from_string(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid numeric value'); + + 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 new file mode 100644 index 00000000..a52b2361 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TsRangeTest.php @@ -0,0 +1,294 @@ + + */ +final class TsRangeTest extends BaseTimestampRangeTestCase +{ + protected function createSimpleRange(): Range + { + return new TsRange( + new \DateTimeImmutable('2023-01-01 10:00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00') + ); + } + + protected function getExpectedSimpleRangeString(): string + { + return '[2023-01-01 10:00:00.000000,2023-01-01 18:00:00.000000)'; + } + + protected function createEmptyRange(): Range + { + return TsRange::empty(); + } + + protected function createInfiniteRange(): Range + { + return TsRange::infinite(); + } + + protected function createInclusiveRange(): Range + { + return new TsRange( + new \DateTimeImmutable('2023-01-01 10:00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00'), + true, + true + ); + } + + protected function getExpectedInclusiveRangeString(): string + { + return '[2023-01-01 10:00:00.000000,2023-01-01 18:00:00.000000]'; + } + + protected function parseFromString(string $input): Range + { + return TsRange::fromString($input); + } + + 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] + public function can_create_hour_range(): void + { + $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()); + } + + public static function provideContainsTestCases(): \Generator + { + $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]; + } + + public static function provideFromStringTestCases(): \Generator + { + 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] + 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); + } + + #[Test] + 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')); + } + + #[Test] + public function can_format_timestamp_values_via_to_string(): void + { + $tsRange = new TsRange( + new \DateTimeImmutable('2023-06-15 14:30:25.123456'), + new \DateTimeImmutable('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 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); + } + + #[Test] + public function can_parse_timestamp_strings_via_from_string(): void + { + $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 new file mode 100644 index 00000000..bdba6e0c --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/TstzRangeTest.php @@ -0,0 +1,258 @@ + + */ +final class TstzRangeTest extends BaseTimestampRangeTestCase +{ + protected function createSimpleRange(): Range + { + return new TstzRange( + new \DateTimeImmutable('2023-01-01 10:00:00+00:00'), + new \DateTimeImmutable('2023-01-01 18:00:00+00:00') + ); + } + + protected function getExpectedSimpleRangeString(): string + { + return '[2023-01-01 10:00:00.000000+00:00,2023-01-01 18:00:00.000000+00:00)'; + } + + protected function createEmptyRange(): Range + { + return TstzRange::empty(); + } + + protected function createInfiniteRange(): Range + { + return TstzRange::infinite(); + } + + protected function createInclusiveRange(): Range + { + 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 + ); + } + + protected function getExpectedInclusiveRangeString(): string + { + return '[2023-01-01 10:00:00.000000+00:00,2023-01-01 18:00:00.000000+00:00]'; + } + + protected function parseFromString(string $input): Range + { + return TstzRange::fromString($input); + } + + 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] + public function can_create_hour_range(): void + { + $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()); + } + + public static function provideContainsTestCases(): \Generator + { + $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]; + } + + public static function provideFromStringTestCases(): \Generator + { + 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] + 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); + } + + #[Test] + 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')); + } + + #[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); + } +}