Skip to content

Commit

Permalink
added object translators (#420)
Browse files Browse the repository at this point in the history
The Translator is now capable to translate objects into Expression via object translators registered by the Connection.
  • Loading branch information
milo authored and dg committed Feb 2, 2023
1 parent 28b6d62 commit 2e3cdc1
Show file tree
Hide file tree
Showing 3 changed files with 230 additions and 0 deletions.
73 changes: 73 additions & 0 deletions src/Dibi/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ class Connection implements IConnection

private ?Translator $translator = null;

/** @var array<string, callable(object): Expression | null> */
private array $translators = [];

private bool $sortTranslators = false;

private HashMap $substitutes;

private int $transactionDepth = 0;
Expand Down Expand Up @@ -522,6 +527,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**/


Expand Down
9 changes: 9 additions & 0 deletions src/Dibi/Translator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
148 changes: 148 additions & 0 deletions tests/dibi/Connection.objectTranslator.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php

/**
* @dataProvider ../databases.ini
*/

declare(strict_types=1);

use Tester\Assert;

require __DIR__ . '/bootstrap.php';

$conn = new Dibi\Connection($config + ['formatDateTime' => "'Y-m-d H:i:s.u'", 'formatDate' => "'Y-m-d'"]);


class Email
{
public $address = '[email protected]';
}

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'[email protected]'",
"'[email protected]'",
]),
$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'[email protected]', 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#, '[email protected]', 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', '[email protected]', 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.",
);
});

0 comments on commit 2e3cdc1

Please sign in to comment.