Lines 96.59% 85 / 88
Methods 87.50% 7 / 8
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 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
 [BlueprintAU\Radiant\Database\Connectors\SqlConnector] defaultOptions 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Database\Connectors\SqlConnector] forcedOptions 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
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}

Inherited from BlueprintAU\Radiant\Database\Connectors\SqlConnector

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