Lines 96.80% 91 / 94
Methods 90.00% 9 / 10
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 dialectLabel 100.00% 1 / 1 100.00% 1 / 1 1
 makeConnection 100.00% 1 / 1 100.00% 1 / 1 1
 validCharset 100.00% 7 / 7 100.00% 1 / 1 5
 forcedOptions 100.00% 3 / 3 100.00% 1 / 1 1
 connect 85.00% 17 / 20 0.00% 0 / 1 4.05
 validConfig 100.00% 26 / 26 100.00% 1 / 1 10
 [BlueprintAU\Radiant\Database\Connectors\SqlConnector] defaultOptions 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connectors\SqlConnector] validDsnField 100.00% 11 / 11 100.00% 1 / 1 5
 [BlueprintAU\Radiant\Database\Connectors\SqlConnector] validateOptions 100.00% 13 / 13 100.00% 1 / 1 6
 [BlueprintAU\Radiant\Database\Connectors\SqlConnector] createPdo 100.00% 11 / 11 100.00% 1 / 1 2
18class MySqlConnector extends SqlConnector
19{
20    /**
21     * The dialect's name in the connector's validation messages.
22     *
23     * @return string
24     */
25    protected function dialectLabel(): string
26    {
27        return 'MySQL';
28    }
29
30    /**
31     * The connection class this connector builds — the factory hook a
32     * dialect subclass overrides.
33     *
34     * @param  \PDO  $pdo
35     * @return MySqlConnection
36     */
37    protected function makeConnection(\Pdo $pdo): MySqlConnection
38    {
39        return new MySqlConnection($pdo);
40    }
41
42    /**
43     * Charsets MySQL accepts on `SET NAMES` — the allowlist the configured
44     * charset must match.
45     *
46     * @var list<string>
47     */
48    private const ALLOWED_CHARSETS = [
49        'utf8mb4', 'utf8mb3', 'utf8', 'latin1', 'latin2', 'ascii', 'binary',
50        'cp1250', 'cp1251', 'cp1256', 'cp932', 'euckr', 'gb18030', 'gb2312',
51        'gbk', 'koi8r', 'koi8u', 'macce', 'macroman', 'sjis', 'tis620',
52        'ucs2', 'ujis', 'utf16', 'utf16le', 'utf32',
53    ];
54
55    /**
56     * Validate a configured charset against the allowlist.
57     *
58     * @param  mixed  $charset
59     * @return string
60     * @throws \InvalidArgumentException
61     */
62    private function validCharset(mixed $charset): string
63    {
64        if (!is_string($charset) || $charset === ''
65            || !in_array(strtolower($charset), self::ALLOWED_CHARSETS, true)) {
66            throw new \InvalidArgumentException(
67                $this->dialectLabel() . ' "charset" must be one of: ' . implode(', ', self::ALLOWED_CHARSETS)
68                . '; got ' . (is_string($charset) ? "[{$charset}]" : get_debug_type($charset)) . '.'
69            );
70        }
71        return strtolower($charset);
72    }
73
74    /**
75     * MySQL-mandated PDO attributes — merged over the base forced layer.
76     *
77     * `PDO::MYSQL_ATTR_FOUND_ROWS` makes UPDATE/DELETE return the number of
78     * rows actually changed rather than rows matched, which the update()
79     * contract depends on.
80     *
81     * @return array<int, int|bool>
82     */
83    #[\Override]
84    protected function forcedOptions(): array
85    {
86        return [
87            \PDO\Mysql::ATTR_FOUND_ROWS => true,
88        ];
89    }
90
91    /**
92     * Create a MySQL connection from the given config.
93     *
94     * @param  array{host?: mixed, port?: mixed, database?: mixed, username?: string|null,
95     *        password?: string|null, charset?: string, options?: PdoOptions,
96     *        ...<mixed>}  $config
97     * @return MySqlConnection
98     * @throws \InvalidArgumentException
99     */
100    #[Override]
101    public function connect(array $config): SqlConnection
102    {
103        $this->validConfig($config);
104
105        $host = $config['host'] ?? null;
106        $port = $config['port'] ?? null;
107        $database = $config['database'] ?? null;
108        $charset = $this->validCharset($config['charset'] ?? 'utf8mb4');
109
110        // validConfig() has already validated host, port and database — the
111        // combined shape check stays as defense-in-depth for direct
112        // connect() calls that skip the manager.
113        if (!is_string($host) || !is_int($port) || !is_string($database)) {
114            throw new \InvalidArgumentException(
115                $this->dialectLabel() . ' requires a string host, an integer port and a string database.'
116            );
117        }
118
119        $dsn = sprintf(
120            'mysql:host=%s;port=%d;dbname=%s;charset=%s',
121            $host,
122            $port,
123            $database,
124            $charset,
125        );
126
127        $options = $config['options'] ?? [];
128        $pdo = $this->createPdo($dsn, $config['username'] ?? null, $config['password'] ?? null, $options);
129
130        // The charset must match the connection: set it once at connect time
131        // (config-driven, so it can't be a static forced option).
132        $pdo->exec("SET NAMES {$charset}");
133
134        return $this->makeConnection($pdo);
135    }
136
137    /**
138     * Validate the shape of a MySQL connection config.
139     *
140     * @param  array<string,mixed>  $config
141     * @throws \InvalidArgumentException
142     */
143    #[Override]
144    public function validConfig(array $config): void
145    {
146        parent::validConfig($config);
147
148        $host = $config['host'] ?? null;
149        $port = $config['port'] ?? null;
150        $database = $config['database'] ?? null;
151
152        // The charset reaches the DSN and a raw SET NAMES statement — validate
153        // it at construction time (fail-fast) as well as at connect time.
154        if (array_key_exists('charset', $config)) {
155            $this->validCharset($config['charset']);
156        }
157
158        if (!is_string($host) || $host === '') {
159            throw new \InvalidArgumentException(
160                $this->dialectLabel() . ' requires a non-empty string "host"; got '
161                . ($host === null ? 'nothing' : get_debug_type($host))
162                . '.'
163            );
164        }
165
166        if (!is_int($port)) {
167            throw new \InvalidArgumentException(
168                $this->dialectLabel() . ' requires an integer "port"; got '
169                . ($port === null ? 'nothing' : get_debug_type($port))
170                . '.'
171            );
172        }
173
174        if (!is_string($database) || $database === '') {
175            throw new \InvalidArgumentException(
176                $this->dialectLabel() . ' requires a non-empty string "database"; got '
177                . ($database === null ? 'nothing' : get_debug_type($database))
178                . '.'
179            );
180        }
181
182        // host and database are interpolated into the DSN — metacharacters
183        // there re-bind the DSN's key-value parsing (a `;` can inject
184        // unix_socket or sslmode). The type/emptiness checks above give the
185        // driver-specific message; this adds the metacharacter bound.
186        $this->validDsnField($host, 'host');
187        $this->validDsnField($database, 'database');
188    }
189}

Inherited from BlueprintAU\Radiant\Database\Connectors\SqlConnector

42    protected function defaultOptions(): array
43    {
44        return [];
45    }
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    }
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    }
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    }