Lines 95.69% 89 / 93
Functions and Methods 94.11% 16 / 17
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
DatabaseManager 95.69% 89 / 93 94.11% 16 / 17 41 0.00% 0 / 1
 __construct 100.00% 3 / 3 100.00% 1 / 1 1
 validateConfig 100.00% 2 / 2 100.00% 1 / 1 2
 validConnectionConfig 100.00% 17 / 17 100.00% 1 / 1 7
 connection 100.00% 8 / 8 100.00% 1 / 1 4
 flush 100.00% 10 / 10 100.00% 1 / 1 5
 setConnectionConfig 100.00% 7 / 7 100.00% 1 / 1 2
 disconnect 100.00% 1 / 1 100.00% 1 / 1 1
 discardConnection 100.00% 4 / 4 100.00% 1 / 1 3
 assertConnectionExists 100.00% 2 / 2 100.00% 1 / 1 2
 sqlConnection 100.00% 4 / 4 100.00% 1 / 1 2
 addConnection 100.00% 6 / 6 100.00% 1 / 1 2
 makeConnection 50.00% 4 / 8 0.00% 0 / 1 2.50
 hasConnection 100.00% 1 / 1 100.00% 1 / 1 1
 currentConnection 100.00% 1 / 1 100.00% 1 / 1 1
 useConnection 100.00% 9 / 9 100.00% 1 / 1 3
 usingConnection 100.00% 5 / 5 100.00% 1 / 1 1
 extendConnector 100.00% 5 / 5 100.00% 1 / 1 2
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Database;
6
7use BlueprintAU\Radiant\Database\Connections\ConnectionInterface;
8use BlueprintAU\Radiant\Database\Connections\SqlConnection;
9use BlueprintAU\Radiant\Database\Connectors\ConnectorInterface;
10use BlueprintAU\Radiant\Database\Exceptions\UnsupportedFeatureException;
11
12/**
13 * Factory + registry for database connections.
14 *
15 * Holds the injected connections map, builds connections on demand, and
16 * caches them by name. Each driver maps to a {@see ConnectorInterface} via
17 * the extensible connector registry — not a hardcoded match — so new
18 * backends can be added without touching this class.
19 *
20 * The connections map is validated at construction time: each entry must be
21 * an array declaring a string `driver` that is registered in the connector
22 * registry. A malformed map is a configuration error and fails fast here,
23 * rather than surfacing later as a confusing connector lookup.
24 */
25final class DatabaseManager
26{
27    /**
28     * Resolved connections, cached by name.
29     *
30     * @var array<string, \BlueprintAU\Radiant\Database\Connections\ConnectionInterface>
31     */
32    protected array $resolved = [];
33
34    /**
35     * The connector registry — driver name to connector class.
36     *
37     * Every driver must be explicitly registered here; an unregistered
38     * driver is a configuration error, not a runtime fallback.
39     *
40     * @var array<string, class-string<\BlueprintAU\Radiant\Database\Connectors\ConnectorInterface>>
41     */
42    protected array $connectors = [
43        'mysql' => \BlueprintAU\Radiant\Database\Connectors\MySqlConnector::class,
44        'mariadb' => \BlueprintAU\Radiant\Database\Connectors\MariaDbConnector::class,
45        'sqlite' => \BlueprintAU\Radiant\Database\Connectors\SqliteConnector::class,
46        'pgsql' => \BlueprintAU\Radiant\Database\Connectors\PostgresConnector::class,
47        'csv' => \BlueprintAU\Radiant\Database\Connectors\CsvConnector::class,
48    ];
49
50    /**
51     * The name of the active connection.
52     *
53     * @var string
54     */
55    protected string $current_connection;
56
57    /**
58     * Create a manager with the injected connections map.
59     *
60     * @param  array<string, array<string, mixed>>  $connections
61     * @param  string  $default
62     * @throws \InvalidArgumentException
63     */
64    public function __construct(
65        protected array $connections,
66        protected readonly string $default = 'default',
67    ) {
68        $this->current_connection = $default;
69        $this->validateConfig($connections);
70        $this->assertConnectionExists($default);
71    }
72
73    /**
74     * Validate the shape of the injected connections map.
75     *
76     * @param  array<string, mixed>  $connections
77     * @throws \InvalidArgumentException
78     */
79    private function validateConfig(array $connections): void
80    {
81        foreach ($connections as $_ => $connectionConfig) {
82            $this->validConnectionConfig($connectionConfig);
83        }
84    }
85
86    /**
87     * Validate the shape of a single named connection's config.
88     *
89     * @param  mixed  $config  Must be an array declaring a non-empty string `driver` key.
90     * @throws \InvalidArgumentException
91     */
92    private function validConnectionConfig(mixed $config): void
93    {
94        if (!is_array($config)) {
95            throw new \InvalidArgumentException(
96                'Each connection must be an array of settings; got ' . get_debug_type($config) . '.'
97            );
98        }
99
100        if (!isset($config['driver']) || !is_string($config['driver']) || $config['driver'] === '') {
101            throw new \InvalidArgumentException(
102                'Each connection must declare a non-empty string "driver"; got '
103                    . (isset($config['driver']) ? get_debug_type($config['driver']) : 'nothing')
104                    . '.'
105            );
106        }
107
108        $connectorClass = $this->connectors[$config['driver']] ?? null;
109        if ($connectorClass === null) {
110            throw new \InvalidArgumentException(
111                "No connector registered for [{$config['driver']}]. "
112                    . "Register one via extendConnector('{$config['driver']}', SomeConnector::class)."
113            );
114        }
115
116        (new $connectorClass())->validConfig($config);
117    }
118
119    /**
120     * Get a connection by name, building and caching it on first use.
121     *
122     * A cached connection marked stale is discarded and rebuilt here.
123     *
124     * @param  string|null  $name
125     * @return ConnectionInterface
126     * @throws \InvalidArgumentException
127     */
128    public function connection(?string $name = null): ConnectionInterface
129    {
130        $name ??= $this->current_connection;
131        $this->assertConnectionExists($name);
132        if (isset($this->resolved[$name])
133            && $this->resolved[$name] instanceof SqlConnection
134            && $this->resolved[$name]->isStale()) {
135            $this->discardConnection($this->resolved[$name]);
136            unset($this->resolved[$name]);
137        }
138        return $this->resolved[$name] ??= $this->makeConnection($this->connections[$name]);
139    }
140
141    /**
142     * Evict resolved connection(s) from the cache.
143     *
144     * Evicting rolls back any open transaction on the connection first.
145     *
146     * @param  string|null  $name
147     * @return void
148     * @throws \InvalidArgumentException
149     */
150    public function flush(?string $name = null): void
151    {
152        if ($name === null) {
153            foreach (array_keys($this->resolved) as $resolvedName) {
154                $this->flush($resolvedName);
155            }
156            return;
157        }
158
159        if (!isset($this->resolved[$name])) {
160            $this->assertConnectionExists($name);
161            return; // not resolved — nothing to evict
162        }
163
164        if ($this->resolved[$name] instanceof SqlConnection) {
165            $this->discardConnection($this->resolved[$name]);
166        }
167        unset($this->resolved[$name]);
168    }
169
170    /**
171     * Replace a named connection's configuration and evict its instance.
172     *
173     * @param  string  $name
174     * @param  array<string, mixed>  $config
175     * @return void
176     * @throws \InvalidArgumentException
177     */
178    public function setConnectionConfig(string $name, array $config): void
179    {
180        if (!isset($this->connections[$name])) {
181            throw new \InvalidArgumentException(
182                "Unknown connection [{$name}]; add it via addConnection() first."
183            );
184        }
185
186        $this->validConnectionConfig($config);
187
188        $this->connections[$name] = $config;
189        $this->flush($name);
190    }
191
192    /**
193     * Evict one connection — a readable alias for {@see flush($name)}.
194     *
195     * @param  string  $name
196     * @return void
197     */
198    public function disconnect(string $name): void
199    {
200        $this->flush($name);
201    }
202
203    /**
204     * Best-effort cleanup before a connection is evicted.
205     *
206     * @param  SqlConnection  $connection
207     * @return void
208     */
209    private function discardConnection(SqlConnection $connection): void
210    {
211        while ($connection->transactionLevel() > 0) {
212            try {
213                $connection->rollBack();
214            } catch (\Throwable) {
215                break; // dead connection — the server-side transaction is gone too
216            }
217        }
218    }
219
220    /**
221     * Fail fast when the named connection is not registered.
222     *
223     * @param  string  $name
224     * @throws \InvalidArgumentException
225     */
226    private function assertConnectionExists(string $name): void
227    {
228        if (!isset($this->connections[$name])) {
229            throw new \InvalidArgumentException("Unknown connection [{$name}].");
230        }
231    }
232
233    /**
234     * Get a connection by name, narrowed to a SQL connection.
235     *
236     * @param  string|null  $name
237     * @return SqlConnection
238     * @throws UnsupportedFeatureException
239     */
240    public function sqlConnection(?string $name = null): SqlConnection
241    {
242        $connection = $this->connection($name);
243        if (!$connection instanceof SqlConnection) {
244            throw new UnsupportedFeatureException('The connection is not a SQL connection.');
245        }
246
247        return $connection;
248    }
249
250    /**
251     * Register a new named connection at runtime.
252     *
253     * @param  string  $name
254     * @param  array<string, mixed>  $connection
255     * @throws \InvalidArgumentException
256     */
257    public function addConnection(string $name, array $connection): void
258    {
259        if (isset($this->connections[$name])) {
260            throw new \InvalidArgumentException(
261                "Connection [{$name}] is already defined."
262            );
263        }
264
265        $this->validConnectionConfig($connection);
266        $this->connections[$name] = $connection;
267    }
268
269    /**
270     * Build a connection from its config via the registered connector.
271     *
272     * @param  array<string, mixed>  $config
273     * @return ConnectionInterface
274     * @throws \InvalidArgumentException
275     */
276    protected function makeConnection(array $config): ConnectionInterface
277    {
278        $driver = $config['driver'];
279        $connectorClass = $this->connectors[$driver] ?? null;
280
281        if ($connectorClass === null) {
282            throw new \InvalidArgumentException(
283                "No connector registered for [{$driver}]. "
284                    . "Register one via extendConnector('{$driver}', SomeConnector::class)."
285            );
286        }
287
288        return (new $connectorClass())->connect($config);
289    }
290
291    /**
292     * Whether a named connection exists in the connections map.
293     *
294     * @param  string  $name
295     * @return bool
296     */
297    public function hasConnection(string $name): bool
298    {
299        return isset($this->connections[$name]);
300    }
301
302    /**
303     * The name of the active connection.
304     *
305     * @return string
306     */
307    public function currentConnection(): string
308    {
309        return $this->current_connection;
310    }
311
312    /**
313     * Make a named connection the active one, persistently.
314     *
315     * Unlike {@see usingConnection()}, the switch is not scoped — it stays
316     * until changed again. Switching away from a connection holding an
317     * open transaction fails fast: the transaction would be left dangling.
318     *
319     * @param  string  $name
320     * @throws \InvalidArgumentException
321     * @throws \LogicException
322     */
323    public function useConnection(string $name): void
324    {
325        $this->assertConnectionExists($name);
326
327        $current = $this->resolved[$this->current_connection] ?? null;
328        if ($current instanceof SqlConnection && $current->transactionLevel() > 0) {
329            throw new \LogicException(
330                "Cannot switch away from connection [{$this->current_connection}] "
331                    . "with an open transaction (level {$current->transactionLevel()}); "
332                    . 'commit or roll back first.'
333            );
334        }
335
336        $this->current_connection = $name;
337    }
338
339    /**
340     * Run a callback with a different active connection, restoring the
341     * previous one afterwards.
342     *
343     * Unlike {@see useConnection()}, an open transaction on the active
344     * connection is allowed — the swap is always restored, so the
345     * transaction stays under the caller's control.
346     *
347     * @template T
348     * @param  string  $name
349     * @param  \Closure(): T  $callback
350     * @return T
351     * @throws \InvalidArgumentException
352     */
353    public function usingConnection(string $name, \Closure $callback): mixed
354    {
355        $this->assertConnectionExists($name);
356
357        $previous = $this->current_connection;
358        $this->current_connection = $name;
359        try {
360            return $callback();
361        } finally {
362            $this->current_connection = $previous;
363        }
364    }
365
366    /**
367     * Register a connector class for a driver.
368     *
369     * @param  string  $driver
370     * @param  class-string<ConnectorInterface>  $connectorClass
371     * @throws \InvalidArgumentException
372     */
373    public function extendConnector(string $driver, string $connectorClass): void
374    {
375        if (!is_subclass_of($connectorClass, ConnectorInterface::class)) {
376            throw new \InvalidArgumentException(
377                "Connector [{$connectorClass}] must implement " . ConnectorInterface::class . '.'
378            );
379        }
380
381        $this->connectors[$driver] = $connectorClass;
382    }
383}