Passed
Push — master ( c327c2...0adcf0 )
by Ondřej
02:45
created

ConnConfig.php$0 ➔ getMoneyDecimalSeparator()   A

Complexity

Conditions 2

Size

Total Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
dl 0
loc 9
rs 9.6666
c 0
b 0
f 0
1
<?php
2
declare(strict_types=1);
3
namespace Ivory\Connection\Config;
4
5
use Ivory\Connection\ConnectionControl;
6
use Ivory\Connection\IObservableTransactionControl;
7
use Ivory\Connection\IStatementExecution;
8
use Ivory\Exception\UnsupportedException;
9
use Ivory\Result\IQueryResult;
10
use Ivory\Value\Quantity;
11
12
/**
13
 * The standard implementation of runtime database configuration manager.
14
 *
15
 * {@inheritdoc}
16
 *
17
 * Besides the interface-specified methods, this implementation exposes the database configuration parameters as dynamic
18
 * ("overloaded") properties. The value currently in effect is always returned. For accessing properties with non-word
19
 * characters in their names (e.g., when using
20
 * {@link http://www.postgresql.org/docs/9.4/static/runtime-config-custom.html customized options}), the standard PHP
21
 * syntax `$config->{'some.prop'}` may be employed, or the {@link get()} method may simply be called.
22
 *
23
 * The implementation of read operations is lazy - no database query is made until actually needed. Some values are
24
 * gathered using the {@link pg_parameter_status()} function (these get cached internally by the PHP driver). Others are
25
 * directly queried and are not cached. For caching these, see the {@link CachingConnConfig} implementation.
26
 *
27
 * Data types of non-standard settings are fetched once and cached for the whole {@link ConnConfig} object lifetime
28
 * ({@link ConnConfig::flushCache()} may, of course, be used for flushing it in any case). Note that
29
 * {@link http://www.postgresql.org/docs/9.4/static/runtime-config-custom.html customized options} are always of type
30
 * `text`.
31
 *
32
 * As for the {@link IObservableConnConfig} implementation, any changes of configuration parameters must be done through
33
 * some of the methods offered by this class. Bypassing them (i.e., setting a parameter value in a raw query, or within
34
 * a database function) prevents the observers from being notified about the parameter value changes. In such cases, the
35
 * {@link ConnConfig::notifyPropertyChange()} or {@link ConnConfig::notifyPropertiesReset()} has to be called manually
36
 * for the new parameter value to be retrieved again.
37
 */
38
class ConnConfig implements IObservableConnConfig
39
{
40
    private $connCtl;
41
    private $stmtExec;
42
    private $txCtl;
43
    private $watcher;
44
45
    private $typeCache = null;
46
    /** @var string[]|null */
47
    private $effectiveSearchPathCache = null;
48
    /** @var IConfigObserver[][] map: parameter name or <tt>''</tt> => list of observers registered for changes of the
49
     *                             parameter (or any parameter for <tt>''</tt> (empty string) entry) */
50
    private $observers = [];
51
52
53
    public function __construct(
54
        ConnectionControl $connCtl,
55
        IStatementExecution $stmtExec,
56
        IObservableTransactionControl $txCtl
57
    ) {
58
        $this->connCtl = $connCtl;
59
        $this->stmtExec = $stmtExec;
60
        $this->txCtl = $txCtl;
61
62
        $this->watcher = new ConnConfigTransactionWatcher($this);
63
        $txCtl->addObserver($this->watcher);
64
    }
65
66
67
    /**
68
     * Alias for {@link ConnConfig::get().
69
     *
70
     * @param string $propertyName name of a configuration option
71
     * @return bool|float|int|Quantity|string|null current value of the requested option, or <tt>null</tt> if no such
72
     *                                               option has been defined yet
73
     */
74
    public function __get($propertyName)
75
    {
76
        return $this->get($propertyName);
77
    }
78
79
    /**
80
     * Alias for {@link ConnConfig::defined()}.
81
     *
82
     * @param string $propertyName name of a configuration option
83
     * @return bool whether the option is defined
84
     */
85
    public function __isset($propertyName)
86
    {
87
        return $this->defined($propertyName);
88
    }
89
90
    /**
91
     * Alias for {@link ConnConfig::setForSession()}.
92
     *
93
     * @param string $propertyName name of a configuration option
94
     * @param bool|string|int|float|Quantity $value the new value, or {@link ConnConfig::DEFAULT_VALUE} to use the
95
     *                                                option's default
96
     */
97
    public function __set($propertyName, $value)
98
    {
99
        $this->setForSession($propertyName, $value);
100
    }
101
102
    public function flushCache(): void
103
    {
104
        $this->typeCache = null;
105
    }
106
107
    public function get(string $propertyName)
108
    {
109
        if (self::isCustomOption($propertyName)) {
110
            return $this->getCustomOptionValue($propertyName);
111
        }
112
113
        static $pgParStatusRecognized = [
114
            ConfigParam::IS_SUPERUSER => true,
115
            ConfigParam::SESSION_AUTHORIZATION => true,
116
            ConfigParam::APPLICATION_NAME => true,
117
            ConfigParam::DATE_STYLE => true,
118
            ConfigParam::INTERVAL_STYLE => true,
119
            ConfigParam::TIME_ZONE => true,
120
            ConfigParam::CLIENT_ENCODING => true,
121
            ConfigParam::STANDARD_CONFORMING_STRINGS => true,
122
            ConfigParam::INTEGER_DATETIMES => true,
123
            ConfigParam::SERVER_ENCODING => true,
124
            ConfigParam::SERVER_VERSION => true,
125
        ];
126
        if (isset($pgParStatusRecognized[$propertyName])) {
127
            $connHandler = $this->connCtl->requireConnection();
128
            $val = pg_parameter_status($connHandler, $propertyName);
129
            if ($val !== false) {
0 ignored issues
show
introduced by
The condition $val !== false can never be false.
Loading history...
130
                $type = ConfigParam::TYPEMAP[$propertyName];
131
                assert($type !== null);
132
                return ConfigParamType::createValue($type, $val);
133
            }
134
        }
135
136
        if ($propertyName == ConfigParam::MONEY_DEC_SEP) {
137
            return $this->getMoneyDecimalSeparator();
138
        }
139
140
        // determine the type
141
        $type = null;
142
        // try exact match first, for performance reasons; hopefully, indexing the type map will not be needed
143
        if (array_key_exists($propertyName, ConfigParam::TYPEMAP)) {
144
            $type = ConfigParam::TYPEMAP[$propertyName];
145
        } else {
146
            // okay, try to search for case-insensitive matches
147
            if ($this->typeCache === null) {
148
                $this->typeCache = array_change_key_case(ConfigParam::TYPEMAP, CASE_LOWER);
149
            }
150
            $lowerPropertyName = strtolower($propertyName);
151
            if (isset($this->typeCache[$lowerPropertyName])) {
152
                $type = $this->typeCache[$lowerPropertyName];
153
            }
154
        }
155
156
        $connHandler = $this->connCtl->requireConnection();
157
158
        if ($type === null) { // type unknown, try to look in the catalog for server configuration
159
            $query = 'SELECT setting, vartype, unit FROM pg_catalog.pg_settings WHERE name ILIKE $1';
160
            $res = pg_query_params($connHandler, $query, [$propertyName]);
161
            if ($res !== false || pg_num_rows($res) > 0) {
0 ignored issues
show
introduced by
The condition $res !== false || pg_num_rows($res) > 0 can never be false.
Loading history...
162
                $row = pg_fetch_assoc($res);
163
                try {
164
                    $type = ConfigParamType::detectType($row['vartype'], $row['setting'], $row['unit']);
165
                    $this->typeCache[$propertyName] = $type;
166
                    $this->typeCache[strtolower($propertyName)] = $type;
167
                    return ConfigParamType::createValue($type, $row['setting'], $row['unit']);
168
                } catch (UnsupportedException $e) {
169
                    throw new UnsupportedException("Unsupported type of configuration parameter '$propertyName'");
170
                }
171
            }
172
        }
173
174
        if ($type === null) {
175
            /* As the last resort, treat the value as a string. Note that the pg_settings view might not contain all,
176
             * e.g., "session_authorization" - which is recognized by pg_parameter_status() and is probably just an
177
             * exception which gets created automatically for the connection, but who knows...
178
             */
179
            $type = ConfigParamType::STRING;
180
        }
181
182
        $res = pg_query_params($connHandler, 'SELECT pg_catalog.current_setting($1)', [$propertyName]);
183
        if ($res !== false) {
0 ignored issues
show
introduced by
The condition $res !== false can never be false.
Loading history...
184
            $val = pg_fetch_result($res, 0, 0);
185
            return ConfigParamType::createValue($type, $val);
186
        } else {
187
            return null;
188
        }
189
    }
190
191
    private function getCustomOptionValue(string $customOptionName): ?string
192
    {
193
        /* The custom option might not be recognized by PostgreSQL yet and an exception might be thrown, which would
194
         * break the current transaction (if any). Hence the savepoint.
195
         * Besides, custom options are always of type string, no need to even worry about the type.
196
         */
197
        $connHandler = $this->connCtl->requireConnection();
198
        $inTx = $this->txCtl->inTransaction();
199
        $savepoint = false;
200
        $needsRollback = false;
201
        try {
202
            if ($inTx) {
203
                $spRes = @pg_query($connHandler, 'SAVEPOINT _ivory_customized_option');
204
                if ($spRes === false) {
0 ignored issues
show
introduced by
The condition $spRes === false can never be true.
Loading history...
205
                    throw new \RuntimeException('Error retrieving a custom option value.');
206
                }
207
                $savepoint = true;
208
            }
209
            $res = @pg_query_params($connHandler, 'SELECT pg_catalog.current_setting($1)', [$customOptionName]);
210
            if ($res !== false) {
0 ignored issues
show
introduced by
The condition $res !== false can never be false.
Loading history...
211
                return pg_fetch_result($res, 0, 0);
212
            } else {
213
                $needsRollback = true;
214
                return null;
215
            }
216
        } finally {
217
            // anything might have been thrown, which must not break the (savepoint-release savepoint) pair
218
            if ($savepoint) {
219
                if ($needsRollback) {
0 ignored issues
show
introduced by
The condition $needsRollback can never be true.
Loading history...
220
                    $rbRes = @pg_query($connHandler, 'ROLLBACK TO SAVEPOINT _ivory_customized_option');
221
                    if ($rbRes === false) {
222
                        throw new \RuntimeException(
223
                            'Error restoring the transaction status - it stayed in the aborted state.'
224
                        );
225
                    }
226
                }
227
228
                $spRes = @pg_query($connHandler, 'RELEASE SAVEPOINT _ivory_customized_option');
229
                if ($spRes === false) {
0 ignored issues
show
introduced by
The condition $spRes === false can never be true.
Loading history...
230
                    throw new \RuntimeException(
231
                        'Error restoring the transaction status - the savepoint probably stayed defined.'
232
                    );
233
                }
234
            }
235
        }
236
    }
237
238
    /**
239
     * @param string $propertyName
240
     * @return bool whether the requested property is a custom option
241
     */
242
    private static function isCustomOption(string $propertyName): bool
243
    {
244
        // see http://www.postgresql.org/docs/9.4/static/runtime-config-custom.html
245
        return (strpos($propertyName, '.') !== false);
246
    }
247
248
    public function defined(string $propertyName): bool
249
    {
250
        return ($this->get($propertyName) !== null);
251
    }
252
253
    public function setForTransaction(string $propertyName, $value): void
254
    {
255
        if (!$this->txCtl->inTransaction()) {
256
            return; // setting the option would have no effect as the implicit transaction would end immediately
257
        }
258
259
        $connHandler = $this->connCtl->requireConnection();
260
        pg_query_params($connHandler, 'SELECT pg_catalog.set_config($1, $2, TRUE)', [$propertyName, $value]);
261
262
        $this->watcher->handleSetForTransaction($propertyName);
263
        $this->notifyPropertyChange($propertyName, $value);
264
    }
265
266
    public function setForSession(string $propertyName, $value): void
267
    {
268
        $connHandler = $this->connCtl->requireConnection();
269
        pg_query_params($connHandler, 'SELECT pg_catalog.set_config($1, $2, FALSE)', [$propertyName, $value]);
270
271
        $this->watcher->handleSetForSession($propertyName);
272
        $this->notifyPropertyChange($propertyName, $value);
273
    }
274
275
    public function resetAll(): void
276
    {
277
        $connHandler = $this->connCtl->requireConnection();
278
        pg_query($connHandler, 'RESET ALL');
279
280
        $this->watcher->handleResetAll();
281
        $this->notifyPropertiesReset();
282
    }
283
284
    public function getEffectiveSearchPath(): array
285
    {
286
        if ($this->effectiveSearchPathCache === null) {
287
            $this->initEffectiveSearchPathCache();
288
            $refresher = function () {
289
                $this->initEffectiveSearchPathCache();
290
            };
291
            $this->addObserver(
292
                new class($refresher) implements IConfigObserver
293
                {
294
                    private $refresher;
295
296
                    public function __construct($refresher)
297
                    {
298
                        $this->refresher = $refresher;
299
                    }
300
301
                    public function handlePropertyChange(string $propertyName, $newValue): void
302
                    {
303
                        call_user_func($this->refresher);
304
                    }
305
306
                    public function handlePropertiesReset(IConnConfig $connConfig): void
307
                    {
308
                        call_user_func($this->refresher);
309
                    }
310
                },
311
                ConfigParam::SEARCH_PATH
312
            );
313
        }
314
315
        return $this->effectiveSearchPathCache;
316
    }
317
318
    private function initEffectiveSearchPathCache(): void
319
    {
320
        $connHandler = $this->connCtl->requireConnection();
321
        $res = pg_query($connHandler, 'SELECT unnest(pg_catalog.current_schemas(TRUE))');
322
        if ($res !== false) {
0 ignored issues
show
introduced by
The condition $res !== false can never be false.
Loading history...
323
            $this->effectiveSearchPathCache = [];
324
            while (($row = pg_fetch_row($res)) !== false) {
325
                $this->effectiveSearchPathCache[] = $row[0];
326
            }
327
        }
328
    }
329
330
    public function getMoneyDecimalSeparator(): string
331
    {
332
        /** @var IQueryResult $r */
333
        $r = $this->stmtExec->rawQuery('SELECT 1.2::money::text');
334
        $v = $r->value();
335
        if (preg_match('~1(\D*)2~', $v, $m)) {
336
            return $m[1];
337
        } else {
338
            return '';
339
        }
340
    }
341
342
343
    //region IObservableConnConfig
344
345
    public function addObserver(IConfigObserver $observer, $parameterName = null): void
346
    {
347
        if (is_array($parameterName)) {
348
            foreach ($parameterName as $pn) {
349
                $this->addObserverImpl($observer, $pn);
350
            }
351
        } else {
352
            $this->addObserverImpl($observer, (string)$parameterName); // null is represented by an empty string
353
        }
354
    }
355
356
    private function addObserverImpl(IConfigObserver $observer, string $parameterName): void
357
    {
358
        if (!isset($this->observers[$parameterName])) {
359
            $this->observers[$parameterName] = [];
360
        }
361
362
        $this->observers[$parameterName][] = $observer;
363
    }
364
365
    public function removeObserver(IConfigObserver $observer): void
366
    {
367
        foreach ($this->observers as $k => $obsList) {
368
            foreach ($obsList as $i => $obs) {
369
                if ($obs === $observer) {
370
                    unset($this->observers[$k][$i]);
371
                }
372
            }
373
        }
374
    }
375
376
    public function removeAllObservers(): void
377
    {
378
        $this->observers = [];
379
    }
380
381
    public function notifyPropertyChange(string $parameterName, $newValue = null): void
382
    {
383
        if ($newValue === null) {
384
            $newValue = $this->get($parameterName);
385
        }
386
387
        foreach ([$parameterName, null] as $k) {
388
            if (isset($this->observers[$k])) {
389
                foreach ($this->observers[$k] as $obs) {
390
                    $obs->handlePropertyChange($parameterName, $newValue);
391
                }
392
            }
393
        }
394
395
        if ($parameterName == ConfigParam::LC_MONETARY) {
396
            $this->notifyPropertyChange(ConfigParam::MONEY_DEC_SEP);
397
        }
398
    }
399
400
    public function notifyPropertiesReset(): void
401
    {
402
        /** @var IConfigObserver[] $obsSet */
403
        $obsSet = [];
404
        foreach ($this->observers as $k => $obsList) {
405
            foreach ($obsList as $obs) {
406
                $obsSet[spl_object_hash($obs)] = $obs;
407
            }
408
        }
409
        foreach ($obsSet as $obs) {
410
            $obs->handlePropertiesReset($this);
411
        }
412
    }
413
414
    //endregion
415
}
416