diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index a1f204c..e05dfb2 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -25,5 +25,4 @@ RUN groupadd -g $GROUP_ID application && \ useradd -m -s /bin/bash -u $USER_ID -g $GROUP_ID application USER application -WORKDIR /avro-php -CMD ["sleep", "infinity"] \ No newline at end of file +WORKDIR /avro-php \ No newline at end of file diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml index 89adc1c..60eb733 100644 --- a/.devcontainer/docker-compose.yaml +++ b/.devcontainer/docker-compose.yaml @@ -12,12 +12,15 @@ services: environment: PHP_IDE_CONFIG: serverName=avro-php working_dir: /avro-php + command: sleep infinity test-generator: - image: node:latest + image: node:24 volumes: - ..:/avro-php environment: npm_config_cache: /tmp/.npm working_dir: /avro-php command: bash -c "cd tests/Integration/TestCaseGenerator && npm ci && node ." + profiles: + - build-tests \ No newline at end of file diff --git a/README.md b/README.md index 937df18..737ef25 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,12 @@ A PHP library that provides schema-based Avro data serialization and deserialization. -It started as a fork of the original Apache Avro implementation available at https://github.com/apache/avro, but now it has been completely rewritten. Some of the original functionality has been removed, and new features have been added. - -The features added by this library are: -- Basic support for logical types -- Default values for record fields +Key features: +- Support for logical types - Developer-friendly error messages for schema validation -- Serialization of objects through getters or public properties - Schema resolution including promotion of primitive types +- Serialization of objects through getters or public properties +- Default values for record fields - Configurable block count and block size for array and map encoding ## Installation @@ -20,6 +18,65 @@ To install auxmoney/avro-php, you can use Composer: composer require auxmoney/avro-php ``` +## Usage + +### Encoding/Decoding Data + +Try out the example scripts: +```bash +php examples/encoding.php +php examples/decoding.php +php examples/logical-type.php +``` + +### Logical Types + +It is possible to configure logical types in a few different ways: + +#### Using Default Logical Types +There are built-in implementations for all logical types described in the AVRO specification, except for `timestamp-nanos` and `local-timestamp-nanos`, because PHP's DateTime doesn't have nanosecond precision. + +To use the default logical types, simply create an AvroFactory without any options: +```php +$avroFactory = AvroFactory::create(); +``` + +#### Overriding Logical Types +You can override default logical types or add custom ones by providing factory implementations: +```php +$defaultLogicalTypeFactories = AvroFactory::getDefaultLogicalTypeFactories(); +$defaultLogicalTypeFactories['custom'] = new MyCustomLogicalTypeFactory(); +$options = new Options(logicalTypeFactories: $defaultLogicalTypeFactories); +$avroFactory = AvroFactory::create($options); +``` + +#### Disabling Logical Types +To disable all logical type processing and treat them as their underlying primitive types: +```php +$options = new Options(logicalTypeFactories: []); +$avroFactory = AvroFactory::create($options); +``` + +### Value Objects for Logical Types + +Some logical types work with their respective value objects to provide type safety and better representation of the data: + +- `decimal`: `Auxmoney\Avro\ValueObject\Decimal` +- `duration`: `Auxmoney\Avro\ValueObject\Duration` +- `time-millis`: `Auxmoney\Avro\ValueObject\TimeOfDay` +- `time-micros`: `Auxmoney\Avro\ValueObject\TimeOfDay` +- `uuid`: `Auxmoney\Avro\ValueObject\Uuid` + +The `local-timestamp-*` and `timestamp-*` types are serialized from/deserialized to `DateTimeInterface`. + +## Documentation + +For more detailed documentation on usage, schema design, and advanced features like schema evolution, please refer to the official Avro documentation. + +## Contribution + +Contributions are welcome! If you find a bug or want to suggest a new feature, feel free to open an issue or submit a pull request. + ## Development Setup This project uses VS Code Dev Containers for development. This ensures a consistent development environment across all contributors. @@ -55,35 +112,6 @@ docker compose -f .devcontainer/docker-compose.yaml run --rm test-generator For more details, see [.devcontainer/README.md](.devcontainer/README.md). -## Usage - -### Encoding/Decoding Data - -Try out the example scripts `examples/encoding.php` and `examples/decoding.php`: -```bash -php examples/encoding.php -php examples/decoding.php -``` - -### Logical Types - -Although this library does not provide an implementation for any logical type, it is possible to use them by providing the factory implementation to `Auxmoney\Avro\AvroFactory::create`. - -The logical type factory must implement the interface `Auxmoney\Avro\Contracts\LogicalTypeFactoryInterface`. - -Try out the example script `examples/logical-type.php`: -```bash -php examples/logical-type.php -``` - -## Documentation - -For more detailed documentation on usage, schema design, and advanced features like schema evolution, please refer to the official Avro documentation. - -## Contribution - -Contributions are welcome! If you find a bug or want to suggest a new feature, feel free to open an issue or submit a pull request. - ## License This library is licensed under the Apache License 2.0. diff --git a/examples/logical-type.php b/examples/logical-type.php index fe3f03f..bec95a6 100644 --- a/examples/logical-type.php +++ b/examples/logical-type.php @@ -3,57 +3,24 @@ require_once __DIR__ . '/autoload.php'; use Auxmoney\Avro\AvroFactory; -use Auxmoney\Avro\Contracts\LogicalTypeFactoryInterface; -use Auxmoney\Avro\Contracts\LogicalTypeInterface; -use Auxmoney\Avro\Contracts\Options; -use Auxmoney\Avro\Contracts\ValidationContextInterface; - -class Base64ExampleType implements LogicalTypeInterface -{ - public function validate(mixed $datum, ?ValidationContextInterface $context): bool - { - if (!is_string($datum)) { - $context?->addError('expected string, got ' . gettype($datum)); - return false; - } - - return true; - } - - public function denormalize(mixed $datum): mixed - { - return base64_decode($datum); - } - - public function normalize(mixed $datum): mixed - { - return base64_encode($datum); - } -} - -class Base64ExampleTypeFactory implements LogicalTypeFactoryInterface -{ - public function getName(): string - { - return 'base64-example'; - } - - public function create(array $attributes): Auxmoney\Avro\Contracts\LogicalTypeInterface - { - return new Base64ExampleType(); - } -} - -$options = new Options(logicalTypeFactories: [new Base64ExampleTypeFactory()]); -$avroFactory = AvroFactory::create($options); - -$schema = '{"type": "string", "logicalType": "base64-example"}'; +use Auxmoney\Avro\ValueObject\Decimal; -$writer = $avroFactory->createWriter($schema); -$writeBuffer = $avroFactory->createStringBuffer(); -$writer->write('Hello, World!', $writeBuffer); -var_dump($writeBuffer->__toString()); +$avroFactory = AvroFactory::create(); + +$schema = '{"type": "long", "logicalType": "timestamp-millis"}'; +$encodedData = "\x00"; -$readBuffer = $avroFactory->createReadableStreamFromString("(SGVsbG8sIFdvcmxkIQ=="); $reader = $avroFactory->createReader($schema); -var_dump($reader->read($readBuffer)); \ No newline at end of file +$buffer = $avroFactory->createReadableStreamFromString($encodedData); +$decodedData = $reader->read($buffer); + +var_dump($decodedData); + + +$schema = '{"type": "bytes", "logicalType": "decimal", "precision": 10, "scale": 4}'; +$decodedData = Decimal::fromString('3.14159'); + +$writer = $avroFactory->createWriter($schema); +$buffer = $avroFactory->createStringBuffer(); +$writer->write($decodedData, $buffer); +var_dump(bin2hex($buffer->__toString())); \ No newline at end of file diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 080d7f8..a3b8cac 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -3,4 +3,5 @@ parameters: level: max paths: - src - - tests \ No newline at end of file + - tests + treatPhpDocTypesAsCertain: false \ No newline at end of file diff --git a/src/AvroFactory.php b/src/AvroFactory.php index eee604c..c41499b 100644 --- a/src/AvroFactory.php +++ b/src/AvroFactory.php @@ -5,6 +5,7 @@ namespace Auxmoney\Avro; use Auxmoney\Avro\Contracts\AvroFactoryInterface; +use Auxmoney\Avro\Contracts\LogicalTypeFactoryInterface; use Auxmoney\Avro\Contracts\Options; use Auxmoney\Avro\Contracts\ReadableStreamInterface; use Auxmoney\Avro\Contracts\ReaderInterface; @@ -13,6 +14,16 @@ use Auxmoney\Avro\Deserialization\BinaryDecoder; use Auxmoney\Avro\IO\ReadableStringBuffer; use Auxmoney\Avro\IO\WritableStringBuffer; +use Auxmoney\Avro\LogicalType\Factory\DateFactory; +use Auxmoney\Avro\LogicalType\Factory\DecimalFactory; +use Auxmoney\Avro\LogicalType\Factory\DurationFactory; +use Auxmoney\Avro\LogicalType\Factory\LocalTimestampMicrosFactory; +use Auxmoney\Avro\LogicalType\Factory\LocalTimestampMillisFactory; +use Auxmoney\Avro\LogicalType\Factory\TimeMicrosFactory; +use Auxmoney\Avro\LogicalType\Factory\TimeMillisFactory; +use Auxmoney\Avro\LogicalType\Factory\TimestampMicrosFactory; +use Auxmoney\Avro\LogicalType\Factory\TimestampMillisFactory; +use Auxmoney\Avro\LogicalType\Factory\UuidFactory; use Auxmoney\Avro\Serialization\BinaryEncoder; use Auxmoney\Avro\Support\DefaultValueConverter; use Auxmoney\Avro\Support\LogicalTypeResolver; @@ -50,7 +61,8 @@ public function createReadableStreamFromString(string $string): ReadableStreamIn public static function create(Options $options = new Options()): AvroFactoryInterface { - $logicalTypeResolver = new LogicalTypeResolver($options->logicalTypeFactories); + $logicalTypeFactories = $options->logicalTypeFactories ?? self::getDefaultLogicalTypeFactories(); + $logicalTypeResolver = new LogicalTypeResolver($logicalTypeFactories); $schemaHelper = new SchemaHelper($logicalTypeResolver); $defaultValueConverter = new DefaultValueConverter($schemaHelper); $writerFactory = new WriterFactory(new BinaryEncoder(), $schemaHelper, $options); @@ -58,4 +70,23 @@ public static function create(Options $options = new Options()): AvroFactoryInte return new self($writerFactory, $readerFactory); } + + /** + * @return array + */ + public static function getDefaultLogicalTypeFactories(): array + { + return [ + 'date' => new DateFactory(), + 'decimal' => new DecimalFactory(), + 'duration' => new DurationFactory(), + 'local-timestamp-micros' => new LocalTimestampMicrosFactory(), + 'local-timestamp-millis' => new LocalTimestampMillisFactory(), + 'time-micros' => new TimeMicrosFactory(), + 'time-millis' => new TimeMillisFactory(), + 'timestamp-micros' => new TimestampMicrosFactory(), + 'timestamp-millis' => new TimestampMillisFactory(), + 'uuid' => new UuidFactory(), + ]; + } } diff --git a/src/Contracts/LogicalTypeFactoryInterface.php b/src/Contracts/LogicalTypeFactoryInterface.php index f5d6423..416530c 100644 --- a/src/Contracts/LogicalTypeFactoryInterface.php +++ b/src/Contracts/LogicalTypeFactoryInterface.php @@ -4,12 +4,15 @@ namespace Auxmoney\Avro\Contracts; +use Auxmoney\Avro\Exceptions\InvalidSchemaException; + interface LogicalTypeFactoryInterface { public function getName(): string; /** * @param array $attributes + * @throws InvalidSchemaException */ public function create(array $attributes): LogicalTypeInterface; } diff --git a/src/Contracts/Options.php b/src/Contracts/Options.php index ece4948..af7bbdf 100644 --- a/src/Contracts/Options.php +++ b/src/Contracts/Options.php @@ -7,10 +7,10 @@ readonly class Options { /** - * @param iterable $logicalTypeFactories + * @param null|iterable $logicalTypeFactories */ public function __construct( - public iterable $logicalTypeFactories = [], + public ?iterable $logicalTypeFactories = null, public bool $arrayWriteBlockSize = false, public int $arrayBlockCount = 0, public bool $mapWriteBlockSize = false, diff --git a/src/Deserialization/EnumReader.php b/src/Deserialization/EnumReader.php index 81f28a8..50402d4 100644 --- a/src/Deserialization/EnumReader.php +++ b/src/Deserialization/EnumReader.php @@ -6,7 +6,7 @@ use Auxmoney\Avro\Contracts\ReadableStreamInterface; use Auxmoney\Avro\Contracts\ReaderInterface; -use RuntimeException; +use Auxmoney\Avro\Exceptions\SchemaMismatchException; class EnumReader implements ReaderInterface { @@ -23,7 +23,7 @@ public function read(ReadableStreamInterface $stream): string { $index = $this->decoder->readLong($stream); if (!isset($this->values[$index])) { - throw new RuntimeException('Invalid enum index: ' . $index); + throw new SchemaMismatchException('Invalid enum index: ' . $index); } return $this->values[$index]; diff --git a/src/Deserialization/UnionReader.php b/src/Deserialization/UnionReader.php index 4afcaa1..12575db 100644 --- a/src/Deserialization/UnionReader.php +++ b/src/Deserialization/UnionReader.php @@ -6,7 +6,7 @@ use Auxmoney\Avro\Contracts\ReadableStreamInterface; use Auxmoney\Avro\Contracts\ReaderInterface; -use RuntimeException; +use Auxmoney\Avro\Exceptions\SchemaMismatchException; class UnionReader implements ReaderInterface { @@ -23,7 +23,7 @@ public function read(ReadableStreamInterface $stream): mixed { $branchIndex = $this->decoder->readLong($stream); if (!isset($this->branchReaders[$branchIndex])) { - throw new RuntimeException('Invalid branch index: ' . $branchIndex); + throw new SchemaMismatchException('Invalid branch index: ' . $branchIndex); } return $this->branchReaders[$branchIndex]->read($stream); @@ -33,7 +33,7 @@ public function skip(ReadableStreamInterface $stream): void { $branchIndex = $this->decoder->readLong($stream); if (!isset($this->branchReaders[$branchIndex])) { - throw new RuntimeException('Invalid branch index: ' . $branchIndex); + throw new SchemaMismatchException('Invalid branch index: ' . $branchIndex); } $this->branchReaders[$branchIndex]->skip($stream); diff --git a/src/Exceptions/AuxmoneyAvroException.php b/src/Exceptions/AuxmoneyAvroException.php new file mode 100644 index 0000000..2b5199c --- /dev/null +++ b/src/Exceptions/AuxmoneyAvroException.php @@ -0,0 +1,17 @@ + $errors @@ -14,7 +14,7 @@ class DataMismatchException extends Exception public function __construct( public readonly array $errors, int $code = 0, - Exception $previous = null, + ?Exception $previous = null, ) { $formattedErrors = array_map(fn ($error) => "- {$error}\n", $errors); $message = "The provided data does not match the AVRO schema.\nErrors:\n" . implode('', $formattedErrors); diff --git a/src/Exceptions/InvalidArgumentException.php b/src/Exceptions/InvalidArgumentException.php new file mode 100644 index 0000000..5bdc117 --- /dev/null +++ b/src/Exceptions/InvalidArgumentException.php @@ -0,0 +1,12 @@ +addError('expected DateTimeInterface, got ' . gettype($datum)); + return false; + } + + return true; + } + + public function normalize(mixed $datum): mixed + { + assert($datum instanceof DateTimeInterface); + return $this->getDaysSinceEpoch($datum); + } + + public function denormalize(mixed $datum): DateTimeInterface + { + $epochDate = DateTimeImmutable::createFromFormat('!Y-m-d', '1970-01-01'); + assert($epochDate !== false, 'Could not create epoch'); + assert(is_int($datum), 'Date logical type datum must be an integer'); + + return $epochDate->modify("{$datum} days"); + } + + private function getDaysSinceEpoch(DateTimeInterface $datum): int + { + $epoch = DateTimeImmutable::createFromFormat('!Y-m-d', '1970-01-01', $datum->getTimezone()); + assert($epoch !== false, 'Could not create epoch'); + + $dateInterval = $epoch->diff($datum); + $days = $dateInterval->days; + assert($days !== false, 'Could not calculate days since epoch'); + + return $dateInterval->invert ? -$days : $days; + } +} diff --git a/src/LogicalType/DecimalType.php b/src/LogicalType/DecimalType.php new file mode 100644 index 0000000..29fafb0 --- /dev/null +++ b/src/LogicalType/DecimalType.php @@ -0,0 +1,73 @@ +precision = $precision; + $this->scale = $scale; + $this->size = $size; + } + + public function validate(mixed $datum, ?ValidationContextInterface $context): bool + { + if (!$datum instanceof Decimal) { + $context?->addError('Decimal value must be an instance of Decimal'); + return false; + } + + // For fixed schemas, validate that the decimal value doesn't exceed the allowed size + if ($this->size !== null) { + $bytes = $datum->withScale($this->scale)->toBytes(); + $currentLength = strlen($bytes); + + if ($currentLength > $this->size) { + $context?->addError("Decimal value requires {$currentLength} bytes but fixed schema only allows {$this->size} bytes"); + return false; + } + } + + return true; + } + + public function normalize(mixed $datum): string + { + assert($datum instanceof Decimal, 'Expected Decimal, got ' . gettype($datum)); + + return $datum->withScale($this->scale)->toBytes($this->size); + } + + public function denormalize(mixed $datum): Decimal + { + assert(is_string($datum), 'Expected bytes string for decimal denormalization'); + + return Decimal::fromBytes($datum, $this->scale); + } + + public function getPrecision(): int + { + return $this->precision; + } + + public function getScale(): int + { + return $this->scale; + } + + public function getSize(): ?int + { + return $this->size; + } +} diff --git a/src/LogicalType/DurationType.php b/src/LogicalType/DurationType.php new file mode 100644 index 0000000..16ca0b5 --- /dev/null +++ b/src/LogicalType/DurationType.php @@ -0,0 +1,41 @@ +addError('Duration value must be a Duration object'); + return false; + } + + public function normalize(mixed $datum): mixed + { + assert($datum instanceof Duration); + + // Pack as 3 little-endian unsigned 32-bit integers (12 bytes total) + return pack('VVV', $datum->months, $datum->days, $datum->milliseconds); + } + + public function denormalize(mixed $datum): Duration + { + assert(is_string($datum) && strlen($datum) === 12, 'Expected 12-byte string for duration denormalization'); + + // Unpack 3 little-endian unsigned 32-bit integers + $values = unpack('V3', $datum); + assert($values !== false && is_int($values[1]) && is_int($values[2]) && is_int($values[3]), 'Failed to unpack duration data'); + + return new Duration(months: $values[1], days: $values[2], milliseconds: $values[3]); + } +} diff --git a/src/LogicalType/Factory/DateFactory.php b/src/LogicalType/Factory/DateFactory.php new file mode 100644 index 0000000..c95bf03 --- /dev/null +++ b/src/LogicalType/Factory/DateFactory.php @@ -0,0 +1,27 @@ + $precision) { + throw new InvalidSchemaException('Decimal scale must be between 0 and precision'); + } + + // For fixed schemas, extract the size from the schema + $size = null; + if ($attributes['type'] === 'fixed' && isset($attributes['size']) && is_int($attributes['size'])) { + $size = $attributes['size']; + if ($size <= 0) { + throw new InvalidSchemaException('Fixed size must be a positive integer'); + } + } + + return new DecimalType($precision, $scale, $size); + } +} diff --git a/src/LogicalType/Factory/DurationFactory.php b/src/LogicalType/Factory/DurationFactory.php new file mode 100644 index 0000000..146313e --- /dev/null +++ b/src/LogicalType/Factory/DurationFactory.php @@ -0,0 +1,31 @@ +zeroDate = new DateTimeImmutable('@0'); + $this->defaultTimeZone = (new DateTimeImmutable())->getTimezone(); + } + + public function validate(mixed $datum, ?ValidationContextInterface $context): bool + { + if (!($datum instanceof DateTimeInterface)) { + $context?->addError('expected DateTimeInterface, got ' . gettype($datum)); + return false; + } + + return true; + } + + public function normalize(mixed $datum): mixed + { + assert($datum instanceof DateTimeInterface); + + // For local timestamp: treat the local time components as if they were UTC + // e.g., 14:00 CEST should be treated as 14:00 UTC, not 12:00 UTC + $utcDateTime = new DateTimeImmutable($datum->format('Y-m-d H:i:s.u'), new DateTimeZone('UTC')); + return $this->getMicrosecondsSinceEpoch($utcDateTime); + } + + public function denormalize(mixed $datum): mixed + { + assert(is_int($datum), 'LocalTimestampMicros logical type datum must be an integer'); + + $seconds = intval($datum / 1000000); + $remainingMicroseconds = $datum % 1000000; + + // Create UTC datetime from epoch microseconds + $utcDateTime = $this->zeroDate + ->modify("+{$seconds} seconds") + ->modify("+{$remainingMicroseconds} microseconds"); + + // Create a new DateTime with the same time components but in local timezone + // This preserves the time (e.g., 12:00 UTC becomes 12:00 local, not shifted) + return new DateTimeImmutable($utcDateTime->format('Y-m-d H:i:s.u'), $this->defaultTimeZone); + } + + private function getMicrosecondsSinceEpoch(DateTimeInterface $datum): int + { + $seconds = (int) $datum->format('U'); + $microseconds = (int) $datum->format('u'); + + return $seconds * 1000000 + $microseconds; + } +} diff --git a/src/LogicalType/LocalTimestampMillisType.php b/src/LogicalType/LocalTimestampMillisType.php new file mode 100644 index 0000000..5377c1b --- /dev/null +++ b/src/LogicalType/LocalTimestampMillisType.php @@ -0,0 +1,63 @@ +zeroDate = new DateTimeImmutable('@0'); + $this->defaultTimeZone = (new DateTimeImmutable())->getTimezone(); + } + + public function validate(mixed $datum, ?ValidationContextInterface $context): bool + { + if (!($datum instanceof DateTimeInterface)) { + $context?->addError('expected DateTimeInterface, got ' . gettype($datum)); + return false; + } + + return true; + } + + public function normalize(mixed $datum): mixed + { + assert($datum instanceof DateTimeInterface); + + // For local timestamp: treat the local time components as if they were UTC + // e.g., 14:00 CEST should be treated as 14:00 UTC, not 12:00 UTC + $utcDateTime = new DateTimeImmutable($datum->format('Y-m-d H:i:s.u'), new DateTimeZone('UTC')); + return $this->getMillisecondsSinceEpoch($utcDateTime); + } + + public function denormalize(mixed $datum): mixed + { + assert(is_int($datum), 'LocalTimestampMillis logical type datum must be an integer'); + + // Create UTC datetime from epoch milliseconds + $utcDateTime = $this->zeroDate->modify("{$datum} milliseconds"); + + // Create a new DateTime with the same time components but in local timezone + // This preserves the time (e.g., 12:00 UTC becomes 12:00 local, not shifted) + return new DateTimeImmutable($utcDateTime->format('Y-m-d H:i:s.u'), $this->defaultTimeZone); + } + + private function getMillisecondsSinceEpoch(DateTimeInterface $datum): int + { + $seconds = (int) $datum->format('U'); + $milliseconds = (int) $datum->format('v'); + + return $seconds * 1000 + $milliseconds; + } +} diff --git a/src/LogicalType/TimeMicrosType.php b/src/LogicalType/TimeMicrosType.php new file mode 100644 index 0000000..8c93fa0 --- /dev/null +++ b/src/LogicalType/TimeMicrosType.php @@ -0,0 +1,36 @@ +addError('Time value must be a TimeOfDay object'); + return false; + } + + public function normalize(mixed $datum): mixed + { + assert($datum instanceof TimeOfDay); + + return $datum->totalMicroseconds; + } + + public function denormalize(mixed $datum): TimeOfDay + { + assert(is_int($datum), 'Expected integer (microseconds since midnight) for time denormalization'); + + return new TimeOfDay($datum); + } +} diff --git a/src/LogicalType/TimeMillisType.php b/src/LogicalType/TimeMillisType.php new file mode 100644 index 0000000..482767b --- /dev/null +++ b/src/LogicalType/TimeMillisType.php @@ -0,0 +1,36 @@ +addError('Time value must be a TimeOfDay object'); + return false; + } + + public function normalize(mixed $datum): mixed + { + assert($datum instanceof TimeOfDay); + + return $datum->getTotalMilliseconds(); + } + + public function denormalize(mixed $datum): TimeOfDay + { + assert(is_int($datum), 'Expected integer (milliseconds since midnight) for time denormalization'); + + return new TimeOfDay($datum * 1000); // Convert milliseconds to microseconds + } +} diff --git a/src/LogicalType/TimestampMicrosType.php b/src/LogicalType/TimestampMicrosType.php new file mode 100644 index 0000000..71601fa --- /dev/null +++ b/src/LogicalType/TimestampMicrosType.php @@ -0,0 +1,60 @@ +zeroDate = new DateTimeImmutable('@0'); + $this->defaultTimeZone = (new DateTimeImmutable())->getTimezone(); + } + + public function validate(mixed $datum, ?ValidationContextInterface $context): bool + { + if (!($datum instanceof DateTimeInterface)) { + $context?->addError('expected DateTimeInterface, got ' . gettype($datum)); + return false; + } + + return true; + } + + public function normalize(mixed $datum): mixed + { + assert($datum instanceof DateTimeInterface); + return $this->getMicrosecondsSinceEpoch($datum); + } + + public function denormalize(mixed $datum): mixed + { + assert(is_int($datum), 'TimestampMicros logical type datum must be an integer'); + + $seconds = intval($datum / 1000000); + $remainingMicroseconds = $datum % 1000000; + + return $this->zeroDate + ->modify("+{$seconds} seconds") + ->modify("+{$remainingMicroseconds} microseconds") + ->setTimezone($this->defaultTimeZone); + } + + private function getMicrosecondsSinceEpoch(DateTimeInterface $datum): int + { + $seconds = (int) $datum->format('U'); + $microseconds = (int) $datum->format('u'); + + return $seconds * 1000000 + $microseconds; + } +} diff --git a/src/LogicalType/TimestampMillisType.php b/src/LogicalType/TimestampMillisType.php new file mode 100644 index 0000000..63ba91f --- /dev/null +++ b/src/LogicalType/TimestampMillisType.php @@ -0,0 +1,54 @@ +zeroDate = new DateTimeImmutable('@0'); + $this->defaultTimeZone = (new DateTimeImmutable())->getTimezone(); + } + + public function validate(mixed $datum, ?ValidationContextInterface $context): bool + { + if (!($datum instanceof DateTimeInterface)) { + $context?->addError('expected DateTimeInterface, got ' . gettype($datum)); + return false; + } + + return true; + } + + public function normalize(mixed $datum): mixed + { + assert($datum instanceof DateTimeInterface); + return $this->getMillisecondsSinceEpoch($datum); + } + + public function denormalize(mixed $datum): mixed + { + assert(is_int($datum), 'TimestampMillis logical type datum must be an integer'); + + return $this->zeroDate->modify("{$datum} milliseconds")->setTimezone($this->defaultTimeZone); + } + + private function getMillisecondsSinceEpoch(DateTimeInterface $datum): int + { + $seconds = (int) $datum->format('U'); + $milliseconds = (int) $datum->format('v'); + + return $seconds * 1000 + $milliseconds; + } +} diff --git a/src/LogicalType/UuidType.php b/src/LogicalType/UuidType.php new file mode 100644 index 0000000..c2e64cd --- /dev/null +++ b/src/LogicalType/UuidType.php @@ -0,0 +1,37 @@ +addError('UUID value must be a Uuid value object'); + return false; + } + + return true; + } + + public function normalize(mixed $datum): mixed + { + assert($datum instanceof Uuid); + + return $datum->toBytes(); + } + + public function denormalize(mixed $datum): mixed + { + assert(is_string($datum) && strlen($datum) === 16); + + // Convert 16-byte binary to Uuid value object + return Uuid::fromBytes($datum); + } +} diff --git a/src/ValueObject/ArbitraryPrecisionInteger.php b/src/ValueObject/ArbitraryPrecisionInteger.php new file mode 100644 index 0000000..6f1a0af --- /dev/null +++ b/src/ValueObject/ArbitraryPrecisionInteger.php @@ -0,0 +1,336 @@ +bytes = $bytes; + } + + public static function fromBytes(string $bytes): self + { + return new self(self::trimBytesString($bytes)); + } + + public static function fromInteger(int|self $value): self + { + return $value instanceof self ? $value : self::fromBytes(pack('J', $value)); + } + + /** + * @throws InvalidArgumentException if the input is not a valid integer string + */ + public static function fromString(string $value): self + { + if (!preg_match('/^-?\d+$/', $value)) { + throw new InvalidArgumentException('Value must be a valid integer string'); + } + + $isNegative = str_starts_with($value, '-'); + $absValue = ltrim($value, '-0') ?: '0'; + + $bytes = []; + foreach (self::toBytesArray($absValue) as $digitByte) { + $digit = $digitByte - 48; // Convert ASCII digit to numeric value (0-9) + + // Multiply current value by 10 + $bytes = self::multiplyBytesByInt($bytes, 10); + + // Add the current digit + if ($digit > 0) { + $bytes = self::addBytesInt($bytes, $digit); + } + } + + array_unshift($bytes, 0); + if ($isNegative) { + $bytes = self::toTwosComplement($bytes); + } + + return self::fromBytes(self::toBytesString($bytes)); + } + + public function toString(): string + { + $decimalDigits = $this->toAbsoluteDecimalDigits(); + foreach ($decimalDigits as $key => $digit) { + $decimalDigits[$key] = 48 + $digit; + } + + if ($this->isNegative()) { + array_unshift($decimalDigits, 45); // ASCII code for '-' + } + + return self::toBytesString($decimalDigits); + } + + /** + * @return array + */ + public function toAbsoluteDecimalDigits(): array + { + $byteArray = self::toBytesArray($this->bytes); + + if ($this->isNegative()) { + $byteArray = self::toTwosComplement($byteArray); + } + + $digits = []; + while ($byteArray !== []) { + [$byteArray, $remainder] = $this->divideBytesByInt($byteArray, 10); + $digits[] = $remainder; + } + + return $digits === [] ? [0] : array_reverse($digits); + } + + /** + * Shift decimal position by moving the implicit decimal point + * Positive positions = multiply by 10^positions, negative = divide with HALF_UP rounding + * + * @param int $positions Number of decimal positions to shift + */ + public function shiftDecimalPosition(int $positions): self + { + if ($positions === 0) { + return $this; + } + + if ($this->bytes === "\x00") { + return $this; + } + + $bytes = self::toBytesArray($this->bytes); + $isNegative = $this->isNegative(); + + if ($isNegative) { + $bytes = self::toTwosComplement($bytes); + } + + if ($positions > 0) { + // Positive positions: multiply by 10^positions + $remainingPositions = $positions; + while ($remainingPositions > 0) { + $iterationPositions = min($remainingPositions, self::MAX_BYTES_EXPONENT); + $multiplier = 10 ** $iterationPositions; + $bytes = self::multiplyBytesByInt($bytes, $multiplier); + $remainingPositions -= $iterationPositions; + } + } else { + // Negative positions: divide by 10^(-positions) with HALF_UP rounding + $remainingPositions = -$positions; + $finalRemainder = 0; + + while ($remainingPositions > 0) { + $iterationPositions = min($remainingPositions, self::MAX_BYTES_EXPONENT); + $divisor = 10 ** $iterationPositions; + [$bytes, $remainder] = self::divideBytesByInt($bytes, $divisor); + + // Keep track of final remainder for rounding + if ($remainingPositions === $iterationPositions) { + $finalRemainder = $remainder; + $finalDivisor = $divisor; + } + + $remainingPositions -= $iterationPositions; + } + + // Apply HALF_UP rounding: if remainder >= divisor/2, round up + if (isset($finalDivisor) && $finalRemainder >= $finalDivisor / 2) { + $bytes = self::addBytesInt($bytes, 1); + } + } + + array_unshift($bytes, 0); + if ($isNegative && $bytes !== [0]) { + $bytes = self::toTwosComplement($bytes); + } + + return self::fromBytes(self::toBytesString($bytes)); + } + + public function toBytes(?int $padLength = null): string + { + $bytes = $this->bytes; + if ($padLength !== null) { + $paddingByte = $this->isNegative() ? "\xFF" : "\x00"; + $bytes = str_pad($bytes, $padLength, $paddingByte, STR_PAD_LEFT); + } + + return $bytes; + } + + /** + * @throws InvalidArgumentException + */ + public function toInteger(): int + { + if (strlen($this->bytes) > 8) { + throw new InvalidArgumentException('Cannot convert to integer: number of bytes exceeds 8'); + } + + // Unpack as signed 64-bit integer (big-endian) + $result = unpack('J', $this->toBytes(8)); + assert($result !== false && is_int($result[1]), 'Failed to unpack integer bytes'); + return $result[1]; + } + + public function isNegative(): bool + { + return self::isBytesNegative($this->bytes); + } + + private static function isBytesNegative(string $bytes): bool + { + return $bytes !== '' && (ord($bytes[0]) & 0x80) !== 0; + } + + /** + * @return array + */ + private static function toBytesArray(string $bytes): array + { + /** @var array $unpackResult */ + $unpackResult = unpack('C*', $bytes); + assert(is_array($unpackResult)); + + return array_values($unpackResult); + } + + /** + * @param array $bytes + */ + private static function toBytesString(array $bytes): string + { + return pack('C*', ...$bytes); + } + + private static function trimBytesString(string $packedBytes): string + { + $isNegative = self::isBytesNegative($packedBytes); + $trimByte = $isNegative ? "\xFF" : "\x00"; + $trimmed = ltrim($packedBytes, $trimByte); + if (self::isBytesNegative($trimmed) !== $isNegative) { + $trimmed = $trimByte . $trimmed; + } + + return $trimmed === '' ? "\x00" : $trimmed; + } + + /** + * Convert two's complement bytes to absolute value (invert bits and add 1) + * + * @param array $bytes + * @return array + */ + private static function toTwosComplement(array $bytes): array + { + $i = count($bytes) - 1; + while ($i >= 0) { + $newByte = ($bytes[$i] ^ 0xFF) + 1; + $bytes[$i] = $newByte & 0xFF; + $i--; + if ($newByte <= 0xFF) { + break; + } + } + + while ($i >= 0) { + $bytes[$i] = $bytes[$i] ^ 0xFF; + $i--; + } + + return $bytes; + } + + /** + * Multiply byte array by an integer value using carry arithmetic + * + * @param array $bytes + * @return array + */ + private static function multiplyBytesByInt(array $bytes, int $multiplier): array + { + $carry = 0; + + // Process from least significant byte (rightmost) to most significant + for ($i = count($bytes) - 1; $i >= 0; $i--) { + $product = $bytes[$i] * $multiplier + $carry; + $bytes[$i] = $product & 0xFF; // Keep only low 8 bits + $carry = $product >> 8; // High bits become carry + } + + // If there's still carry, prepend new bytes + while ($carry > 0) { + array_unshift($bytes, $carry & 0xFF); + $carry >>= 8; + } + + return $bytes; + } + + /** + * Divide byte array by an integer value and return both quotient and remainder + * + * @param array $bytes + * @return array{array, int} + */ + private static function divideBytesByInt(array $bytes, int $divisor): array + { + $remainder = 0; + + // Process from most significant byte (leftmost) to least significant + for ($i = 0; $i < count($bytes); $i++) { + $dividend = ($remainder << 8) + $bytes[$i]; + $bytes[$i] = intdiv($dividend, $divisor); + $remainder = $dividend % $divisor; + } + + $firstNonZero = 0; + // Remove leading zero bytes + while ($firstNonZero < count($bytes) && $bytes[$firstNonZero] === 0) { + $firstNonZero++; + } + $bytes = array_slice($bytes, $firstNonZero); + + return [$bytes, $remainder]; + } + + /** + * Add a small integer value to a byte array using carry arithmetic + * + * @param array $bytes + * @return array + */ + private static function addBytesInt(array $bytes, int $value): array + { + $carry = $value; + + // Process from least significant byte (rightmost) to most significant + for ($i = count($bytes) - 1; $i >= 0 && $carry > 0; $i--) { + $sum = $bytes[$i] + $carry; + $bytes[$i] = $sum & 0xFF; // Keep only low 8 bits + $carry = $sum >> 8; // High bits become carry + } + + // If there's still carry, prepend new bytes + while ($carry > 0) { + array_unshift($bytes, $carry & 0xFF); + $carry >>= 8; + } + + return $bytes; + } +} diff --git a/src/ValueObject/Decimal.php b/src/ValueObject/Decimal.php new file mode 100644 index 0000000..37c6f2d --- /dev/null +++ b/src/ValueObject/Decimal.php @@ -0,0 +1,167 @@ +toString(); + } + + /** + * @throws InvalidArgumentException if scale is negative + */ + public static function fromUnscaledValue(int|ArbitraryPrecisionInteger $unscaledValue, int $scale): self + { + if ($scale < 0) { + throw new InvalidArgumentException('Scale must be non-negative'); + } + + return new self(ArbitraryPrecisionInteger::fromInteger($unscaledValue), $scale); + } + + /** + * @throws InvalidArgumentException if the input is not a valid decimal string + */ + public static function fromString(string $value): self + { + if (!preg_match('/^-?\d+(\.\d+)?$/', $value)) { + throw new InvalidArgumentException('Invalid decimal format'); + } + + $isNegative = str_starts_with($value, '-'); + $absValue = ltrim($value, '-'); + + // Split into integer and fractional parts + $parts = explode('.', $absValue); + $integerPart = $parts[0]; + $fractionalPart = rtrim($parts[1] ?? '', '0'); + + // Use actual decimal places as scale + $scale = strlen($fractionalPart); + + // Create unscaled integer string + $unscaled = $integerPart . $fractionalPart; + $unscaled = ltrim($unscaled, '0') ?: '0'; + + if ($isNegative && $unscaled !== '0') { + $unscaled = '-' . $unscaled; + } + + return new self(ArbitraryPrecisionInteger::fromString($unscaled), $scale); + } + + public static function fromInteger(int|ArbitraryPrecisionInteger $value): self + { + $unscaledValue = ArbitraryPrecisionInteger::fromInteger($value); + return new self($unscaledValue, 0); + } + + /** + * @throws InvalidArgumentException if the float is not finite + */ + public static function fromFloat(float $value, int $decimals): self + { + if (!is_finite($value)) { + throw new InvalidArgumentException('Float value must be finite'); + } + + $stringValue = number_format($value, $decimals, '.', ''); + + return self::fromString($stringValue); + } + + public static function fromCents(int|ArbitraryPrecisionInteger $cents): self + { + return new self(ArbitraryPrecisionInteger::fromInteger($cents), 2); + } + + public function getUnscaledValue(): ArbitraryPrecisionInteger + { + return $this->unscaledValue; + } + + public function isNegative(): bool + { + return $this->unscaledValue->isNegative(); + } + + public function getScale(): int + { + return $this->scale; + } + + public function toString(): string + { + if ($this->scale === 0) { + return $this->unscaledValue->toString(); + } + + $isNegative = $this->unscaledValue->isNegative(); + + $digits = $this->unscaledValue->toAbsoluteDecimalDigits(); + $digits = array_pad($digits, -$this->scale - 1, 0); + + $decimalPlaces = $this->scale; + $last = count($digits) - 1; + while ($decimalPlaces > 0 && $last > 0 && $digits[$last] === 0) { + $decimalPlaces--; + $last--; + } + + foreach ($digits as $index => $digit) { + $digits[$index] = 48 + $digit; // Convert to ASCII + } + + if ($decimalPlaces > 0) { + array_splice($digits, -$this->scale, 0, 46); // Insert decimal point (ASCII 46) + $last++; + } + + $digits = array_slice($digits, 0, $last + 1); + if ($isNegative) { + array_unshift($digits, 45); // Insert minus sign (ASCII 45) + } + + return pack('C*', ...$digits); + } + + public function toBytes(?int $padLength = null): string + { + return $this->unscaledValue->toBytes($padLength); + } + + public static function fromBytes(string $bytes, int $scale): self + { + $unscaledValue = ArbitraryPrecisionInteger::fromBytes($bytes); + return new self($unscaledValue, $scale); + } + + public function withScale(int $newScale): self + { + if ($newScale < 0) { + throw new InvalidArgumentException('Scale must be non-negative'); + } + + if ($newScale === $this->scale) { + return $this; + } + + // Calculate scale difference and shift the decimal position accordingly + $scaleDelta = $newScale - $this->scale; + $newUnscaledValue = $this->unscaledValue->shiftDecimalPosition($scaleDelta); + + return new self($newUnscaledValue, $newScale); + } +} diff --git a/src/ValueObject/Duration.php b/src/ValueObject/Duration.php new file mode 100644 index 0000000..4a91281 --- /dev/null +++ b/src/ValueObject/Duration.php @@ -0,0 +1,46 @@ +months; + * $days = $duration->days; + * $milliseconds = $duration->milliseconds; + */ +readonly class Duration +{ + public function __construct( + public int $months = 0, + public int $days = 0, + public int $milliseconds = 0, + ) { + if ($months < 0) { + throw new InvalidArgumentException('Months must be non-negative'); + } + if ($days < 0) { + throw new InvalidArgumentException('Days must be non-negative'); + } + if ($milliseconds < 0) { + throw new InvalidArgumentException('Milliseconds must be non-negative'); + } + } +} diff --git a/src/ValueObject/TimeOfDay.php b/src/ValueObject/TimeOfDay.php new file mode 100644 index 0000000..b22088f --- /dev/null +++ b/src/ValueObject/TimeOfDay.php @@ -0,0 +1,92 @@ += 86400000000) { + throw new InvalidArgumentException('Total microseconds must be between 0 and 86399999999 (midnight to 23:59:59.999999)'); + } + } + + public function __toString(): string + { + return sprintf('%02d:%02d:%02d.%06d', $this->getHours(), $this->getMinutes(), $this->getSeconds(), $this->getMicroseconds()); + } + + public static function fromComponents( + int $hours, + int $minutes = 0, + int $seconds = 0, + int $milliseconds = 0, + int $microseconds = 0, + ): self { + if ($hours < 0 || $hours > 23) { + throw new InvalidArgumentException('Hours must be between 0 and 23'); + } + if ($minutes < 0 || $minutes > 59) { + throw new InvalidArgumentException('Minutes must be between 0 and 59'); + } + if ($seconds < 0 || $seconds > 59) { + throw new InvalidArgumentException('Seconds must be between 0 and 59'); + } + if ($milliseconds < 0 || $milliseconds > 999) { + throw new InvalidArgumentException('Milliseconds must be between 0 and 999'); + } + if ($microseconds < 0 || $microseconds > 999) { + throw new InvalidArgumentException('Microseconds must be between 0 and 999'); + } + + $totalMicroseconds = ($hours * 3600 + $minutes * 60 + $seconds) * 1000000 + $milliseconds * 1000 + $microseconds; + + return new self($totalMicroseconds); + } + + public static function fromDateTime(DateTimeInterface $dateTime): self + { + $hours = (int) $dateTime->format('H'); + $minutes = (int) $dateTime->format('i'); + $seconds = (int) $dateTime->format('s'); + $microseconds = (int) $dateTime->format('u'); + + return self::fromComponents($hours, $minutes, $seconds, intval($microseconds / 1000), $microseconds % 1000); + } + + public function getHours(): int + { + return intval($this->totalMicroseconds / 3600000000); + } + + public function getMinutes(): int + { + return intval(($this->totalMicroseconds % 3600000000) / 60000000); + } + + public function getSeconds(): int + { + return intval(($this->totalMicroseconds % 60000000) / 1000000); + } + + public function getMilliseconds(): int + { + return intval(($this->totalMicroseconds % 1000000) / 1000); + } + + public function getMicroseconds(): int + { + return $this->totalMicroseconds % 1000000; + } + + public function getTotalMilliseconds(): int + { + return intval($this->totalMicroseconds / 1000); + } +} diff --git a/src/ValueObject/Uuid.php b/src/ValueObject/Uuid.php new file mode 100644 index 0000000..0afd25d --- /dev/null +++ b/src/ValueObject/Uuid.php @@ -0,0 +1,106 @@ +toString(); + * + * // Get binary bytes + * $binaryValue = $uuid->toBytes(); + */ +readonly class Uuid +{ + private function __construct( + public string $bytes, + ) { + if (strlen($bytes) !== 16) { + throw new InvalidArgumentException('UUID bytes must be exactly 16 bytes long'); + } + } + + /** + * Creates a UUID from a 16-byte binary string. + * + * @param string $bytes The 16-byte binary representation of the UUID + * @throws InvalidArgumentException if the bytes are not exactly 16 bytes long + */ + public static function fromBytes(string $bytes): self + { + return new self($bytes); + } + + /** + * Creates a UUID from a string in the standard UUID format. + * + * @param string $uuidString The UUID string in format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + * @throws InvalidArgumentException if the string is not a valid UUID format + */ + public static function fromString(string $uuidString): self + { + if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $uuidString)) { + throw new InvalidArgumentException('Invalid UUID format. Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'); + } + + // Remove hyphens and convert hex to binary + $hex = str_replace('-', '', $uuidString); + $bytes = hex2bin($hex); + + if ($bytes === false) { + throw new InvalidArgumentException('Failed to convert UUID string to bytes'); + } + + return new self($bytes); + } + + /** + * Returns the UUID as a 16-byte binary string. + * + * @return string The 16-byte binary representation + */ + public function toBytes(): string + { + return $this->bytes; + } + + /** + * Returns the UUID as a string in the standard format. + * + * @return string The UUID string in format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + */ + public function toString(): string + { + $hex = bin2hex($this->bytes); + + // Format as UUID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + return sprintf( + '%s-%s-%s-%s-%s', + substr($hex, 0, 8), + substr($hex, 8, 4), + substr($hex, 12, 4), + substr($hex, 16, 4), + substr($hex, 20, 12), + ); + } +} diff --git a/tests/Unit/Deserialization/ArrayReaderTest.php b/tests/Unit/Deserialization/ArrayReaderTest.php index a515dc9..5594c65 100644 --- a/tests/Unit/Deserialization/ArrayReaderTest.php +++ b/tests/Unit/Deserialization/ArrayReaderTest.php @@ -9,7 +9,6 @@ use Auxmoney\Avro\Deserialization\ArrayReader; use Auxmoney\Avro\Deserialization\BinaryDecoder; use PHPUnit\Framework\TestCase; -use RuntimeException; class ArrayReaderTest extends TestCase { @@ -110,39 +109,6 @@ public function testReadWithMixedBlockTypes(): void $this->assertSame(['item1', 'item2', 'item3'], $result); } - public function testReadWithDecoderException(): void - { - $this->decoder->expects($this->once()) - ->method('readLong') - ->with($this->stream) - ->willThrowException(new RuntimeException('Decoder error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Decoder error'); - - $this->reader->read($this->stream); - } - - public function testReadWithItemReaderException(): void - { - // When an exception occurs during item reading, the process stops immediately - // so readLong is only called once (for the block count), not twice (no terminator read) - $this->decoder->expects($this->once()) - ->method('readLong') - ->with($this->stream) - ->willReturn(2); // Block count - - $this->itemReader->expects($this->once()) - ->method('read') - ->with($this->stream) - ->willThrowException(new RuntimeException('Item reader error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Item reader error'); - - $this->reader->read($this->stream); - } - public function testSkipWithEmptyArray(): void { $this->decoder->expects($this->once()) @@ -217,42 +183,4 @@ public function testSkipWithMixedBlockTypes(): void $this->reader->skip($this->stream); } - - public function testSkipWithItemReaderException(): void - { - // When an exception occurs during item skipping, the process stops immediately - // so readLong is only called once (for the block count), not twice (no terminator read) - $this->decoder->expects($this->once()) - ->method('readLong') - ->with($this->stream) - ->willReturn(2); // Block count - - $this->itemReader->expects($this->once()) - ->method('skip') - ->with($this->stream) - ->willThrowException(new RuntimeException('Skip error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Skip error'); - - $this->reader->skip($this->stream); - } - - public function testSkipWithStreamException(): void - { - $this->decoder->expects($this->exactly(2)) - ->method('readLong') - ->with($this->stream) - ->willReturnOnConsecutiveCalls(-2, 20); - - $this->stream->expects($this->once()) - ->method('skip') - ->with(20) - ->willThrowException(new RuntimeException('Stream skip error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Stream skip error'); - - $this->reader->skip($this->stream); - } } diff --git a/tests/Unit/Deserialization/BooleanReaderTest.php b/tests/Unit/Deserialization/BooleanReaderTest.php index 2e7432b..4c75217 100644 --- a/tests/Unit/Deserialization/BooleanReaderTest.php +++ b/tests/Unit/Deserialization/BooleanReaderTest.php @@ -7,7 +7,6 @@ use Auxmoney\Avro\Contracts\ReadableStreamInterface; use Auxmoney\Avro\Deserialization\BooleanReader; use PHPUnit\Framework\TestCase; -use RuntimeException; class BooleanReaderTest extends TestCase { @@ -56,19 +55,6 @@ public function testReadWithNonZeroValue(): void $this->assertTrue($result); } - public function testReadWithStreamException(): void - { - $this->stream->expects($this->once()) - ->method('read') - ->with(1) - ->willThrowException(new RuntimeException('Stream error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Stream error'); - - $this->reader->read($this->stream); - } - public function testSkipWithValidOperation(): void { $this->stream->expects($this->once()) @@ -77,17 +63,4 @@ public function testSkipWithValidOperation(): void $this->reader->skip($this->stream); } - - public function testSkipWithStreamException(): void - { - $this->stream->expects($this->once()) - ->method('skip') - ->with(1) - ->willThrowException(new RuntimeException('Skip error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Skip error'); - - $this->reader->skip($this->stream); - } } diff --git a/tests/Unit/Deserialization/DoubleReaderTest.php b/tests/Unit/Deserialization/DoubleReaderTest.php index ac06626..7f480b6 100644 --- a/tests/Unit/Deserialization/DoubleReaderTest.php +++ b/tests/Unit/Deserialization/DoubleReaderTest.php @@ -8,7 +8,6 @@ use Auxmoney\Avro\Deserialization\BinaryDecoder; use Auxmoney\Avro\Deserialization\DoubleReader; use PHPUnit\Framework\TestCase; -use RuntimeException; class DoubleReaderTest extends TestCase { @@ -119,19 +118,6 @@ public function testReadWithNaN(): void $this->assertNan($result); } - public function testReadWithDecoderException(): void - { - $this->decoder->expects($this->once()) - ->method('readDouble') - ->with($this->stream) - ->willThrowException(new RuntimeException('Decoder error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Decoder error'); - - $this->reader->read($this->stream); - } - public function testSkipWithValidOperation(): void { $this->stream->expects($this->once()) @@ -141,19 +127,6 @@ public function testSkipWithValidOperation(): void $this->reader->skip($this->stream); } - public function testSkipWithStreamException(): void - { - $this->stream->expects($this->once()) - ->method('skip') - ->with(8) - ->willThrowException(new RuntimeException('Skip error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Skip error'); - - $this->reader->skip($this->stream); - } - public function testSkipWithMultipleCalls(): void { $this->stream->expects($this->exactly(3)) diff --git a/tests/Unit/Deserialization/EnumReaderTest.php b/tests/Unit/Deserialization/EnumReaderTest.php index 94abad4..8dcc2cc 100644 --- a/tests/Unit/Deserialization/EnumReaderTest.php +++ b/tests/Unit/Deserialization/EnumReaderTest.php @@ -7,8 +7,8 @@ use Auxmoney\Avro\Contracts\ReadableStreamInterface; use Auxmoney\Avro\Deserialization\BinaryDecoder; use Auxmoney\Avro\Deserialization\EnumReader; +use Auxmoney\Avro\Exceptions\SchemaMismatchException; use PHPUnit\Framework\TestCase; -use RuntimeException; class EnumReaderTest extends TestCase { @@ -72,7 +72,7 @@ public function testReadWithInvalidPositiveIndex(): void ->with($this->stream) ->willReturn($invalidIndex); - $this->expectException(RuntimeException::class); + $this->expectException(SchemaMismatchException::class); $this->expectExceptionMessage("Invalid enum index: {$invalidIndex}"); $this->reader->read($this->stream); @@ -87,25 +87,12 @@ public function testReadWithNegativeIndex(): void ->with($this->stream) ->willReturn($invalidIndex); - $this->expectException(RuntimeException::class); + $this->expectException(SchemaMismatchException::class); $this->expectExceptionMessage("Invalid enum index: {$invalidIndex}"); $this->reader->read($this->stream); } - public function testReadWithDecoderException(): void - { - $this->decoder->expects($this->once()) - ->method('readLong') - ->with($this->stream) - ->willThrowException(new RuntimeException('Decoder error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Decoder error'); - - $this->reader->read($this->stream); - } - public function testReadWithEmptyEnumValues(): void { $emptyReader = new EnumReader([], $this->decoder); @@ -115,7 +102,7 @@ public function testReadWithEmptyEnumValues(): void ->with($this->stream) ->willReturn(0); - $this->expectException(RuntimeException::class); + $this->expectException(SchemaMismatchException::class); $this->expectExceptionMessage('Invalid enum index: 0'); $emptyReader->read($this->stream); @@ -144,7 +131,7 @@ public function testReadWithSingleValueEnumInvalidIndex(): void ->with($this->stream) ->willReturn(1); // Invalid for single-value enum - $this->expectException(RuntimeException::class); + $this->expectException(SchemaMismatchException::class); $this->expectExceptionMessage('Invalid enum index: 1'); $singleValueReader->read($this->stream); @@ -159,19 +146,6 @@ public function testSkipWithValidOperation(): void $this->reader->skip($this->stream); } - public function testSkipWithDecoderException(): void - { - $this->decoder->expects($this->once()) - ->method('skipLong') - ->with($this->stream) - ->willThrowException(new RuntimeException('Skip error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Skip error'); - - $this->reader->skip($this->stream); - } - public function testReadWithBoundaryValues(): void { // Test exactly at the boundary @@ -197,7 +171,7 @@ public function testReadWithJustOverBoundary(): void ->with($this->stream) ->willReturn($invalidIndex); - $this->expectException(RuntimeException::class); + $this->expectException(SchemaMismatchException::class); $this->expectExceptionMessage("Invalid enum index: {$invalidIndex}"); $this->reader->read($this->stream); diff --git a/tests/Unit/Deserialization/FixedReaderTest.php b/tests/Unit/Deserialization/FixedReaderTest.php index dd1d394..6cc0848 100644 --- a/tests/Unit/Deserialization/FixedReaderTest.php +++ b/tests/Unit/Deserialization/FixedReaderTest.php @@ -7,7 +7,6 @@ use Auxmoney\Avro\Contracts\ReadableStreamInterface; use Auxmoney\Avro\Deserialization\FixedReader; use PHPUnit\Framework\TestCase; -use RuntimeException; class FixedReaderTest extends TestCase { @@ -83,22 +82,6 @@ public function testReadWithBinaryData(): void $this->assertSame($expectedData, $result); } - public function testReadWithStreamException(): void - { - $size = 4; - $this->reader = new FixedReader($size); - - $this->stream->expects($this->once()) - ->method('read') - ->with($size) - ->willThrowException(new RuntimeException('Stream error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Stream error'); - - $this->reader->read($this->stream); - } - public function testSkipWithSmallSize(): void { $size = 4; @@ -135,22 +118,6 @@ public function testSkipWithZeroSize(): void $this->reader->skip($this->stream); } - public function testSkipWithStreamException(): void - { - $size = 4; - $this->reader = new FixedReader($size); - - $this->stream->expects($this->once()) - ->method('skip') - ->with($size) - ->willThrowException(new RuntimeException('Skip error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Skip error'); - - $this->reader->skip($this->stream); - } - public function testSkipWithMultipleCalls(): void { $size = 8; diff --git a/tests/Unit/Deserialization/FloatReaderTest.php b/tests/Unit/Deserialization/FloatReaderTest.php index 7bc051d..fd39b9b 100644 --- a/tests/Unit/Deserialization/FloatReaderTest.php +++ b/tests/Unit/Deserialization/FloatReaderTest.php @@ -8,7 +8,6 @@ use Auxmoney\Avro\Deserialization\BinaryDecoder; use Auxmoney\Avro\Deserialization\FloatReader; use PHPUnit\Framework\TestCase; -use RuntimeException; class FloatReaderTest extends TestCase { @@ -91,19 +90,6 @@ public function testReadWithNaN(): void $this->assertNan($result); } - public function testReadWithDecoderException(): void - { - $this->decoder->expects($this->once()) - ->method('readFloat') - ->with($this->stream) - ->willThrowException(new RuntimeException('Decoder error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Decoder error'); - - $this->reader->read($this->stream); - } - public function testSkipWithValidOperation(): void { $this->stream->expects($this->once()) @@ -113,19 +99,6 @@ public function testSkipWithValidOperation(): void $this->reader->skip($this->stream); } - public function testSkipWithStreamException(): void - { - $this->stream->expects($this->once()) - ->method('skip') - ->with(4) - ->willThrowException(new RuntimeException('Skip error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Skip error'); - - $this->reader->skip($this->stream); - } - public function testSkipWithMultipleCalls(): void { $this->stream->expects($this->exactly(3)) diff --git a/tests/Unit/Deserialization/LogicalTypeReaderTest.php b/tests/Unit/Deserialization/LogicalTypeReaderTest.php index fc53a31..21d7c49 100644 --- a/tests/Unit/Deserialization/LogicalTypeReaderTest.php +++ b/tests/Unit/Deserialization/LogicalTypeReaderTest.php @@ -9,7 +9,6 @@ use Auxmoney\Avro\Contracts\ReaderInterface; use Auxmoney\Avro\Deserialization\LogicalTypeReader; use PHPUnit\Framework\TestCase; -use RuntimeException; class LogicalTypeReaderTest extends TestCase { @@ -107,42 +106,6 @@ public function testReadWithArrayValue(): void $this->assertSame($denormalizedValue, $result); } - public function testReadWithRawReaderException(): void - { - $this->rawReader->expects($this->once()) - ->method('read') - ->with($this->stream) - ->willThrowException(new RuntimeException('Raw reader error')); - - $this->logicalType->expects($this->never()) - ->method('denormalize'); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Raw reader error'); - - $this->reader->read($this->stream); - } - - public function testReadWithLogicalTypeException(): void - { - $rawValue = 'invalid_value'; - - $this->rawReader->expects($this->once()) - ->method('read') - ->with($this->stream) - ->willReturn($rawValue); - - $this->logicalType->expects($this->once()) - ->method('denormalize') - ->with($rawValue) - ->willThrowException(new RuntimeException('Logical type error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Logical type error'); - - $this->reader->read($this->stream); - } - public function testSkipWithValidOperation(): void { $this->rawReader->expects($this->once()) @@ -155,22 +118,6 @@ public function testSkipWithValidOperation(): void $this->reader->skip($this->stream); } - public function testSkipWithRawReaderException(): void - { - $this->rawReader->expects($this->once()) - ->method('skip') - ->with($this->stream) - ->willThrowException(new RuntimeException('Skip error')); - - $this->logicalType->expects($this->never()) - ->method('denormalize'); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Skip error'); - - $this->reader->skip($this->stream); - } - public function testSkipWithMultipleCalls(): void { $this->rawReader->expects($this->exactly(3)) diff --git a/tests/Unit/Deserialization/LongReaderTest.php b/tests/Unit/Deserialization/LongReaderTest.php index 69afed0..d4f2fc0 100644 --- a/tests/Unit/Deserialization/LongReaderTest.php +++ b/tests/Unit/Deserialization/LongReaderTest.php @@ -8,7 +8,6 @@ use Auxmoney\Avro\Deserialization\BinaryDecoder; use Auxmoney\Avro\Deserialization\LongReader; use PHPUnit\Framework\TestCase; -use RuntimeException; class LongReaderTest extends TestCase { @@ -77,19 +76,6 @@ public function testReadWithLargeValue(): void $this->assertSame($expectedValue, $result); } - public function testReadWithDecoderException(): void - { - $this->decoder->expects($this->once()) - ->method('readLong') - ->with($this->stream) - ->willThrowException(new RuntimeException('Decoder error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Decoder error'); - - $this->reader->read($this->stream); - } - public function testSkipWithValidOperation(): void { $this->decoder->expects($this->once()) @@ -99,19 +85,6 @@ public function testSkipWithValidOperation(): void $this->reader->skip($this->stream); } - public function testSkipWithDecoderException(): void - { - $this->decoder->expects($this->once()) - ->method('skipLong') - ->with($this->stream) - ->willThrowException(new RuntimeException('Skip error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Skip error'); - - $this->reader->skip($this->stream); - } - public function testSkipWithMultipleCalls(): void { $this->decoder->expects($this->exactly(3)) diff --git a/tests/Unit/Deserialization/MapReaderTest.php b/tests/Unit/Deserialization/MapReaderTest.php index b209f3e..3e97b36 100644 --- a/tests/Unit/Deserialization/MapReaderTest.php +++ b/tests/Unit/Deserialization/MapReaderTest.php @@ -11,7 +11,6 @@ use Auxmoney\Avro\Deserialization\BinaryDecoder; use Auxmoney\Avro\Deserialization\MapReader; use PHPUnit\Framework\TestCase; -use RuntimeException; class MapReaderTest extends TestCase { @@ -128,63 +127,6 @@ public function testReadWithNegativeBlockCount(): void $this->assertSame([$key => $value], $result); } - public function testReadWithDecoderException(): void - { - $this->decoder->expects($this->once()) - ->method('readLong') - ->with($this->stream) - ->willThrowException(new RuntimeException('Decoder error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Decoder error'); - - $this->reader->read($this->stream); - } - - public function testReadWithStreamException(): void - { - $this->decoder->expects($this->exactly(2)) - ->method('readLong') - ->with($this->stream) - ->willReturnOnConsecutiveCalls(1, 5); - - $this->stream->expects($this->once()) - ->method('read') - ->with(5) - ->willThrowException(new RuntimeException('Stream error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Stream error'); - - $this->reader->read($this->stream); - } - - public function testReadWithValueReaderException(): void - { - $key = 'test_key'; - $keyLength = strlen($key); - - $this->decoder->expects($this->exactly(2)) - ->method('readLong') - ->with($this->stream) - ->willReturnOnConsecutiveCalls(1, $keyLength); - - $this->stream->expects($this->once()) - ->method('read') - ->with($keyLength) - ->willReturn($key); - - $this->valueReader->expects($this->once()) - ->method('read') - ->with($this->stream) - ->willThrowException(new RuntimeException('Value reader error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Value reader error'); - - $this->reader->read($this->stream); - } - public function testSkipWithEmptyMap(): void { $this->decoder->expects($this->once()) @@ -260,59 +202,4 @@ public function testSkipWithNegativeBlockCount(): void $this->reader->skip($this->stream); } - - public function testSkipWithDecoderException(): void - { - $this->decoder->expects($this->once()) - ->method('readLong') - ->with($this->stream) - ->willThrowException(new RuntimeException('Decoder error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Decoder error'); - - $this->reader->skip($this->stream); - } - - public function testSkipWithStreamException(): void - { - $this->decoder->expects($this->exactly(2)) - ->method('readLong') - ->with($this->stream) - ->willReturnOnConsecutiveCalls(1, 5); - - $this->stream->expects($this->once()) - ->method('skip') - ->with(5) - ->willThrowException(new RuntimeException('Stream error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Stream error'); - - $this->reader->skip($this->stream); - } - - public function testSkipWithValueReaderException(): void - { - $keyLength = 8; - - $this->decoder->expects($this->exactly(2)) - ->method('readLong') - ->with($this->stream) - ->willReturnOnConsecutiveCalls(1, $keyLength); - - $this->stream->expects($this->once()) - ->method('skip') - ->with($keyLength); - - $this->valueReader->expects($this->once()) - ->method('skip') - ->with($this->stream) - ->willThrowException(new RuntimeException('Value skip error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Value skip error'); - - $this->reader->skip($this->stream); - } } diff --git a/tests/Unit/Deserialization/RecordReaderTest.php b/tests/Unit/Deserialization/RecordReaderTest.php index af2b847..b12d91d 100644 --- a/tests/Unit/Deserialization/RecordReaderTest.php +++ b/tests/Unit/Deserialization/RecordReaderTest.php @@ -7,7 +7,6 @@ use Auxmoney\Avro\Contracts\ReadableStreamInterface; use Auxmoney\Avro\Deserialization\RecordReader; use PHPUnit\Framework\TestCase; -use RuntimeException; class RecordReaderTest extends TestCase { @@ -56,17 +55,4 @@ public function testSkipWithNoProperties(): void $reader->skip($this->stream); } - - public function testSkipWithPropertyReaderException(): void - { - $this->propertyReader1->expects($this->once()) - ->method('skip') - ->with($this->stream) - ->willThrowException(new RuntimeException('Skip error')); - $this->propertyReader2->expects($this->never()) - ->method('skip'); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Skip error'); - $this->reader->skip($this->stream); - } } diff --git a/tests/Unit/Deserialization/StringReaderTest.php b/tests/Unit/Deserialization/StringReaderTest.php index 6aaea4f..8ca9997 100644 --- a/tests/Unit/Deserialization/StringReaderTest.php +++ b/tests/Unit/Deserialization/StringReaderTest.php @@ -118,37 +118,6 @@ public function testReadWithLargeString(): void $this->assertSame($largeString, $result); } - public function testReadWithStreamException(): void - { - $this->decoder->expects($this->once()) - ->method('readLong') - ->with($this->stream) - ->willReturn(10); - - $this->stream->expects($this->once()) - ->method('read') - ->with(10) - ->willThrowException(new RuntimeException('Stream read error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Stream read error'); - - $this->reader->read($this->stream); - } - - public function testReadWithDecoderException(): void - { - $this->decoder->expects($this->once()) - ->method('readLong') - ->with($this->stream) - ->willThrowException(new RuntimeException('Decoder error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Decoder error'); - - $this->reader->read($this->stream); - } - public function testReadWithTruncatedData(): void { $this->decoder->expects($this->once()) @@ -217,37 +186,6 @@ public function testSkipWithValidLength(): void $this->reader->skip($this->stream); } - public function testSkipWithStreamException(): void - { - $this->decoder->expects($this->once()) - ->method('readLong') - ->with($this->stream) - ->willReturn(10); - - $this->stream->expects($this->once()) - ->method('skip') - ->with(10) - ->willThrowException(new RuntimeException('Stream skip error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Stream skip error'); - - $this->reader->skip($this->stream); - } - - public function testSkipWithDecoderException(): void - { - $this->decoder->expects($this->once()) - ->method('readLong') - ->with($this->stream) - ->willThrowException(new RuntimeException('Decoder error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Decoder error'); - - $this->reader->skip($this->stream); - } - public function testSkipWithNegativeLength(): void { $this->decoder->expects($this->once()) diff --git a/tests/Unit/Deserialization/UnionReaderTest.php b/tests/Unit/Deserialization/UnionReaderTest.php index 72bc283..1d6c4cd 100644 --- a/tests/Unit/Deserialization/UnionReaderTest.php +++ b/tests/Unit/Deserialization/UnionReaderTest.php @@ -8,8 +8,8 @@ use Auxmoney\Avro\Contracts\ReaderInterface; use Auxmoney\Avro\Deserialization\BinaryDecoder; use Auxmoney\Avro\Deserialization\UnionReader; +use Auxmoney\Avro\Exceptions\SchemaMismatchException; use PHPUnit\Framework\TestCase; -use RuntimeException; class UnionReaderTest extends TestCase { @@ -86,7 +86,7 @@ public function testReadWithInvalidBranchIndex(): void $this->branchReader2->expects($this->never()) ->method('read'); - $this->expectException(RuntimeException::class); + $this->expectException(SchemaMismatchException::class); $this->expectExceptionMessage('Invalid branch index: 2'); $this->reader->read($this->stream); @@ -105,43 +105,12 @@ public function testReadWithNegativeBranchIndex(): void $this->branchReader2->expects($this->never()) ->method('read'); - $this->expectException(RuntimeException::class); + $this->expectException(SchemaMismatchException::class); $this->expectExceptionMessage('Invalid branch index: -1'); $this->reader->read($this->stream); } - public function testReadWithDecoderException(): void - { - $this->decoder->expects($this->once()) - ->method('readLong') - ->with($this->stream) - ->willThrowException(new RuntimeException('Decoder error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Decoder error'); - - $this->reader->read($this->stream); - } - - public function testReadWithBranchReaderException(): void - { - $this->decoder->expects($this->once()) - ->method('readLong') - ->with($this->stream) - ->willReturn(0); - - $this->branchReader1->expects($this->once()) - ->method('read') - ->with($this->stream) - ->willThrowException(new RuntimeException('Branch reader error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Branch reader error'); - - $this->reader->read($this->stream); - } - public function testSkipWithFirstBranch(): void { $this->decoder->expects($this->once()) @@ -189,7 +158,7 @@ public function testSkipWithInvalidBranchIndex(): void $this->branchReader2->expects($this->never()) ->method('skip'); - $this->expectException(RuntimeException::class); + $this->expectException(SchemaMismatchException::class); $this->expectExceptionMessage('Invalid branch index: 2'); $this->reader->skip($this->stream); @@ -208,43 +177,12 @@ public function testSkipWithNegativeBranchIndex(): void $this->branchReader2->expects($this->never()) ->method('skip'); - $this->expectException(RuntimeException::class); + $this->expectException(SchemaMismatchException::class); $this->expectExceptionMessage('Invalid branch index: -1'); $this->reader->skip($this->stream); } - public function testSkipWithDecoderException(): void - { - $this->decoder->expects($this->once()) - ->method('readLong') - ->with($this->stream) - ->willThrowException(new RuntimeException('Decoder error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Decoder error'); - - $this->reader->skip($this->stream); - } - - public function testSkipWithBranchReaderException(): void - { - $this->decoder->expects($this->once()) - ->method('readLong') - ->with($this->stream) - ->willReturn(0); - - $this->branchReader1->expects($this->once()) - ->method('skip') - ->with($this->stream) - ->willThrowException(new RuntimeException('Branch skip error')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Branch skip error'); - - $this->reader->skip($this->stream); - } - public function testSkipWithMultipleCalls(): void { $this->decoder->expects($this->exactly(2)) diff --git a/tests/Unit/LogicalType/DateTypeTest.php b/tests/Unit/LogicalType/DateTypeTest.php new file mode 100644 index 0000000..00c1ec8 --- /dev/null +++ b/tests/Unit/LogicalType/DateTypeTest.php @@ -0,0 +1,186 @@ +dateType = new DateType(); + } + + public function testValidateWithValidDateTime(): void + { + $dateTime = new DateTime('2023-05-15'); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->dateType->validate($dateTime, $context); + + $this->assertTrue($result); + } + + public function testValidateWithValidDateTimeImmutable(): void + { + $dateTime = new DateTimeImmutable('2023-05-15'); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->dateType->validate($dateTime, $context); + + $this->assertTrue($result); + } + + public function testValidateWithInvalidDatum(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('expected DateTimeInterface, got string'); + + $result = $this->dateType->validate('not a date', $context); + + $this->assertFalse($result); + } + + public function testValidateWithNullDatum(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('expected DateTimeInterface, got NULL'); + + $result = $this->dateType->validate(null, $context); + + $this->assertFalse($result); + } + + public function testValidateWithoutContext(): void + { + $dateTime = new DateTime('2023-05-15'); + + $result = $this->dateType->validate($dateTime, null); + + $this->assertTrue($result); + } + + public function testValidateWithInvalidDatumAndNullContext(): void + { + $result = $this->dateType->validate('not a date', null); + + $this->assertFalse($result); + } + + public function testNormalizeEpochDate(): void + { + $epochDate = new DateTimeImmutable('1970-01-01'); + + $result = $this->dateType->normalize($epochDate); + + $this->assertSame(0, $result); + } + + public function testNormalizeDateAfterEpoch(): void + { + $date = new DateTimeImmutable('1970-01-02'); + + $result = $this->dateType->normalize($date); + + $this->assertSame(1, $result); + } + + public function testNormalizeDateBeforeEpoch(): void + { + $date = new DateTimeImmutable('1969-12-31'); + + $result = $this->dateType->normalize($date); + + $this->assertSame(-1, $result); + } + + public function testNormalizeDateFarFromEpoch(): void + { + $date = new DateTimeImmutable('2000-01-01'); + + $result = $this->dateType->normalize($date); + + // 2000-01-01 is 10957 days after 1970-01-01 + $this->assertSame(10957, $result); + } + + public function testNormalizeWithTimezone(): void + { + $utc = new DateTimeZone('UTC'); + $pacific = new DateTimeZone('America/Los_Angeles'); + + $utcDate = new DateTimeImmutable('2023-05-15 00:00:00', $utc); + $pacificDate = new DateTimeImmutable('2023-05-15 00:00:00', $pacific); + + $utcResult = $this->dateType->normalize($utcDate); + $pacificResult = $this->dateType->normalize($pacificDate); + + $this->assertSame($utcResult, $pacificResult); + } + + public function testDenormalizeEpochDate(): void + { + $result = $this->dateType->denormalize(0); + + $this->assertInstanceOf(DateTimeInterface::class, $result); + $this->assertSame('1970-01-01', $result->format('Y-m-d')); + } + + public function testDenormalizeDateAfterEpoch(): void + { + $result = $this->dateType->denormalize(1); + + $this->assertInstanceOf(DateTimeInterface::class, $result); + $this->assertSame('1970-01-02', $result->format('Y-m-d')); + } + + public function testDenormalizeDateBeforeEpoch(): void + { + $result = $this->dateType->denormalize(-1); + + $this->assertInstanceOf(DateTimeInterface::class, $result); + $this->assertSame('1969-12-31', $result->format('Y-m-d')); + } + + public function testDenormalizeDateFarFromEpoch(): void + { + $result = $this->dateType->denormalize(10957); + + $this->assertInstanceOf(DateTimeInterface::class, $result); + $this->assertSame('2000-01-01', $result->format('Y-m-d')); + } + + public function testNormalizeAndDenormalizeRoundTrip(): void + { + $originalDate = new DateTimeImmutable('2023-05-15'); + + $normalized = $this->dateType->normalize($originalDate); + $denormalized = $this->dateType->denormalize($normalized); + + $this->assertSame($originalDate->format('Y-m-d'), $denormalized->format('Y-m-d')); + } + + public function testNormalizeAndDenormalizeRoundTripWithBeforeEpoch(): void + { + $originalDate = new DateTimeImmutable('1950-03-15'); + + $normalized = $this->dateType->normalize($originalDate); + $denormalized = $this->dateType->denormalize($normalized); + + $this->assertSame($originalDate->format('Y-m-d'), $denormalized->format('Y-m-d')); + } +} diff --git a/tests/Unit/LogicalType/DecimalTypeTest.php b/tests/Unit/LogicalType/DecimalTypeTest.php new file mode 100644 index 0000000..fb67740 --- /dev/null +++ b/tests/Unit/LogicalType/DecimalTypeTest.php @@ -0,0 +1,342 @@ +decimalType = new DecimalType(10, 2); + } + + public function testConstructWithDefaults(): void + { + $decimalType = new DecimalType(5); + + $this->assertSame(5, $decimalType->getPrecision()); + $this->assertSame(0, $decimalType->getScale()); + $this->assertNull($decimalType->getSize()); + } + + public function testConstructWithPrecisionAndScale(): void + { + $decimalType = new DecimalType(10, 3); + + $this->assertSame(10, $decimalType->getPrecision()); + $this->assertSame(3, $decimalType->getScale()); + $this->assertNull($decimalType->getSize()); + } + + public function testConstructWithPrecisionScaleAndSize(): void + { + $decimalType = new DecimalType(10, 3, 8); + + $this->assertSame(10, $decimalType->getPrecision()); + $this->assertSame(3, $decimalType->getScale()); + $this->assertSame(8, $decimalType->getSize()); + } + + public function testGetPrecision(): void + { + $this->assertSame(10, $this->decimalType->getPrecision()); + } + + public function testGetScale(): void + { + $this->assertSame(2, $this->decimalType->getScale()); + } + + public function testValidateWithValidDecimal(): void + { + $decimal = Decimal::fromString('123.45'); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->decimalType->validate($decimal, $context); + + $this->assertTrue($result); + } + + public function testValidateWithInvalidDatum(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('Decimal value must be an instance of Decimal'); + + $result = $this->decimalType->validate(123.45, $context); + + $this->assertFalse($result); + } + + public function testValidateWithString(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('Decimal value must be an instance of Decimal'); + + $result = $this->decimalType->validate('123.45', $context); + + $this->assertFalse($result); + } + + public function testValidateWithNull(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('Decimal value must be an instance of Decimal'); + + $result = $this->decimalType->validate(null, $context); + + $this->assertFalse($result); + } + + public function testValidateWithoutContext(): void + { + $decimal = Decimal::fromString('123.45'); + + $result = $this->decimalType->validate($decimal, null); + + $this->assertTrue($result); + } + + public function testValidateWithInvalidDatumAndNullContext(): void + { + $result = $this->decimalType->validate(123.45, null); + + $this->assertFalse($result); + } + + public function testNormalizeWithDecimal(): void + { + $decimal = Decimal::fromString('123.45'); + + $result = $this->decimalType->normalize($decimal); + + // The result should be the bytes representation of the decimal + // scaled to the configured precision (2 decimal places) + $this->assertSame($decimal->withScale(2)->toBytes(), $result); + } + + public function testNormalizeWithDifferentScale(): void + { + $decimal = Decimal::fromString('123.4567'); // 4 decimal places + + $result = $this->decimalType->normalize($decimal); + + // Should be scaled down to 2 decimal places as configured + $scaledDecimal = $decimal->withScale(2); + $this->assertSame($scaledDecimal->toBytes(), $result); + } + + public function testDenormalizeWithValidBytes(): void + { + $originalDecimal = Decimal::fromString('123.45'); + $bytes = $originalDecimal->withScale(2)->toBytes(); + + $result = $this->decimalType->denormalize($bytes); + + $this->assertInstanceOf(Decimal::class, $result); + $this->assertSame(2, $result->getScale()); + } + + public function testNormalizeAndDenormalizeRoundTrip(): void + { + $originalDecimal = Decimal::fromString('987.65'); + + $normalized = $this->decimalType->normalize($originalDecimal); + $denormalized = $this->decimalType->denormalize($normalized); + + // Should maintain the same value after round trip, scaled to the configured precision + $expectedDecimal = $originalDecimal->withScale(2); + $this->assertSame($expectedDecimal->toString(), $denormalized->toString()); + $this->assertSame(2, $denormalized->getScale()); + } + + public function testNormalizeAndDenormalizeWithZeroScale(): void + { + $decimalType = new DecimalType(5, 0); + $originalDecimal = Decimal::fromInteger(123); + + $normalized = $decimalType->normalize($originalDecimal); + $denormalized = $decimalType->denormalize($normalized); + + $this->assertSame($originalDecimal->toString(), $denormalized->toString()); + $this->assertSame(0, $denormalized->getScale()); + } + + public function testWithLargePrecisionAndScale(): void + { + $decimalType = new DecimalType(20, 10); + $decimal = Decimal::fromString('1234567890.1234567890'); + + $normalized = $decimalType->normalize($decimal); + $denormalized = $decimalType->denormalize($normalized); + + $this->assertInstanceOf(Decimal::class, $denormalized); + $this->assertSame(10, $denormalized->getScale()); + } + + public function testWithNegativeDecimal(): void + { + $decimal = Decimal::fromString('-123.45'); + + $normalized = $this->decimalType->normalize($decimal); + $denormalized = $this->decimalType->denormalize($normalized); + + $expectedDecimal = $decimal->withScale(2); + $this->assertSame($expectedDecimal->toString(), $denormalized->toString()); + } + + public function testFixedDecimalTypeWithValidSize(): void + { + $decimalType = new DecimalType(10, 2, 4); // 4 bytes fixed size + $decimal = Decimal::fromString('123.45'); + + $normalized = $decimalType->normalize($decimal); + $this->assertSame(4, strlen($normalized)); + + $denormalized = $decimalType->denormalize($normalized); + $expectedDecimal = $decimal->withScale(2); + $this->assertSame($expectedDecimal->toString(), $denormalized->toString()); + } + + public function testFixedDecimalTypeWithSmallerValue(): void + { + $decimalType = new DecimalType(10, 2, 8); // 8 bytes fixed size + $decimal = Decimal::fromString('1.23'); // Small value that needs padding + + $normalized = $decimalType->normalize($decimal); + $this->assertSame(8, strlen($normalized)); // Should be padded to 8 bytes + + $denormalized = $decimalType->denormalize($normalized); + $expectedDecimal = $decimal->withScale(2); + $this->assertSame($expectedDecimal->toString(), $denormalized->toString()); + } + + public function testFixedDecimalTypeWithNegativeValue(): void + { + $decimalType = new DecimalType(10, 2, 8); // 8 bytes fixed size + $decimal = Decimal::fromString('-1.23'); // Negative value that needs padding + + $normalized = $decimalType->normalize($decimal); + $this->assertSame(8, strlen($normalized)); // Should be padded to 8 bytes + + // Verify padding is correct for negative numbers (should be 0xFF bytes) + $paddingByte = ord($normalized[0]); + $this->assertSame(0xFF, $paddingByte, 'Negative numbers should be padded with 0xFF bytes'); + + $denormalized = $decimalType->denormalize($normalized); + $expectedDecimal = $decimal->withScale(2); + $this->assertSame($expectedDecimal->toString(), $denormalized->toString()); + } + + public function testFixedDecimalTypeWithPositiveValuePadding(): void + { + $decimalType = new DecimalType(10, 2, 8); // 8 bytes fixed size + $decimal = Decimal::fromString('1.23'); // Positive value that needs padding + + $normalized = $decimalType->normalize($decimal); + $this->assertSame(8, strlen($normalized)); // Should be padded to 8 bytes + + // Verify padding is correct for positive numbers (should be 0x00 bytes) + $paddingByte = ord($normalized[0]); + $this->assertSame(0x00, $paddingByte, 'Positive numbers should be padded with 0x00 bytes'); + + $denormalized = $decimalType->denormalize($normalized); + $expectedDecimal = $decimal->withScale(2); + $this->assertSame($expectedDecimal->toString(), $denormalized->toString()); + } + + public function testFixedDecimalTypeValueExceedsSize(): void + { + $decimalType = new DecimalType(10, 2, 2); // Only 2 bytes fixed size + $decimal = Decimal::fromString('1234567.89'); // Large value that exceeds 2 bytes + $context = $this->createMock(ValidationContextInterface::class); + + $context->expects($this->once())->method('addError') + ->with('Decimal value requires 4 bytes but fixed schema only allows 2 bytes'); + + $result = $decimalType->validate($decimal, $context); + $this->assertFalse($result); + } + + public function testFixedDecimalTypeValidationPasses(): void + { + $decimalType = new DecimalType(10, 2, 4); // 4 bytes fixed size + $decimal = Decimal::fromString('123.45'); // Value that fits in 4 bytes + $context = $this->createMock(ValidationContextInterface::class); + + $context->expects($this->never())->method('addError'); + + $result = $decimalType->validate($decimal, $context); + $this->assertTrue($result); + + // And normalize should work without throwing + $normalized = $decimalType->normalize($decimal); + $this->assertSame(4, strlen($normalized)); + } + + public function testFixedDecimalTypeWithZero(): void + { + $decimalType = new DecimalType(10, 2, 4); // 4 bytes fixed size + $decimal = Decimal::fromString('0.00'); + + $normalized = $decimalType->normalize($decimal); + $this->assertSame(4, strlen($normalized)); // Should be padded to 4 bytes + + // Verify zero is padded correctly + $this->assertSame(str_repeat("\x00", 4), $normalized, 'Zero should be padded with 0x00 bytes'); + + $denormalized = $decimalType->denormalize($normalized); + $this->assertSame('0', $denormalized->toString()); + } + + public function testFixedDecimalTypeExactSize(): void + { + $decimalType = new DecimalType(10, 0, 4); // 4 bytes fixed size, no scale + // Create a decimal that produces exactly 4 bytes + $decimal = Decimal::fromInteger(16777215); // 0x00FFFFFF = 4 bytes + + $normalizedWithoutFixedSize = $decimal->toBytes(); + // Adjust the test value if needed to get exactly the right size + if (strlen($normalizedWithoutFixedSize) !== 4) { + $decimal = Decimal::fromInteger(2147483647); // Max 32-bit signed int + $normalizedWithoutFixedSize = $decimal->toBytes(); + + if (strlen($normalizedWithoutFixedSize) > 4) { + $decimal = Decimal::fromInteger(8388607); // 0x7FFFFF = 3 bytes, will be padded to 4 + } + } + + $normalized = $decimalType->normalize($decimal); + $this->assertSame(4, strlen($normalized)); + + $denormalized = $decimalType->denormalize($normalized); + $this->assertSame($decimal->toString(), $denormalized->toString()); + } + + public function testBytesDecimalTypeUnaffectedBySize(): void + { + // Test that bytes type (size = null) works as before + $decimalType = new DecimalType(10, 2); // No size specified + $decimal = Decimal::fromString('12345.67'); + + $normalized = $decimalType->normalize($decimal); + + // Should not be fixed size - length can vary + $this->assertGreaterThan(0, strlen($normalized)); + + $denormalized = $decimalType->denormalize($normalized); + $expectedDecimal = $decimal->withScale(2); + $this->assertSame($expectedDecimal->toString(), $denormalized->toString()); + } +} diff --git a/tests/Unit/LogicalType/DurationTypeTest.php b/tests/Unit/LogicalType/DurationTypeTest.php new file mode 100644 index 0000000..bddeba4 --- /dev/null +++ b/tests/Unit/LogicalType/DurationTypeTest.php @@ -0,0 +1,179 @@ +durationType = new DurationType(); + } + + public function testValidateWithValidDuration(): void + { + $duration = new Duration(12, 30, 45000); // 12 months, 30 days, 45000 milliseconds + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->durationType->validate($duration, $context); + + $this->assertTrue($result); + } + + public function testValidateWithZeroValues(): void + { + $duration = new Duration(0, 0, 0); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->durationType->validate($duration, $context); + + $this->assertTrue($result); + } + + public function testValidateWithDurationValueObject(): void + { + $duration = new Duration(5, 10, 2500); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->durationType->validate($duration, $context); + + $this->assertTrue($result); + } + + public function testValidateWithInvalidType(): void + { + $duration = 123; + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('Duration value must be a Duration object'); + + $result = $this->durationType->validate($duration, $context); + + $this->assertFalse($result); + } + + public function testValidateWithInvalidObject(): void + { + $duration = new stdClass(); + + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('Duration value must be a Duration object'); + + $result = $this->durationType->validate($duration, $context); + + $this->assertFalse($result); + } + + public function testValidateWithoutContext(): void + { + $duration = new Duration(12, 30, 45000); + + $result = $this->durationType->validate($duration, null); + + $this->assertTrue($result); + } + + public function testNormalizeWithDuration(): void + { + $duration = new Duration(12, 30, 45000); + + $result = $this->durationType->normalize($duration); + + $this->assertIsString($result); + $this->assertSame(12, strlen($result)); + + // Verify the packed values + $unpacked = unpack('V3', $result); + $this->assertSame([ + 1 => 12, + 2 => 30, + 3 => 45000, + ], $unpacked); + } + + public function testNormalizeWithDurationValueObject(): void + { + $duration = new Duration(5, 15, 2500); + + $result = $this->durationType->normalize($duration); + + $this->assertIsString($result); + $this->assertSame(12, strlen($result)); + + // Verify the packed values + $unpacked = unpack('V3', $result); + $this->assertSame([ + 1 => 5, + 2 => 15, + 3 => 2500, + ], $unpacked); + } + + public function testNormalizeWithZeroValues(): void + { + $duration = new Duration(0, 0, 0); + + $result = $this->durationType->normalize($duration); + + $this->assertIsString($result); + $this->assertSame(12, strlen($result)); + + // Verify all zeros + $unpacked = unpack('V3', $result); + $this->assertSame([ + 1 => 0, + 2 => 0, + 3 => 0, + ], $unpacked); + } + + public function testDenormalizeWithValidBytes(): void + { + $bytes = pack('VVV', 12, 30, 45000); + + $result = $this->durationType->denormalize($bytes); + + $this->assertInstanceOf(Duration::class, $result); + $this->assertSame(12, $result->months); + $this->assertSame(30, $result->days); + $this->assertSame(45000, $result->milliseconds); + } + + public function testDenormalizeWithZeroValues(): void + { + $bytes = pack('VVV', 0, 0, 0); + + $result = $this->durationType->denormalize($bytes); + + $this->assertInstanceOf(Duration::class, $result); + $this->assertSame(0, $result->months); + $this->assertSame(0, $result->days); + $this->assertSame(0, $result->milliseconds); + } + + public function testNormalizeAndDenormalizeRoundTripWithDurationValueObject(): void + { + $original = new Duration(10, 25, 5500); + + $normalized = $this->durationType->normalize($original); + $denormalized = $this->durationType->denormalize($normalized); + + $this->assertInstanceOf(Duration::class, $denormalized); + $this->assertSame(10, $denormalized->months); + $this->assertSame(25, $denormalized->days); + $this->assertSame(5500, $denormalized->milliseconds); + } +} diff --git a/tests/Unit/LogicalType/Factory/DateFactoryTest.php b/tests/Unit/LogicalType/Factory/DateFactoryTest.php new file mode 100644 index 0000000..f310971 --- /dev/null +++ b/tests/Unit/LogicalType/Factory/DateFactoryTest.php @@ -0,0 +1,68 @@ +factory = new DateFactory(); + } + + public function testGetName(): void + { + $this->assertSame('date', $this->factory->getName()); + } + + public function testCreateWithEmptyAttributes(): void + { + $result = $this->factory->create(['type' => 'int']); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(DateType::class, $result); + } + + public function testCreateWithAttributes(): void + { + $result = $this->factory->create(['type' => 'int', 'someAttribute' => 'value']); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(DateType::class, $result); + } + + public function testCreateReturnsNewInstanceEachTime(): void + { + $result1 = $this->factory->create(['type' => 'int']); + $result2 = $this->factory->create(['type' => 'int']); + + $this->assertInstanceOf(DateType::class, $result1); + $this->assertInstanceOf(DateType::class, $result2); + $this->assertNotSame($result1, $result2); + } + + public function testCreateWithWrongTypeThrowsException(): void + { + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('The "date" logical type can only be used with an "int" type'); + + $this->factory->create(['type' => 'string']); + } + + public function testCreateWithoutTypeThrowsException(): void + { + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('The "date" logical type can only be used with an "int" type'); + + $this->factory->create([]); + } +} diff --git a/tests/Unit/LogicalType/Factory/DecimalFactoryTest.php b/tests/Unit/LogicalType/Factory/DecimalFactoryTest.php new file mode 100644 index 0000000..22c95bb --- /dev/null +++ b/tests/Unit/LogicalType/Factory/DecimalFactoryTest.php @@ -0,0 +1,288 @@ +factory = new DecimalFactory(); + } + + public function testGetName(): void + { + $this->assertSame('decimal', $this->factory->getName()); + } + + public function testCreateWithValidPrecision(): void + { + $result = $this->factory->create([ + 'type' => 'bytes', + 'precision' => 10, + ]); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(DecimalType::class, $result); + + /** @var DecimalType $result */ + $this->assertSame(10, $result->getPrecision()); + $this->assertSame(0, $result->getScale()); // Default scale + } + + public function testCreateWithValidPrecisionAndScale(): void + { + $result = $this->factory->create([ + 'type' => 'bytes', + 'precision' => 10, + 'scale' => 3, + ]); + + $this->assertInstanceOf(DecimalType::class, $result); + + /** @var DecimalType $result */ + $this->assertSame(10, $result->getPrecision()); + $this->assertSame(3, $result->getScale()); + } + + public function testCreateWithStringPrecision(): void + { + $result = $this->factory->create([ + 'type' => 'fixed', + 'precision' => 10, + 'scale' => 3, + ]); + + $this->assertInstanceOf(DecimalType::class, $result); + + /** @var DecimalType $result */ + $this->assertSame(10, $result->getPrecision()); + $this->assertSame(3, $result->getScale()); + } + + public function testCreateWithZeroScale(): void + { + $result = $this->factory->create([ + 'type' => 'bytes', + 'precision' => 5, + 'scale' => 0, + ]); + + $this->assertInstanceOf(DecimalType::class, $result); + + /** @var DecimalType $result */ + $this->assertSame(5, $result->getPrecision()); + $this->assertSame(0, $result->getScale()); + } + + public function testCreateWithMaxValidScale(): void + { + $result = $this->factory->create([ + 'type' => 'bytes', + 'precision' => 10, + 'scale' => 10, + ]); + + $this->assertInstanceOf(DecimalType::class, $result); + + /** @var DecimalType $result */ + $this->assertSame(10, $result->getPrecision()); + $this->assertSame(10, $result->getScale()); + } + + public function testCreateWithMissingPrecision(): void + { + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('Decimal logical type requires "precision" attribute'); + + $this->factory->create(['type' => 'bytes']); + } + + public function testCreateWithMissingPrecisionButHasScale(): void + { + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('Decimal logical type requires "precision" attribute'); + + $this->factory->create(['type' => 'bytes', 'scale' => 3]); + } + + public function testCreateWithZeroPrecision(): void + { + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('Decimal precision must be a positive integer'); + + $this->factory->create(['type' => 'bytes', 'precision' => 0]); + } + + public function testCreateWithNegativePrecision(): void + { + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('Decimal precision must be a positive integer'); + + $this->factory->create(['type' => 'bytes', 'precision' => -5]); + } + + public function testCreateWithNegativeScale(): void + { + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('Decimal scale must be between 0 and precision'); + + $this->factory->create(['type' => 'bytes', 'precision' => 10, 'scale' => -1]); + } + + public function testCreateWithScaleGreaterThanPrecision(): void + { + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('Decimal scale must be between 0 and precision'); + + $this->factory->create(['type' => 'bytes', 'precision' => 5, 'scale' => 6]); + } + + public function testCreateReturnsNewInstanceEachTime(): void + { + $attributes = ['type' => 'bytes', 'precision' => 10, 'scale' => 2]; + + $result1 = $this->factory->create($attributes); + $result2 = $this->factory->create($attributes); + + $this->assertInstanceOf(DecimalType::class, $result1); + $this->assertInstanceOf(DecimalType::class, $result2); + $this->assertNotSame($result1, $result2); + + // But they should have the same configuration + /** @var DecimalType $result1 */ + /** @var DecimalType $result2 */ + $this->assertSame($result1->getPrecision(), $result2->getPrecision()); + $this->assertSame($result1->getScale(), $result2->getScale()); + } + + public function testCreateWithExtraAttributes(): void + { + $result = $this->factory->create([ + 'type' => 'bytes', + 'precision' => 8, + 'scale' => 2, + 'extraAttribute' => 'ignored', + 'anotherOne' => 123, + ]); + + $this->assertInstanceOf(DecimalType::class, $result); + + /** @var DecimalType $result */ + $this->assertSame(8, $result->getPrecision()); + $this->assertSame(2, $result->getScale()); + } + + public function testCreateWithLargePrecision(): void + { + $result = $this->factory->create(['type' => 'bytes', 'precision' => 1000, 'scale' => 500]); + + $this->assertInstanceOf(DecimalType::class, $result); + + /** @var DecimalType $result */ + $this->assertSame(1000, $result->getPrecision()); + $this->assertSame(500, $result->getScale()); + } + + public function testCreateWithFixedTypeAndSize(): void + { + $result = $this->factory->create([ + 'type' => 'fixed', + 'precision' => 10, + 'scale' => 2, + 'size' => 8, + ]); + + $this->assertInstanceOf(DecimalType::class, $result); + + /** @var DecimalType $result */ + $this->assertSame(10, $result->getPrecision()); + $this->assertSame(2, $result->getScale()); + $this->assertSame(8, $result->getSize()); + } + + public function testCreateWithFixedTypeWithoutSize(): void + { + $result = $this->factory->create([ + 'type' => 'fixed', + 'precision' => 10, + 'scale' => 2, + ]); + + $this->assertInstanceOf(DecimalType::class, $result); + + /** @var DecimalType $result */ + $this->assertSame(10, $result->getPrecision()); + $this->assertSame(2, $result->getScale()); + $this->assertNull($result->getSize()); + } + + public function testCreateWithBytesTypeIgnoresSize(): void + { + $result = $this->factory->create([ + 'type' => 'bytes', + 'precision' => 10, + 'scale' => 2, + 'size' => 8, // This should be ignored for bytes type + ]); + + $this->assertInstanceOf(DecimalType::class, $result); + + /** @var DecimalType $result */ + $this->assertSame(10, $result->getPrecision()); + $this->assertSame(2, $result->getScale()); + $this->assertNull($result->getSize()); // Should be null for bytes type + } + + public function testCreateWithInvalidSizeType(): void + { + $result = $this->factory->create([ + 'type' => 'fixed', + 'precision' => 10, + 'scale' => 2, + 'size' => '8', // String instead of int - should be ignored + ]); + + $this->assertInstanceOf(DecimalType::class, $result); + + /** @var DecimalType $result */ + $this->assertSame(10, $result->getPrecision()); + $this->assertSame(2, $result->getScale()); + $this->assertNull($result->getSize()); // Should be null due to invalid type + } + + public function testCreateWithZeroSize(): void + { + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('Fixed size must be a positive integer'); + + $this->factory->create([ + 'type' => 'fixed', + 'precision' => 10, + 'scale' => 2, + 'size' => 0, + ]); + } + + public function testCreateWithNegativeSize(): void + { + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('Fixed size must be a positive integer'); + + $this->factory->create([ + 'type' => 'fixed', + 'precision' => 10, + 'scale' => 2, + 'size' => -5, + ]); + } +} diff --git a/tests/Unit/LogicalType/Factory/DurationFactoryTest.php b/tests/Unit/LogicalType/Factory/DurationFactoryTest.php new file mode 100644 index 0000000..c89735c --- /dev/null +++ b/tests/Unit/LogicalType/Factory/DurationFactoryTest.php @@ -0,0 +1,64 @@ +factory = new DurationFactory(); + } + + public function testGetName(): void + { + $this->assertSame('duration', $this->factory->getName()); + } + + public function testCreateWithEmptyAttributes(): void + { + $result = $this->factory->create([ + 'type' => 'fixed', + 'size' => 12, + ]); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(DurationType::class, $result); + } + + public function testCreateWithAttributes(): void + { + $result = $this->factory->create([ + 'type' => 'fixed', + 'size' => 12, + 'someAttribute' => 'value', + ]); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(DurationType::class, $result); + } + + public function testCreateReturnsNewInstanceEachTime(): void + { + $result1 = $this->factory->create([ + 'type' => 'fixed', + 'size' => 12, + ]); + $result2 = $this->factory->create([ + 'type' => 'fixed', + 'size' => 12, + ]); + + $this->assertInstanceOf(DurationType::class, $result1); + $this->assertInstanceOf(DurationType::class, $result2); + $this->assertNotSame($result1, $result2); + } +} diff --git a/tests/Unit/LogicalType/Factory/LocalTimestampMicrosFactoryTest.php b/tests/Unit/LogicalType/Factory/LocalTimestampMicrosFactoryTest.php new file mode 100644 index 0000000..f4500d7 --- /dev/null +++ b/tests/Unit/LogicalType/Factory/LocalTimestampMicrosFactoryTest.php @@ -0,0 +1,51 @@ +factory = new LocalTimestampMicrosFactory(); + } + + public function testGetName(): void + { + $this->assertSame('local-timestamp-micros', $this->factory->getName()); + } + + public function testCreateWithEmptyAttributes(): void + { + $result = $this->factory->create(['type' => 'long']); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(LocalTimestampMicrosType::class, $result); + } + + public function testCreateWithAttributes(): void + { + $result = $this->factory->create(['type' => 'long', 'someAttribute' => 'value']); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(LocalTimestampMicrosType::class, $result); + } + + public function testCreateReturnsNewInstanceEachTime(): void + { + $result1 = $this->factory->create(['type' => 'long']); + $result2 = $this->factory->create(['type' => 'long']); + + $this->assertInstanceOf(LocalTimestampMicrosType::class, $result1); + $this->assertInstanceOf(LocalTimestampMicrosType::class, $result2); + $this->assertNotSame($result1, $result2); + } +} diff --git a/tests/Unit/LogicalType/Factory/LocalTimestampMillisFactoryTest.php b/tests/Unit/LogicalType/Factory/LocalTimestampMillisFactoryTest.php new file mode 100644 index 0000000..d3b3106 --- /dev/null +++ b/tests/Unit/LogicalType/Factory/LocalTimestampMillisFactoryTest.php @@ -0,0 +1,51 @@ +factory = new LocalTimestampMillisFactory(); + } + + public function testGetName(): void + { + $this->assertSame('local-timestamp-millis', $this->factory->getName()); + } + + public function testCreateWithEmptyAttributes(): void + { + $result = $this->factory->create(['type' => 'long']); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(LocalTimestampMillisType::class, $result); + } + + public function testCreateWithAttributes(): void + { + $result = $this->factory->create(['type' => 'long', 'someAttribute' => 'value']); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(LocalTimestampMillisType::class, $result); + } + + public function testCreateReturnsNewInstanceEachTime(): void + { + $result1 = $this->factory->create(['type' => 'long']); + $result2 = $this->factory->create(['type' => 'long']); + + $this->assertInstanceOf(LocalTimestampMillisType::class, $result1); + $this->assertInstanceOf(LocalTimestampMillisType::class, $result2); + $this->assertNotSame($result1, $result2); + } +} diff --git a/tests/Unit/LogicalType/Factory/TimeMicrosFactoryTest.php b/tests/Unit/LogicalType/Factory/TimeMicrosFactoryTest.php new file mode 100644 index 0000000..b857e94 --- /dev/null +++ b/tests/Unit/LogicalType/Factory/TimeMicrosFactoryTest.php @@ -0,0 +1,51 @@ +factory = new TimeMicrosFactory(); + } + + public function testGetName(): void + { + $this->assertSame('time-micros', $this->factory->getName()); + } + + public function testCreateWithEmptyAttributes(): void + { + $result = $this->factory->create(['type' => 'long']); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(TimeMicrosType::class, $result); + } + + public function testCreateWithAttributes(): void + { + $result = $this->factory->create(['type' => 'long', 'someAttribute' => 'value']); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(TimeMicrosType::class, $result); + } + + public function testCreateReturnsNewInstanceEachTime(): void + { + $result1 = $this->factory->create(['type' => 'long']); + $result2 = $this->factory->create(['type' => 'long']); + + $this->assertInstanceOf(TimeMicrosType::class, $result1); + $this->assertInstanceOf(TimeMicrosType::class, $result2); + $this->assertNotSame($result1, $result2); + } +} diff --git a/tests/Unit/LogicalType/Factory/TimeMillisFactoryTest.php b/tests/Unit/LogicalType/Factory/TimeMillisFactoryTest.php new file mode 100644 index 0000000..88059cf --- /dev/null +++ b/tests/Unit/LogicalType/Factory/TimeMillisFactoryTest.php @@ -0,0 +1,51 @@ +factory = new TimeMillisFactory(); + } + + public function testGetName(): void + { + $this->assertSame('time-millis', $this->factory->getName()); + } + + public function testCreateWithEmptyAttributes(): void + { + $result = $this->factory->create(['type' => 'int']); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(TimeMillisType::class, $result); + } + + public function testCreateWithAttributes(): void + { + $result = $this->factory->create(['type' => 'int', 'someAttribute' => 'value']); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(TimeMillisType::class, $result); + } + + public function testCreateReturnsNewInstanceEachTime(): void + { + $result1 = $this->factory->create(['type' => 'int']); + $result2 = $this->factory->create(['type' => 'int']); + + $this->assertInstanceOf(TimeMillisType::class, $result1); + $this->assertInstanceOf(TimeMillisType::class, $result2); + $this->assertNotSame($result1, $result2); + } +} diff --git a/tests/Unit/LogicalType/Factory/TimestampMicrosFactoryTest.php b/tests/Unit/LogicalType/Factory/TimestampMicrosFactoryTest.php new file mode 100644 index 0000000..f237af1 --- /dev/null +++ b/tests/Unit/LogicalType/Factory/TimestampMicrosFactoryTest.php @@ -0,0 +1,51 @@ +factory = new TimestampMicrosFactory(); + } + + public function testGetName(): void + { + $this->assertSame('timestamp-micros', $this->factory->getName()); + } + + public function testCreateWithEmptyAttributes(): void + { + $result = $this->factory->create(['type' => 'long']); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(TimestampMicrosType::class, $result); + } + + public function testCreateWithAttributes(): void + { + $result = $this->factory->create(['type' => 'long', 'someAttribute' => 'value']); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(TimestampMicrosType::class, $result); + } + + public function testCreateReturnsNewInstanceEachTime(): void + { + $result1 = $this->factory->create(['type' => 'long']); + $result2 = $this->factory->create(['type' => 'long']); + + $this->assertInstanceOf(TimestampMicrosType::class, $result1); + $this->assertInstanceOf(TimestampMicrosType::class, $result2); + $this->assertNotSame($result1, $result2); + } +} diff --git a/tests/Unit/LogicalType/Factory/TimestampMillisFactoryTest.php b/tests/Unit/LogicalType/Factory/TimestampMillisFactoryTest.php new file mode 100644 index 0000000..f01ace3 --- /dev/null +++ b/tests/Unit/LogicalType/Factory/TimestampMillisFactoryTest.php @@ -0,0 +1,51 @@ +factory = new TimestampMillisFactory(); + } + + public function testGetName(): void + { + $this->assertSame('timestamp-millis', $this->factory->getName()); + } + + public function testCreateWithEmptyAttributes(): void + { + $result = $this->factory->create(['type' => 'long']); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(TimestampMillisType::class, $result); + } + + public function testCreateWithAttributes(): void + { + $result = $this->factory->create(['type' => 'long', 'someAttribute' => 'value']); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(TimestampMillisType::class, $result); + } + + public function testCreateReturnsNewInstanceEachTime(): void + { + $result1 = $this->factory->create(['type' => 'long']); + $result2 = $this->factory->create(['type' => 'long']); + + $this->assertInstanceOf(TimestampMillisType::class, $result1); + $this->assertInstanceOf(TimestampMillisType::class, $result2); + $this->assertNotSame($result1, $result2); + } +} diff --git a/tests/Unit/LogicalType/Factory/UuidFactoryTest.php b/tests/Unit/LogicalType/Factory/UuidFactoryTest.php new file mode 100644 index 0000000..39379c8 --- /dev/null +++ b/tests/Unit/LogicalType/Factory/UuidFactoryTest.php @@ -0,0 +1,95 @@ +factory = new UuidFactory(); + } + + public function testGetName(): void + { + $this->assertSame('uuid', $this->factory->getName()); + } + + public function testCreateWithEmptyAttributes(): void + { + $result = $this->factory->create([ + 'type' => 'fixed', + 'size' => 16, + ]); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(UuidType::class, $result); + } + + public function testCreateWithAttributes(): void + { + $result = $this->factory->create([ + 'type' => 'fixed', + 'size' => 16, + 'someAttribute' => 'value', + ]); + + $this->assertInstanceOf(LogicalTypeInterface::class, $result); + $this->assertInstanceOf(UuidType::class, $result); + } + + public function testCreateReturnsNewInstanceEachTime(): void + { + $result1 = $this->factory->create([ + 'type' => 'fixed', + 'size' => 16, + ]); + $result2 = $this->factory->create([ + 'type' => 'fixed', + 'size' => 16, + ]); + + $this->assertInstanceOf(UuidType::class, $result1); + $this->assertInstanceOf(UuidType::class, $result2); + $this->assertNotSame($result1, $result2); + } + + public function testCreateWithWrongTypeThrowsException(): void + { + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('The "uuid" logical type can only be used with a "fixed" type'); + + $this->factory->create([ + 'type' => 'string', + 'size' => 16, + ]); + } + + public function testCreateWithWrongSizeThrowsException(): void + { + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('The "uuid" logical type must be used with a "fixed" type of size 16'); + + $this->factory->create([ + 'type' => 'fixed', + 'size' => 8, + ]); + } + + public function testCreateWithoutTypeThrowsException(): void + { + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('The "uuid" logical type can only be used with a "fixed" type'); + + $this->factory->create(['size' => 16]); + } +} diff --git a/tests/Unit/LogicalType/LocalTimestampMicrosTypeTest.php b/tests/Unit/LogicalType/LocalTimestampMicrosTypeTest.php new file mode 100644 index 0000000..cea32e1 --- /dev/null +++ b/tests/Unit/LogicalType/LocalTimestampMicrosTypeTest.php @@ -0,0 +1,264 @@ +timestampType = new LocalTimestampMicrosType(); + } + + public function testValidateWithValidDateTime(): void + { + $dateTime = new DateTime('2023-05-15 12:30:45'); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->timestampType->validate($dateTime, $context); + + $this->assertTrue($result); + } + + public function testValidateWithValidDateTimeImmutable(): void + { + $dateTime = new DateTimeImmutable('2023-05-15 12:30:45'); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->timestampType->validate($dateTime, $context); + + $this->assertTrue($result); + } + + public function testValidateWithInvalidDatum(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('expected DateTimeInterface, got string'); + + $result = $this->timestampType->validate('2023-05-15', $context); + + $this->assertFalse($result); + } + + public function testValidateWithInteger(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('expected DateTimeInterface, got integer'); + + $result = $this->timestampType->validate(1684152645, $context); + + $this->assertFalse($result); + } + + public function testValidateWithoutContext(): void + { + $dateTime = new DateTime('2023-05-15 12:30:45'); + + $result = $this->timestampType->validate($dateTime, null); + + $this->assertTrue($result); + } + + public function testNormalizeIgnoresTimezone(): void + { + $utc = new DateTimeImmutable('2023-05-15 12:30:45.123456', new DateTimeZone('UTC')); + $pst = new DateTimeImmutable('2023-05-15 12:30:45.123456', new DateTimeZone('America/Los_Angeles')); + + $utcResult = $this->timestampType->normalize($utc); + $pstResult = $this->timestampType->normalize($pst); + + // Should be the same since local timestamp ignores timezone + $this->assertSame($utcResult, $pstResult); + } + + public function testNormalizeWithMicroseconds(): void + { + $dateTime = new DateTimeImmutable('2023-05-15 12:30:45.123456'); + + $result = $this->timestampType->normalize($dateTime); + + // The result should be microseconds since epoch, ignoring timezone + $this->assertIsInt($result); + $this->assertGreaterThan(0, $result); + } + + public function testNormalizeWithoutMicroseconds(): void + { + $dateTime = new DateTimeImmutable('2023-05-15 12:30:45'); + + $result = $this->timestampType->normalize($dateTime); + + $this->assertIsInt($result); + $this->assertSame(0, $result % 1000000); // Should be exact seconds in microseconds + } + + public function testNormalizeWithEpochTime(): void + { + $dateTime = new DateTimeImmutable('1970-01-01 00:00:00.000000'); + + $result = $this->timestampType->normalize($dateTime); + + // Should be close to 0, accounting for local timezone offset + $this->assertIsInt($result); + } + + public function testDenormalizeWithZero(): void + { + $result = $this->timestampType->denormalize(0); + + $this->assertInstanceOf(DateTimeInterface::class, $result); + $this->assertSame('1970-01-01T00:00:00', $result->format('Y-m-d\TH:i:s')); + } + + public function testDenormalizeWithMicroseconds(): void + { + $microseconds = 1684152645123456; // Some timestamp with microseconds + + $result = $this->timestampType->denormalize($microseconds); + + $this->assertInstanceOf(DateTimeInterface::class, $result); + $this->assertStringContainsString('.123456', $result->format('Y-m-d H:i:s.u')); + } + + public function testDenormalizeWithoutMicroseconds(): void + { + $microseconds = 1684152645000000; // Exact seconds + + $result = $this->timestampType->denormalize($microseconds); + + $this->assertInstanceOf(DateTimeInterface::class, $result); + $this->assertStringNotContainsString('.', $result->format('Y-m-d H:i:s')); // No microseconds shown when zero + } + + public function testDenormalizeWithPartialMicroseconds(): void + { + $microseconds = 1684152645000100; // 100 microseconds + + $result = $this->timestampType->denormalize($microseconds); + + $this->assertInstanceOf(DateTimeInterface::class, $result); + $this->assertStringContainsString('.000100', $result->format('Y-m-d H:i:s.u')); + } + + public function testNormalizeAndDenormalizeRoundTrip(): void + { + $originalDateTime = new DateTimeImmutable('2023-05-15 12:30:45.123456'); + + $normalized = $this->timestampType->normalize($originalDateTime); + $denormalized = $this->timestampType->denormalize($normalized); + + // Check that we get back a DateTimeInterface object + $this->assertInstanceOf(DateTimeInterface::class, $denormalized); + $this->assertStringContainsString('12:30:45.123456', $denormalized->format('Y-m-d H:i:s.u')); + } + + public function testNormalizeAndDenormalizeRoundTripWithoutMicroseconds(): void + { + $originalDateTime = new DateTimeImmutable('2023-05-15 12:30:45'); + + $normalized = $this->timestampType->normalize($originalDateTime); + $denormalized = $this->timestampType->denormalize($normalized); + + // Check that we get back a DateTimeInterface object + $this->assertInstanceOf(DateTimeInterface::class, $denormalized); + $this->assertStringContainsString('12:30:45', $denormalized->format('Y-m-d H:i:s')); + $this->assertStringNotContainsString('.', $denormalized->format('Y-m-d H:i:s')); // No decimal when no microseconds + } + + public function testNormalizeWithNegativeTimestamp(): void + { + $dateTime = new DateTimeImmutable('1969-12-31 23:59:59.500000'); + + $result = $this->timestampType->normalize($dateTime); + + $this->assertIsInt($result); + } + + public function testDenormalizeWithNegativeTimestamp(): void + { + $microseconds = -1000000; // 1 second before epoch + + $result = $this->timestampType->denormalize($microseconds); + + $this->assertInstanceOf(DateTimeInterface::class, $result); + $this->assertStringContainsString('1969-12-31 23:59:59', $result->format('Y-m-d H:i:s')); + } + + public function testTimezoneIgnoredInNormalization(): void + { + $tokyo = new DateTimeImmutable('2023-05-15 12:30:45.123456', new DateTimeZone('Asia/Tokyo')); + $london = new DateTimeImmutable('2023-05-15 12:30:45.123456', new DateTimeZone('Europe/London')); + $newYork = new DateTimeImmutable('2023-05-15 12:30:45.123456', new DateTimeZone('America/New_York')); + + $tokyoResult = $this->timestampType->normalize($tokyo); + $londonResult = $this->timestampType->normalize($london); + $newYorkResult = $this->timestampType->normalize($newYork); + + // All should be the same since timezone is ignored + $this->assertSame($tokyoResult, $londonResult); + $this->assertSame($londonResult, $newYorkResult); + } + + public function testDenormalizeFormatsWithoutTimezoneIndicator(): void + { + $microseconds = 1684152645123456; + + $result = $this->timestampType->denormalize($microseconds); + + $this->assertInstanceOf(DateTimeInterface::class, $result); + + // The result should be a proper DateTimeInterface object + // Format it to check it doesn't contain timezone indicators when formatted as local + $formatted = $result->format('Y-m-d H:i:s.u'); + $this->assertStringContainsString('12:10:45.123456', $formatted); + } + + public static function localTimestampMicrosecondsProvider(): Generator + { + // For local timestamp: time components are treated as UTC regardless of original timezone + yield '12 microseconds after epoch (local as UTC)' => [new DateTime('1970-01-01 00:00:00.000012'), 12]; + yield '1001 microseconds after epoch (local as UTC)' => [new DateTime('1970-01-01 00:00:00.001001'), 1001]; + yield 'epoch (local as UTC)' => [new DateTime('1970-01-01 00:00:00.000000'), 0]; + yield '877 microseconds before epoch (local as UTC)' => [new DateTime('1969-12-31 23:59:59.999123'), -877]; + yield '1001 microseconds before epoch (local as UTC)' => [new DateTime('1969-12-31 23:59:59.998999'), -1001]; + // These should be treated as the time components in UTC, ignoring timezone + yield 'date within summer time (local as UTC)' => [new DateTime('2024-04-01T14:05:00.123456+02:00'), 1711980300123456]; + yield 'date out of summer time (local as UTC)' => [new DateTime('2024-03-30T14:05:00.123456+01:00'), 1711807500123456]; + yield 'large future date (local as UTC)' => [new DateTime('2100-01-01 00:00:00.000000'), 4102444800000000]; + } + + #[DataProvider('localTimestampMicrosecondsProvider')] + public function testNormalizeWithProvider(object $dateTime, int $expected): void + { + $actual = $this->timestampType->normalize($dateTime); + self::assertSame($expected, $actual); + } + + #[DataProvider('localTimestampMicrosecondsProvider')] + public function testDenormalizeWithProvider(DateTimeInterface $expected, int $input): void + { + $actual = $this->timestampType->denormalize($input); + + self::assertInstanceOf(DateTimeInterface::class, $actual); + self::assertSame((new DateTime())->getTimezone()->getName(), $actual->getTimezone()->getName()); + // For local timestamp: the time components should match (ignoring timezone) + self::assertSame($expected->format('Y-m-d H:i:s.u'), $actual->format('Y-m-d H:i:s.u')); + } +} diff --git a/tests/Unit/LogicalType/LocalTimestampMillisTypeTest.php b/tests/Unit/LogicalType/LocalTimestampMillisTypeTest.php new file mode 100644 index 0000000..36d883c --- /dev/null +++ b/tests/Unit/LogicalType/LocalTimestampMillisTypeTest.php @@ -0,0 +1,303 @@ +timestampType = new LocalTimestampMillisType(); + } + + public function testValidateWithValidDateTime(): void + { + $dateTime = new DateTime('2023-05-15 12:30:45'); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->timestampType->validate($dateTime, $context); + + $this->assertTrue($result); + } + + public function testValidateWithValidDateTimeImmutable(): void + { + $dateTime = new DateTimeImmutable('2023-05-15 12:30:45'); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->timestampType->validate($dateTime, $context); + + $this->assertTrue($result); + } + + public function testValidateWithInvalidDatum(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('expected DateTimeInterface, got string'); + + $result = $this->timestampType->validate('2023-05-15', $context); + + $this->assertFalse($result); + } + + public function testValidateWithInteger(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('expected DateTimeInterface, got integer'); + + $result = $this->timestampType->validate(1684152645, $context); + + $this->assertFalse($result); + } + + public function testValidateWithoutContext(): void + { + $dateTime = new DateTime('2023-05-15 12:30:45'); + + $result = $this->timestampType->validate($dateTime, null); + + $this->assertTrue($result); + } + + public function testNormalizeIgnoresTimezone(): void + { + $utc = new DateTimeImmutable('2023-05-15 12:30:45.123', new DateTimeZone('UTC')); + $pst = new DateTimeImmutable('2023-05-15 12:30:45.123', new DateTimeZone('America/Los_Angeles')); + + $utcResult = $this->timestampType->normalize($utc); + $pstResult = $this->timestampType->normalize($pst); + + // Should be the same since local timestamp ignores timezone + $this->assertSame($utcResult, $pstResult); + } + + public function testNormalizeWithMilliseconds(): void + { + $dateTime = new DateTimeImmutable('2023-05-15 12:30:45.123'); + + $result = $this->timestampType->normalize($dateTime); + + // The result should be milliseconds since epoch, ignoring timezone + $this->assertIsInt($result); + $this->assertGreaterThan(0, $result); + } + + public function testNormalizeWithMicroseconds(): void + { + // Test that microseconds are truncated to milliseconds + $dateTime = new DateTimeImmutable('2023-05-15 12:30:45.123456'); + + $result = $this->timestampType->normalize($dateTime); + + $this->assertIsInt($result); + // Should only include first 3 digits of microseconds (123 milliseconds) + $expectedMilliseconds = $result % 1000; + $this->assertSame(123, $expectedMilliseconds); + } + + public function testNormalizeWithoutMilliseconds(): void + { + $dateTime = new DateTimeImmutable('2023-05-15 12:30:45'); + + $result = $this->timestampType->normalize($dateTime); + + $this->assertIsInt($result); + $this->assertSame(0, $result % 1000); // Should be exact seconds in milliseconds + } + + public function testNormalizeWithEpochTime(): void + { + $dateTime = new DateTimeImmutable('1970-01-01 00:00:00.000'); + + $result = $this->timestampType->normalize($dateTime); + + // Should be close to 0, accounting for local timezone offset + $this->assertIsInt($result); + } + + public function testDenormalizeWithMilliseconds(): void + { + $milliseconds = 1684152645123; // Some timestamp with milliseconds + + $result = $this->timestampType->denormalize($milliseconds); + + $this->assertInstanceOf(DateTimeInterface::class, $result); + $formatted = $result->format('Y-m-d H:i:s.v'); + $this->assertStringContainsString('.123', $formatted); + // Local timestamp should not show timezone indicators when formatted as local + } + + public function testDenormalizeWithoutMilliseconds(): void + { + $milliseconds = 1684152645000; // Exact seconds + + $result = $this->timestampType->denormalize($milliseconds); + + $this->assertInstanceOf(DateTimeInterface::class, $result); + $formatted = $result->format('Y-m-d H:i:s.v'); + $this->assertStringContainsString('.000', $formatted); // Milliseconds shown as .000 when zero + } + + public function testDenormalizeWithPartialMilliseconds(): void + { + $milliseconds = 1684152645001; // 1 millisecond + + $result = $this->timestampType->denormalize($milliseconds); + + $this->assertInstanceOf(DateTimeInterface::class, $result); + $formatted = $result->format('Y-m-d H:i:s.v'); + $this->assertStringContainsString('.001', $formatted); + } + + public function testNormalizeAndDenormalizeRoundTrip(): void + { + $originalDateTime = new DateTimeImmutable('2023-05-15 12:30:45.123'); + + $normalized = $this->timestampType->normalize($originalDateTime); + $denormalized = $this->timestampType->denormalize($normalized); + + // Check that we get back a DateTimeInterface with the correct time components + $this->assertInstanceOf(DateTimeInterface::class, $denormalized); + $formatted = $denormalized->format('H:i:s.v'); + $this->assertStringContainsString('12:30:45.123', $formatted); + } + + public function testNormalizeAndDenormalizeRoundTripWithoutMilliseconds(): void + { + $originalDateTime = new DateTimeImmutable('2023-05-15 12:30:45'); + + $normalized = $this->timestampType->normalize($originalDateTime); + $denormalized = $this->timestampType->denormalize($normalized); + + // Check that we get back a DateTimeInterface with the correct time components + $this->assertInstanceOf(DateTimeInterface::class, $denormalized); + $formatted = $denormalized->format('H:i:s'); + $this->assertStringContainsString('12:30:45', $formatted); + } + + public function testNormalizeWithNegativeTimestamp(): void + { + $dateTime = new DateTimeImmutable('1969-12-31 23:59:59.500'); + + $result = $this->timestampType->normalize($dateTime); + + $this->assertIsInt($result); + } + + public function testDenormalizeWithNegativeTimestamp(): void + { + $milliseconds = -1000; // 1 second before epoch + + $result = $this->timestampType->denormalize($milliseconds); + + $this->assertInstanceOf(DateTimeInterface::class, $result); + $formatted = $result->format('Y-m-d H:i:s'); + $this->assertStringContainsString('1969-12-31 23:59:59', $formatted); + } + + public function testTimezoneIgnoredInNormalization(): void + { + $tokyo = new DateTimeImmutable('2023-05-15 12:30:45.123', new DateTimeZone('Asia/Tokyo')); + $london = new DateTimeImmutable('2023-05-15 12:30:45.123', new DateTimeZone('Europe/London')); + $newYork = new DateTimeImmutable('2023-05-15 12:30:45.123', new DateTimeZone('America/New_York')); + + $tokyoResult = $this->timestampType->normalize($tokyo); + $londonResult = $this->timestampType->normalize($london); + $newYorkResult = $this->timestampType->normalize($newYork); + + // All should be the same since timezone is ignored + $this->assertSame($tokyoResult, $londonResult); + $this->assertSame($londonResult, $newYorkResult); + } + + public function testDenormalizeFormatsWithoutTimezoneIndicator(): void + { + $milliseconds = 1684152645123; + + $result = $this->timestampType->denormalize($milliseconds); + + $this->assertInstanceOf(DateTimeInterface::class, $result); + $formatted = $result->format('Y-m-d H:i:s.v'); + + // Should not contain any timezone indicators in the formatted string + $this->assertStringNotContainsString('Z', $formatted); + $this->assertStringNotContainsString('+', $formatted); + $this->assertStringNotContainsString('UTC', $formatted); + // Check that it uses local timezone + $this->assertEquals((new DateTimeImmutable())->getTimezone()->getName(), $result->getTimezone()->getName()); + } + + public function testNormalizeWithMicrosecondsRounding(): void + { + // Test various microsecond values to ensure proper millisecond conversion + $testCases = [ + '123456' => 123, // Truncated + '567890' => 567, // Truncated + '999999' => 999, // Truncated + '000123' => 0, // Truncated + ]; + + foreach ($testCases as $microseconds => $expectedMilliseconds) { + $dateTime = new DateTimeImmutable("2023-05-15 12:30:45.{$microseconds}"); + + $result = $this->timestampType->normalize($dateTime); + $this->assertIsInt($result); + $actualMilliseconds = $result % 1000; + + $this->assertSame( + $expectedMilliseconds, + $actualMilliseconds, + "Failed for microseconds {$microseconds}, expected {$expectedMilliseconds} ms, got {$actualMilliseconds} ms", + ); + } + } + + public static function localTimestampMillisecondsProvider(): Generator + { + // For local timestamp: time components are treated as UTC regardless of original timezone + yield '12 milliseconds after epoch (local as UTC)' => [new DateTime('1970-01-01 00:00:00.012'), 12]; + yield '1001 milliseconds after epoch (local as UTC)' => [new DateTime('1970-01-01 00:00:01.001'), 1001]; + yield 'epoch (local as UTC)' => [new DateTime('1970-01-01 00:00:00.000'), 0]; + yield '877 milliseconds before epoch (local as UTC)' => [new DateTime('1969-12-31 23:59:59.123'), -877]; + yield '1001 milliseconds before epoch (local as UTC)' => [new DateTime('1969-12-31 23:59:58.999'), -1001]; + // These should be treated as the time components in UTC, ignoring timezone + yield 'date within summer time (local as UTC)' => [new DateTime('2024-04-01T14:05:00.123+02:00'), 1711980300123]; + yield 'date out of summer time (local as UTC)' => [new DateTime('2024-03-30T14:05:00.123+01:00'), 1711807500123]; + yield 'large future date (local as UTC)' => [new DateTime('2100-01-01 00:00:00.000'), 4102444800000]; + } + + #[DataProvider('localTimestampMillisecondsProvider')] + public function testNormalizeWithProvider(object $dateTime, int $expected): void + { + $actual = $this->timestampType->normalize($dateTime); + self::assertSame($expected, $actual); + } + + #[DataProvider('localTimestampMillisecondsProvider')] + public function testDenormalizeWithProvider(DateTimeInterface $expected, int $input): void + { + $actual = $this->timestampType->denormalize($input); + + self::assertInstanceOf(DateTimeInterface::class, $actual); + self::assertSame((new DateTime())->getTimezone()->getName(), $actual->getTimezone()->getName()); + // For local timestamp: the time components should match (ignoring timezone) + self::assertSame($expected->format('Y-m-d H:i:s.v'), $actual->format('Y-m-d H:i:s.v')); + } +} diff --git a/tests/Unit/LogicalType/TimeMicrosTypeTest.php b/tests/Unit/LogicalType/TimeMicrosTypeTest.php new file mode 100644 index 0000000..83844e2 --- /dev/null +++ b/tests/Unit/LogicalType/TimeMicrosTypeTest.php @@ -0,0 +1,252 @@ +timeMicrosType = new TimeMicrosType(); + } + + public function testValidateWithValidTimeOfDay(): void + { + $timeOfDay = TimeOfDay::fromComponents(12, 30, 45, 123, 456); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->timeMicrosType->validate($timeOfDay, $context); + + $this->assertTrue($result); + } + + public function testValidateWithMidnight(): void + { + $timeOfDay = TimeOfDay::fromComponents(0, 0, 0, 0, 0); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->timeMicrosType->validate($timeOfDay, $context); + + $this->assertTrue($result); + } + + public function testValidateWithAlmostMidnight(): void + { + $timeOfDay = TimeOfDay::fromComponents(23, 59, 59, 999, 999); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->timeMicrosType->validate($timeOfDay, $context); + + $this->assertTrue($result); + } + + public function testValidateWithInvalidDatum(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('Time value must be a TimeOfDay object'); + + $result = $this->timeMicrosType->validate('12:30:45', $context); + + $this->assertFalse($result); + } + + public function testValidateWithInteger(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('Time value must be a TimeOfDay object'); + + $result = $this->timeMicrosType->validate(45045123456, $context); + + $this->assertFalse($result); + } + + public function testValidateWithDateTime(): void + { + $dateTime = new DateTime('12:30:45'); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('Time value must be a TimeOfDay object'); + + $result = $this->timeMicrosType->validate($dateTime, $context); + + $this->assertFalse($result); + } + + public function testValidateWithoutContext(): void + { + $timeOfDay = TimeOfDay::fromComponents(12, 30, 45); + + $result = $this->timeMicrosType->validate($timeOfDay, null); + + $this->assertTrue($result); + } + + public function testNormalizeWithMidnight(): void + { + $timeOfDay = TimeOfDay::fromComponents(0, 0, 0, 0, 0); + + $result = $this->timeMicrosType->normalize($timeOfDay); + + $this->assertSame(0, $result); + } + + public function testNormalizeWithSpecificTime(): void + { + $timeOfDay = TimeOfDay::fromComponents(12, 30, 45, 123, 456); + + $result = $this->timeMicrosType->normalize($timeOfDay); + + // Calculate expected microseconds: (12*3600 + 30*60 + 45) * 1000000 + 123*1000 + 456 + $expected = (12 * 3600 + 30 * 60 + 45) * 1000000 + 123 * 1000 + 456; + $this->assertSame($expected, $result); + $this->assertSame($timeOfDay->totalMicroseconds, $result); + } + + public function testNormalizeWithAlmostMidnight(): void + { + $timeOfDay = TimeOfDay::fromComponents(23, 59, 59, 999, 999); + + $result = $this->timeMicrosType->normalize($timeOfDay); + + $expected = (23 * 3600 + 59 * 60 + 59) * 1000000 + 999 * 1000 + 999; + $this->assertSame($expected, $result); + $this->assertSame($timeOfDay->totalMicroseconds, $result); + } + + public function testNormalizeWithNoon(): void + { + $timeOfDay = TimeOfDay::fromComponents(12, 0, 0, 0, 0); + + $result = $this->timeMicrosType->normalize($timeOfDay); + + $expected = 12 * 3600 * 1000000; // 12:00:00.000000 + $this->assertSame($expected, $result); + } + + public function testDenormalizeWithZero(): void + { + $result = $this->timeMicrosType->denormalize(0); + + $this->assertInstanceOf(TimeOfDay::class, $result); + $this->assertSame(0, $result->totalMicroseconds); + $this->assertSame(0, $result->getHours()); + $this->assertSame(0, $result->getMinutes()); + $this->assertSame(0, $result->getSeconds()); + $this->assertSame(0, $result->getMilliseconds()); + $this->assertSame(0, $result->getMicroseconds()); + } + + public function testDenormalizeWithSpecificTime(): void + { + $microseconds = (12 * 3600 + 30 * 60 + 45) * 1000000 + 123 * 1000 + 456; + + $result = $this->timeMicrosType->denormalize($microseconds); + + $this->assertInstanceOf(TimeOfDay::class, $result); + $this->assertSame($microseconds, $result->totalMicroseconds); + $this->assertSame(12, $result->getHours()); + $this->assertSame(30, $result->getMinutes()); + $this->assertSame(45, $result->getSeconds()); + $this->assertSame(123, $result->getMilliseconds()); + $this->assertSame(123456, $result->getMicroseconds()); + } + + public function testDenormalizeWithAlmostMidnight(): void + { + $microseconds = (23 * 3600 + 59 * 60 + 59) * 1000000 + 999 * 1000 + 999; + + $result = $this->timeMicrosType->denormalize($microseconds); + + $this->assertInstanceOf(TimeOfDay::class, $result); + $this->assertSame(23, $result->getHours()); + $this->assertSame(59, $result->getMinutes()); + $this->assertSame(59, $result->getSeconds()); + $this->assertSame(999, $result->getMilliseconds()); + $this->assertSame(999999, $result->getMicroseconds()); + } + + public function testNormalizeAndDenormalizeRoundTrip(): void + { + $originalTime = TimeOfDay::fromComponents(14, 25, 30, 789, 123); + + $normalized = $this->timeMicrosType->normalize($originalTime); + $denormalized = $this->timeMicrosType->denormalize($normalized); + + $this->assertInstanceOf(TimeOfDay::class, $denormalized); + $this->assertSame($originalTime->totalMicroseconds, $denormalized->totalMicroseconds); + $this->assertSame($originalTime->getHours(), $denormalized->getHours()); + $this->assertSame($originalTime->getMinutes(), $denormalized->getMinutes()); + $this->assertSame($originalTime->getSeconds(), $denormalized->getSeconds()); + $this->assertSame($originalTime->getMilliseconds(), $denormalized->getMilliseconds()); + $this->assertSame($originalTime->getMicroseconds(), $denormalized->getMicroseconds()); + } + + public function testNormalizeAndDenormalizeRoundTripWithMidnight(): void + { + $originalTime = TimeOfDay::fromComponents(0, 0, 0, 0, 0); + + $normalized = $this->timeMicrosType->normalize($originalTime); + $denormalized = $this->timeMicrosType->denormalize($normalized); + + $this->assertSame($originalTime->totalMicroseconds, $denormalized->totalMicroseconds); + $this->assertSame(0, $normalized); + } + + public function testNormalizeAndDenormalizeRoundTripWithAlmostMidnight(): void + { + $originalTime = TimeOfDay::fromComponents(23, 59, 59, 999, 999); + + $normalized = $this->timeMicrosType->normalize($originalTime); + $denormalized = $this->timeMicrosType->denormalize($normalized); + + $this->assertSame($originalTime->totalMicroseconds, $denormalized->totalMicroseconds); + $this->assertSame(86399999999, $normalized); // Should be max value for a day + } + + public function testNormalizeWithTimeFromDateTime(): void + { + $dateTime = new DateTimeImmutable('2023-05-15 14:30:45.123456'); + $timeOfDay = TimeOfDay::fromDateTime($dateTime); + + $result = $this->timeMicrosType->normalize($timeOfDay); + + $this->assertIsInt($result); + $this->assertGreaterThan(0, $result); + + // Verify the time components are preserved + $denormalized = $this->timeMicrosType->denormalize($result); + $this->assertSame(14, $denormalized->getHours()); + $this->assertSame(30, $denormalized->getMinutes()); + $this->assertSame(45, $denormalized->getSeconds()); + $this->assertSame(123, $denormalized->getMilliseconds()); + $this->assertSame(123456, $denormalized->getMicroseconds()); + } + + public function testValidateAndNormalizeChain(): void + { + $timeOfDay = TimeOfDay::fromComponents(9, 15, 30, 500, 750); + $context = $this->createMock(ValidationContextInterface::class); + + $isValid = $this->timeMicrosType->validate($timeOfDay, $context); + $this->assertTrue($isValid); + + $normalized = $this->timeMicrosType->normalize($timeOfDay); + $this->assertIsInt($normalized); + $this->assertSame($timeOfDay->totalMicroseconds, $normalized); + } +} diff --git a/tests/Unit/LogicalType/TimeMillisTypeTest.php b/tests/Unit/LogicalType/TimeMillisTypeTest.php new file mode 100644 index 0000000..2039612 --- /dev/null +++ b/tests/Unit/LogicalType/TimeMillisTypeTest.php @@ -0,0 +1,315 @@ +timeMillisType = new TimeMillisType(); + } + + public function testValidateWithValidTimeOfDay(): void + { + $timeOfDay = TimeOfDay::fromComponents(12, 30, 45, 123); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->timeMillisType->validate($timeOfDay, $context); + + $this->assertTrue($result); + } + + public function testValidateWithMidnight(): void + { + $timeOfDay = TimeOfDay::fromComponents(0, 0, 0, 0, 0); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->timeMillisType->validate($timeOfDay, $context); + + $this->assertTrue($result); + } + + public function testValidateWithAlmostMidnight(): void + { + $timeOfDay = TimeOfDay::fromComponents(23, 59, 59, 999); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->timeMillisType->validate($timeOfDay, $context); + + $this->assertTrue($result); + } + + public function testValidateWithInvalidDatum(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('Time value must be a TimeOfDay object'); + + $result = $this->timeMillisType->validate('12:30:45', $context); + + $this->assertFalse($result); + } + + public function testValidateWithInteger(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('Time value must be a TimeOfDay object'); + + $result = $this->timeMillisType->validate(45045123, $context); + + $this->assertFalse($result); + } + + public function testValidateWithDateTime(): void + { + $dateTime = new DateTime('12:30:45'); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('Time value must be a TimeOfDay object'); + + $result = $this->timeMillisType->validate($dateTime, $context); + + $this->assertFalse($result); + } + + public function testValidateWithoutContext(): void + { + $timeOfDay = TimeOfDay::fromComponents(12, 30, 45); + + $result = $this->timeMillisType->validate($timeOfDay, null); + + $this->assertTrue($result); + } + + public function testNormalizeWithMidnight(): void + { + $timeOfDay = TimeOfDay::fromComponents(0, 0, 0, 0, 0); + + $result = $this->timeMillisType->normalize($timeOfDay); + + $this->assertSame(0, $result); + } + + public function testNormalizeWithSpecificTime(): void + { + $timeOfDay = TimeOfDay::fromComponents(12, 30, 45, 123); + + $result = $this->timeMillisType->normalize($timeOfDay); + + // Calculate expected milliseconds: (12*3600 + 30*60 + 45) * 1000 + 123 + $expected = (12 * 3600 + 30 * 60 + 45) * 1000 + 123; + $this->assertSame($expected, $result); + $this->assertSame($timeOfDay->getTotalMilliseconds(), $result); + } + + public function testNormalizeWithMicroseconds(): void + { + // Microseconds should be truncated in millisecond normalization + $timeOfDay = TimeOfDay::fromComponents(12, 30, 45, 123, 456); + + $result = $this->timeMillisType->normalize($timeOfDay); + + // Should only include milliseconds, not microseconds + $expected = (12 * 3600 + 30 * 60 + 45) * 1000 + 123; + $this->assertSame($expected, $result); + } + + public function testNormalizeWithAlmostMidnight(): void + { + $timeOfDay = TimeOfDay::fromComponents(23, 59, 59, 999); + + $result = $this->timeMillisType->normalize($timeOfDay); + + $expected = (23 * 3600 + 59 * 60 + 59) * 1000 + 999; + $this->assertSame($expected, $result); + } + + public function testNormalizeWithNoon(): void + { + $timeOfDay = TimeOfDay::fromComponents(12, 0, 0, 0, 0); + + $result = $this->timeMillisType->normalize($timeOfDay); + + $expected = 12 * 3600 * 1000; // 12:00:00.000 + $this->assertSame($expected, $result); + } + + public function testDenormalizeWithZero(): void + { + $result = $this->timeMillisType->denormalize(0); + + $this->assertInstanceOf(TimeOfDay::class, $result); + $this->assertSame(0, $result->totalMicroseconds); + $this->assertSame(0, $result->getHours()); + $this->assertSame(0, $result->getMinutes()); + $this->assertSame(0, $result->getSeconds()); + $this->assertSame(0, $result->getMilliseconds()); + $this->assertSame(0, $result->getMicroseconds()); + } + + public function testDenormalizeWithSpecificTime(): void + { + $milliseconds = (12 * 3600 + 30 * 60 + 45) * 1000 + 123; + + $result = $this->timeMillisType->denormalize($milliseconds); + + $this->assertInstanceOf(TimeOfDay::class, $result); + $this->assertSame(12, $result->getHours()); + $this->assertSame(30, $result->getMinutes()); + $this->assertSame(45, $result->getSeconds()); + $this->assertSame(123, $result->getMilliseconds()); + $this->assertSame(123000, $result->getMicroseconds()); // 123 milliseconds = 123000 microseconds + } + + public function testDenormalizeWithAlmostMidnight(): void + { + $milliseconds = (23 * 3600 + 59 * 60 + 59) * 1000 + 999; + + $result = $this->timeMillisType->denormalize($milliseconds); + + $this->assertInstanceOf(TimeOfDay::class, $result); + $this->assertSame(23, $result->getHours()); + $this->assertSame(59, $result->getMinutes()); + $this->assertSame(59, $result->getSeconds()); + $this->assertSame(999, $result->getMilliseconds()); + $this->assertSame(999000, $result->getMicroseconds()); + } + + public function testNormalizeAndDenormalizeRoundTrip(): void + { + $originalTime = TimeOfDay::fromComponents(14, 25, 30, 789); + + $normalized = $this->timeMillisType->normalize($originalTime); + $denormalized = $this->timeMillisType->denormalize($normalized); + + $this->assertInstanceOf(TimeOfDay::class, $denormalized); + $this->assertSame($originalTime->getHours(), $denormalized->getHours()); + $this->assertSame($originalTime->getMinutes(), $denormalized->getMinutes()); + $this->assertSame($originalTime->getSeconds(), $denormalized->getSeconds()); + $this->assertSame($originalTime->getMilliseconds(), $denormalized->getMilliseconds()); + $this->assertSame($originalTime->getMilliseconds() * 1000, $denormalized->getMicroseconds()); // Only millisecond precision + } + + public function testNormalizeAndDenormalizeRoundTripWithMicroseconds(): void + { + // Test that microseconds are lost in round trip + $originalTime = TimeOfDay::fromComponents(14, 25, 30, 789, 456); + + $normalized = $this->timeMillisType->normalize($originalTime); + $denormalized = $this->timeMillisType->denormalize($normalized); + + $this->assertInstanceOf(TimeOfDay::class, $denormalized); + $this->assertSame($originalTime->getHours(), $denormalized->getHours()); + $this->assertSame($originalTime->getMinutes(), $denormalized->getMinutes()); + $this->assertSame($originalTime->getSeconds(), $denormalized->getSeconds()); + $this->assertSame($originalTime->getMilliseconds(), $denormalized->getMilliseconds()); + $this->assertSame($originalTime->getMilliseconds() * 1000, $denormalized->getMicroseconds()); // Microseconds are truncated + } + + public function testNormalizeAndDenormalizeRoundTripWithMidnight(): void + { + $originalTime = TimeOfDay::fromComponents(0, 0, 0, 0, 0); + + $normalized = $this->timeMillisType->normalize($originalTime); + $denormalized = $this->timeMillisType->denormalize($normalized); + + $this->assertSame($originalTime->getHours(), $denormalized->getHours()); + $this->assertSame($originalTime->getMinutes(), $denormalized->getMinutes()); + $this->assertSame($originalTime->getSeconds(), $denormalized->getSeconds()); + $this->assertSame($originalTime->getMilliseconds(), $denormalized->getMilliseconds()); + $this->assertSame(0, $normalized); + } + + public function testNormalizeAndDenormalizeRoundTripWithAlmostMidnight(): void + { + $originalTime = TimeOfDay::fromComponents(23, 59, 59, 999); + + $normalized = $this->timeMillisType->normalize($originalTime); + $denormalized = $this->timeMillisType->denormalize($normalized); + + $this->assertSame($originalTime->getHours(), $denormalized->getHours()); + $this->assertSame($originalTime->getMinutes(), $denormalized->getMinutes()); + $this->assertSame($originalTime->getSeconds(), $denormalized->getSeconds()); + $this->assertSame($originalTime->getMilliseconds(), $denormalized->getMilliseconds()); + $this->assertSame(86399999, $normalized); // Should be max value for a day in milliseconds + } + + public function testNormalizeWithTimeFromDateTime(): void + { + $dateTime = new DateTimeImmutable('2023-05-15 14:30:45.123456'); + $timeOfDay = TimeOfDay::fromDateTime($dateTime); + + $result = $this->timeMillisType->normalize($timeOfDay); + + $this->assertIsInt($result); + $this->assertGreaterThan(0, $result); + + // Verify the time components are preserved (except microseconds) + $denormalized = $this->timeMillisType->denormalize($result); + $this->assertSame(14, $denormalized->getHours()); + $this->assertSame(30, $denormalized->getMinutes()); + $this->assertSame(45, $denormalized->getSeconds()); + $this->assertSame(123, $denormalized->getMilliseconds()); + $this->assertSame(123000, $denormalized->getMicroseconds()); // Microseconds are truncated + } + + public function testValidateAndNormalizeChain(): void + { + $timeOfDay = TimeOfDay::fromComponents(9, 15, 30, 500); + $context = $this->createMock(ValidationContextInterface::class); + + $isValid = $this->timeMillisType->validate($timeOfDay, $context); + $this->assertTrue($isValid); + + $normalized = $this->timeMillisType->normalize($timeOfDay); + $this->assertIsInt($normalized); + $this->assertSame($timeOfDay->getTotalMilliseconds(), $normalized); + } + + public function testDenormalizeConvertsMillisecondsToMicroseconds(): void + { + $milliseconds = 123456; // Arbitrary milliseconds since midnight + + $result = $this->timeMillisType->denormalize($milliseconds); + + // The constructor should receive microseconds (milliseconds * 1000) + $expectedMicroseconds = $milliseconds * 1000; + $this->assertSame($expectedMicroseconds, $result->totalMicroseconds); + } + + public function testMicrosecondPrecisionLoss(): void + { + // Test various microsecond values to ensure they're truncated + $testCases = [ + ['input' => TimeOfDay::fromComponents(12, 30, 45, 123, 456), 'expectedMs' => 123], + ['input' => TimeOfDay::fromComponents(12, 30, 45, 789, 999), 'expectedMs' => 789], + ['input' => TimeOfDay::fromComponents(12, 30, 45, 0, 500), 'expectedMs' => 0], + ]; + + foreach ($testCases as $testCase) { + $normalized = $this->timeMillisType->normalize($testCase['input']); + $denormalized = $this->timeMillisType->denormalize($normalized); + + $this->assertSame($testCase['expectedMs'], $denormalized->getMilliseconds()); + $this->assertSame( + $testCase['expectedMs'] * 1000, + $denormalized->getMicroseconds(), + ); // Always milliseconds * 1000 after round trip + } + } +} diff --git a/tests/Unit/LogicalType/TimestampMicrosTypeTest.php b/tests/Unit/LogicalType/TimestampMicrosTypeTest.php new file mode 100644 index 0000000..c6bc5d2 --- /dev/null +++ b/tests/Unit/LogicalType/TimestampMicrosTypeTest.php @@ -0,0 +1,139 @@ +timestampType = new TimestampMicrosType(); + } + + public function testValidateWithValidDateTime(): void + { + $dateTime = new DateTime('2023-05-15 12:30:45'); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->timestampType->validate($dateTime, $context); + + $this->assertTrue($result); + } + + public function testValidateWithValidDateTimeImmutable(): void + { + $dateTime = new DateTimeImmutable('2023-05-15 12:30:45'); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->timestampType->validate($dateTime, $context); + + $this->assertTrue($result); + } + + public function testValidateWithInvalidDatum(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('expected DateTimeInterface, got string'); + + $result = $this->timestampType->validate('2023-05-15', $context); + + $this->assertFalse($result); + } + + public function testValidateWithInteger(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('expected DateTimeInterface, got integer'); + + $result = $this->timestampType->validate(1684152645, $context); + + $this->assertFalse($result); + } + + public function testValidateWithoutContext(): void + { + $dateTime = new DateTime('2023-05-15 12:30:45'); + + $result = $this->timestampType->validate($dateTime, null); + + $this->assertTrue($result); + } + + public static function microsecondsProvider(): Generator + { + yield '12 microseconds after epoch' => [new DateTime('1970-01-01 00:00:00.000012+00'), 12]; + yield '1001 microseconds after epoch' => [new DateTime('1970-01-01 00:00:00.001001+00'), 1001]; + yield 'epoch' => [new DateTime('1970-01-01 00:00:00.000000+00'), 0]; + yield '877 microseconds before epoch' => [new DateTime('1969-12-31 23:59:59.999123+00'), -877]; + yield '1001 microseconds before epoch' => [new DateTime('1969-12-31 23:59:59.998999+00'), -1001]; + yield 'date within summer time' => [new DateTime('2024-04-01T14:05:00.123456+00:00'), 1711980300123456]; + yield 'date out of summer time' => [new DateTime('2024-03-30T14:05:00.123456+00:00'), 1711807500123456]; + yield 'large future date' => [new DateTime('2100-01-01 00:00:00.000000+00'), 4102444800000000]; + } + + #[DataProvider('microsecondsProvider')] + public function testNormalize(object $dateTime, int $expected): void + { + $actual = $this->timestampType->normalize($dateTime); + self::assertSame($expected, $actual); + } + + #[DataProvider('microsecondsProvider')] + public function testDenormalize(DateTimeInterface $expected, int $input): void + { + $actual = $this->timestampType->denormalize($input); + + self::assertInstanceOf(DateTimeInterface::class, $actual); + self::assertSame((new DateTime())->getTimezone()->getName(), $actual->getTimezone()->getName()); + self::assertEquals($expected, $actual); + } + + public function testNormalizeWithoutMicroseconds(): void + { + $dateTime = new DateTimeImmutable('2023-05-15 12:30:45', new DateTimeZone('UTC')); + + $result = $this->timestampType->normalize($dateTime); + + $expected = $dateTime->getTimestamp() * 1000000; + $this->assertSame($expected, $result); + } + + public function testDenormalizeWithoutMicroseconds(): void + { + $microseconds = 1684152645000000; // Exact seconds + + $result = $this->timestampType->denormalize($microseconds); + + $this->assertInstanceOf(DateTimeImmutable::class, $result); + $this->assertStringEndsWith('.000000', $result->format('Y-m-d\TH:i:s.u')); + } + + public function testNormalizeAndDenormalizeRoundTrip(): void + { + $originalDateTime = new DateTimeImmutable('2023-05-15 12:30:45.123456', new DateTimeZone('UTC')); + + $normalized = $this->timestampType->normalize($originalDateTime); + $denormalized = $this->timestampType->denormalize($normalized); + + // Check that we get back a DateTimeImmutable object + $this->assertInstanceOf(DateTimeImmutable::class, $denormalized); + $this->assertSame('2023-05-15T12:30:45.123456', $denormalized->format('Y-m-d\TH:i:s.u')); + } +} diff --git a/tests/Unit/LogicalType/TimestampMillisTypeTest.php b/tests/Unit/LogicalType/TimestampMillisTypeTest.php new file mode 100644 index 0000000..742f5a1 --- /dev/null +++ b/tests/Unit/LogicalType/TimestampMillisTypeTest.php @@ -0,0 +1,175 @@ +timestampType = new TimestampMillisType(); + } + + public function testValidateWithValidDateTime(): void + { + $dateTime = new DateTime('2023-05-15 12:30:45'); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->timestampType->validate($dateTime, $context); + + $this->assertTrue($result); + } + + public function testValidateWithValidDateTimeImmutable(): void + { + $dateTime = new DateTimeImmutable('2023-05-15 12:30:45'); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->timestampType->validate($dateTime, $context); + + $this->assertTrue($result); + } + + public function testValidateWithInvalidDatum(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('expected DateTimeInterface, got string'); + + $result = $this->timestampType->validate('2023-05-15', $context); + + $this->assertFalse($result); + } + + public function testValidateWithInteger(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('expected DateTimeInterface, got integer'); + + $result = $this->timestampType->validate(1684152645, $context); + + $this->assertFalse($result); + } + + public function testValidateWithoutContext(): void + { + $dateTime = new DateTime('2023-05-15 12:30:45'); + + $result = $this->timestampType->validate($dateTime, null); + + $this->assertTrue($result); + } + + public static function millisecondsProvider(): Generator + { + yield '12 milliseconds after epoch' => [new DateTime('1970-01-01 00:00:00.012+00'), 12]; + yield '1001 milliseconds after epoch' => [new DateTime('1970-01-01 00:00:01.001+00'), 1001]; + yield 'epoch' => [new DateTime('1970-01-01 00:00:00.000+00'), 0]; + yield '877 milliseconds before epoch' => [new DateTime('1969-12-31 23:59:59.123+00'), -877]; + yield '1001 milliseconds before epoch' => [new DateTime('1969-12-31 23:59:58.999+00'), -1001]; + yield 'date within summer time' => [new DateTime('2024-04-01T14:05:00.123+00:00'), 1711980300123]; + yield 'date out of summer time' => [new DateTime('2024-03-30T14:05:00.123+00:00'), 1711807500123]; + yield 'large future date' => [new DateTime('2100-01-01 00:00:00.000+00'), 4102444800000]; + } + + #[DataProvider('millisecondsProvider')] + public function testNormalize(object $dateTime, int $expected): void + { + $actual = $this->timestampType->normalize($dateTime); + self::assertSame($expected, $actual); + } + + #[DataProvider('millisecondsProvider')] + public function testDenormalize(DateTimeInterface $expected, int $input): void + { + $actual = $this->timestampType->denormalize($input); + + self::assertInstanceOf(DateTimeInterface::class, $actual); + self::assertSame((new DateTime())->getTimezone()->getName(), $actual->getTimezone()->getName()); + self::assertEquals($expected, $actual); + } + + public function testNormalizeWithMicroseconds(): void + { + // Test that microseconds are truncated to milliseconds + $dateTime = new DateTimeImmutable('2023-05-15 12:30:45.123456', new DateTimeZone('UTC')); + + $result = $this->timestampType->normalize($dateTime); + + $expected = $dateTime->getTimestamp() * 1000 + 123; // Only first 3 digits + $this->assertSame($expected, $result); + } + + public function testNormalizeWithoutMilliseconds(): void + { + $dateTime = new DateTimeImmutable('2023-05-15 12:30:45', new DateTimeZone('UTC')); + + $result = $this->timestampType->normalize($dateTime); + + $expected = $dateTime->getTimestamp() * 1000; + $this->assertSame($expected, $result); + } + + public function testDenormalizeWithoutMilliseconds(): void + { + $milliseconds = 1684152645000; // Exact seconds + + $result = $this->timestampType->denormalize($milliseconds); + + $this->assertInstanceOf(DateTimeImmutable::class, $result); + $this->assertStringEndsWith('.000', $result->format('Y-m-d\TH:i:s.v')); + } + + public function testNormalizeAndDenormalizeRoundTrip(): void + { + $originalDateTime = new DateTimeImmutable('2023-05-15 12:30:45.123', new DateTimeZone('UTC')); + + $normalized = $this->timestampType->normalize($originalDateTime); + $denormalized = $this->timestampType->denormalize($normalized); + + // Check that we get back a DateTimeImmutable object + $this->assertInstanceOf(DateTimeImmutable::class, $denormalized); + $this->assertSame('2023-05-15T12:30:45.123', $denormalized->format('Y-m-d\TH:i:s.v')); + } + + public function testNormalizeWithMicrosecondsRounding(): void + { + // Test various microsecond values to ensure proper millisecond conversion + $testCases = [ + '123456' => 123, // Truncated + '567890' => 567, // Truncated + '999999' => 999, // Truncated + '000123' => 0, // Truncated + ]; + + foreach ($testCases as $microseconds => $expectedMilliseconds) { + $dateTime = new DateTimeImmutable("2023-05-15 12:30:45.{$microseconds}", new DateTimeZone('UTC')); + + $result = $this->timestampType->normalize($dateTime); + $this->assertIsInt($result); + $actualMilliseconds = $result % 1000; + + $this->assertSame( + $expectedMilliseconds, + $actualMilliseconds, + "Failed for microseconds {$microseconds}, expected {$expectedMilliseconds} ms, got {$actualMilliseconds} ms", + ); + } + } +} diff --git a/tests/Unit/LogicalType/UuidTypeTest.php b/tests/Unit/LogicalType/UuidTypeTest.php new file mode 100644 index 0000000..9bbdedd --- /dev/null +++ b/tests/Unit/LogicalType/UuidTypeTest.php @@ -0,0 +1,323 @@ +uuidType = new UuidType(); + } + + public function testValidateWithValidUuidValueObject(): void + { + $uuid = Uuid::fromString('12345678-1234-1234-1234-123456789abc'); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->uuidType->validate($uuid, $context); + + $this->assertTrue($result); + } + + public function testValidateWithValidNilUuidValueObject(): void + { + $uuid = Uuid::fromString('00000000-0000-0000-0000-000000000000'); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->uuidType->validate($uuid, $context); + + $this->assertTrue($result); + } + + public function testValidateWithValidMaxUuidValueObject(): void + { + $uuid = Uuid::fromString('ffffffff-ffff-ffff-ffff-ffffffffffff'); + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $result = $this->uuidType->validate($uuid, $context); + + $this->assertTrue($result); + } + + public function testValidateWithNonUuidObjectDatum(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('UUID value must be a Uuid value object'); + + $result = $this->uuidType->validate(123456, $context); + + $this->assertFalse($result); + } + + public function testValidateWithStringDatum(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('UUID value must be a Uuid value object'); + + $result = $this->uuidType->validate('12345678-1234-1234-1234-123456789abc', $context); + + $this->assertFalse($result); + } + + public function testValidateWithArrayDatum(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('UUID value must be a Uuid value object'); + + $result = $this->uuidType->validate(['uuid' => '12345678-1234-1234-1234-123456789abc'], $context); + + $this->assertFalse($result); + } + + public function testValidateWithNullDatum(): void + { + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->once())->method('addError') + ->with('UUID value must be a Uuid value object'); + + $result = $this->uuidType->validate(null, $context); + + $this->assertFalse($result); + } + + public function testValidateWithoutContext(): void + { + $uuid = Uuid::fromString('12345678-1234-1234-1234-123456789abc'); + + $result = $this->uuidType->validate($uuid, null); + + $this->assertTrue($result); + } + + public function testValidateWithInvalidDatumAndNullContext(): void + { + $result = $this->uuidType->validate(123456, null); + + $this->assertFalse($result); + } + + public function testNormalizeWithUuidValueObject(): void + { + $uuid = Uuid::fromString('12345678-1234-1234-1234-123456789abc'); + + $result = $this->uuidType->normalize($uuid); + + $this->assertIsString($result); + $this->assertSame(16, strlen($result)); // 16 bytes + + // Verify it's the correct binary representation + $expectedBinary = hex2bin('12345678123412341234123456789abc'); + $this->assertSame($expectedBinary, $result); + } + + public function testNormalizeWithUppercaseUuidValueObject(): void + { + $uuid = Uuid::fromString('12345678-1234-1234-1234-123456789ABC'); + + $result = $this->uuidType->normalize($uuid); + + $this->assertIsString($result); + $this->assertSame(16, strlen($result)); + + // Should handle uppercase correctly + $expectedBinary = hex2bin('12345678123412341234123456789ABC'); + $this->assertSame($expectedBinary, $result); + } + + public function testNormalizeWithNilUuidValueObject(): void + { + $uuid = Uuid::fromString('00000000-0000-0000-0000-000000000000'); + + $result = $this->uuidType->normalize($uuid); + + $this->assertIsString($result); + $this->assertSame(16, strlen($result)); + $this->assertSame(str_repeat("\x00", 16), $result); + } + + public function testNormalizeWithMaxUuidValueObject(): void + { + $uuid = Uuid::fromString('ffffffff-ffff-ffff-ffff-ffffffffffff'); + + $result = $this->uuidType->normalize($uuid); + + $this->assertIsString($result); + $this->assertSame(16, strlen($result)); + $this->assertSame(str_repeat("\xff", 16), $result); + } + + public function testDenormalizeWithValidBinary(): void + { + $binary = hex2bin('12345678123412341234123456789abc'); + + $result = $this->uuidType->denormalize($binary); + + $this->assertInstanceOf(Uuid::class, $result); + $this->assertSame('12345678-1234-1234-1234-123456789abc', $result->toString()); + } + + public function testDenormalizeWithNilBinary(): void + { + $binary = str_repeat("\x00", 16); + + $result = $this->uuidType->denormalize($binary); + + $this->assertInstanceOf(Uuid::class, $result); + $this->assertSame('00000000-0000-0000-0000-000000000000', $result->toString()); + } + + public function testDenormalizeWithMaxBinary(): void + { + $binary = str_repeat("\xff", 16); + + $result = $this->uuidType->denormalize($binary); + + $this->assertInstanceOf(Uuid::class, $result); + $this->assertSame('ffffffff-ffff-ffff-ffff-ffffffffffff', $result->toString()); + } + + public function testDenormalizeWithRandomBinary(): void + { + $binary = hex2bin('a1b2c3d4e5f6789012345678901234ab'); + + $result = $this->uuidType->denormalize($binary); + + $this->assertInstanceOf(Uuid::class, $result); + $this->assertSame('a1b2c3d4-e5f6-7890-1234-5678901234ab', $result->toString()); + } + + public function testNormalizeAndDenormalizeRoundTrip(): void + { + $originalUuid = Uuid::fromString('12345678-1234-1234-1234-123456789abc'); + + $normalized = $this->uuidType->normalize($originalUuid); + $denormalized = $this->uuidType->denormalize($normalized); + + $this->assertInstanceOf(Uuid::class, $denormalized); + $this->assertSame($originalUuid->toString(), $denormalized->toString()); + } + + public function testNormalizeAndDenormalizeRoundTripWithUppercase(): void + { + $originalUuid = Uuid::fromString('12345678-1234-1234-1234-123456789ABC'); + + $normalized = $this->uuidType->normalize($originalUuid); + $denormalized = $this->uuidType->denormalize($normalized); + + // Should return lowercase + $this->assertInstanceOf(Uuid::class, $denormalized); + $this->assertSame('12345678-1234-1234-1234-123456789abc', $denormalized->toString()); + } + + public function testNormalizeAndDenormalizeRoundTripWithNil(): void + { + $originalUuid = Uuid::fromString('00000000-0000-0000-0000-000000000000'); + + $normalized = $this->uuidType->normalize($originalUuid); + $denormalized = $this->uuidType->denormalize($normalized); + + $this->assertInstanceOf(Uuid::class, $denormalized); + $this->assertSame($originalUuid->toString(), $denormalized->toString()); + } + + public function testNormalizeAndDenormalizeRoundTripWithMax(): void + { + $originalUuid = Uuid::fromString('ffffffff-ffff-ffff-ffff-ffffffffffff'); + + $normalized = $this->uuidType->normalize($originalUuid); + $denormalized = $this->uuidType->denormalize($normalized); + + $this->assertInstanceOf(Uuid::class, $denormalized); + $this->assertSame($originalUuid->toString(), $denormalized->toString()); + } + + public function testValidateAndNormalizeChain(): void + { + $uuid = Uuid::fromString('a1b2c3d4-e5f6-7890-1234-567890123456'); + $context = $this->createMock(ValidationContextInterface::class); + + $isValid = $this->uuidType->validate($uuid, $context); + $this->assertTrue($isValid); + + $normalized = $this->uuidType->normalize($uuid); + $this->assertIsString($normalized); + $this->assertSame(16, strlen($normalized)); + } + + public function testBinaryIntegrityAfterNormalization(): void + { + $testUuids = [ + '12345678-1234-1234-1234-123456789abc', + '00000000-0000-0000-0000-000000000000', + 'ffffffff-ffff-ffff-ffff-ffffffffffff', + 'a1b2c3d4-e5f6-7890-1234-567890123456', + 'ABCDEF12-3456-7890-ABCD-EF1234567890', + ]; + + foreach ($testUuids as $uuidString) { + $uuid = Uuid::fromString($uuidString); + $normalized = $this->uuidType->normalize($uuid); + $this->assertIsString($normalized); + + // Verify binary length + $this->assertSame(16, strlen($normalized), "Failed for UUID: {$uuidString}"); + + // Verify round trip + $denormalized = $this->uuidType->denormalize($normalized); + $this->assertInstanceOf(Uuid::class, $denormalized, "Round trip failed for UUID: {$uuidString}"); + $this->assertSame(strtolower($uuidString), $denormalized->toString(), "Round trip failed for UUID: {$uuidString}"); + } + } + + public function testFormatConsistency(): void + { + $binary = hex2bin('123456789abcdef0123456789abcdef0'); + + $result = $this->uuidType->denormalize($binary); + $this->assertInstanceOf(Uuid::class, $result); + + // Check format pattern + $this->assertMatchesRegularExpression('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $result->toString()); + } + + public function testCaseInsensitiveValidation(): void + { + $lowerUuid = Uuid::fromString('abcdef12-3456-7890-abcd-ef1234567890'); + $upperUuid = Uuid::fromString('ABCDEF12-3456-7890-ABCD-EF1234567890'); + $mixedUuid = Uuid::fromString('AbCdEf12-3456-7890-AbCd-Ef1234567890'); + + $context = $this->createMock(ValidationContextInterface::class); + $context->expects($this->never())->method('addError'); + + $this->assertTrue($this->uuidType->validate($lowerUuid, $context)); + $this->assertTrue($this->uuidType->validate($upperUuid, $context)); + $this->assertTrue($this->uuidType->validate($mixedUuid, $context)); + } + + public function testNormalizeAndDenormalizeRoundTripWithUuidValueObject(): void + { + $originalUuid = Uuid::fromString('12345678-1234-1234-1234-123456789abc'); + + $normalized = $this->uuidType->normalize($originalUuid); + $denormalized = $this->uuidType->denormalize($normalized); + + $this->assertInstanceOf(Uuid::class, $denormalized); + $this->assertSame($originalUuid->toString(), $denormalized->toString()); + $this->assertSame($originalUuid->toBytes(), $denormalized->toBytes()); + } +} diff --git a/tests/Unit/ValueObject/ArbitraryPrecisionIntegerTest.php b/tests/Unit/ValueObject/ArbitraryPrecisionIntegerTest.php new file mode 100644 index 0000000..330c72a --- /dev/null +++ b/tests/Unit/ValueObject/ArbitraryPrecisionIntegerTest.php @@ -0,0 +1,719 @@ +toString()); + } + + #[DataProvider('constructorValidStringProvider')] + public function testConstructorWithBasicStringValues(string $input, string $expected): void + { + $integer = ArbitraryPrecisionInteger::fromString($input); + self::assertSame($expected, $integer->toString()); + } + + /** + * @return array + */ + public static function constructorValidIntegerProvider(): array + { + return [ + // Integer inputs + [0, '0'], + [1, '1'], + [-1, '-1'], + [42, '42'], + [-42, '-42'], + [123456789, '123456789'], + [-123456789, '-123456789'], + [PHP_INT_MAX, (string) PHP_INT_MAX], + [PHP_INT_MIN, (string) PHP_INT_MIN], + ]; + } + + /** + * @return array + */ + public static function constructorValidStringProvider(): array + { + return [ + // String inputs + ['0', '0'], + ['1', '1'], + ['-1', '-1'], + ['42', '42'], + ['-42', '-42'], + ['123456789', '123456789'], + ['-123456789', '-123456789'], + ['000123', '123'], + ['-000123', '-123'], + + // Large numbers (beyond native int range) + ['12345678901234567890', '12345678901234567890'], + ['-12345678901234567890', '-12345678901234567890'], + ['999999999999999999999999999999', '999999999999999999999999999999'], + ['-999999999999999999999999999999', '-999999999999999999999999999999'], + + // Leading zeros handling + ['000', '0'], + ['-000', '0'], + ['00042', '42'], + ['-00042', '-42'], + ]; + } + + #[DataProvider('constructorInvalidInputProvider')] + public function testConstructorWithInvalidInputs(string $input): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Value must be a valid integer string'); + + ArbitraryPrecisionInteger::fromString($input); + } + + /** + * @return array + */ + public static function constructorInvalidInputProvider(): array + { + return [ + [''], + ['abc'], + ['123abc'], + ['abc123'], + ['12.34'], + ['-'], + ['+'], + ['+123'], + ['--123'], + ['12-34'], + ['1 2 3'], + ['1.0'], + ['1e5'], + ['0x123'], + ]; + } + + #[DataProvider('getValueIntegerProvider')] + public function testGetValueWithInteger(int $input, string $expected): void + { + $integer = ArbitraryPrecisionInteger::fromInteger($input); + $this->assertSame($expected, $integer->toString()); + } + + #[DataProvider('getValueStringProvider')] + public function testGetValueWithString(string $input, string $expected): void + { + $integer = ArbitraryPrecisionInteger::fromString($input); + $this->assertSame($expected, $integer->toString()); + } + + /** + * @return array + */ + public static function getValueIntegerProvider(): array + { + return [[0, '0'], [1, '1'], [-1, '-1'], [42, '42'], [-42, '-42']]; + } + + /** + * @return array + */ + public static function getValueStringProvider(): array + { + return [ + ['12345678901234567890', '12345678901234567890'], + ['-12345678901234567890', '-12345678901234567890'], + ['0', '0'], + ['000', '0'], + ['-000', '0'], + ]; + } + + #[DataProvider('scalePositiveIntegerProvider')] + public function testScalePositiveWithInteger(int $input, int $exponent, string $expected): void + { + $integer = ArbitraryPrecisionInteger::fromInteger($input); + $shifted = $integer->shiftDecimalPosition($exponent); + $this->assertSame($expected, $shifted->toString()); + } + + #[DataProvider('scalePositiveStringProvider')] + public function testScalePositiveWithString(string $input, int $exponent, string $expected): void + { + $integer = ArbitraryPrecisionInteger::fromString($input); + $shifted = $integer->shiftDecimalPosition($exponent); + $this->assertSame($expected, $shifted->toString()); + } + + /** + * @return array + */ + public static function scalePositiveIntegerProvider(): array + { + return [ + // Zero scaling + [0, 0, '0'], + [42, 0, '42'], + [-42, 0, '-42'], + + // Single digit scaling + [1, 1, '10'], + [1, 2, '100'], + [1, 3, '1000'], + [42, 1, '420'], + [42, 2, '4200'], + [-42, 1, '-420'], + [-42, 2, '-4200'], + + // Large scaling + [1, 9, '1000000000'], // Test base boundary + [1, 10, '10000000000'], + [1, 18, '1000000000000000000'], + [123, 5, '12300000'], + [-123, 5, '-12300000'], + + // Zero input with positive shift + [0, 5, '0'], + [0, 100, '0'], + ]; + } + + /** + * @return array + */ + public static function scalePositiveStringProvider(): array + { + return [ + // Large number scaling + ['12345678901234567890', 3, '12345678901234567890000'], + ['-12345678901234567890', 3, '-12345678901234567890000'], + ]; + } + + #[DataProvider('scaleNegativeIntegerProvider')] + public function testScaleNegativeWithInteger(int $input, int $exponent, string $expected): void + { + $integer = ArbitraryPrecisionInteger::fromInteger($input); + $shifted = $integer->shiftDecimalPosition($exponent); + $this->assertSame($expected, $shifted->toString()); + } + + #[DataProvider('scaleNegativeStringProvider')] + public function testScaleNegativeWithString(string $input, int $exponent, string $expected): void + { + $integer = ArbitraryPrecisionInteger::fromString($input); + $shifted = $integer->shiftDecimalPosition($exponent); + $this->assertSame($expected, $shifted->toString()); + } + + /** + * @return array + */ + public static function scaleNegativeIntegerProvider(): array + { + return [ + // Single digit right scaling + [10, -1, '1'], + [100, -2, '1'], + [1000, -3, '1'], + [420, -1, '42'], + [4200, -2, '42'], + [-420, -1, '-42'], + [-4200, -2, '-42'], + + // Scales that result in zero or round up with HALF_UP rounding + [1, -1, '0'], // 1/10 = 0.1 → rounds down to 0 + [9, -1, '1'], // 9/10 = 0.9 → rounds up to 1 + [99, -2, '1'], // 99/100 = 0.99 → rounds up to 1 + [999, -3, '1'], // 999/1000 = 0.999 → rounds up to 1 + [-1, -1, '0'], // -1/10 = -0.1 → rounds down to 0 + [-9, -1, '-1'], // -9/10 = -0.9 → rounds to -1 + + // Zero input with negative shift + [0, -5, '0'], + [0, -100, '0'], + + // Partial reductions + [12345, -2, '123'], + [-12345, -2, '-123'], + [12345, -4, '1'], + [12345, -5, '0'], + ]; + } + + /** + * @return array + */ + public static function scaleNegativeStringProvider(): array + { + return [ + // Large number right scaling + ['12345678901234567890000', -3, '12345678901234567890'], + ['-12345678901234567890000', -3, '-12345678901234567890'], + + // Base boundary tests + ['1000000000', -9, '1'], // Exactly one base unit + ['10000000000', -10, '1'], + ]; + } + + public function testScaleZeroPositions(): void + { + $integer = ArbitraryPrecisionInteger::fromInteger(42); + $shifted = $integer->shiftDecimalPosition(0); + $this->assertSame($integer, $shifted); + } + + public function testScaleZeroValue(): void + { + $integer = ArbitraryPrecisionInteger::fromInteger(0); + + // Test positive scaling (multiplication by 10^100) + $shifted = $integer->shiftDecimalPosition(100); + + // Should return the same instance when shifting zero + $this->assertSame($integer, $shifted); + + // Test negative scaling (division by 10^100) + $shiftedNegative = $integer->shiftDecimalPosition(-100); + + // Should return the same instance when shifting zero + $this->assertSame($integer, $shiftedNegative); + } + + #[DataProvider('bytesProvider')] + public function testToBytes(string $expectedHex, string $input): void + { + $integer = ArbitraryPrecisionInteger::fromString($input); + $bytes = $integer->toBytes(); + $actualHex = bin2hex($bytes); + $this->assertSame($expectedHex, $actualHex); + } + + #[DataProvider('bytesProvider')] + public function testFromBytes(string $hexInput, string $expectedValue): void + { + $bytes = hex2bin($hexInput); + $this->assertNotFalse($bytes, 'Invalid hex input'); + $integer = ArbitraryPrecisionInteger::fromBytes($bytes); + $this->assertSame($expectedValue, $integer->toString()); + } + + /** + * @return array + */ + public static function bytesProvider(): array + { + return [ + // Zero + ['00', '0'], + + // Small positive numbers + ['01', '1'], + ['7f', '127'], + ['0080', '128'], + ['00ff', '255'], + ['0100', '256'], + + // Small negative numbers + ['ff', '-1'], + ['81', '-127'], + ['80', '-128'], + ['ff7f', '-129'], + ['ff00', '-256'], + + // Larger numbers + ['7fff', '32767'], + ['008000', '32768'], + ['8000', '-32768'], + ['ff7fff', '-32769'], + + // Multi-byte numbers + ['00ffff', '65535'], + ['010000', '65536'], + ['ff0000', '-65536'], + + // Large numbers + ['7fffffff', '2147483647'], + ['0080000000', '2147483648'], + ['80000000', '-2147483648'], + ['ff7fffffff', '-2147483649'], + + ['00ab54a98ceb1f0ad2', '12345678901234567890'], + ]; + } + + public function testFromBytesEmptyInput(): void + { + $integer = ArbitraryPrecisionInteger::fromBytes(''); + $this->assertSame('0', $integer->toString()); + } + + /** + * Test round-trip consistency: value -> toBytes -> fromBytes -> value (integers) + */ + public function testRoundTripConsistencyWithIntegers(): void + { + $testValues = [0, 1, -1, 127, -128, 255, -256, 32767, -32768, 65535, -65536, 2147483647, -2147483648]; + + foreach ($testValues as $value) { + $original = ArbitraryPrecisionInteger::fromInteger($value); + $bytes = $original->toBytes(); + $restored = ArbitraryPrecisionInteger::fromBytes($bytes); + + $this->assertSame($original->toString(), $restored->toString(), "Round-trip failed for value: {$value}"); + } + } + + /** + * Test round-trip consistency: value -> toBytes -> fromBytes -> value (strings) + */ + public function testRoundTripConsistencyWithStrings(): void + { + $testValues = ['12345678901234567890', '-12345678901234567890']; + + foreach ($testValues as $value) { + $original = ArbitraryPrecisionInteger::fromString($value); + $bytes = $original->toBytes(); + $restored = ArbitraryPrecisionInteger::fromBytes($bytes); + + $this->assertSame($original->toString(), $restored->toString(), "Round-trip failed for value: {$value}"); + } + } + + /** + * Test edge cases and boundary conditions + */ + public function testEdgeCases(): void + { + // Test very large numbers + $largePositive = '999999999999999999999999999999999999999999999999999999999999'; + $integer = ArbitraryPrecisionInteger::fromString($largePositive); + $this->assertSame($largePositive, $integer->toString()); + + $largeNegative = '-999999999999999999999999999999999999999999999999999999999999'; + $integer = ArbitraryPrecisionInteger::fromString($largeNegative); + $this->assertSame($largeNegative, $integer->toString()); + + // Test number with many digits that cross base boundaries + $manyDigits = '123456789012345678901234567890123456789012345678901234567890'; + $integer = ArbitraryPrecisionInteger::fromString($manyDigits); + $this->assertSame($manyDigits, $integer->toString()); + + // Test shift operations on large numbers + $shifted = $integer->shiftDecimalPosition(10); + $expected = $manyDigits . '0000000000'; + $this->assertSame($expected, $shifted->toString()); + } + + public function testImmutability(): void + { + $original = ArbitraryPrecisionInteger::fromInteger(42); + $shifted = $original->shiftDecimalPosition(2); + + // Original should be unchanged + $this->assertSame('42', $original->toString()); + $this->assertSame('4200', $shifted->toString()); + + // Should be different instances + $this->assertNotSame($original, $shifted); + } + + public function testLargeScales(): void + { + // Test very large positive scaling + $integer = ArbitraryPrecisionInteger::fromInteger(1); + $shifted = $integer->shiftDecimalPosition(100); + $expected = '1' . str_repeat('0', 100); + $this->assertSame($expected, $shifted->toString()); + + // Test very large negative scaling on large numbers + $large = '1' . str_repeat('0', 100); + $integer = ArbitraryPrecisionInteger::fromString($large); + + $shifted = $integer->shiftDecimalPosition(-99); + $this->assertSame('10', $shifted->toString()); + + $shifted = $integer->shiftDecimalPosition(-100); + $this->assertSame('1', $shifted->toString()); + + $shifted = $integer->shiftDecimalPosition(-101); + $this->assertSame('0', $shifted->toString()); + } + + #[DataProvider('isNegativeIntegerProvider')] + public function testIsNegativeWithInteger(int $input, bool $expected): void + { + $integer = ArbitraryPrecisionInteger::fromInteger($input); + $this->assertSame($expected, $integer->isNegative()); + } + + #[DataProvider('isNegativeStringProvider')] + public function testIsNegativeWithString(string $input, bool $expected): void + { + $integer = ArbitraryPrecisionInteger::fromString($input); + $this->assertSame($expected, $integer->isNegative()); + } + + /** + * @return array + */ + public static function isNegativeIntegerProvider(): array + { + return [ + // Zero cases + [0, false], + + // Positive cases + [1, false], + [42, false], + [PHP_INT_MAX, false], + + // Negative cases + [-1, true], + [-42, true], + [PHP_INT_MIN, true], + ]; + } + + /** + * @return array + */ + public static function isNegativeStringProvider(): array + { + return [ + // Zero cases + ['0', false], + ['000', false], + ['-0', false], // -0 should be normalized to 0 (not negative) + + // Positive cases + ['12345678901234567890', false], + ['999999999999999999999999999999', false], + + // Negative cases + ['-12345678901234567890', true], + ['-999999999999999999999999999999', true], + ]; + } + + /** + * @param array $expectedDigits + */ + #[DataProvider('toAbsoluteDecimalDigitsIntegerProvider')] + public function testToAbsoluteDecimalDigitsWithInteger(int $input, array $expectedDigits): void + { + $integer = ArbitraryPrecisionInteger::fromInteger($input); + $digits = $integer->toAbsoluteDecimalDigits(); + $this->assertSame($expectedDigits, $digits); + } + + /** + * @param array $expectedDigits + */ + #[DataProvider('toAbsoluteDecimalDigitsStringProvider')] + public function testToAbsoluteDecimalDigitsWithString(string $input, array $expectedDigits): void + { + $integer = ArbitraryPrecisionInteger::fromString($input); + $digits = $integer->toAbsoluteDecimalDigits(); + $this->assertSame($expectedDigits, $digits); + } + + /** + * @return array}> + */ + public static function toAbsoluteDecimalDigitsIntegerProvider(): array + { + return [ + // Zero case + [0, [0]], + + // Single digit cases + [1, [1]], + [5, [5]], + [9, [9]], + [-1, [1]], // Absolute value + [-5, [5]], + [-9, [9]], + + // Multi-digit cases + [42, [4, 2]], + [123, [1, 2, 3]], + [987, [9, 8, 7]], + [-42, [4, 2]], // Absolute value + [-123, [1, 2, 3]], + [-987, [9, 8, 7]], + + // Larger numbers + [12345, [1, 2, 3, 4, 5]], + [98765, [9, 8, 7, 6, 5]], + [-12345, [1, 2, 3, 4, 5]], // Absolute value + [-98765, [9, 8, 7, 6, 5]], + ]; + } + + /** + * @return array}> + */ + public static function toAbsoluteDecimalDigitsStringProvider(): array + { + return [ + // Zero case + ['0', [0]], + + // Very large numbers + ['12345678901234567890', [1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0]], + ['-12345678901234567890', [1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0]], + ]; + } + + public function testEmptyBytesHandling(): void + { + // Test that empty bytes are handled correctly in edge cases + $zero1 = ArbitraryPrecisionInteger::fromBytes(''); + $zero2 = ArbitraryPrecisionInteger::fromInteger(0); + + $this->assertSame($zero2->toString(), $zero1->toString()); + $this->assertFalse($zero1->isNegative()); + $this->assertSame([0], $zero1->toAbsoluteDecimalDigits()); + + // Scaling zero with empty bytes should return same instance + $scaled = $zero1->shiftDecimalPosition(5); + $this->assertSame($zero1, $scaled); + } + + #[DataProvider('toIntegerValidIntegerProvider')] + public function testToIntegerValidWithInteger(int $input, int $expected): void + { + $integer = ArbitraryPrecisionInteger::fromInteger($input); + $result = $integer->toInteger(); + $this->assertSame($expected, $result); + } + + #[DataProvider('toIntegerValidStringProvider')] + public function testToIntegerValidWithString(string $input, int $expected): void + { + $integer = ArbitraryPrecisionInteger::fromString($input); + $result = $integer->toInteger(); + $this->assertSame($expected, $result); + } + + /** + * @return array + */ + public static function toIntegerValidIntegerProvider(): array + { + return [ + // Zero + [0, 0], + + // Small positive numbers + [1, 1], + [42, 42], + [127, 127], + [128, 128], + [255, 255], + [256, 256], + + // Small negative numbers + [-1, -1], + [-42, -42], + [-127, -127], + [-128, -128], + [-129, -129], + [-256, -256], + + // Larger numbers (within 8 bytes) + [32767, 32767], + [32768, 32768], + [-32768, -32768], + [-32769, -32769], + [65535, 65535], + [65536, 65536], + [-65536, -65536], + [2147483647, 2147483647], + [2147483648, 2147483648], + [-2147483648, -2147483648], + [-2147483649, -2147483649], + + // PHP_INT boundaries + [PHP_INT_MAX, PHP_INT_MAX], + [PHP_INT_MIN, PHP_INT_MIN], + ]; + } + + /** + * @return array + */ + public static function toIntegerValidStringProvider(): array + { + return [ + // Zero + ['0', 0], + ]; + } + + public function testToIntegerExceedsMaxBytes(): void + { + // Create a number that requires more than 8 bytes + $largeNumber = '12345678901234567890123456789'; // Much larger than max 64-bit int + $integer = ArbitraryPrecisionInteger::fromString($largeNumber); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot convert to integer: number of bytes exceeds 8'); + + $integer->toInteger(); + } + + public function testToIntegerRoundTrip(): void + { + // Test that fromInteger -> toInteger is consistent + $testValues = [ + 0, 1, -1, 42, -42, 127, -128, 255, -256, 32767, -32768, 65535, -65536, + 2147483647, -2147483648, PHP_INT_MAX, PHP_INT_MIN, + ]; + + foreach ($testValues as $value) { + $integer = ArbitraryPrecisionInteger::fromInteger($value); + $result = $integer->toInteger(); + + $this->assertSame($value, $result, "Round-trip failed for value: {$value}"); + } + } + + public function testToIntegerEdgeCases(): void + { + // Test edge case with exactly 8 bytes + $maxInt64 = '9223372036854775807'; // 2^63 - 1 (max signed 64-bit) + $integer = ArbitraryPrecisionInteger::fromString($maxInt64); + $result = $integer->toInteger(); + $this->assertSame(PHP_INT_MAX, $result); + + $minInt64 = '-9223372036854775808'; // -2^63 (min signed 64-bit) + $integer = ArbitraryPrecisionInteger::fromString($minInt64); + $result = $integer->toInteger(); + $this->assertSame(PHP_INT_MIN, $result); + + // Test number slightly larger than max 64-bit (should throw) + $tooLargePositive = '9223372036854775808'; // 2^63 + $integer = ArbitraryPrecisionInteger::fromString($tooLargePositive); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot convert to integer: number of bytes exceeds 8'); + $integer->toInteger(); + } +} diff --git a/tests/Unit/ValueObject/DecimalTest.php b/tests/Unit/ValueObject/DecimalTest.php new file mode 100644 index 0000000..35400b4 --- /dev/null +++ b/tests/Unit/ValueObject/DecimalTest.php @@ -0,0 +1,556 @@ +getUnscaledValue()->toInteger()); + self::assertSame(2, $decimal->getScale()); + } + + /** + * @throws InvalidArgumentException + */ + public function testFromUnscaledValueWithZeroScale(): void + { + $decimal = Decimal::fromUnscaledValue(123, 0); + + self::assertSame(123, $decimal->getUnscaledValue()->toInteger()); + self::assertSame(0, $decimal->getScale()); + } + + public function testFromUnscaledValueWithNegativeScaleThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Scale must be non-negative'); + + Decimal::fromUnscaledValue(123, -1); + } + + /** + * @throws InvalidArgumentException + */ + #[DataProvider('fromDecimalRepresentationValidProvider')] + public function testFromDecimalRepresentationWithValidValues(string $input, string $expectedUnscaled, int $expectedScale): void + { + $decimal = Decimal::fromString($input); + + self::assertSame($expectedUnscaled, $decimal->getUnscaledValue()->toString()); + self::assertSame($expectedScale, $decimal->getScale()); + } + + /** + * @return array + */ + public static function fromDecimalRepresentationValidProvider(): array + { + return [ + // Integer values + ['0', '0', 0], + ['1', '1', 0], + ['-1', '-1', 0], + ['123', '123', 0], + ['-123', '-123', 0], + + // Decimal values + ['1.5', '15', 1], + ['-1.5', '-15', 1], + ['123.45', '12345', 2], + ['-123.45', '-12345', 2], + ['0.5', '5', 1], + ['-0.5', '-5', 1], + ['0.123', '123', 3], + ['-0.123', '-123', 3], + + // Edge cases + ['0.0', '0', 0], + ['0.00', '0', 0], + ['1.0', '1', 0], + ['10.0', '10', 0], + ['123', '123', 0], + + // Large numbers + ['123456789.987654321', '123456789987654321', 9], + ['-123456789.987654321', '-123456789987654321', 9], + ]; + } + + #[DataProvider('fromDecimalRepresentationInvalidProvider')] + public function testFromDecimalRepresentationWithInvalidValues(string $input): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid decimal format'); + + Decimal::fromString($input); + } + + /** + * @return array + */ + public static function fromDecimalRepresentationInvalidProvider(): array + { + return [[''], ['abc'], ['1.2.3'], ['1,23'], ['1e5'], ['1.23e2'], ['+123'], ['--123'], ['123-'], ['123.'], ['.123'], ['1..2']]; + } + + #[DataProvider('fromIntegerProvider')] + public function testFromInteger(int $input, string $expectedUnscaled, int $expectedScale): void + { + $decimal = Decimal::fromInteger($input); + + self::assertSame($expectedUnscaled, $decimal->getUnscaledValue()->toString()); + self::assertSame($expectedScale, $decimal->getScale()); + } + + /** + * @return array + */ + public static function fromIntegerProvider(): array + { + return [ + [0, '0', 0], + [1, '1', 0], + [-1, '-1', 0], + [123, '123', 0], + [-123, '-123', 0], + [PHP_INT_MAX, (string) PHP_INT_MAX, 0], + [PHP_INT_MIN, (string) PHP_INT_MIN, 0], + ]; + } + + /** + * @return array + */ + public static function fromFloatValidProvider(): array + { + return [[0.0, 2], [1.0, 2], [-1.0, 2], [1.5, 1], [-1.5, 1], [123.45, 2], [-123.45, 2], [0.123, 3], [3.14159, 5]]; + } + + #[DataProvider('fromFloatInvalidProvider')] + public function testFromFloatWithInvalidValues(float $input, int $decimals): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Float value must be finite'); + + Decimal::fromFloat($input, $decimals); + } + + /** + * @return array + */ + public static function fromFloatInvalidProvider(): array + { + return [[INF, 2], [-INF, 2], [NAN, 2]]; + } + + #[DataProvider('fromCentsProvider')] + public function testFromCents(int $cents, string $expectedUnscaled, int $expectedScale, string $expectedString): void + { + $decimal = Decimal::fromCents($cents); + + self::assertSame($expectedUnscaled, $decimal->getUnscaledValue()->toString()); + self::assertSame($expectedScale, $decimal->getScale()); + self::assertSame($expectedString, $decimal->toString()); + } + + /** + * @return array + */ + public static function fromCentsProvider(): array + { + return [ + [0, '0', 2, '0'], + [1, '1', 2, '0.01'], + [10, '10', 2, '0.1'], + [99, '99', 2, '0.99'], + [100, '100', 2, '1'], + [123, '123', 2, '1.23'], + [1000, '1000', 2, '10'], + [12345, '12345', 2, '123.45'], + [-1, '-1', 2, '-0.01'], + [-10, '-10', 2, '-0.1'], + [-99, '-99', 2, '-0.99'], + [-100, '-100', 2, '-1'], + [-123, '-123', 2, '-1.23'], + [-1000, '-1000', 2, '-10'], + [-12345, '-12345', 2, '-123.45'], + [PHP_INT_MAX, (string) PHP_INT_MAX, 2, '92233720368547758.07'], + [PHP_INT_MIN, (string) PHP_INT_MIN, 2, '-92233720368547758.08'], + ]; + } + + /** + * @throws InvalidArgumentException + */ + #[DataProvider('toStringProvider')] + public function testToString(string $decimalInput, string $expectedOutput): void + { + $decimal = Decimal::fromString($decimalInput); + + self::assertSame($expectedOutput, $decimal->toString()); + self::assertSame($expectedOutput, (string) $decimal); + } + + /** + * @return array + */ + public static function toStringProvider(): array + { + return [ + // Integer values + ['0', '0'], + ['1', '1'], + ['-1', '-1'], + ['123', '123'], + ['-123', '-123'], + + // Decimal values + ['1.5', '1.5'], + ['-1.5', '-1.5'], + ['123.45', '123.45'], + ['-123.45', '-123.45'], + ['0.5', '0.5'], // Leading zero should be preserved + ['-0.5', '-0.5'], // Leading zero should be preserved + ['0.123', '0.123'], // Leading zero should be preserved + + // Values with trailing zeros + ['1.0', '1'], + ['1.00', '1'], + ['1.50', '1.5'], + ['123.000', '123'], + ['0.0', '0'], + ['0.00', '0'], + ]; + } + + /** + * @throws InvalidArgumentException + */ + public function testToBytesAndFromBytes(): void + { + $original = Decimal::fromString('123.45'); + $bytes = $original->toBytes(); + $restored = Decimal::fromBytes($bytes, 2); + + self::assertSame($original->getUnscaledValue()->toString(), $restored->getUnscaledValue()->toString()); + self::assertSame($original->getScale(), $restored->getScale()); + self::assertSame($original->toString(), $restored->toString()); + } + + #[DataProvider('bytesRoundTripProvider')] + public function testBytesRoundTrip(string $decimalValue): void + { + $original = Decimal::fromString($decimalValue); + $bytes = $original->toBytes(); + $restored = Decimal::fromBytes($bytes, $original->getScale()); + + self::assertSame($original->toString(), $restored->toString()); + } + + /** + * @return array + */ + public static function bytesRoundTripProvider(): array + { + return [ + ['0'], + ['1'], + ['-1'], + ['123'], + ['-123'], + ['1.5'], + ['-1.5'], + ['123.45'], + ['-123.45'], + ['0.123'], + ['0.00000000000000000000000000000000001'], + ['-0.00000000000000000000000000000000001'], + ]; + } + + /** + * @throws InvalidArgumentException + */ + #[DataProvider('withScaleProvider')] + public function testWithScale(string $originalValue, int $newScale, string $expectedValue): void + { + $decimal = Decimal::fromString($originalValue); + $scaledDecimal = $decimal->withScale($newScale); + + self::assertSame($expectedValue, $scaledDecimal->toString()); + self::assertSame($newScale, $scaledDecimal->getScale()); + + // Original should be unchanged + self::assertSame($originalValue, $decimal->toString()); + } + + /** + * @return array + */ + public static function withScaleProvider(): array + { + return [ + // Scaling up (increasing scale multiplies unscaled value) + ['123', 2, '123'], // 123 * 10^2 / 10^2 = 123 (no visible change, but internal representation changes) + ['123.4', 3, '123.4'], // 1234 * 10^1 / 10^3 = 123.4 (no visible change) + ['0', 2, '0'], // 0 * 10^2 / 10^2 = 0 + ['-123', 1, '-123'], // -123 * 10^1 / 10^1 = -123 + + // Scaling down (decreasing scale divides unscaled value with HALF_UP rounding) + ['123.45', 1, '123.5'], // 12345 / 10^1 / 10^1 = 123.5 (HALF_UP rounding: 0.5 rounds up) + ['123.45', 0, '123'], // 12345 / 10^2 / 10^0 = 123 (HALF_UP rounding: 0.45 rounds down) + ['1.234', 2, '1.23'], // 1234 / 10^1 / 10^2 = 1.23 (HALF_UP rounding: 0.4 rounds down) + + // Same scale (should return same instance) + ['123.45', 2, '123.45'], + ]; + } + + public function testWithScaleSameScaleReturnsSameInstance(): void + { + $decimal = Decimal::fromString('123.45'); + $scaledDecimal = $decimal->withScale(2); + + self::assertSame($decimal, $scaledDecimal); + } + + public function testWithScaleNegativeScaleThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Scale must be non-negative'); + + $decimal = Decimal::fromString('123.45'); + $decimal->withScale(-1); + } + + public function testGettersReturnCorrectValues(): void + { + $decimal = Decimal::fromUnscaledValue(12345, 3); + + self::assertSame(12345, $decimal->getUnscaledValue()->toInteger()); + self::assertSame(3, $decimal->getScale()); + } + + #[DataProvider('largeNumberProvider')] + public function testLargeNumbers(string $input, int $expectedScale, string $expectedOutput): void + { + $decimal = Decimal::fromString($input); + + self::assertSame($expectedScale, $decimal->getScale()); + self::assertSame($expectedOutput, $decimal->toString()); + } + + /** + * @return array + */ + public static function largeNumberProvider(): array + { + return [ + ['123456789012345678901234567890', 0, '123456789012345678901234567890'], + ['123456789012345678901234567890.123456789', 9, '123456789012345678901234567890.123456789'], + ['-123456789012345678901234567890.123456789', 9, '-123456789012345678901234567890.123456789'], + ['1.000000000000000000000001', 24, '1.000000000000000000000001'], + ['-1.000000000000000000000001', 24, '-1.000000000000000000000001'], + ]; + } + + public function testReadonlyClass(): void + { + $decimal = Decimal::fromString('123.45'); + $reflection = new ReflectionClass($decimal); + + self::assertTrue($reflection->isReadOnly()); + } + + public function testFromFloatWithUnusedDecimalsParameter(): void + { + // The decimals parameter in fromFloat is not actually used in the current implementation + // The method uses number_format with 17 decimals regardless of the input + $decimal1 = Decimal::fromFloat(1.5, 1); + $decimal2 = Decimal::fromFloat(1.5, 5); + + // Both should produce the same result since the decimals parameter is ignored + self::assertSame($decimal1->toString(), $decimal2->toString()); + self::assertSame($decimal1->getScale(), $decimal2->getScale()); + } + + #[DataProvider('fromFloatPrecisionProvider')] + public function testFromFloatPrecision(float $input, string $expectedOutput, int $decimals): void + { + $decimal = Decimal::fromFloat($input, $decimals); + self::assertSame($expectedOutput, $decimal->toString()); + } + + /** + * @return array + */ + public static function fromFloatPrecisionProvider(): array + { + return [ + [0.0, '0', 0], + [1.0, '1', 0], + [1.5, '1.5', 1], + [1.25, '1.25', 2], + [0.1, '0.1', 1], + [0.125, '0.125', 3], + [-1.5, '-1.5', 1], + [-0.125, '-0.125', 3], + [3.141592653589793, '3.141592653589793', 15], + [3.141592653589793, '3.1416', 4], + ]; + } + + public function testFromBytesWithZeroScale(): void + { + $original = Decimal::fromString('123'); + $bytes = $original->toBytes(); + $restored = Decimal::fromBytes($bytes, 0); + + self::assertSame('123', $restored->toString()); + self::assertSame(0, $restored->getScale()); + } + + public function testFromBytesWithLargeScale(): void + { + $original = Decimal::fromString('1'); + $bytes = $original->toBytes(); + $restored = Decimal::fromBytes($bytes, 10); + + self::assertSame('0.0000000001', $restored->toString()); + self::assertSame(10, $restored->getScale()); + } + + public function testFromDecimalRepresentationWithLeadingZeros(): void + { + $decimal = Decimal::fromString('000123.45000'); + + self::assertSame('12345', $decimal->getUnscaledValue()->toString()); + self::assertSame(2, $decimal->getScale()); + self::assertSame('123.45', $decimal->toString()); + } + + public function testWithScalePreservesImmutability(): void + { + $original = Decimal::fromString('123.45'); + $scaled = $original->withScale(3); + + // Original should be unchanged + self::assertSame('123.45', $original->toString()); + self::assertSame(2, $original->getScale()); + + // New instance should have new scale + self::assertSame('123.45', $scaled->toString()); + self::assertSame(3, $scaled->getScale()); + + // Should be different instances + self::assertNotSame($original, $scaled); + } + + public function testToStringWithVeryLargeNumbers(): void + { + $largeNumber = '999999999999999999999999999999999999999999999999.12345678901234567890123456789'; + $decimal = Decimal::fromString($largeNumber); + + $result = $decimal->toString(); + self::assertSame($largeNumber, $result); + } + + public function testToStringWithZeroIntegerPart(): void + { + // Test various cases where integer part is zero + $testCases = [ + '0' => '0', + '0.0' => '0', + '0.1' => '0.1', + '0.01' => '0.01', + '0.001' => '0.001', + '0.1000' => '0.1', // trailing zeros removed + ]; + + foreach ($testCases as $input => $expected) { + $decimal = Decimal::fromString((string) $input); + self::assertSame($expected, $decimal->toString(), "Failed for input: {$input}"); + } + } + + public function testEdgeCaseWithMaximumScale(): void + { + // Test with very large scale value + $decimal = Decimal::fromUnscaledValue(1, 100); + + $result = $decimal->toString(); + self::assertTrue(str_starts_with($result, '0.')); + self::assertSame(100, $decimal->getScale()); + } + + public function testWithScaleExtremeValues(): void + { + $decimal = Decimal::fromString('123.456'); + + // Scale up significantly + $scaledUp = $decimal->withScale(20); + self::assertSame(20, $scaledUp->getScale()); + self::assertSame('123.456', $scaledUp->toString()); + self::assertSame('12345600000000000000000', $scaledUp->getUnscaledValue()->toString()); + + // Scale down to zero + $scaledDown = $decimal->withScale(0); + self::assertSame(0, $scaledDown->getScale()); + self::assertSame('123', $scaledDown->toString()); // Should truncate + self::assertSame('123', $scaledDown->getUnscaledValue()->toString()); + + // Scale with rounding + $scaledDown = $decimal->withScale(2); + self::assertSame(2, $scaledDown->getScale()); + self::assertSame('123.46', $scaledDown->toString()); // Should round + self::assertSame('12346', $scaledDown->getUnscaledValue()->toString()); + } + + public function testFromDecimalRepresentationZeroHandling(): void + { + // Test various representations of zero + $zeroInputs = ['0', '0.0', '0.00', '0.000']; + + foreach ($zeroInputs as $input) { + $decimal = Decimal::fromString($input); + self::assertSame('0', $decimal->getUnscaledValue()->toString(), "Failed for zero input: {$input}"); + self::assertSame(0, $decimal->getScale(), "Scale failed for zero input: {$input}"); + } + } + + public function testNegativeZeroHandling(): void + { + // Test that -0 is normalized to 0 + $decimal = Decimal::fromString('-0'); + self::assertSame('0', $decimal->toString()); + self::assertSame('0', $decimal->getUnscaledValue()->toString()); + self::assertFalse($decimal->getUnscaledValue()->isNegative()); + } + + public function testBytesRoundTripWithNegativeNumbers(): void + { + $testValues = ['-1', '-123', '-123.45', '-0.123']; + + foreach ($testValues as $value) { + $original = Decimal::fromString($value); + $bytes = $original->toBytes(); + $restored = Decimal::fromBytes($bytes, $original->getScale()); + + self::assertSame($original->toString(), $restored->toString(), "Round-trip failed for: {$value}"); + } + } +} diff --git a/tests/Unit/ValueObject/DurationTest.php b/tests/Unit/ValueObject/DurationTest.php new file mode 100644 index 0000000..0b5d6bd --- /dev/null +++ b/tests/Unit/ValueObject/DurationTest.php @@ -0,0 +1,61 @@ +assertSame(12, $duration->months); + $this->assertSame(25, $duration->days); + $this->assertSame(5000, $duration->milliseconds); + } + + public function testConstructorWithZeroComponents(): void + { + $duration = new Duration(0, 0, 0); + + $this->assertSame(0, $duration->months); + $this->assertSame(0, $duration->days); + $this->assertSame(0, $duration->milliseconds); + } + + #[DataProvider('invalidComponentsProvider')] + public function testConstructorWithInvalidComponents(int $months, int $days, int $milliseconds, string $expectedMessage): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + + new Duration($months, $days, $milliseconds); + } + + /** + * @return array + */ + public static function invalidComponentsProvider(): array + { + return [ + 'negative months' => [-1, 0, 0, 'Months must be non-negative'], + 'negative days' => [0, -1, 0, 'Days must be non-negative'], + 'negative milliseconds' => [0, 0, -1, 'Milliseconds must be non-negative'], + ]; + } + + public function testConstructorWithSpecificComponents(): void + { + $duration = new Duration(3, 10, 1500); + + $this->assertSame(3, $duration->months); + $this->assertSame(10, $duration->days); + $this->assertSame(1500, $duration->milliseconds); + } +} diff --git a/tests/Unit/ValueObject/TimeOfDayTest.php b/tests/Unit/ValueObject/TimeOfDayTest.php new file mode 100644 index 0000000..820b7c3 --- /dev/null +++ b/tests/Unit/ValueObject/TimeOfDayTest.php @@ -0,0 +1,389 @@ +assertSame(43200000000, $timeOfDay->totalMicroseconds); + $this->assertSame(12, $timeOfDay->getHours()); + $this->assertSame(0, $timeOfDay->getMinutes()); + $this->assertSame(0, $timeOfDay->getSeconds()); + $this->assertSame(0, $timeOfDay->getMilliseconds()); + $this->assertSame(0, $timeOfDay->getMicroseconds()); + } + + public function testConstructorWithMidnight(): void + { + $timeOfDay = new TimeOfDay(0); + + $this->assertSame(0, $timeOfDay->totalMicroseconds); + $this->assertSame(0, $timeOfDay->getHours()); + $this->assertSame(0, $timeOfDay->getMinutes()); + $this->assertSame(0, $timeOfDay->getSeconds()); + $this->assertSame(0, $timeOfDay->getMilliseconds()); + $this->assertSame(0, $timeOfDay->getMicroseconds()); + } + + public function testConstructorWithMaxValidTime(): void + { + $timeOfDay = new TimeOfDay(86399999999); // 23:59:59.999999 + + $this->assertSame(86399999999, $timeOfDay->totalMicroseconds); + $this->assertSame(23, $timeOfDay->getHours()); + $this->assertSame(59, $timeOfDay->getMinutes()); + $this->assertSame(59, $timeOfDay->getSeconds()); + $this->assertSame(999, $timeOfDay->getMilliseconds()); + $this->assertSame(999999, $timeOfDay->getMicroseconds()); + } + + public function testConstructorWithNegativeMicrosecondsThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Total microseconds must be between 0 and 86399999999 (midnight to 23:59:59.999999)'); + + new TimeOfDay(-1); + } + + public function testConstructorWithTooLargeMicrosecondsThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Total microseconds must be between 0 and 86399999999 (midnight to 23:59:59.999999)'); + + new TimeOfDay(86400000000); // Exactly midnight of next day + } + + public function testFromComponentsWithValidValues(): void + { + $timeOfDay = TimeOfDay::fromComponents(14, 30, 45, 123, 456); + + $this->assertSame(14, $timeOfDay->getHours()); + $this->assertSame(30, $timeOfDay->getMinutes()); + $this->assertSame(45, $timeOfDay->getSeconds()); + $this->assertSame(123, $timeOfDay->getMilliseconds()); + $this->assertSame(123456, $timeOfDay->getMicroseconds()); + } + + public function testFromComponentsWithOnlyHours(): void + { + $timeOfDay = TimeOfDay::fromComponents(9); + + $this->assertSame(9, $timeOfDay->getHours()); + $this->assertSame(0, $timeOfDay->getMinutes()); + $this->assertSame(0, $timeOfDay->getSeconds()); + $this->assertSame(0, $timeOfDay->getMilliseconds()); + $this->assertSame(0, $timeOfDay->getMicroseconds()); + } + + public function testFromComponentsWithMidnight(): void + { + $timeOfDay = TimeOfDay::fromComponents(0, 0, 0, 0, 0); + + $this->assertSame(0, $timeOfDay->getHours()); + $this->assertSame(0, $timeOfDay->getMinutes()); + $this->assertSame(0, $timeOfDay->getSeconds()); + $this->assertSame(0, $timeOfDay->getMilliseconds()); + $this->assertSame(0, $timeOfDay->getMicroseconds()); + $this->assertSame(0, $timeOfDay->totalMicroseconds); + } + + public function testFromComponentsWithMaxValidTime(): void + { + $timeOfDay = TimeOfDay::fromComponents(23, 59, 59, 999, 999); + + $this->assertSame(23, $timeOfDay->getHours()); + $this->assertSame(59, $timeOfDay->getMinutes()); + $this->assertSame(59, $timeOfDay->getSeconds()); + $this->assertSame(999, $timeOfDay->getMilliseconds()); + $this->assertSame(999999, $timeOfDay->getMicroseconds()); + $this->assertSame(86399999999, $timeOfDay->totalMicroseconds); + } + + #[DataProvider('invalidHoursProvider')] + public function testFromComponentsWithInvalidHoursThrowsException(int $hours): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Hours must be between 0 and 23'); + + TimeOfDay::fromComponents($hours); + } + + /** + * @return array + */ + public static function invalidHoursProvider(): array + { + return [[-1], [24], [25], [-10]]; + } + + #[DataProvider('invalidMinutesProvider')] + public function testFromComponentsWithInvalidMinutesThrowsException(int $minutes): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Minutes must be between 0 and 59'); + + TimeOfDay::fromComponents(12, $minutes); + } + + /** + * @return array + */ + public static function invalidMinutesProvider(): array + { + return [[-1], [60], [61], [-10]]; + } + + #[DataProvider('invalidSecondsProvider')] + public function testFromComponentsWithInvalidSecondsThrowsException(int $seconds): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Seconds must be between 0 and 59'); + + TimeOfDay::fromComponents(12, 30, $seconds); + } + + /** + * @return array + */ + public static function invalidSecondsProvider(): array + { + return [[-1], [60], [61], [-10]]; + } + + #[DataProvider('invalidMillisecondsProvider')] + public function testFromComponentsWithInvalidMillisecondsThrowsException(int $milliseconds): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Milliseconds must be between 0 and 999'); + + TimeOfDay::fromComponents(12, 30, 45, $milliseconds); + } + + /** + * @return array + */ + public static function invalidMillisecondsProvider(): array + { + return [[-1], [1000], [1001], [-10]]; + } + + #[DataProvider('invalidMicrosecondsProvider')] + public function testFromComponentsWithInvalidMicrosecondsThrowsException(int $microseconds): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Microseconds must be between 0 and 999'); + + TimeOfDay::fromComponents(12, 30, 45, 123, $microseconds); + } + + /** + * @return array + */ + public static function invalidMicrosecondsProvider(): array + { + return [[-1], [1000], [1001], [-10]]; + } + + public function testFromDateTimeWithDateTime(): void + { + $dateTime = DateTime::createFromFormat('H:i:s.u', '14:30:45.123456'); + $this->assertInstanceOf(DateTime::class, $dateTime); + + $timeOfDay = TimeOfDay::fromDateTime($dateTime); + + $this->assertSame(14, $timeOfDay->getHours()); + $this->assertSame(30, $timeOfDay->getMinutes()); + $this->assertSame(45, $timeOfDay->getSeconds()); + $this->assertSame(123, $timeOfDay->getMilliseconds()); + $this->assertSame(123456, $timeOfDay->getMicroseconds()); + } + + public function testFromDateTimeWithDateTimeImmutable(): void + { + $dateTime = DateTimeImmutable::createFromFormat('H:i:s.u', '09:15:30.500250'); + $this->assertInstanceOf(DateTimeImmutable::class, $dateTime); + + $timeOfDay = TimeOfDay::fromDateTime($dateTime); + + $this->assertSame(9, $timeOfDay->getHours()); + $this->assertSame(15, $timeOfDay->getMinutes()); + $this->assertSame(30, $timeOfDay->getSeconds()); + $this->assertSame(500, $timeOfDay->getMilliseconds()); + $this->assertSame(500250, $timeOfDay->getMicroseconds()); + } + + public function testFromDateTimeWithMidnight(): void + { + $dateTime = new DateTime('00:00:00.000000'); + $timeOfDay = TimeOfDay::fromDateTime($dateTime); + + $this->assertSame(0, $timeOfDay->getHours()); + $this->assertSame(0, $timeOfDay->getMinutes()); + $this->assertSame(0, $timeOfDay->getSeconds()); + $this->assertSame(0, $timeOfDay->getMilliseconds()); + $this->assertSame(0, $timeOfDay->getMicroseconds()); + } + + public function testFromDateTimeWithAlmostMidnight(): void + { + $dateTime = DateTime::createFromFormat('H:i:s.u', '23:59:59.999999'); + $this->assertInstanceOf(DateTime::class, $dateTime); + + $timeOfDay = TimeOfDay::fromDateTime($dateTime); + + $this->assertSame(23, $timeOfDay->getHours()); + $this->assertSame(59, $timeOfDay->getMinutes()); + $this->assertSame(59, $timeOfDay->getSeconds()); + $this->assertSame(999, $timeOfDay->getMilliseconds()); + $this->assertSame(999999, $timeOfDay->getMicroseconds()); + } + + #[DataProvider('getterMethodsProvider')] + public function testGetterMethods( + int $totalMicroseconds, + int $expectedHours, + int $expectedMinutes, + int $expectedSeconds, + int $expectedMilliseconds, + int $expectedMicroseconds, + ): void { + $timeOfDay = new TimeOfDay($totalMicroseconds); + + $this->assertSame($expectedHours, $timeOfDay->getHours()); + $this->assertSame($expectedMinutes, $timeOfDay->getMinutes()); + $this->assertSame($expectedSeconds, $timeOfDay->getSeconds()); + $this->assertSame($expectedMilliseconds, $timeOfDay->getMilliseconds()); + $this->assertSame($expectedMicroseconds, $timeOfDay->getMicroseconds()); + } + + /** + * @return array + */ + public static function getterMethodsProvider(): array + { + return [ + 'midnight' => [0, 0, 0, 0, 0, 0], + 'noon' => [43200000000, 12, 0, 0, 0, 0], + 'afternoon with microseconds' => [52245123456, 14, 30, 45, 123, 123456], // 14:30:45.123456 + 'evening' => [72000000000, 20, 0, 0, 0, 0], // 20:00:00.000000 + 'almost midnight' => [86399999999, 23, 59, 59, 999, 999999], // 23:59:59.999999 + 'early morning' => [3723500750, 1, 2, 3, 500, 500750], // 01:02:03.500750 + ]; + } + + public function testGetTotalMilliseconds(): void + { + $timeOfDay = TimeOfDay::fromComponents(14, 30, 45, 123, 456); + $expectedMilliseconds = (14 * 3600 + 30 * 60 + 45) * 1000 + 123; + + $this->assertSame($expectedMilliseconds, $timeOfDay->getTotalMilliseconds()); + } + + public function testGetTotalMillisecondsWithMidnight(): void + { + $timeOfDay = new TimeOfDay(0); + + $this->assertSame(0, $timeOfDay->getTotalMilliseconds()); + } + + public function testGetTotalMillisecondsWithMaxValue(): void + { + $timeOfDay = new TimeOfDay(86399999999); + $expectedMilliseconds = 86399999; // Last millisecond of the day + + $this->assertSame($expectedMilliseconds, $timeOfDay->getTotalMilliseconds()); + } + + #[DataProvider('toStringProvider')] + public function testToString(int $totalMicroseconds, string $expected): void + { + $timeOfDay = new TimeOfDay($totalMicroseconds); + + $this->assertSame($expected, $timeOfDay->__toString()); + $this->assertSame($expected, (string) $timeOfDay); + } + + /** + * @return array + */ + public static function toStringProvider(): array + { + return [ + 'midnight' => [0, '00:00:00.000000'], + 'noon' => [43200000000, '12:00:00.000000'], + 'afternoon with microseconds' => [52245123456, '14:30:45.123456'], + 'early morning' => [3723500750, '01:02:03.500750'], + 'almost midnight' => [86399999999, '23:59:59.999999'], + 'single digits' => [32523001002, '09:02:03.001002'], + ]; + } + + public function testRoundTripFromComponentsToGetters(): void + { + $hours = 15; + $minutes = 42; + $seconds = 33; + $milliseconds = 789; + $microseconds = 123; + + $timeOfDay = TimeOfDay::fromComponents($hours, $minutes, $seconds, $milliseconds, $microseconds); + + $this->assertSame($hours, $timeOfDay->getHours()); + $this->assertSame($minutes, $timeOfDay->getMinutes()); + $this->assertSame($seconds, $timeOfDay->getSeconds()); + $this->assertSame($milliseconds, $timeOfDay->getMilliseconds()); + $this->assertSame($milliseconds * 1000 + $microseconds, $timeOfDay->getMicroseconds()); + } + + public function testRoundTripFromDateTimeToGetters(): void + { + $originalDateTime = DateTime::createFromFormat('H:i:s.u', '17:25:42.654321'); + $this->assertInstanceOf(DateTime::class, $originalDateTime); + + $timeOfDay = TimeOfDay::fromDateTime($originalDateTime); + + $this->assertSame(17, $timeOfDay->getHours()); + $this->assertSame(25, $timeOfDay->getMinutes()); + $this->assertSame(42, $timeOfDay->getSeconds()); + $this->assertSame(654, $timeOfDay->getMilliseconds()); + $this->assertSame(654321, $timeOfDay->getMicroseconds()); + } + + public function testTotalMicrosecondsCalculation(): void + { + $hours = 2; + $minutes = 30; + $seconds = 45; + $milliseconds = 678; + $microseconds = 910; + + $timeOfDay = TimeOfDay::fromComponents($hours, $minutes, $seconds, $milliseconds, $microseconds); + + $expectedTotal = ($hours * 3600 + $minutes * 60 + $seconds) * 1000000 + $milliseconds * 1000 + $microseconds; + $this->assertSame($expectedTotal, $timeOfDay->totalMicroseconds); + } + + public function testGetMicrosecondsVsGetMicrosecondComponent(): void + { + // Demonstrate the difference between the two methods + $timeOfDay = TimeOfDay::fromComponents(10, 20, 30, 456, 789); + + // getMicroseconds() returns total microseconds within the second + $this->assertSame(456789, $timeOfDay->getMicroseconds()); + + // getMilliseconds() returns just the millisecond component + $this->assertSame(456, $timeOfDay->getMilliseconds()); + } +} diff --git a/tests/Unit/ValueObject/UuidTest.php b/tests/Unit/ValueObject/UuidTest.php new file mode 100644 index 0000000..3a71079 --- /dev/null +++ b/tests/Unit/ValueObject/UuidTest.php @@ -0,0 +1,137 @@ +assertSame($uuidString, $uuid->toString()); + $this->assertSame(hex2bin('12345678123412341234123456789abc'), $uuid->toBytes()); + } + + public function testFromStringWithUppercaseUuid(): void + { + $uuidString = '12345678-1234-1234-1234-123456789ABC'; + $uuid = Uuid::fromString($uuidString); + + // toString should return lowercase + $this->assertSame('12345678-1234-1234-1234-123456789abc', $uuid->toString()); + $this->assertSame(hex2bin('12345678123412341234123456789ABC'), $uuid->toBytes()); + } + + public function testFromBytesWithValidBytes(): void + { + $bytes = hex2bin('12345678123412341234123456789abc'); + $this->assertIsString($bytes); // Assert hex2bin didn't return false + $uuid = Uuid::fromBytes($bytes); + + $this->assertSame('12345678-1234-1234-1234-123456789abc', $uuid->toString()); + $this->assertSame($bytes, $uuid->toBytes()); + } + + public function testFromStringWithInvalidFormat(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid UUID format. Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'); + + Uuid::fromString('invalid-uuid-format'); + } + + public function testFromStringWithMissingHyphens(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid UUID format. Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'); + + Uuid::fromString('12345678123412341234123456789abc'); + } + + public function testFromBytesWithInvalidLength(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('UUID bytes must be exactly 16 bytes long'); + + Uuid::fromBytes('invalid'); + } + + public function testFromBytesWithEmptyString(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('UUID bytes must be exactly 16 bytes long'); + + Uuid::fromBytes(''); + } + + public function testFromBytesTooLong(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('UUID bytes must be exactly 16 bytes long'); + + Uuid::fromBytes(str_repeat('x', 17)); + } + + public function testNilUuid(): void + { + $nilString = '00000000-0000-0000-0000-000000000000'; + $nilBytes = str_repeat("\x00", 16); + + $uuidFromString = Uuid::fromString($nilString); + $uuidFromBytes = Uuid::fromBytes($nilBytes); + + $this->assertSame($nilString, $uuidFromString->toString()); + $this->assertSame($nilBytes, $uuidFromString->toBytes()); + $this->assertSame($nilString, $uuidFromBytes->toString()); + $this->assertSame($nilBytes, $uuidFromBytes->toBytes()); + } + + public function testMaxUuid(): void + { + $maxString = 'ffffffff-ffff-ffff-ffff-ffffffffffff'; + $maxBytes = str_repeat("\xff", 16); + + $uuidFromString = Uuid::fromString($maxString); + $uuidFromBytes = Uuid::fromBytes($maxBytes); + + $this->assertSame($maxString, $uuidFromString->toString()); + $this->assertSame($maxBytes, $uuidFromString->toBytes()); + $this->assertSame($maxString, $uuidFromBytes->toString()); + $this->assertSame($maxBytes, $uuidFromBytes->toBytes()); + } + + public function testRoundTripConversion(): void + { + $testCases = [ + '12345678-1234-1234-1234-123456789abc', + '00000000-0000-0000-0000-000000000000', + 'ffffffff-ffff-ffff-ffff-ffffffffffff', + 'a1b2c3d4-e5f6-7890-1234-567890123456', + ]; + + foreach ($testCases as $originalString) { + // String -> Bytes -> String + $uuid1 = Uuid::fromString($originalString); + $bytes = $uuid1->toBytes(); + $uuid2 = Uuid::fromBytes($bytes); + $finalString = $uuid2->toString(); + + $this->assertSame($originalString, $finalString, "Round trip failed for: {$originalString}"); + } + } + + public function testReadonlyProperty(): void + { + $uuid = Uuid::fromString('12345678-1234-1234-1234-123456789abc'); + $expectedBytes = hex2bin('12345678123412341234123456789abc'); + + $this->assertSame($expectedBytes, $uuid->bytes); + } +}