From a0003f8b3dde24f8c204d0ca0919f5a21db8e982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miloslav=20H=C5=AFla?= Date: Tue, 22 Nov 2022 09:13:41 +0100 Subject: [PATCH] added object translators (#420) The Translator is now capable to translate objects into Expression via object translators registered by the Connection. --- src/Dibi/Connection.php | 72 ++++++++++ src/Dibi/Translator.php | 9 ++ tests/dibi/Connection.objectTranslator.phpt | 148 ++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 tests/dibi/Connection.objectTranslator.phpt diff --git a/src/Dibi/Connection.php b/src/Dibi/Connection.php index f5f6dd0f..1d10501d 100644 --- a/src/Dibi/Connection.php +++ b/src/Dibi/Connection.php @@ -30,6 +30,10 @@ class Connection implements IConnection private array $formats; private ?Driver $driver = null; private ?Translator $translator = null; + + /** @var array */ + private array $translators = []; + private bool $sortTranslators = false; private HashMap $substitutes; private int $transactionDepth = 0; @@ -516,6 +520,74 @@ public function substitute(string $value): string } + /********************* value objects translation ****************d*g**/ + + + /** + * @param callable(object): Expression $translator + */ + public function setObjectTranslator(callable $translator): void + { + if (!$translator instanceof \Closure) { + $translator = \Closure::fromCallable($translator); + } + + $param = (new \ReflectionFunction($translator))->getParameters()[0] ?? null; + $type = $param?->getType(); + $types = match (true) { + $type instanceof \ReflectionNamedType => [$type], + $type instanceof \ReflectionUnionType => $type->getTypes(), + default => throw new Exception('Object translator must have exactly one parameter with class typehint.'), + }; + + foreach ($types as $type) { + if ($type->isBuiltin() || $type->allowsNull()) { + throw new Exception("Object translator must have exactly one parameter with non-nullable class typehint, got '$type'."); + } + $this->translators[$type->getName()] = $translator; + } + $this->sortTranslators = true; + } + + + public function translateObject(object $object): ?Expression + { + if ($this->sortTranslators) { + $this->translators = array_filter($this->translators); + uksort($this->translators, fn($a, $b) => is_subclass_of($a, $b) ? -1 : 1); + $this->sortTranslators = false; + } + + if (!array_key_exists($object::class, $this->translators)) { + $translator = null; + foreach ($this->translators as $class => $t) { + if ($object instanceof $class) { + $translator = $t; + break; + } + } + $this->translators[$object::class] = $translator; + } + + $translator = $this->translators[$object::class]; + if ($translator === null) { + return null; + } + + $result = $translator($object); + if (!$result instanceof Expression) { + throw new Exception(sprintf( + "Object translator for class '%s' returned '%s' but %s expected.", + $object::class, + get_debug_type($result), + Expression::class, + )); + } + + return $result; + } + + /********************* shortcuts ****************d*g**/ diff --git a/src/Dibi/Translator.php b/src/Dibi/Translator.php index 57d89f34..85bf3992 100644 --- a/src/Dibi/Translator.php +++ b/src/Dibi/Translator.php @@ -305,6 +305,15 @@ public function formatValue(mixed $value, ?string $modifier): string } } + if (is_object($value) + && $modifier === null + && !$value instanceof Literal + && !$value instanceof Expression + && $result = $this->connection->translateObject($value) + ) { + return $this->connection->translate(...$result->getValues()); + } + // object-to-scalar procession if ($value instanceof \BackedEnum && is_scalar($value->value)) { $value = $value->value; diff --git a/tests/dibi/Connection.objectTranslator.phpt b/tests/dibi/Connection.objectTranslator.phpt new file mode 100644 index 00000000..694c82ee --- /dev/null +++ b/tests/dibi/Connection.objectTranslator.phpt @@ -0,0 +1,148 @@ + "'Y-m-d H:i:s.u'", 'formatDate' => "'Y-m-d'"]); + + +class Email +{ + public $address = 'address@example.com'; +} + +class Time extends DateTimeImmutable +{ +} + + +test('Without object translator', function () use ($conn) { + Assert::exception(function () use ($conn) { + $conn->translate('?', new Email); + }, Dibi\Exception::class, 'SQL translate error: Unexpected Email'); +}); + + +test('Basics', function () use ($conn) { + $conn->setObjectTranslator(fn(Email $email) => new Dibi\Expression('?', $email->address)); + Assert::same( + reformat([ + 'sqlsrv' => "N'address@example.com'", + "'address@example.com'", + ]), + $conn->translate('?', new Email), + ); +}); + + +test('DateTime', function () use ($conn) { + $stamp = Time::createFromFormat('Y-m-d H:i:s', '2022-11-22 12:13:14'); + + // Without object translator, DateTime child is translated by driver + Assert::same( + $conn->getDriver()->escapeDateTime($stamp), + $conn->translate('?', $stamp), + ); + + + // With object translator + $conn->setObjectTranslator(fn(Time $time) => new Dibi\Expression('OwnTime(?)', $time->format('H:i:s'))); + Assert::same( + reformat([ + 'sqlsrv' => "OwnTime(N'12:13:14')", + "OwnTime('12:13:14')", + ]), + $conn->translate('?', $stamp), + ); + + + // With modifier, it is still translated by driver + Assert::same( + $conn->getDriver()->escapeDateTime($stamp), + $conn->translate('%dt', $stamp), + ); + Assert::same( + $conn->getDriver()->escapeDateTime($stamp), + $conn->translate('%t', $stamp), + ); + Assert::same( + $conn->getDriver()->escapeDate($stamp), + $conn->translate('%d', $stamp), + ); + + + // DateTimeImmutable as a Time parent is not affected and still translated by driver + $dt = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2022-11-22 12:13:14'); + Assert::same( + $conn->getDriver()->escapeDateTime($dt), + $conn->translate('?', $dt), + ); + + // But DateTime translation can be overloaded + $conn->setObjectTranslator(fn(DateTimeInterface $dt) => new Dibi\Expression('OwnDateTime')); + Assert::same( + 'OwnDateTime', + $conn->translate('?', $dt), + ); +}); + + +test('Complex structures', function () use ($conn) { + $conn->setObjectTranslator(fn(Email $email) => new Dibi\Expression('?', $email->address)); + $conn->setObjectTranslator(fn(Time $time) => new Dibi\Expression('OwnTime(?)', $time->format('H:i:s'))); + $conn->setObjectTranslator(fn(DateTimeInterface $dt) => new Dibi\Expression('OwnDateTime')); + + $time = Time::createFromFormat('Y-m-d H:i:s', '2022-11-22 12:13:14'); + Assert::same( + reformat([ + 'sqlsrv' => "([a], [b], [c], [d], [e], [f], [g]) VALUES (OwnTime(N'12:13:14'), '2022-11-22', CONVERT(DATETIME2(7), '2022-11-22 12:13:14.000000'), CONVERT(DATETIME2(7), '2022-11-22 12:13:14.000000'), N'address@example.com', OwnDateTime, OwnDateTime)", + 'odbc' => "([a], [b], [c], [d], [e], [f], [g]) VALUES (OwnTime('12:13:14'), #11/22/2022#, #11/22/2022 12:13:14.000000#, #11/22/2022 12:13:14.000000#, 'address@example.com', OwnDateTime, OwnDateTime)", + "([a], [b], [c], [d], [e], [f], [g]) VALUES (OwnTime('12:13:14'), '2022-11-22', '2022-11-22 12:13:14.000000', '2022-11-22 12:13:14.000000', 'address@example.com', OwnDateTime, OwnDateTime)", + ]), + $conn->translate('%v', [ + 'a' => $time, + 'b%d' => $time, + 'c%t' => $time, + 'd%dt' => $time, + 'e' => new Email, + 'f' => new DateTime, + 'g' => new DateTimeImmutable, + ]), + ); +}); + + +test('Invalid translator', function () use ($conn) { + Assert::exception( + fn() => $conn->setObjectTranslator(fn($email) => 'foo'), + Dibi\Exception::class, "Object translator must have exactly one parameter with class typehint.", + ); + + Assert::exception( + fn() => $conn->setObjectTranslator(fn(string $email) => 'foo'), + Dibi\Exception::class, "Object translator must have exactly one parameter with non-nullable class typehint, got 'string'.", + ); + + Assert::exception( + fn() => $conn->setObjectTranslator(fn(Email|bool $email) => 'foo'), + Dibi\Exception::class, "Object translator must have exactly one parameter with non-nullable class typehint, got 'bool'.", + ); + + Assert::exception( + fn() => $conn->setObjectTranslator(fn(Email|null $email) => 'foo'), + Dibi\Exception::class, "Object translator must have exactly one parameter with non-nullable class typehint, got '?Email'.", + ); + + $conn->setObjectTranslator(fn(Email $email) => 'foo'); + Assert::exception( + fn() => $conn->translate('?', new Email), + Dibi\Exception::class, "Object translator for class 'Email' returned 'string' but Dibi\Expression expected.", + ); +});