diff --git a/src/Dibi/Connection.php b/src/Dibi/Connection.php index 9882ad76..ee1a026c 100644 --- a/src/Dibi/Connection.php +++ b/src/Dibi/Connection.php @@ -35,6 +35,11 @@ class Connection implements IConnection private ?Translator $translator = null; + /** @var array */ + private array $translators = []; + + private bool $sortTranslators = false; + private HashMap $substitutes; private int $transactionDepth = 0; @@ -522,6 +527,59 @@ public function substitute(string $value): string } + /********************* value objects translation ****************d*g**/ + + + /** + * @param callable(object): Expression $translator + */ + public function setObjectTranslator(string $class, callable $translator): void + { + $this->translators[$class] = $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 = $this->translators[$object::class]; + + } else { + $translator = null; + foreach ($this->translators as $class => $t) { + if ($object instanceof $class) { + $translator = $t; + break; + } + } + $this->translators[$object::class] = $translator; + } + + 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 e9289e22..5cf124c9 100644 --- a/src/Dibi/Translator.php +++ b/src/Dibi/Translator.php @@ -314,6 +314,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..1664cd33 --- /dev/null +++ b/tests/dibi/Connection.objectTranslator.phpt @@ -0,0 +1,141 @@ + "'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( + Email::class, + 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( + Time::class, + 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( + DateTimeInterface::class, + fn (DateTimeInterface $dt) => new Dibi\Expression('OwnDateTime'), + ); + Assert::same( + 'OwnDateTime', + $conn->translate('?', $dt), + ); +}); + + +test('Complex structures', function () use ($conn) { + $conn->setObjectTranslator(Email::class, fn(Email $email) => new Dibi\Expression('?', $email->address)); + $conn->setObjectTranslator(Time::class, fn (Time $time) => new Dibi\Expression('OwnTime(?)', $time->format('H:i:s'))); + $conn->setObjectTranslator(DateTimeInterface::class, 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) { + $conn->setObjectTranslator( + Email::class, + 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.", + ); +});