Lines 100.00% 39 / 39
Functions and Methods 100.00% 6 / 6
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
SqlConnector 100.00% 39 / 39 100.00% 6 / 6 17 100.00% 1 / 1
 defaultOptions 100.00% 1 / 1 100.00% 1 / 1 1
 forcedOptions 100.00% 1 / 1 100.00% 1 / 1 1
 connect n/a 0 / 0 n/a 0 / 0 0
 validConfig 100.00% 2 / 2 100.00% 1 / 1 2
 validDsnField 100.00% 11 / 11 100.00% 1 / 1 5
 validateOptions 100.00% 13 / 13 100.00% 1 / 1 6
 createPdo 100.00% 11 / 11 100.00% 1 / 1 2
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Database\Connectors;
6
7use BlueprintAU\Radiant\Database\Connections\SqlConnection;
8use BlueprintAU\Radiant\Database\Exceptions\ConnectionException;
9
10/**
11 * Base class for SQL connectors — shares the PDO construction logic
12 * across drivers.
13 *
14 * @see \BlueprintAU\Radiant\Database\Connections\SqlConnection
15 *
16 * @phpstan-type PdoOptions array<int, int|bool|array<mixed>>
17 */
18abstract class SqlConnector implements ConnectorInterface
19{
20    /**
21     * Default PDO attributes applied to every SQL connection.
22     *
23     * Subclasses add their own via {@see defaultOptions()}.
24     */
25    private const DEFAULT_OPTIONS = [
26        \PDO::ATTR_STRINGIFY_FETCHES => false
27    ];
28
29    /**
30     * PDO attributes that cannot be overridden by the user.
31     */
32    private const FORCED_OPTIONS = [
33        \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
34        \PDO::ATTR_EMULATE_PREPARES => false,
35    ];
36
37    /**
38     * The subclass's driver-appropriate default options.
39     *
40     * @return array<int, int|bool>
41     */
42    protected function defaultOptions(): array
43    {
44        return [];
45    }
46
47    /**
48     * The subclass's deliberate dialect mandates.
49     *
50     * @return array<int, int|bool>
51     */
52    protected function forcedOptions(): array
53    {
54        return [];
55    }
56
57    /**
58     * Create a connection for the given config.
59     *
60     * @param  array<string,mixed>  $config
61     * @return SqlConnection
62     * @throws ConnectionException
63     */
64    #[\Override]
65    abstract public function connect(array $config): SqlConnection;
66
67    /**
68     * Validate the shape of a connection config before it reaches
69     * {@see connect()}.
70     *
71     * @param  array<string,mixed>  $config
72     * @throws \InvalidArgumentException
73     */
74    #[\Override]
75    public function validConfig(array $config): void
76    {
77        if (array_key_exists('options', $config)) {
78            $this->validateOptions($config['options']);
79        }
80    }
81
82    /**
83     * Validate a config value that is interpolated into the DSN.
84     *
85     * @param  mixed  $value  Must be a non-empty string free of DSN metacharacters.
86     * @param  string  $field
87     * @return string
88     *
89     * @throws \InvalidArgumentException
90     */
91    protected function validDsnField(mixed $value, string $field): string
92    {
93        if (!is_string($value) || $value === '') {
94            throw new \InvalidArgumentException(
95                'The "' . $field . '" config field must be a non-empty string; got '
96                . ($value === null ? 'nothing' : get_debug_type($value)) . '.'
97            );
98        }
99        if (preg_match('/[;\s\x00-\x1f\x7f]/', $value) === 1) {
100            throw new \InvalidArgumentException(
101                'The "' . $field . '" config field must not contain semicolons, whitespace '
102                . 'or control characters (they are DSN metacharacters); got a value that does.'
103            );
104        }
105        return $value;
106    }
107
108    /**
109     * Validate the user-supplied PDO options before they reach the driver.
110     *
111     * @param  mixed  $options
112     * @throws \InvalidArgumentException
113     */
114    final protected function validateOptions(mixed $options): void
115    {
116        if (!is_array($options)) {
117            throw new \InvalidArgumentException(
118                'PDO options must be an array of attributes; got ' . get_debug_type($options) . '.'
119            );
120        }
121
122        foreach ($options as $key => $value) {
123            if (!is_int($key)) {
124                throw new \InvalidArgumentException(
125                    'PDO option keys must be integer attribute constants (PDO::ATTR_*); got ' . get_debug_type($key) . '.'
126                );
127            }
128
129            if (!is_scalar($value) && !is_array($value)) {
130                throw new \InvalidArgumentException(
131                    'PDO option values must be a scalar or array; got ' . get_debug_type($value) . ' for option ' . $key . '.'
132                );
133            }
134        }
135    }
136
137    /**
138     * Create a PDO instance from a DSN, merging option layers in strict
139     * precedence order.
140     *
141     * `PDO::connect()` (PHP 8.4+) is used rather than `new \PDO()` because
142     * it returns the driver-specific subclass chosen by the DSN.
143     *
144     * @param  string  $dsn
145     * @param  string|null  $username
146     * @param  string|null  $password
147     * @param  PdoOptions  $options
148     * @return \PDO
149     *
150     * @throws ConnectionException
151     */
152    final protected function createPdo(string $dsn, ?string $username, #[\SensitiveParameter] ?string $password, array $options): \Pdo
153    {
154        $this->validateOptions($options);
155        $options = array_replace(
156            self::DEFAULT_OPTIONS,
157            static::defaultOptions(),
158            $options,
159            self::FORCED_OPTIONS,
160            static::forcedOptions(),
161        );
162        try {
163            return \PDO::connect($dsn, $username, $password, $options);
164        } catch (\PDOException $e) {
165            throw new ConnectionException("Could not connect to database.", $e->getCode(), $e);
166        }
167    }
168}