Skip to content

Commit

Permalink
OutputConverter interface and default implementations
Browse files Browse the repository at this point in the history
  • Loading branch information
pounard committed Nov 29, 2023
1 parent 7fa0750 commit 0965bed
Show file tree
Hide file tree
Showing 13 changed files with 743 additions and 20 deletions.
108 changes: 91 additions & 17 deletions src/Converter/Converter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -111,49 +169,65 @@ 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) {}
}

throw new ValueConversionError();
}

/**
* 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();
}

/**
Expand Down
24 changes: 22 additions & 2 deletions src/Converter/ConverterPluginRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -20,6 +23,8 @@ class ConverterPluginRegistry
{
/** @var array<string,array<InputConverter>> */
private array $inputConverters = [];
/** @var array<string,array<OutputConverter>> */
private array $outputConverters = [];
/** @var array<InputTypeGuesser> */
private array $typeGuessers = [];

Expand All @@ -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());
}
}

Expand All @@ -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;
}

Expand All @@ -74,6 +88,12 @@ public function getInputConverters(string $type): iterable
return $this->inputConverters[$type] ?? [];
}

/** @return iterable<OutputConverter> */
public function getOutputConverters(string $type): iterable
{
return $this->outputConverters[$type] ?? [];
}

/** @return iterable<InputTypeGuesser> */
public function getTypeGuessers(): iterable
{
Expand Down
5 changes: 4 additions & 1 deletion src/Converter/InputConverter/DateInputConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions src/Converter/OutputConverter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace MakinaCorpus\QueryBuilder\Converter;

use MakinaCorpus\QueryBuilder\Error\ValueConversionError;

/**
* Convert an SQL formatted value to a PHP value.
*/
interface OutputConverter extends ConverterPlugin
{
/**
* Get supported PHP types.
*
* @return array<string>
* 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;
}
118 changes: 118 additions & 0 deletions src/Converter/OutputConverter/DateOutputConverter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

declare(strict_types=1);

namespace MakinaCorpus\QueryBuilder\Converter\OutputConverter;

use MakinaCorpus\QueryBuilder\Converter\ConverterContext;
use MakinaCorpus\QueryBuilder\Converter\OutputConverter;
use MakinaCorpus\QueryBuilder\Converter\InputConverter\DateInputConverter;
use MakinaCorpus\QueryBuilder\Error\ValueConversionError;

/**
* This will fit with most RDBMS.
*
* @see https://www.postgresql.org/docs/13/datatype-datetime.html
*/
class DateOutputConverter implements OutputConverter
{
/**
* {@inheritdoc}
*/
public function supportedOutputTypes(): array
{
return [
\DateTime::class,
\DateTimeImmutable::class,
\DateTimeInterface::class,
];
}

/**
* {@inheritdoc}
*/
public function fromSql(string $type, int|float|string $value, ConverterContext $context): mixed
{
// I have no idea why this is still here. Probably an old bug.
if (!$value = \trim((string) $value)) {
return null;
}

// Matches all date and times, with or without timezone.
if (\preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', $value)) {
$userTimeZone = new \DateTimeZone($context->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.");
}
}
Loading

0 comments on commit 0965bed

Please sign in to comment.