Lines 94.11% 48 / 51
Functions and Methods 66.66% 2 / 3
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
PostgresConnector 94.11% 48 / 51 66.66% 2 / 3 19.07 0.00% 0 / 1
 validSslmode 100.00% 6 / 6 100.00% 1 / 1 4
 connect 84.21% 16 / 19 0.00% 0 / 1 5.10
 validConfig 100.00% 26 / 26 100.00% 1 / 1 10
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Database\Connectors;
6
7use BlueprintAU\Radiant\Database\Connections\PostgresConnection;
8use BlueprintAU\Radiant\Database\Connections\SqlConnection;
9use Override;
10
11/**
12 * Postgres connector — builds a PDO Postgres connection from config.
13 *
14 * @see \BlueprintAU\Radiant\Database\Connections\SqlConnection
15 *
16 * @phpstan-import-type PdoOptions from \BlueprintAU\Radiant\Database\Connectors\SqlConnector
17 */
18final class PostgresConnector extends SqlConnector
19{
20    /**
21     * `sslmode` values Postgres accepts.
22     *
23     * @var list<string>
24     */
25    private const ALLOWED_SSLMODES = ['disable', 'allow', 'prefer', 'require', 'verify-ca', 'verify-full'];
26
27    /**
28     * Validate a configured sslmode against the allowlist.
29     *
30     * @param  mixed  $sslmode
31     * @return string
32     * @throws \InvalidArgumentException
33     */
34    private function validSslmode(mixed $sslmode): string
35    {
36        if (!is_string($sslmode) || !in_array(strtolower($sslmode), self::ALLOWED_SSLMODES, true)) {
37            throw new \InvalidArgumentException(
38                'Postgres "sslmode" must be one of: ' . implode(', ', self::ALLOWED_SSLMODES)
39                . '; got ' . (is_string($sslmode) ? "[{$sslmode}]" : get_debug_type($sslmode)) . '.'
40            );
41        }
42        return strtolower($sslmode);
43    }
44    /**
45     * Create a Postgres connection from the given config.
46     *
47     * @param  array{host?: mixed, port?: mixed, database?: mixed, sslmode?: mixed,
48     *        username?: string|null, password?: string|null, options?: PdoOptions,
49     *        ...<mixed>}  $config
50     * @return PostgresConnection
51     * @throws \InvalidArgumentException
52     */
53    #[Override]
54    public function connect(array $config): SqlConnection
55    {
56        $this->validConfig($config);
57
58        $host = $config['host'] ?? null;
59        $port = $config['port'] ?? 5432;
60        $database = $config['database'] ?? null;
61
62        if (!is_string($host) || !is_int($port) || !is_string($database)) {
63            throw new \InvalidArgumentException(
64                'Postgres requires a string host, an integer port and a string database.'
65            );
66        }
67
68        // (Port's type was already validated by validConfig() above; the
69        // combined check remains as defense-in-depth for direct connect()
70        // calls that skip the manager.)
71
72        $dsn = sprintf(
73            'pgsql:host=%s;port=%d;dbname=%s',
74            $host,
75            $port,
76            $database,
77        );
78
79        // sslmode goes through its own validated config slot — it is a
80        // DSN-integrated key, so it must come from config, allowlisted, not
81        // injected through a metacharacter in host/database.
82        if (array_key_exists('sslmode', $config)) {
83            $dsn .= ';sslmode=' . $this->validSslmode($config['sslmode']);
84        }
85
86        $options = $config['options'] ?? [];
87        $pdo = $this->createPdo($dsn, $config['username'] ?? null, $config['password'] ?? null, $options);
88
89        // Postgres only supports native prepared statements (no emulation),
90        // so the inherited EMULATE_PREPARES => false default is moot but
91        // harmless. Native types with STRINGIFY_FETCHES => false are the
92        // Postgres codec's contract (microsecond datetimes). No extra forced
93        // attributes — Postgres' needs are met by the base defaults.
94        return new PostgresConnection($pdo);
95    }
96
97    /**
98     * Validate the shape of a Postgres connection config.
99     *
100     * @param  array<string,mixed>  $config
101     * @throws \InvalidArgumentException
102     */
103    #[Override]
104    public function validConfig(array $config): void
105    {
106        parent::validConfig($config);
107
108        $host = $config['host'] ?? null;
109        $port = $config['port'] ?? null;
110        $database = $config['database'] ?? null;
111
112        if (!is_string($host) || $host === '') {
113            throw new \InvalidArgumentException(
114                'Postgres requires a non-empty string "host"; got '
115                . ($host === null ? 'nothing' : get_debug_type($host))
116                . '.'
117            );
118        }
119
120        if (array_key_exists('port', $config) && !is_int($config['port'])) {
121            throw new \InvalidArgumentException(
122                'Postgres "port" must be an integer; got '
123                . get_debug_type($config['port'])
124                . '.'
125            );
126        }
127
128        if (!is_string($database) || $database === '') {
129            throw new \InvalidArgumentException(
130                'Postgres requires a non-empty string "database"; got '
131                . ($database === null ? 'nothing' : get_debug_type($database))
132                . '.'
133            );
134        }
135
136        // host and database are interpolated into the DSN — metacharacters
137        // there re-bind the DSN's key-value parsing (a `;` can inject
138        // sslmode=disable or a unix socket). sslmode itself is validated
139        // separately when present.
140        $this->validDsnField($host, 'host');
141        $this->validDsnField($database, 'database');
142        if (array_key_exists('sslmode', $config)) {
143            $this->validSslmode($config['sslmode']);
144        }
145    }
146}