src/Database/functions.php source

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