Skip to content

Commit 0ee3834

Browse files
milodg
authored andcommitted
added object translators (#420)
The Translator is now capable to translate objects into Expression via object translators registered by the Connection.
1 parent 9fd3ddd commit 0ee3834

3 files changed

Lines changed: 229 additions & 0 deletions

File tree

src/Dibi/Connection.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ class Connection implements IConnection
3030
private array $formats;
3131
private ?Driver $driver = null;
3232
private ?Translator $translator = null;
33+
34+
/** @var array<string, callable(object): Expression | null> */
35+
private array $translators = [];
36+
private bool $sortTranslators = false;
3337
private HashMap $substitutes;
3438
private int $transactionDepth = 0;
3539

@@ -516,6 +520,74 @@ public function substitute(string $value): string
516520
}
517521

518522

523+
/********************* value objects translation ****************d*g**/
524+
525+
526+
/**
527+
* @param callable(object): Expression $translator
528+
*/
529+
public function setObjectTranslator(callable $translator): void
530+
{
531+
if (!$translator instanceof \Closure) {
532+
$translator = \Closure::fromCallable($translator);
533+
}
534+
535+
$param = (new \ReflectionFunction($translator))->getParameters()[0] ?? null;
536+
$type = $param?->getType();
537+
$types = match (true) {
538+
$type instanceof \ReflectionNamedType => [$type],
539+
$type instanceof \ReflectionUnionType => $type->getTypes(),
540+
default => throw new Exception('Object translator must have exactly one parameter with class typehint.'),
541+
};
542+
543+
foreach ($types as $type) {
544+
if ($type->isBuiltin() || $type->allowsNull()) {
545+
throw new Exception("Object translator must have exactly one parameter with non-nullable class typehint, got '$type'.");
546+
}
547+
$this->translators[$type->getName()] = $translator;
548+
}
549+
$this->sortTranslators = true;
550+
}
551+
552+
553+
public function translateObject(object $object): ?Expression
554+
{
555+
if ($this->sortTranslators) {
556+
$this->translators = array_filter($this->translators);
557+
uksort($this->translators, fn($a, $b) => is_subclass_of($a, $b) ? -1 : 1);
558+
$this->sortTranslators = false;
559+
}
560+
561+
if (!array_key_exists($object::class, $this->translators)) {
562+
$translator = null;
563+
foreach ($this->translators as $class => $t) {
564+
if ($object instanceof $class) {
565+
$translator = $t;
566+
break;
567+
}
568+
}
569+
$this->translators[$object::class] = $translator;
570+
}
571+
572+
$translator = $this->translators[$object::class];
573+
if ($translator === null) {
574+
return null;
575+
}
576+
577+
$result = $translator($object);
578+
if (!$result instanceof Expression) {
579+
throw new Exception(sprintf(
580+
"Object translator for class '%s' returned '%s' but %s expected.",
581+
$object::class,
582+
get_debug_type($result),
583+
Expression::class,
584+
));
585+
}
586+
587+
return $result;
588+
}
589+
590+
519591
/********************* shortcuts ****************d*g**/
520592

521593

src/Dibi/Translator.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,15 @@ public function formatValue(mixed $value, ?string $modifier): string
305305
}
306306
}
307307

308+
if (is_object($value)
309+
&& $modifier === null
310+
&& !$value instanceof Literal
311+
&& !$value instanceof Expression
312+
&& $result = $this->connection->translateObject($value)
313+
) {
314+
return $this->connection->translate(...$result->getValues());
315+
}
316+
308317
// object-to-scalar procession
309318
if ($value instanceof \BackedEnum && is_scalar($value->value)) {
310319
$value = $value->value;
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
<?php
2+
3+
/**
4+
* @dataProvider ../databases.ini
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
use Tester\Assert;
10+
11+
require __DIR__ . '/bootstrap.php';
12+
13+
$conn = new Dibi\Connection($config + ['formatDateTime' => "'Y-m-d H:i:s.u'", 'formatDate' => "'Y-m-d'"]);
14+
15+
16+
class Email
17+
{
18+
public $address = 'address@example.com';
19+
}
20+
21+
class Time extends DateTimeImmutable
22+
{
23+
}
24+
25+
26+
test('Without object translator', function () use ($conn) {
27+
Assert::exception(function () use ($conn) {
28+
$conn->translate('?', new Email);
29+
}, Dibi\Exception::class, 'SQL translate error: Unexpected Email');
30+
});
31+
32+
33+
test('Basics', function () use ($conn) {
34+
$conn->setObjectTranslator(fn(Email $email) => new Dibi\Expression('?', $email->address));
35+
Assert::same(
36+
reformat([
37+
'sqlsrv' => "N'address@example.com'",
38+
"'address@example.com'",
39+
]),
40+
$conn->translate('?', new Email),
41+
);
42+
});
43+
44+
45+
test('DateTime', function () use ($conn) {
46+
$stamp = Time::createFromFormat('Y-m-d H:i:s', '2022-11-22 12:13:14');
47+
48+
// Without object translator, DateTime child is translated by driver
49+
Assert::same(
50+
$conn->getDriver()->escapeDateTime($stamp),
51+
$conn->translate('?', $stamp),
52+
);
53+
54+
55+
// With object translator
56+
$conn->setObjectTranslator(fn(Time $time) => new Dibi\Expression('OwnTime(?)', $time->format('H:i:s')));
57+
Assert::same(
58+
reformat([
59+
'sqlsrv' => "OwnTime(N'12:13:14')",
60+
"OwnTime('12:13:14')",
61+
]),
62+
$conn->translate('?', $stamp),
63+
);
64+
65+
66+
// With modifier, it is still translated by driver
67+
Assert::same(
68+
$conn->getDriver()->escapeDateTime($stamp),
69+
$conn->translate('%dt', $stamp),
70+
);
71+
Assert::same(
72+
$conn->getDriver()->escapeDateTime($stamp),
73+
$conn->translate('%t', $stamp),
74+
);
75+
Assert::same(
76+
$conn->getDriver()->escapeDate($stamp),
77+
$conn->translate('%d', $stamp),
78+
);
79+
80+
81+
// DateTimeImmutable as a Time parent is not affected and still translated by driver
82+
$dt = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2022-11-22 12:13:14');
83+
Assert::same(
84+
$conn->getDriver()->escapeDateTime($dt),
85+
$conn->translate('?', $dt),
86+
);
87+
88+
// But DateTime translation can be overloaded
89+
$conn->setObjectTranslator(fn(DateTimeInterface $dt) => new Dibi\Expression('OwnDateTime'));
90+
Assert::same(
91+
'OwnDateTime',
92+
$conn->translate('?', $dt),
93+
);
94+
});
95+
96+
97+
test('Complex structures', function () use ($conn) {
98+
$conn->setObjectTranslator(fn(Email $email) => new Dibi\Expression('?', $email->address));
99+
$conn->setObjectTranslator(fn(Time $time) => new Dibi\Expression('OwnTime(?)', $time->format('H:i:s')));
100+
$conn->setObjectTranslator(fn(DateTimeInterface $dt) => new Dibi\Expression('OwnDateTime'));
101+
102+
$time = Time::createFromFormat('Y-m-d H:i:s', '2022-11-22 12:13:14');
103+
Assert::same(
104+
reformat([
105+
'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)",
106+
'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)",
107+
"([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)",
108+
]),
109+
$conn->translate('%v', [
110+
'a' => $time,
111+
'b%d' => $time,
112+
'c%t' => $time,
113+
'd%dt' => $time,
114+
'e' => new Email,
115+
'f' => new DateTime,
116+
'g' => new DateTimeImmutable,
117+
]),
118+
);
119+
});
120+
121+
122+
test('Invalid translator', function () use ($conn) {
123+
Assert::exception(
124+
fn() => $conn->setObjectTranslator(fn($email) => 'foo'),
125+
Dibi\Exception::class, "Object translator must have exactly one parameter with class typehint.",
126+
);
127+
128+
Assert::exception(
129+
fn() => $conn->setObjectTranslator(fn(string $email) => 'foo'),
130+
Dibi\Exception::class, "Object translator must have exactly one parameter with non-nullable class typehint, got 'string'.",
131+
);
132+
133+
Assert::exception(
134+
fn() => $conn->setObjectTranslator(fn(Email|bool $email) => 'foo'),
135+
Dibi\Exception::class, "Object translator must have exactly one parameter with non-nullable class typehint, got 'bool'.",
136+
);
137+
138+
Assert::exception(
139+
fn() => $conn->setObjectTranslator(fn(Email|null $email) => 'foo'),
140+
Dibi\Exception::class, "Object translator must have exactly one parameter with non-nullable class typehint, got '?Email'.",
141+
);
142+
143+
$conn->setObjectTranslator(fn(Email $email) => 'foo');
144+
Assert::exception(
145+
fn() => $conn->translate('?', new Email),
146+
Dibi\Exception::class, "Object translator for class 'Email' returned 'string' but Dibi\Expression expected.",
147+
);
148+
});

0 commit comments

Comments
 (0)