From 0965bedd3626c65455693783988494128583b6b9 Mon Sep 17 00:00:00 2001 From: Pierre Rineau Date: Wed, 29 Nov 2023 14:03:16 +0100 Subject: [PATCH] OutputConverter interface and default implementations --- src/Converter/Converter.php | 108 +++++++++++-- src/Converter/ConverterPluginRegistry.php | 24 ++- .../InputConverter/DateInputConverter.php | 5 +- src/Converter/OutputConverter.php | 34 ++++ .../OutputConverter/DateOutputConverter.php | 118 ++++++++++++++ .../IntervalOutputConverter.php | 58 +++++++ .../RamseyUuidOutputConverter.php | 36 +++++ .../SymfonyUidOutputConverter.php | 41 +++++ tests/Converter/ConverterUnitTest.php | 76 +++++++++ .../DateOutputConverterTest.php | 151 ++++++++++++++++++ .../IntervalOutputConverterTest.php | 36 +++++ .../RamseyUuidOutputConverterTest.php | 33 ++++ .../SymfonyUidOutputConverterTest.php | 43 +++++ 13 files changed, 743 insertions(+), 20 deletions(-) create mode 100644 src/Converter/OutputConverter.php create mode 100644 src/Converter/OutputConverter/DateOutputConverter.php create mode 100644 src/Converter/OutputConverter/IntervalOutputConverter.php create mode 100644 src/Converter/OutputConverter/RamseyUuidOutputConverter.php create mode 100644 src/Converter/OutputConverter/SymfonyUidOutputConverter.php create mode 100644 tests/Converter/OutputConverter/DateOutputConverterTest.php create mode 100644 tests/Converter/OutputConverter/IntervalOutputConverterTest.php create mode 100644 tests/Converter/OutputConverter/RamseyUuidOutputConverterTest.php create mode 100644 tests/Converter/OutputConverter/SymfonyUidOutputConverterTest.php diff --git a/src/Converter/Converter.php b/src/Converter/Converter.php index abdbb3e..2033d99 100644 --- a/src/Converter/Converter.php +++ b/src/Converter/Converter.php @@ -61,6 +61,64 @@ public function toExpression(mixed $value, ?string $type = null): Expression }; } + /** + * Convert SQL formatted value to given PHP type. + * + * You may return null for null values. + * + * @return mixed + * + * @throws ValueConversionError + * In case of value conversion error. + */ + public function fromSql(string $type, int|float|string $value): mixed + { + if (null === $value) { + return null; + } + + // @todo Maybe later, too slow for hydration. Never do this automatically. + // if (null === $type) { + // $type = $this->guessOutputType($value); + // } + + if (\str_ends_with($type, '[]')) { + // @todo Handle collections. + throw new ValueConversionError("Handling arrays is not implemented yet."); + } + + try { + return $this->fromSqlUsingPlugins($value, $type); + } catch (ValueConversionError) {} + + try { + return $this->fromSqlUsingPlugins($value, '*'); + } catch (ValueConversionError) {} + + // Calling default implementation after plugins allows API users to + // override default behavior and implement their own logic pretty + // much everywhere. + return $this->fromSqlDefault($type, $value); + } + + /** + * Proceed to naive PHP type conversion. + */ + public function guessInputType(mixed $value): string + { + if (\is_object($value)) { + foreach ($this->registry->getTypeGuessers() as $plugin) { + \assert($plugin instanceof InputTypeGuesser); + + if ($type = $plugin->guessInputType($value)) { + return $type; + } + } + } + + return \get_debug_type($value); + } + /** * Convert PHP native value to given SQL type. * @@ -111,19 +169,27 @@ public function toSql(mixed $value, ?string $type = null): null|int|float|string return $this->toSqlDefault($type, $value); } + /** + * Allow bridge specific implementations to create their own context. + */ + protected function getConverterContext(): ConverterContext + { + return new ConverterContext($this); + } + /** * Run all plugins to convert a value. */ - protected function toSqlUsingPlugins(mixed $value, string $type, ?string $realType = null): null|int|float|string|object + protected function fromSqlUsingPlugins(null|int|float|string|object $value, string $type, ?string $realType = null): mixed { $realType ??= $type; $context = $this->getConverterContext(); - foreach ($this->registry->getInputConverters($type) as $plugin) { - \assert($plugin instanceof InputConverter); + foreach ($this->registry->getOutputConverters($type) as $plugin) { + \assert($plugin instanceof OutputConverter); try { - return $plugin->toSql($realType, $value, $context); + return $plugin->fromSql($realType, $value, $context); } catch (ValueConversionError) {} } @@ -131,29 +197,37 @@ protected function toSqlUsingPlugins(mixed $value, string $type, ?string $realTy } /** - * Allow bridge specific implementations to create their own context. + * Handles common primitive types. */ - protected function getConverterContext(): ConverterContext + protected function fromSqlDefault(string $type, null|int|float|string|object $value): mixed { - return new ConverterContext($this); + return match ($type) { + 'bool' => \is_int($value) ? (bool) $value : ((!$value || 'f' === $value || 'F' === $value || 'false' === \strtolower($value)) ? false : true), + 'float' => (float) $value, + 'int' => (int) $value, + 'json' => \json_decode($value, true), + 'string' => (string) $value, + default => throw new ValueConversionError(\sprintf("Unhandled PHP type '%s'", $type)), + }; } /** - * Proceed to naive PHP type conversion. + * Run all plugins to convert a value. */ - public function guessInputType(mixed $value): string + protected function toSqlUsingPlugins(mixed $value, string $type, ?string $realType = null): null|int|float|string|object { - if (\is_object($value)) { - foreach ($this->registry->getTypeGuessers() as $plugin) { - \assert($plugin instanceof InputTypeGuesser); + $realType ??= $type; + $context = $this->getConverterContext(); - if ($type = $plugin->guessInputType($value)) { - return $type; - } - } + foreach ($this->registry->getInputConverters($type) as $plugin) { + \assert($plugin instanceof InputConverter); + + try { + return $plugin->toSql($realType, $value, $context); + } catch (ValueConversionError) {} } - return \get_debug_type($value); + throw new ValueConversionError(); } /** diff --git a/src/Converter/ConverterPluginRegistry.php b/src/Converter/ConverterPluginRegistry.php index 62c6e00..9405644 100644 --- a/src/Converter/ConverterPluginRegistry.php +++ b/src/Converter/ConverterPluginRegistry.php @@ -8,6 +8,9 @@ use MakinaCorpus\QueryBuilder\Converter\InputConverter\IntervalInputConverter; use MakinaCorpus\QueryBuilder\Converter\InputConverter\RamseyUuidInputConverter; use MakinaCorpus\QueryBuilder\Converter\InputConverter\SymfonyUidInputConverter; +use MakinaCorpus\QueryBuilder\Converter\OutputConverter\DateOutputConverter; +use MakinaCorpus\QueryBuilder\Converter\OutputConverter\RamseyUuidOutputConverter; +use MakinaCorpus\QueryBuilder\Converter\OutputConverter\SymfonyUidOutputConverter; use Ramsey\Uuid\UuidInterface; use Symfony\Component\Uid\AbstractUid; @@ -20,6 +23,8 @@ class ConverterPluginRegistry { /** @var array> */ private array $inputConverters = []; + /** @var array> */ + private array $outputConverters = []; /** @var array */ private array $typeGuessers = []; @@ -34,12 +39,15 @@ public function __construct(?iterable $plugins = null) // Register defaults. $this->register(new DateInputConverter()); + $this->register(new DateOutputConverter()); $this->register(new IntervalInputConverter()); if (\class_exists(UuidInterface::class)) { $this->register(new RamseyUuidInputConverter()); + $this->register(new RamseyUuidOutputConverter()); } if (\class_exists(AbstractUid::class)) { $this->register(new SymfonyUidInputConverter()); + $this->register(new SymfonyUidOutputConverter()); } } @@ -49,17 +57,23 @@ public function __construct(?iterable $plugins = null) public function register(ConverterPlugin $plugin): void { $found = false; + if ($plugin instanceof InputConverter) { $found = true; - foreach ($plugin->supportedInputTypes() as $type) { $this->inputConverters[$type][] = $plugin; } } - if ($plugin instanceof InputTypeGuesser) { + if ($plugin instanceof OutputConverter) { $found = true; + foreach ($plugin->supportedOutputTypes() as $type) { + $this->outputConverters[$type][] = $plugin; + } + } + if ($plugin instanceof InputTypeGuesser) { + $found = true; $this->typeGuessers[] = $plugin; } @@ -74,6 +88,12 @@ public function getInputConverters(string $type): iterable return $this->inputConverters[$type] ?? []; } + /** @return iterable */ + public function getOutputConverters(string $type): iterable + { + return $this->outputConverters[$type] ?? []; + } + /** @return iterable */ public function getTypeGuessers(): iterable { diff --git a/src/Converter/InputConverter/DateInputConverter.php b/src/Converter/InputConverter/DateInputConverter.php index 268bd30..0feba39 100644 --- a/src/Converter/InputConverter/DateInputConverter.php +++ b/src/Converter/InputConverter/DateInputConverter.php @@ -101,8 +101,11 @@ public function toSql(string $type, mixed $value, ConverterContext $context): nu } return $value->format(self::FORMAT_TIME_USEC); - case 'timestamp': + case 'datetime': + case 'datetimez': case 'timestamp with time zone': + case 'timestamp': + case 'timestampz': default: $userTimeZone = new \DateTimeZone($context->getClientTimeZone()); // If user given date time is not using the client timezone diff --git a/src/Converter/OutputConverter.php b/src/Converter/OutputConverter.php new file mode 100644 index 0000000..252e03a --- /dev/null +++ b/src/Converter/OutputConverter.php @@ -0,0 +1,34 @@ + + * If you return ['*'], the input converted will be dynamically called + * late if no other was able to deal with the given type as a fallback. + */ + public function supportedOutputTypes(): array; + + /** + * Convert SQL formatted value to given PHP type. + * + * You may return null for null values. + * + * @return mixed + * + * @throws ValueConversionError + * In case of value conversion error. + */ + public function fromSql(string $type, int|float|string $value, ConverterContext $context): mixed; +} diff --git a/src/Converter/OutputConverter/DateOutputConverter.php b/src/Converter/OutputConverter/DateOutputConverter.php new file mode 100644 index 0000000..72d5145 --- /dev/null +++ b/src/Converter/OutputConverter/DateOutputConverter.php @@ -0,0 +1,118 @@ +getClientTimeZone()); + + // Attempt all possible outcomes. + if ($ret = \DateTimeImmutable::createFromFormat(DateInputConverter::FORMAT_DATETIME_USEC_TZ, $value)) { + // Time zone is within the date, as an offset. Convert the + // date to the user configured time zone, this conversion + // is safe and time will not shift. + $doConvert = true; + } else if ($ret = \DateTimeImmutable::createFromFormat(DateInputConverter::FORMAT_DATETIME_USEC, $value, $userTimeZone)) { + // We have no offset, change object timezone to be the user + // configured one if different from PHP default one. This + // will cause possible time shifts if client that inserted + // this date did not have the same timezone configured. + $doConvert = false; + } else if ($ret = \DateTimeImmutable::createFromFormat(DateInputConverter::FORMAT_DATETIME_TZ, $value)) { + // Once again, we have an offset. See upper. + $doConvert = true; + } else if ($ret = \DateTimeImmutable::createFromFormat(DateInputConverter::FORMAT_DATETIME, $value, $userTimeZone)) { + // Once again, no offset. See upper. + $doConvert = false; + } else { + throw new ValueConversionError(\sprintf("Given datetime '%s' could not be parsed.", $value)); + } + + if ($doConvert && $ret->getTimezone()->getName() !== $userTimeZone->getName()) { + return $ret->setTimezone($userTimeZone); + } + return $ret; + } + + // All other use case, simply date. + if (\preg_match('/^\d{4}-\d{2}-\d{2}/', $value)) { + if ($ret = \DateTimeImmutable::createFromFormat(DateInputConverter::FORMAT_DATE, $value)) { + // Date only do not care about time zone. + } else { + throw new ValueConversionError(\sprintf("Given date '%s' could not be parsed.", $value)); + } + return $ret; + } + + // This is a fallback for "time" types, but it will set the current + // date, and time offset will break your times. + if (\preg_match('/^\d{2}:\d{2}:\d{2}/', $value)) { + $userTimeZone = new \DateTimeZone($context->getClientTimeZone()); + + // Attempt all possible outcomes. + if ($ret = \DateTimeImmutable::createFromFormat(DateInputConverter::FORMAT_TIME_USEC_TZ, $value)) { + // Time zone is within the date, as an offset. Convert the + // date to the user configured time zone, this conversion + // is safe and time will not shift. + $doConvert = true; + } else if ($ret = \DateTimeImmutable::createFromFormat(DateInputConverter::FORMAT_TIME_USEC, $value, $userTimeZone)) { + // We have no offset, change object timezone to be the user + // configured one if different from PHP default one. This + // will cause possible time shifts if client that inserted + // this date did not have the same timezone configured. + $doConvert = false; + } else if ($ret = \DateTimeImmutable::createFromFormat(DateInputConverter::FORMAT_TIME_TZ, $value)) { + // Once again, we have an offset. See upper. + $doConvert = true; + } else if ($ret = \DateTimeImmutable::createFromFormat(DateInputConverter::FORMAT_TIME, $value, $userTimeZone)) { + // Once again, no offset. See upper. + $doConvert = false; + } else { + throw new ValueConversionError(\sprintf("Given time '%s' could not be parsed.", $value)); + } + + if ($doConvert && $ret->getTimezone()->getName() !== $userTimeZone->getName()) { + return $ret->setTimezone($userTimeZone); + } + return $ret; + } + + throw new ValueConversionError("Value is not an SQL date."); + } +} diff --git a/src/Converter/OutputConverter/IntervalOutputConverter.php b/src/Converter/OutputConverter/IntervalOutputConverter.php new file mode 100644 index 0000000..3c50b1c --- /dev/null +++ b/src/Converter/OutputConverter/IntervalOutputConverter.php @@ -0,0 +1,58 @@ + 'months', + 'mon' => 'month', + ])); + } else { + // Mixed PostgreSQL format "1 year... HH:MM:SS" + $date = \mb_substr($value, 0, $pos); + $time = \mb_substr($value, $pos + 1); + list($hour, $min, $sec) = \explode(':', $time); + return \DateInterval::createFromDateString(\sprintf("%s %d hour %d min %d sec", $date, $hour, $min, $sec)); + } + } + + /** + * {@inheritdoc} + */ + public function fromSql(string $type, int|float|string $value, ConverterContext $context): mixed + { + return self::extractPostgreSQLAsInterval((string) $value); + } +} diff --git a/src/Converter/OutputConverter/RamseyUuidOutputConverter.php b/src/Converter/OutputConverter/RamseyUuidOutputConverter.php new file mode 100644 index 0000000..cefc4a6 --- /dev/null +++ b/src/Converter/OutputConverter/RamseyUuidOutputConverter.php @@ -0,0 +1,36 @@ +getType()); } + public function testFromSqlBool(): void + { + $converter = new Converter(); + + self::assertFalse($converter->fromSql('bool', "false")); + self::assertFalse($converter->fromSql('bool', "f")); + self::assertFalse($converter->fromSql('bool', "FALSE")); + self::assertFalse($converter->fromSql('bool', "F")); + self::assertTrue($converter->fromSql('bool', "true")); + self::assertTrue($converter->fromSql('bool', "t")); + self::assertTrue($converter->fromSql('bool', "TRUE")); + self::assertTrue($converter->fromSql('bool', "T")); + } + + public function testFromSqlBoolAsInt(): void + { + $converter = new Converter(); + + self::assertFalse($converter->fromSql('bool', 0)); + self::assertTrue($converter->fromSql('bool', 1)); + } + + public function testFromSqlBoolAsIntString(): void + { + $converter = new Converter(); + + self::assertFalse($converter->fromSql('bool', "0")); + self::assertTrue($converter->fromSql('bool', "1")); + } + + public function testFromSqlIntAsString(): void + { + $converter = new Converter(); + + self::assertSame(12, $converter->fromSql('int', "12")); + self::assertSame(12, $converter->fromSql('int', "12.2")); + } + + public function testFromSqlInt(): void + { + $converter = new Converter(); + + self::assertSame(12, $converter->fromSql('int', 12)); + self::assertSame(12, $converter->fromSql('int', 12.2)); + } + + public function testFromSqlFloat(): void + { + $converter = new Converter(); + + self::assertSame(12.0, $converter->fromSql('float', "12")); + self::assertSame(12.2, $converter->fromSql('float', "12.2")); + } + + public function testFromSqlFloatAsString(): void + { + $converter = new Converter(); + + self::assertSame(12.0, $converter->fromSql('float', 12)); + self::assertSame(12.2, $converter->fromSql('float', 12.2)); + } + + public function testFromSqlString(): void + { + $converter = new Converter(); + + self::assertSame("weeeeh", $converter->fromSql('string', "weeeeh")); + } + + public function testFromSqlPlugin(): void + { + $converter = new Converter(); + + self::assertInstanceof(\DateTimeImmutable::class, $converter->fromSql(\DateTimeImmutable::class, "2012-12-12 12:12:12")); + } + public function testToSqlInt(): void { self::assertSame( diff --git a/tests/Converter/OutputConverter/DateOutputConverterTest.php b/tests/Converter/OutputConverter/DateOutputConverterTest.php new file mode 100644 index 0000000..e2595ff --- /dev/null +++ b/tests/Converter/OutputConverter/DateOutputConverterTest.php @@ -0,0 +1,151 @@ +fromSql($phpType, $sqlDate, $context)->format(DateInputConverter::FORMAT_DATETIME_USEC_TZ), + '2020-11-27 14:42:34.901965+01:00' + ); + } + + /** + * @dataProvider dataPhpType() + */ + public function testFromSqlDateTimeWithTz(string $phpType): void + { + // This time zone is GMT+1 on Europe/Paris. + $sqlDate = '2020-11-27 13:42:34+00'; + + $context = self::contextWithTimeZone('Europe/Paris'); + $instance = new DateOutputConverter(); + + self::assertSame( + $instance->fromSql($phpType, $sqlDate, $context)->format(DateInputConverter::FORMAT_DATETIME_USEC_TZ), + '2020-11-27 14:42:34.000000+01:00' + ); + } + + /** + * @dataProvider dataPhpType() + */ + public function testFromSqlDateTimeWithUsec(string $phpType): void + { + // This time zone is GMT+1 on Europe/Paris. + // Date will remain the same, since we don't know the original TZ. + $sqlDate = '2020-11-27 13:42:34.901965'; + + $context = self::contextWithTimeZone('Europe/Paris'); + $instance = new DateOutputConverter(); + + self::assertSame( + $instance->fromSql($phpType, $sqlDate, $context)->format(DateInputConverter::FORMAT_DATETIME_USEC_TZ), + '2020-11-27 13:42:34.901965+01:00' + ); + } + + /** + * @dataProvider dataPhpType() + */ + public function testFromSqlDateTime(string $phpType): void + { + // This time zone is GMT+1 on Europe/Paris. + // Date will remain the same, since we don't know the original TZ. + $sqlDate = '2020-11-27 13:42:34'; + + $context = self::contextWithTimeZone('Europe/Paris'); + $instance = new DateOutputConverter(); + + self::assertSame( + $instance->fromSql($phpType, $sqlDate, $context)->format(DateInputConverter::FORMAT_DATETIME_USEC_TZ), + '2020-11-27 13:42:34.000000+01:00' + ); + } + + /** + * @dataProvider dataPhpType() + */ + public function testFromSqlTimeWithUsecTz(string $phpType): void + { + $sqlDate = '13:42:34.901965+00'; + + $context = self::contextWithTimeZone('UTC'); + $instance = new DateOutputConverter(); + + self::assertSame( + $instance->fromSql($phpType, $sqlDate, $context)->format(DateInputConverter::FORMAT_TIME_USEC_TZ), + '13:42:34.901965+00:00' + ); + } + + /** + * @dataProvider dataPhpType() + */ + public function testFromSqlTimeWithTz(string $phpType): void + { + $sqlDate = '13:42:34+00'; + + $context = self::contextWithTimeZone('UTC'); + $instance = new DateOutputConverter(); + + self::assertSame( + $instance->fromSql($phpType, $sqlDate, $context)->format(DateInputConverter::FORMAT_TIME_USEC_TZ), + '13:42:34.000000+00:00' + ); + } + + /** + * @dataProvider dataPhpType() + */ + public function testFromSqlTimeWithUsec(string $phpType): void + { + $sqlDate = '13:42:34.901965'; + + $context = self::contextWithTimeZone('UTC'); + $instance = new DateOutputConverter(); + + self::assertSame( + $instance->fromSql($phpType, $sqlDate, $context)->format(DateInputConverter::FORMAT_TIME_USEC_TZ), + '13:42:34.901965+00:00' + ); + } + + /** + * @dataProvider dataPhpType() + */ + public function testFromSqlTime(string $phpType): void + { + $sqlDate = '13:42:34'; + + $context = self::contextWithTimeZone('UTC'); + $instance = new DateOutputConverter(); + + self::assertSame( + $instance->fromSql($phpType, $sqlDate, $context)->format(DateInputConverter::FORMAT_TIME_USEC_TZ), + '13:42:34.000000+00:00' + ); + } +} diff --git a/tests/Converter/OutputConverter/IntervalOutputConverterTest.php b/tests/Converter/OutputConverter/IntervalOutputConverterTest.php new file mode 100644 index 0000000..44773e6 --- /dev/null +++ b/tests/Converter/OutputConverter/IntervalOutputConverterTest.php @@ -0,0 +1,36 @@ +fromSQL(\DateInterval::class, $value, self::context()); + + self::assertInstanceOf(\DateInterval::class, $extracted); + self::assertSame($expected, IntervalInputConverter::intervalToIso8601($extracted)); + } +} diff --git a/tests/Converter/OutputConverter/RamseyUuidOutputConverterTest.php b/tests/Converter/OutputConverter/RamseyUuidOutputConverterTest.php new file mode 100644 index 0000000..cd73516 --- /dev/null +++ b/tests/Converter/OutputConverter/RamseyUuidOutputConverterTest.php @@ -0,0 +1,33 @@ +fromSql(Uuid::class, 'e8dbb2fb-8615-47d7-be75-cbdc423bfc9a', self::context()), + ); + } + + public function testFromSqlWithUuidInterface(): void + { + $instance = new RamseyUuidOutputConverter(); + + self::assertSame( + 'e8dbb2fb-8615-47d7-be75-cbdc423bfc9a', + (string) $instance->fromSql(UuidInterface::class, 'e8dbb2fb-8615-47d7-be75-cbdc423bfc9a', self::context()), + ); + } +} diff --git a/tests/Converter/OutputConverter/SymfonyUidOutputConverterTest.php b/tests/Converter/OutputConverter/SymfonyUidOutputConverterTest.php new file mode 100644 index 0000000..98cfac4 --- /dev/null +++ b/tests/Converter/OutputConverter/SymfonyUidOutputConverterTest.php @@ -0,0 +1,43 @@ +fromSql(Uuid::class, 'e8dbb2fb-8615-47d7-be75-cbdc423bfc9a', self::context()), + ); + } + + public function testFromSqlWithUuid4(): void + { + $instance = new SymfonyUidOutputConverter(); + + self::assertSame( + 'e8dbb2fb-8615-47d7-be75-cbdc423bfc9a', + (string) $instance->fromSql(Uuid::class, 'e8dbb2fb-8615-47d7-be75-cbdc423bfc9a', self::context()), + ); + } + + public function testFromSqlWithUlid(): void + { + $instance = new SymfonyUidOutputConverter(); + + self::assertSame( + '78VESFQ1GN8ZBVWXEBVH13QZ4T', + (string) $instance->fromSql(Ulid::class, 'e8dbb2fb-8615-47d7-be75-cbdc423bfc9a', self::context()), + ); + } +}