Passed
Pull Request — 2.x (#91)
by Aleksei
18:20
created

PostgresColumn::resolveConstrains()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 32
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 0
Metric Value
cc 5
eloc 16
nc 5
nop 3
dl 0
loc 32
ccs 0
cts 0
cp 0
crap 30
rs 9.4222
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * This file is part of Cycle ORM package.
5
 *
6
 * For the full copyright and license information, please view the LICENSE
7
 * file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace Cycle\Database\Driver\Postgres\Schema;
13
14
use Cycle\Database\Driver\DriverInterface;
15
use Cycle\Database\Exception\SchemaException;
16
use Cycle\Database\Injection\Fragment;
17
use Cycle\Database\Schema\AbstractColumn;
0 ignored issues
show
Bug introduced by
The type Cycle\Database\Schema\AbstractColumn was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
18
19
/**
20
 * @method $this timestamptz(int $size = 0)
21
 * @method $this timetz()
22
 */
23
class PostgresColumn extends AbstractColumn
24
{
25
    private const WITH_TIMEZONE = 'with time zone';
26
    private const WITHOUT_TIMEZONE = 'without time zone';
27
28
    /**
29
     * Default timestamp expression (driver specific).
30
     */
31
    public const DATETIME_NOW = 'now()';
32
33
    /**
34
     * Private state related values.
35
     */
36
    public const EXCLUDE_FROM_COMPARE = [
37
        'userType',
38
        'timezone',
39
        'constrained',
40
        'constrainName',
41
    ];
42
43
    protected array $mapping = [
44
        //Primary sequences
45
        'primary'     => ['type' => 'serial', 'autoIncrement' => true, 'nullable' => false],
46
        'bigPrimary'  => ['type' => 'bigserial', 'autoIncrement' => true, 'nullable' => false],
47
48
        //Enum type (mapped via method)
49
        'enum'        => 'enum',
50
51
        //Logical types
52
        'boolean'     => 'boolean',
53
54
        //Integer types (size can always be changed with size method), longInteger has method alias
55
        //bigInteger
56
        'integer'     => 'integer',
57
        'tinyInteger' => 'smallint',
58
        'smallInteger'=> 'smallint',
59
        'bigInteger'  => 'bigint',
60
61
        //String with specified length (mapped via method)
62
        'string'      => 'character varying',
63
64
        //Generic types
65
        'text'        => 'text',
66
        'tinyText'    => 'text',
67
        'longText'    => 'text',
68
69
        //Real types
70
        'double'      => 'double precision',
71
        'float'       => 'real',
72
73
        //Decimal type (mapped via method)
74
        'decimal'     => 'numeric',
75
76
        //Date and Time types
77
        'datetime'    => 'timestamp',
78
        'date'        => 'date',
79
        'time'        => 'time',
80
        'timetz'      => ['type' => 'time', 'withTimezone' => true],
81
        'timestamp'   => 'timestamp',
82
        'timestamptz' => ['type' => 'timestamp', 'withTimezone' => true],
83
84
        //Binary types
85
        'binary'      => 'bytea',
86
        'tinyBinary'  => 'bytea',
87
        'longBinary'  => 'bytea',
88
89
        //Additional types
90
        'json'        => 'text',
91
        'jsonb'       => 'jsonb',
92
        'uuid'        => 'uuid',
93
    ];
94
95
    protected array $reverseMapping = [
96
        'primary'     => ['serial'],
97
        'bigPrimary'  => ['bigserial'],
98
        'enum'        => ['enum'],
99
        'boolean'     => ['boolean'],
100
        'integer'     => ['int', 'integer', 'int4'],
101
        'tinyInteger' => ['smallint'],
102
        'smallInteger'=> ['smallint'],
103
        'bigInteger'  => ['bigint', 'int8'],
104
        'string'      => ['character varying', 'character'],
105
        'text'        => ['text'],
106
        'double'      => ['double precision'],
107
        'float'       => ['real', 'money'],
108
        'decimal'     => ['numeric'],
109
        'date'        => ['date'],
110
        'time'        => [['type' => 'time', 'withTimezone' => false]],
111
        'timetz'      => [['type' => 'time', 'withTimezone' => true]],
112
        'timestamp'   => [['type' => 'timestamp', 'withTimezone' => false]],
113
        'timestamptz' => [['type' => 'timestamp', 'withTimezone' => true]],
114
        'binary'      => ['bytea'],
115
        'json'        => ['json'],
116
        'jsonb'       => ['jsonb'],
117
    ];
118
119
    /**
120
     * Field is auto incremental.
121
     */
122 8
    protected bool $autoIncrement = false;
123
124 8
    /**
125
     * Indication that column has enum constrain.
126 8
     */
127
    protected bool $constrained = false;
128
129
    /**
130 8
     * Name of enum constraint associated with field.
131
     */
132
    protected string $constrainName = '';
133
134
    protected bool $withTimezone = false;
135
136 516
    public function getConstraints(): array
137
    {
138 516
        $constraints = parent::getConstraints();
139
140
        if ($this->constrained) {
141 366
            $constraints[] = $this->constrainName;
142
        }
143 366
144
        return $constraints;
145
    }
146
147
    /**
148
     * @psalm-return non-empty-string
149
     */
150 366
    public function getAbstractType(): string
151
    {
152
        return !empty($this->enumValues) ? 'enum' : parent::getAbstractType();
153 2
    }
154
155 2
    public function primary(): AbstractColumn
156
    {
157
        if (!empty($this->type) && $this->type !== 'serial') {
158
            //Change type of already existed column (we can't use "serial" alias here)
159
            $this->type = 'integer';
0 ignored issues
show
Bug Best Practice introduced by
The property type does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
160
161
            return $this;
162 2
        }
163
164
        return $this->type('primary');
165 156
    }
166
167 156
    public function bigPrimary(): AbstractColumn
168
    {
169 156
        if (!empty($this->type) && $this->type !== 'bigserial') {
170 156
            //Change type of already existed column (we can't use "serial" alias here)
171 156
            $this->type = 'bigint';
0 ignored issues
show
Bug Best Practice introduced by
The property type does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
172
173
            return $this;
174 156
        }
175
176
        return $this->type('bigPrimary');
177
    }
178
179
    public function enum(string|array $values): AbstractColumn
180 514
    {
181
        $this->enumValues = array_map('strval', \is_array($values) ? $values : \func_get_args());
0 ignored issues
show
Bug Best Practice introduced by
The property enumValues does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
introduced by
The condition is_array($values) is always true.
Loading history...
182 514
183
        $this->type = 'character varying';
0 ignored issues
show
Bug Best Practice introduced by
The property type does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
184 514
        foreach ($this->enumValues as $value) {
185
            $this->size = max((int)$this->size, \strlen($value));
0 ignored issues
show
Bug Best Practice introduced by
The property size does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
186 506
        }
187
188
        return $this;
189
    }
190 154
191 154
    /**
192 154
     * @psalm-return non-empty-string
193
     */
194
    public function sqlStatement(DriverInterface $driver): string
195 154
    {
196 154
        $statement = [$driver->identifier($this->name), $this->type];
0 ignored issues
show
Bug introduced by
The method identifier() does not exist on Cycle\Database\Driver\DriverInterface. It seems like you code against a sub-type of Cycle\Database\Driver\DriverInterface such as Cycle\Database\Driver\Driver. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

196
        $statement = [$driver->/** @scrutinizer ignore-call */ identifier($this->name), $this->type];
Loading history...
197 154
198
        if ($this->getAbstractType() === 'enum') {
199 154
            //Enum specific column options
200
            if (!empty($enumDefinition = $this->quoteEnum($driver))) {
201
                $statement[] = $enumDefinition;
202
            }
203
        } elseif (!empty($this->precision)) {
204
            $statement[] = "({$this->precision}, {$this->scale})";
205 40
        } elseif (!empty($this->size) || $this->type === 'timestamp' || $this->type === 'time') {
206
            $statement[] = "({$this->size})";
207 40
        }
208
209
        if ($this->type === 'timestamp' || $this->type === 'time') {
210 40
            $statement[] = $this->withTimezone ? self::WITH_TIMEZONE : self::WITHOUT_TIMEZONE;
211 40
        }
212
213 40
        $statement[] = $this->nullable ? 'NULL' : 'NOT NULL';
214
215
        if ($this->defaultValue !== null) {
216
            $statement[] = "DEFAULT {$this->quoteDefault($driver)}";
217
        }
218 40
219 18
        $statement = \implode(' ', $statement);
220
221 2
        //We have add constraint for enum type
222 2
        if ($this->getAbstractType() === 'enum') {
223 2
            $enumValues = [];
224
            foreach ($this->enumValues as $value) {
225
                $enumValues[] = $driver->quote($value);
226 2
            }
227
228 16
            $constrain = $driver->identifier($this->enumConstraint());
229
            $column = $driver->identifier($this->getName());
230 16
            $values = \implode(', ', $enumValues);
231 6
232 12
            return "{$statement} CONSTRAINT {$constrain} CHECK ($column IN ({$values}))";
233
        }
234
235
        //Nothing special
236
        return $statement;
237 16
    }
238
239
    /**
240
     * Generate set of operations need to change column.
241
     */
242 40
    public function alterOperations(DriverInterface $driver, AbstractColumn $initial): array
243 6
    {
244
        $operations = [];
245
246
        //To simplify comparation
247 40
        $currentType = [$this->type, $this->size, $this->precision, $this->scale];
248 10
        $initialType = [$initial->type, $initial->size, $initial->precision, $initial->scale];
249 2
250
        $identifier = $driver->identifier($this->getName());
251 8
252
        /*
253
         * This block defines column type and all variations.
254
         */
255
        if ($currentType !== $initialType) {
256 40
            if ($this->getAbstractType() === 'enum') {
257 4
                //Getting longest value
258
                $enumSize = $this->size;
259
                foreach ($this->enumValues as $value) {
260 40
                    $enumSize = max($enumSize, strlen($value));
261 6
                }
262 6
263 6
                $operations[] = "ALTER COLUMN {$identifier} TYPE character varying($enumSize)";
264
            } else {
265
                $type = "ALTER COLUMN {$identifier} TYPE {$this->type}";
266 6
267 6
                if (!empty($this->size)) {
268
                    $type .= "($this->size)";
269
                } elseif (!empty($this->precision)) {
270 40
                    $type .= "($this->precision, $this->scale)";
271
                }
272
273
                //Required to perform cross conversion
274
                $operations[] = "{$type} USING {$identifier}::{$this->type}";
275
            }
276
        }
277
278 516
        //Dropping enum constrain before any operation
279
        if ($this->constrained && $initial->getAbstractType() === 'enum') {
280
            $operations[] = 'DROP CONSTRAINT ' . $driver->identifier($this->enumConstraint());
281
        }
282
283 516
        //Default value set and dropping
284
        if ($initial->defaultValue !== $this->defaultValue) {
285 516
            if ($this->defaultValue === null) {
286 516
                $operations[] = "ALTER COLUMN {$identifier} DROP DEFAULT";
287 516
            } else {
288
                $operations[] = "ALTER COLUMN {$identifier} SET DEFAULT {$this->quoteDefault($driver)}";
289
            }
290 516
        }
291 516
292 516
        //Nullable option
293
        if ($initial->nullable !== $this->nullable) {
294 368
            $operations[] = "ALTER COLUMN {$identifier} " . (!$this->nullable ? 'SET' : 'DROP') . ' NOT NULL';
295 368
        }
296
297 368
        if ($this->getAbstractType() === 'enum') {
298
            $enumValues = [];
299 368
            foreach ($this->enumValues as $value) {
300
                $enumValues[] = $driver->quote($value);
301
            }
302 470
303 264
            $operations[] = "ADD CONSTRAINT {$driver->identifier($this->enumConstraint())} "
304
                . "CHECK ({$identifier} IN (" . implode(', ', $enumValues) . '))';
305
        }
306 470
307 14
        return $operations;
308 14
    }
309
310
    /**
311 470
     * @psalm-param non-empty-string $table Table name.
312 2
     *
313
     * @param DriverInterface $driver Postgres columns are bit more complex.
314
     */
315
    public static function createInstance(
316
        string $table,
317
        array $schema,
318 2
        DriverInterface $driver
319
    ): self {
320
        $column = new self($table, $schema['column_name'], $driver->getTimezone());
321 470
322
        $column->type = match (true) {
0 ignored issues
show
Bug Best Practice introduced by
The property type does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
323 264
            $schema['typname'] === 'timestamp' || $schema['typname'] === 'timestamptz' => 'timestamp',
324
            $schema['typname'] === 'date' => 'date',
325
            $schema['typname'] === 'time' || $schema['typname'] === 'timetz' => 'time',
326 470
            default => $schema['data_type']
327
        };
328 470
329
        $column->defaultValue = $schema['column_default'];
0 ignored issues
show
Bug Best Practice introduced by
The property defaultValue does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
330
        $column->nullable = $schema['is_nullable'] === 'YES';
0 ignored issues
show
Bug Best Practice introduced by
The property nullable does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
331 516
332
        if (
333 516
            \is_string($column->defaultValue)
334 506
            && \in_array($column->type, ['int', 'bigint', 'integer'])
335
            && preg_match('/nextval(.*)/', $column->defaultValue)
336
        ) {
337
            $column->type = ($column->type === 'bigint' ? 'bigserial' : 'serial');
338 370
            $column->autoIncrement = true;
339 370
340
            $column->defaultValue = new Fragment($column->defaultValue);
341
342
            return $column;
343
        }
344
345
        if ($schema['character_maximum_length'] !== null && str_contains($column->type, 'char')) {
346
            $column->size = (int) $schema['character_maximum_length'];
0 ignored issues
show
Bug Best Practice introduced by
The property size does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
347
        }
348
349
        if ($column->type === 'numeric') {
350
            $column->precision = (int) $schema['numeric_precision'];
0 ignored issues
show
Bug Best Practice introduced by
The property precision does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
351 154
            $column->scale = (int) $schema['numeric_scale'];
0 ignored issues
show
Bug Best Practice introduced by
The property scale does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
352
        }
353
354 154
        if ($column->type === 'USER-DEFINED' && $schema['typtype'] === 'e') {
355
            $column->type = $schema['typname'];
356
357
            /**
358
             * Attention, this is not default enum type emulated via CHECK.
359
             * This is real Postgres enum type.
360 156
             */
361
            self::resolveEnum($driver, $column);
362 156
        }
363 156
364
        if ($column->type === 'timestamp' || $column->type === 'time') {
365
            $column->size = (int) $schema['datetime_precision'];
366 156
        }
367
368
        if ($schema['typname'] === 'timestamptz' || $schema['typname'] === 'timetz') {
369
            $column->withTimezone = true;
370
        }
371
372 470
        if (!empty($column->size) && str_contains($column->type, 'char')) {
373
            //Potential enum with manually created constraint (check in)
374 470
            self::resolveConstrains($driver, $schema, $column);
375 402
        }
376
377
        $column->normalizeDefault();
378 214
379
        return $column;
380 188
    }
381 160
382
    public function compare(AbstractColumn $initial): bool
383
    {
384
        if (parent::compare($initial)) {
385 160
            return true;
386 138
        }
387
388
        return (bool) (
389 214
            in_array($this->getAbstractType(), ['primary', 'bigPrimary'])
390 214
            && $initial->getDefaultValue() != $this->getDefaultValue()
391 152
        )
392
            //PG adds default values to primary keys
393 152
394
395
396 214
         ;
397
    }
398
399
    public function timestamp(int $size = 0): self
400
    {
401 264
        $this->type('timestamp');
402
403
        ($size < 0 || $size > 6) && throw new SchemaException('Invalid timestamp length value');
404
405
        $this->size = $size;
0 ignored issues
show
Bug Best Practice introduced by
The property size does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
406 264
407
        return $this;
408
    }
409 264
410
    /**
411
     * @psalm-return non-empty-string
412 264
     */
413 264
    protected function quoteEnum(DriverInterface $driver): string
414
    {
415
        //Postgres enums are just constrained strings
416
        return '(' . $this->size . ')';
417 264
    }
418 156
419 156
    /**
420 156
     * Get/generate name for enum constraint.
421 156
     */
422
    private function enumConstraint(): string
423 156
    {
424
        if (empty($this->constrainName)) {
425
            $this->constrainName = str_replace('.', '_', $this->table) . '_' . $this->getName() . '_enum_' . uniqid();
426 156
        }
427
428 156
        return $this->constrainName;
429
    }
430 156
431 156
    /**
432 156
     * Normalize default value.
433
     */
434
    private function normalizeDefault(): void
435 264
    {
436
        if (!$this->hasDefaultValue()) {
437
            return;
438
        }
439
440 2
        if (preg_match('/^\'?(.*?)\'?::(.+)/', $this->defaultValue, $matches)) {
441
            //In database: 'value'::TYPE
442 2
            $this->defaultValue = $matches[1];
0 ignored issues
show
Bug Best Practice introduced by
The property defaultValue does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
443
        } elseif ($this->type === 'bit') {
444 2
            $this->defaultValue = bindec(
445
                substr($this->defaultValue, 2, strpos($this->defaultValue, '::') - 3)
446 2
            );
447
        } elseif ($this->type === 'boolean') {
448
            $this->defaultValue = (strtolower($this->defaultValue) === 'true');
449
        }
450
451
        $type = $this->getType();
452
        if ($type === self::FLOAT || $type === self::INT) {
453
            if (preg_match('/^\(?(.*?)\)?(?!::(.+))?$/', $this->defaultValue, $matches)) {
454 2
                //Negative numeric values
455
                $this->defaultValue = $matches[1];
456
            }
457
        }
458
    }
459
460
    /**
461
     * Resolving enum constrain and converting it into proper enum values set.
462
     */
463
    private static function resolveConstrains(
464
        DriverInterface $driver,
465
        array $schema,
466
        self $column
467
    ): void {
468
        $query = "SELECT conname, pg_get_constraintdef(oid) as consrc FROM pg_constraint
469
        WHERE conrelid = ? AND contype = 'c' AND conkey = ?";
470
471
        $constraints = $driver->query(
472
            $query,
473
            [
474
                $schema['tableOID'],
475
                '{' . $schema['dtd_identifier'] . '}',
476
            ]
477
        );
478
479
        foreach ($constraints as $constraint) {
480
            if (preg_match('/ARRAY\[([^\]]+)\]/', $constraint['consrc'], $matches)) {
481
                $enumValues = explode(',', $matches[1]);
482
                foreach ($enumValues as &$value) {
483
                    if (preg_match("/^'?(.*?)'?::(.+)/", trim($value, ' ()'), $matches)) {
484
                        //In database: 'value'::TYPE
485
                        $value = $matches[1];
486
                    }
487
488
                    unset($value);
489
                }
490
                unset($value);
491
492
                $column->enumValues = $enumValues;
0 ignored issues
show
Bug Best Practice introduced by
The property enumValues does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
493
                $column->constrainName = $constraint['conname'];
494
                $column->constrained = true;
495
            }
496
        }
497
    }
498
499
    /**
500
     * Resolve native ENUM type if presented.
501
     */
502
    private static function resolveEnum(DriverInterface $driver, self $column): void
503
    {
504
        $range = $driver->query('SELECT enum_range(NULL::' . $column->type . ')')->fetchColumn(0);
505
506
        $column->enumValues = explode(',', substr($range, 1, -1));
0 ignored issues
show
Bug Best Practice introduced by
The property enumValues does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
507
508
        if (!empty($column->defaultValue)) {
509
            //In database: 'value'::enumType
510
            $column->defaultValue = substr(
0 ignored issues
show
Bug Best Practice introduced by
The property defaultValue does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
511
                $column->defaultValue,
512
                1,
513
                strpos($column->defaultValue, $column->type) - 4
514
            );
515
        }
516
    }
517
}
518