Skip to content

Commit 304e448

Browse files
committed
fix(database): improve raw sql serialization consistency
1 parent c728ca7 commit 304e448

14 files changed

+334
-34
lines changed

packages/database/src/Database.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,55 @@
66

77
use Tempest\Database\Builder\QueryBuilders\BuildsQuery;
88
use Tempest\Database\Config\DatabaseDialect;
9+
use Tempest\Support\Str\ImmutableString;
910
use UnitEnum;
1011

12+
/**
13+
* Represents a database that can execute queries.
14+
*/
1115
interface Database
1216
{
17+
/**
18+
* The dialect of this database.
19+
*/
1320
public DatabaseDialect $dialect {
1421
get;
1522
}
1623

24+
/**
25+
* The tag associated with this database, if any.
26+
*/
1727
public null|string|UnitEnum $tag {
1828
get;
1929
}
2030

31+
/**
32+
* Executes the given query.
33+
*/
2134
public function execute(BuildsQuery|Query $query): void;
2235

36+
/**
37+
* Returns the last inserted primary key, if any.
38+
*/
2339
public function getLastInsertId(): ?PrimaryKey;
2440

41+
/**
42+
* Fetches all results for the given query.
43+
*/
2544
public function fetch(BuildsQuery|Query $query): array;
2645

46+
/**
47+
* Fetches the first result for the given query.
48+
*/
2749
public function fetchFirst(BuildsQuery|Query $query): ?array;
2850

51+
/**
52+
* Executes the given callback within a transaction.
53+
*/
2954
public function withinTransaction(callable $callback): bool;
55+
56+
/**
57+
* Returns the raw SQL representation of the given query for debugging purposes.
58+
*/
59+
public function getRawSql(Query $query): ImmutableString;
3060
}

packages/database/src/GenericDatabase.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use Tempest\Database\Exceptions\QueryWasInvalid;
1414
use Tempest\Database\Transactions\TransactionManager;
1515
use Tempest\Mapper\SerializerFactory;
16+
use Tempest\Support\Str\ImmutableString;
1617
use Throwable;
1718
use UnitEnum;
1819

@@ -127,6 +128,16 @@ public function withinTransaction(callable $callback): bool
127128
return true;
128129
}
129130

131+
public function getRawSql(Query $query): ImmutableString
132+
{
133+
return new RawSql(
134+
dialect: $this->dialect,
135+
sql: (string) $query->compile(),
136+
bindings: $query->bindings,
137+
serializerFactory: $this->serializerFactory,
138+
)->toImmutableString();
139+
}
140+
130141
private function resolveBindings(Query $query): array
131142
{
132143
$bindings = [];

packages/database/src/Query.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99

1010
use function Tempest\get;
1111

12+
/**
13+
* A database query that can be executed.
14+
*/
1215
final class Query
1316
{
1417
use OnDatabase;
@@ -84,7 +87,7 @@ public function compile(): ImmutableString
8487
*/
8588
public function toRawSql(): ImmutableString
8689
{
87-
return new RawSql($this->dialect, (string) $this->compile(), $this->bindings)->toImmutableString();
90+
return $this->database->getRawSql($this);
8891
}
8992

9093
public function append(string $append): self

packages/database/src/RawSql.php

Lines changed: 18 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@
22

33
namespace Tempest\Database;
44

5-
use BackedEnum;
65
use Tempest\Database\Config\DatabaseDialect;
6+
use Tempest\Mapper\SerializerFactory;
77
use Tempest\Support\Str\ImmutableString;
8-
use UnitEnum;
98

109
final class RawSql
1110
{
11+
private ?RawSqlDatabaseContext $context {
12+
get => $this->context ??= new RawSqlDatabaseContext($this->dialect);
13+
}
14+
1215
public function __construct(
1316
private(set) DatabaseDialect $dialect,
1417
private(set) string $sql,
1518
private(set) array $bindings,
19+
private SerializerFactory $serializerFactory,
1620
) {}
1721

1822
public function compile(): string
@@ -39,9 +43,11 @@ public function __toString(): string
3943
private function replaceNamedBindings(string $sql, array $bindings): string
4044
{
4145
foreach ($bindings as $key => $value) {
42-
$placeholder = ':' . $key;
43-
$formattedValue = $this->formatValueForSql($value);
44-
$sql = str_replace($placeholder, $formattedValue, $sql);
46+
$sql = str_replace(
47+
search: ':' . $key,
48+
replace: $this->formatValueForSql($value),
49+
subject: $sql,
50+
);
4551
}
4652

4753
return $sql;
@@ -71,15 +77,14 @@ private function resolveBindingsForDisplay(): array
7177
$bindings = [];
7278

7379
foreach ($this->bindings as $key => $value) {
74-
if (is_bool($value)) {
75-
$value = match ($this->dialect) {
76-
DatabaseDialect::POSTGRESQL => $value ? 'true' : 'false',
77-
default => $value ? '1' : '0',
78-
};
80+
if ($value instanceof Query) {
81+
$bindings[$key] = "({$value->toRawSql()})";
82+
continue;
7983
}
8084

81-
if ($value instanceof Query) {
82-
$value = '(' . $value->toRawSql() . ')';
85+
if ($serializer = $this->serializerFactory->in($this->context)->forValue($value)) {
86+
$bindings[$key] = $serializer->serialize($value);
87+
continue;
8388
}
8489

8590
$bindings[$key] = $value;
@@ -94,26 +99,6 @@ private function formatValueForSql(mixed $value): string
9499
return 'NULL';
95100
}
96101

97-
if (is_string($value)) {
98-
if (str_starts_with($value, '(') && str_ends_with($value, ')')) {
99-
return $value;
100-
}
101-
102-
return "'" . str_replace("'", "''", $value) . "'";
103-
}
104-
105-
if (is_numeric($value)) {
106-
return (string) $value;
107-
}
108-
109-
if ($value instanceof BackedEnum) {
110-
return $value->value;
111-
}
112-
113-
if ($value instanceof UnitEnum) {
114-
return $value->name;
115-
}
116-
117-
return "'" . str_replace("'", "''", (string) $value) . "'";
102+
return (string) $value;
118103
}
119104
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace Tempest\Database;
4+
5+
use Tempest\Database\Config\DatabaseDialect;
6+
use Tempest\Mapper\Context;
7+
8+
final class RawSqlDatabaseContext implements Context
9+
{
10+
private(set) string $name = self::class;
11+
12+
public function __construct(
13+
private(set) DatabaseDialect $dialect,
14+
) {}
15+
}

packages/database/src/Serializers/DateTimeSerializer.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Tempest\Database\Serializers;
66

77
use DateTimeInterface as NativeDateTimeInterface;
8+
use Tempest\Core\Priority;
89
use Tempest\Database\DatabaseContext;
910
use Tempest\DateTime\DateTime;
1011
use Tempest\DateTime\DateTimeInterface;
@@ -16,6 +17,7 @@
1617
use Tempest\Reflection\PropertyReflector;
1718
use Tempest\Reflection\TypeReflector;
1819

20+
#[Priority(Priority::HIGH)]
1921
#[Context(DatabaseContext::class)]
2022
final readonly class DateTimeSerializer implements Serializer, DynamicSerializer
2123
{
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Serializers;
6+
7+
use Tempest\Database\Config\DatabaseDialect;
8+
use Tempest\Database\RawSqlDatabaseContext;
9+
use Tempest\Mapper\Attributes\Context;
10+
use Tempest\Mapper\DynamicSerializer;
11+
use Tempest\Mapper\Exceptions\ValueCouldNotBeSerialized;
12+
use Tempest\Mapper\Serializer;
13+
use Tempest\Reflection\PropertyReflector;
14+
use Tempest\Reflection\TypeReflector;
15+
16+
#[Context(RawSqlDatabaseContext::class)]
17+
final class RawSqlBooleanSerializer implements Serializer, DynamicSerializer
18+
{
19+
public function __construct(
20+
private RawSqlDatabaseContext $context,
21+
) {}
22+
23+
public static function accepts(PropertyReflector|TypeReflector $type): bool
24+
{
25+
$type = $type instanceof PropertyReflector
26+
? $type->getType()
27+
: $type;
28+
29+
return $type->getName() === 'bool' || $type->getName() === 'boolean';
30+
}
31+
32+
public function serialize(mixed $input): string
33+
{
34+
if (! is_bool($input)) {
35+
throw new ValueCouldNotBeSerialized('boolean');
36+
}
37+
38+
return match ($this->context->dialect) {
39+
DatabaseDialect::POSTGRESQL => $input ? 'true' : 'false',
40+
default => $input ? '1' : '0',
41+
};
42+
}
43+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Serializers;
6+
7+
use DateTimeInterface as NativeDateTimeInterface;
8+
use Tempest\Core\Priority;
9+
use Tempest\Database\RawSqlDatabaseContext;
10+
use Tempest\DateTime\DateTime;
11+
use Tempest\DateTime\DateTimeInterface;
12+
use Tempest\DateTime\FormatPattern;
13+
use Tempest\Mapper\Attributes\Context;
14+
use Tempest\Mapper\DynamicSerializer;
15+
use Tempest\Mapper\Exceptions\ValueCouldNotBeSerialized;
16+
use Tempest\Mapper\Serializer;
17+
use Tempest\Reflection\PropertyReflector;
18+
use Tempest\Reflection\TypeReflector;
19+
20+
#[Priority(Priority::HIGH)]
21+
#[Context(RawSqlDatabaseContext::class)]
22+
final class RawSqlDateTimeSerializer implements Serializer, DynamicSerializer
23+
{
24+
public static function accepts(PropertyReflector|TypeReflector $type): bool
25+
{
26+
$type = $type instanceof PropertyReflector
27+
? $type->getType()
28+
: $type;
29+
30+
return $type->matches(DateTime::class) || $type->matches(DateTimeInterface::class) || $type->matches(NativeDateTimeInterface::class);
31+
}
32+
33+
public function serialize(mixed $input): string
34+
{
35+
if ($input instanceof NativeDateTimeInterface) {
36+
$input = DateTime::parse($input);
37+
}
38+
39+
if (! $input instanceof DateTimeInterface) {
40+
throw new ValueCouldNotBeSerialized(DateTimeInterface::class);
41+
}
42+
43+
return "'" . $input->format(FormatPattern::SQL_DATE_TIME) . "'";
44+
}
45+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Serializers;
6+
7+
use BackedEnum;
8+
use Tempest\Core\Priority;
9+
use Tempest\Database\RawSqlDatabaseContext;
10+
use Tempest\Mapper\Attributes\Context;
11+
use Tempest\Mapper\DynamicSerializer;
12+
use Tempest\Mapper\Exceptions\ValueCouldNotBeSerialized;
13+
use Tempest\Mapper\Serializer;
14+
use Tempest\Reflection\PropertyReflector;
15+
use Tempest\Reflection\TypeReflector;
16+
use UnitEnum;
17+
18+
#[Priority(Priority::NORMAL)]
19+
#[Context(RawSqlDatabaseContext::class)]
20+
final class RawSqlEnumSerializer implements Serializer, DynamicSerializer
21+
{
22+
public static function accepts(PropertyReflector|TypeReflector $input): bool
23+
{
24+
$type = $input instanceof PropertyReflector
25+
? $input->getType()
26+
: $input;
27+
28+
return $type->matches(UnitEnum::class);
29+
}
30+
31+
public function serialize(mixed $input): string
32+
{
33+
if ($input instanceof BackedEnum) {
34+
return (string) $input->value;
35+
}
36+
37+
if ($input instanceof UnitEnum) {
38+
return $input->name;
39+
}
40+
41+
throw new ValueCouldNotBeSerialized('enum');
42+
}
43+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Serializers;
6+
7+
use Tempest\Core\Priority;
8+
use Tempest\Database\RawSqlDatabaseContext;
9+
use Tempest\Mapper\Attributes\Context;
10+
use Tempest\Mapper\DynamicSerializer;
11+
use Tempest\Mapper\Exceptions\ValueCouldNotBeSerialized;
12+
use Tempest\Mapper\Serializer;
13+
use Tempest\Reflection\PropertyReflector;
14+
use Tempest\Reflection\TypeReflector;
15+
16+
#[Priority(Priority::NORMAL)]
17+
#[Context(RawSqlDatabaseContext::class)]
18+
final class RawSqlNumberSerializer implements Serializer, DynamicSerializer
19+
{
20+
public static function accepts(PropertyReflector|TypeReflector $input): bool
21+
{
22+
$type = $input instanceof PropertyReflector
23+
? $input->getType()
24+
: $input;
25+
26+
return in_array($type->getName(), ['int', 'integer', 'float', 'double'], strict: true);
27+
}
28+
29+
public function serialize(mixed $input): string
30+
{
31+
if (! is_int($input) && ! is_float($input)) {
32+
throw new ValueCouldNotBeSerialized('integer or float');
33+
}
34+
35+
return (string) $input;
36+
}
37+
}

0 commit comments

Comments
 (0)