|
1
|
<?php |
|
2
|
|
|
3
|
namespace mini; |
|
4
|
|
|
5
|
use mini\Converter\ConverterRegistryInterface; |
|
6
|
use mini\Database\DatabaseInterface; |
|
7
|
use mini\Database\PDOService; |
|
8
|
use mini\Database\SqlValueHydrator; |
|
9
|
use mini\Database\SqlValue; |
|
10
|
use PDO; |
|
11
|
|
|
12
|
// Register services |
|
13
|
Mini::$mini->addService(PDO::class, Lifetime::Scoped, function() { |
|
14
|
$pdo = Mini::$mini->loadServiceConfig(PDO::class); |
|
15
|
PDOService::configure($pdo); |
|
16
|
return $pdo; |
|
17
|
}); |
|
18
|
Mini::$mini->addService(DatabaseInterface::class, Lifetime::Scoped, fn() => Mini::$mini->loadServiceConfig(DatabaseInterface::class)); |
|
19
|
|
|
20
|
// Register sql-value converters for common types |
|
21
|
Mini::$mini->phase->onEnteredState(Phase::Ready, function() { |
|
22
|
$registry = Mini::$mini->get(ConverterRegistryInterface::class); |
|
23
|
|
|
24
|
// ========================================================================= |
|
25
|
// PHP → SQL (for query parameters) |
|
26
|
// Target type: 'sql-value' |
|
27
|
// ========================================================================= |
|
28
|
|
|
29
|
// DateTime -> string (converted to sqlTimezone) |
|
30
|
$registry->register(function(\DateTimeInterface $dt): string { |
|
31
|
$dbTz = new \DateTimeZone(Mini::$mini->sqlTimezone); |
|
32
|
if ($dt instanceof \DateTime) { |
|
33
|
$dt = \DateTimeImmutable::createFromMutable($dt); |
|
34
|
} |
|
35
|
return $dt->setTimezone($dbTz)->format('Y-m-d H:i:s'); |
|
36
|
}, 'sql-value'); |
|
37
|
|
|
38
|
// BackedEnum -> its backing value (string or int) |
|
39
|
$registry->register(fn(\BackedEnum $enum) => $enum->value, 'sql-value'); |
|
40
|
|
|
41
|
// UnitEnum -> string (name) |
|
42
|
$registry->register(fn(\UnitEnum $enum) => $enum->name, 'sql-value'); |
|
43
|
|
|
44
|
// Stringable -> string |
|
45
|
$registry->register(fn(\Stringable $obj) => (string) $obj, 'sql-value'); |
|
46
|
|
|
47
|
// SqlValue -> scalar |
|
48
|
$registry->register(fn(SqlValue $obj) => $obj->toSqlValue(), 'sql-value'); |
|
49
|
|
|
50
|
// ========================================================================= |
|
51
|
// SQL → PHP (for entity hydration) |
|
52
|
// Source type: 'sql-value' |
|
53
|
// ========================================================================= |
|
54
|
|
|
55
|
// sql-value -> bool |
|
56
|
// Handles: 0/1 (int), "0"/"1" (string), "" (empty string) |
|
57
|
$registry->register(function(int|string $v): bool { |
|
58
|
return $v !== 0 && $v !== '0' && $v !== ''; |
|
59
|
}, null, 'sql-value'); |
|
60
|
|
|
61
|
// sql-value -> DateTimeImmutable |
|
62
|
// Interprets DB values in sqlTimezone, converts to application timezone. |
|
63
|
$registry->register(function(string|int|float $v): \DateTimeImmutable { |
|
64
|
$dbTz = new \DateTimeZone(Mini::$mini->sqlTimezone); |
|
65
|
$appTz = new \DateTimeZone(date_default_timezone_get()); |
|
66
|
|
|
67
|
if (is_string($v)) { |
|
68
|
// Parse in database timezone, convert to app timezone |
|
69
|
$dt = new \DateTimeImmutable($v, $dbTz); |
|
70
|
return $dt->setTimezone($appTz); |
|
71
|
} |
|
72
|
// Unix timestamps are always UTC regardless of sqlTimezone setting |
|
73
|
if (is_float($v)) { |
|
74
|
// Float: seconds with microsecond precision |
|
75
|
$sec = (int) $v; |
|
76
|
$usec = (int) (($v - $sec) * 1_000_000); |
|
77
|
$dt = \DateTimeImmutable::createFromFormat('U u', "$sec $usec") ?: new \DateTimeImmutable("@$sec"); |
|
78
|
return $dt->setTimezone($appTz); |
|
79
|
} |
|
80
|
// Integer: detect seconds vs milliseconds |
|
81
|
// Timestamps < 100 billion are seconds (covers until year ~5138) |
|
82
|
// Timestamps >= 100 billion are milliseconds |
|
83
|
if ($v >= 100_000_000_000) { |
|
84
|
$sec = intdiv($v, 1000); |
|
85
|
$usec = ($v % 1000) * 1000; |
|
86
|
$dt = \DateTimeImmutable::createFromFormat('U u', "$sec $usec") ?: new \DateTimeImmutable("@$sec"); |
|
87
|
return $dt->setTimezone($appTz); |
|
88
|
} |
|
89
|
$dt = new \DateTimeImmutable("@$v"); |
|
90
|
return $dt->setTimezone($appTz); |
|
91
|
}, null, 'sql-value'); |
|
92
|
|
|
93
|
// sql-value -> DateTime |
|
94
|
// Interprets DB values in sqlTimezone, converts to application timezone. |
|
95
|
$registry->register(function(string|int|float $v): \DateTime { |
|
96
|
$dbTz = new \DateTimeZone(Mini::$mini->sqlTimezone); |
|
97
|
$appTz = new \DateTimeZone(date_default_timezone_get()); |
|
98
|
|
|
99
|
if (is_string($v)) { |
|
100
|
// Parse in database timezone, convert to app timezone |
|
101
|
$dt = new \DateTime($v, $dbTz); |
|
102
|
$dt->setTimezone($appTz); |
|
103
|
return $dt; |
|
104
|
} |
|
105
|
// Unix timestamps are always UTC regardless of sqlTimezone setting |
|
106
|
if (is_float($v)) { |
|
107
|
$sec = (int) $v; |
|
108
|
$usec = (int) (($v - $sec) * 1_000_000); |
|
109
|
$dt = \DateTime::createFromFormat('U u', "$sec $usec") ?: new \DateTime("@$sec"); |
|
110
|
$dt->setTimezone($appTz); |
|
111
|
return $dt; |
|
112
|
} |
|
113
|
if ($v >= 100_000_000_000) { |
|
114
|
$sec = intdiv($v, 1000); |
|
115
|
$usec = ($v % 1000) * 1000; |
|
116
|
$dt = \DateTime::createFromFormat('U u', "$sec $usec") ?: new \DateTime("@$sec"); |
|
117
|
$dt->setTimezone($appTz); |
|
118
|
return $dt; |
|
119
|
} |
|
120
|
$dt = new \DateTime("@$v"); |
|
121
|
$dt->setTimezone($appTz); |
|
122
|
return $dt; |
|
123
|
}, null, 'sql-value'); |
|
124
|
|
|
125
|
// ========================================================================= |
|
126
|
// Fallback handler for type families |
|
127
|
// ========================================================================= |
|
128
|
|
|
129
|
$registry->fallback->listen(function(mixed $input, string $targetType, ?string $sourceType): mixed { |
|
130
|
if ($sourceType !== 'sql-value') { |
|
131
|
return null; |
|
132
|
} |
|
133
|
|
|
134
|
// sql-value -> BackedEnum (any BackedEnum subclass) |
|
135
|
if (is_subclass_of($targetType, \BackedEnum::class)) { |
|
136
|
return $targetType::from($input); |
|
137
|
} |
|
138
|
|
|
139
|
// sql-value -> SqlValueHydrator (custom value objects) |
|
140
|
if (is_subclass_of($targetType, SqlValueHydrator::class)) { |
|
141
|
return $targetType::fromSqlValue($input); |
|
142
|
} |
|
143
|
|
|
144
|
return null; |
|
145
|
}); |
|
146
|
}); |
|
147
|
|
|
148
|
/** |
|
149
|
* Get the database service instance |
|
150
|
* |
|
151
|
* Returns a lazy-loaded DatabaseInterface for executing queries. |
|
152
|
* Works out of the box with SQLite (_database.sqlite3 in project root). |
|
153
|
* |
|
154
|
* Configure via environment variables: |
|
155
|
* - DATABASE_URL: mysql://user:pass@host/dbname, postgresql://..., sqlite:///path |
|
156
|
* - MINI_DATABASE_URL: Same format, takes precedence over DATABASE_URL |
|
157
|
* |
|
158
|
* @return DatabaseInterface The database service |
|
159
|
*/ |
|
160
|
function db(): DatabaseInterface { |
|
161
|
return Mini::$mini->get(DatabaseInterface::class); |
|
162
|
} |
|
163
|
|
|
164
|
/** |
|
165
|
* Convert a value to SQL-bindable scalar |
|
166
|
* |
|
167
|
* Uses the 'sql-value' converter target type. Returns the value unchanged |
|
168
|
* if it's already a scalar or null. |
|
169
|
* |
|
170
|
* @param mixed $value Value to convert |
|
171
|
* @return string|int|float|bool|null SQL-bindable value |
|
172
|
* @throws \InvalidArgumentException If value cannot be converted |
|
173
|
*/ |
|
174
|
function sqlval(mixed $value): string|int|float|bool|null |
|
175
|
{ |
|
176
|
if ($value === null || is_scalar($value)) { |
|
177
|
return $value; |
|
178
|
} |
|
179
|
|
|
180
|
$converted = convert($value, 'sql-value'); |
|
181
|
if ($converted !== null) { |
|
182
|
return $converted; |
|
183
|
} |
|
184
|
|
|
185
|
throw new \InvalidArgumentException( |
|
186
|
'Cannot convert ' . get_debug_type($value) . ' to SQL parameter. ' . |
|
187
|
'Implement SqlValue or register an sql-value converter.' |
|
188
|
); |
|
189
|
} |
|
190
|
|