Passed
Pull Request — 2.x (#88)
by Aleksei
19:47
created

PostgresColumn::primary()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 10
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 3.1406

Importance

Changes 0
Metric Value
cc 3
eloc 4
nc 2
nop 0
dl 0
loc 10
ccs 3
cts 4
cp 0.75
crap 3.1406
rs 10
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\Injection\Fragment;
16
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...
17
use Cycle\Database\Schema\Attribute\ColumnAttribute;
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
    public const DATETIME_PRECISION = 6;
33
34
    /**
35
     * Private state related values.
36
     */
37
    public const EXCLUDE_FROM_COMPARE = [
38
        'userType',
39
        'timezone',
40
        'constrained',
41
        'constrainName',
42
        'attributes',
43
    ];
44
45
    protected array $mapping = [
46
        //Primary sequences
47
        'primary'     => ['type' => 'serial', 'autoIncrement' => true, 'nullable' => false],
48
        'bigPrimary'  => ['type' => 'bigserial', 'autoIncrement' => true, 'nullable' => false],
49
50
        //Enum type (mapped via method)
51
        'enum'        => 'enum',
52
53
        //Logical types
54
        'boolean'     => 'boolean',
55
56
        //Integer types (size can always be changed with size method), longInteger has method alias
57
        //bigInteger
58
        'integer'     => 'integer',
59
        'tinyInteger' => 'smallint',
60
        'smallInteger'=> 'smallint',
61
        'bigInteger'  => 'bigint',
62
63
        //String with specified length (mapped via method)
64
        'string'      => 'character varying',
65
66
        //Generic types
67
        'text'        => 'text',
68
        'tinyText'    => 'text',
69
        'longText'    => 'text',
70
71
        //Real types
72
        'double'      => 'double precision',
73
        'float'       => 'real',
74
75
        //Decimal type (mapped via method)
76
        'decimal'     => 'numeric',
77
78
        //Date and Time types
79
        'datetime'    => 'timestamp',
80
        'date'        => 'date',
81
        'time'        => 'time',
82
        'timetz'      => ['type' => 'time', 'withTimezone' => true],
83
        'timestamp'   => 'timestamp',
84
        'timestamptz' => ['type' => 'timestamp', 'withTimezone' => true],
85
86
        //Binary types
87
        'binary'      => 'bytea',
88
        'tinyBinary'  => 'bytea',
89
        'longBinary'  => 'bytea',
90
91
        //Additional types
92
        'json'        => 'text',
93
        'jsonb'       => 'jsonb',
94
        'uuid'        => 'uuid',
95
    ];
96
97
    protected array $reverseMapping = [
98
        'primary'     => ['serial'],
99
        'bigPrimary'  => ['bigserial'],
100
        'enum'        => ['enum'],
101
        'boolean'     => ['boolean'],
102
        'integer'     => ['int', 'integer', 'int4'],
103
        'tinyInteger' => ['smallint'],
104
        'smallInteger'=> ['smallint'],
105
        'bigInteger'  => ['bigint', 'int8'],
106
        'string'      => ['character varying', 'character'],
107
        'text'        => ['text'],
108
        'double'      => ['double precision'],
109
        'float'       => ['real', 'money'],
110
        'decimal'     => ['numeric'],
111
        'date'        => ['date'],
112
        'time'        => [['type' => 'time', 'withTimezone' => false]],
113
        'timetz'      => [['type' => 'time', 'withTimezone' => true]],
114
        'timestamp'   => [['type' => 'timestamp', 'withTimezone' => false]],
115
        'timestamptz' => [['type' => 'timestamp', 'withTimezone' => true]],
116
        'binary'      => ['bytea'],
117
        'json'        => ['json'],
118
        'jsonb'       => ['jsonb'],
119
    ];
120
121
    /**
122 8
     * Field is auto incremental.
123
     */
124 8
    protected bool $autoIncrement = false;
125
126 8
    /**
127
     * Indication that column has enum constrain.
128
     */
129
    protected bool $constrained = false;
130 8
131
    /**
132
     * Name of enum constraint associated with field.
133
     */
134
    protected string $constrainName = '';
135
136 516
    #[ColumnAttribute(['timestamp', 'time', 'timestamptz', 'timetz'])]
137
    protected bool $withTimezone = false;
138 516
139
    public function getConstraints(): array
140
    {
141 366
        $constraints = parent::getConstraints();
142
143 366
        if ($this->constrained) {
144
            $constraints[] = $this->constrainName;
145
        }
146
147
        return $constraints;
148
    }
149
150 366
    /**
151
     * @psalm-return non-empty-string
152
     */
153 2
    public function getAbstractType(): string
154
    {
155 2
        return !empty($this->enumValues) ? 'enum' : parent::getAbstractType();
156
    }
157
158
    public function primary(): AbstractColumn
159
    {
160
        if (!empty($this->type) && $this->type !== 'serial') {
161
            //Change type of already existed column (we can't use "serial" alias here)
162 2
            $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...
163
164
            return $this;
165 156
        }
166
167 156
        return $this->type('primary');
168
    }
169 156
170 156
    public function bigPrimary(): AbstractColumn
171 156
    {
172
        if (!empty($this->type) && $this->type !== 'bigserial') {
173
            //Change type of already existed column (we can't use "serial" alias here)
174 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...
175
176
            return $this;
177
        }
178
179
        return $this->type('bigPrimary');
180 514
    }
181
182 514
    public function enum(string|array $values): AbstractColumn
183
    {
184 514
        $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...
185
186 506
        $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...
187
        foreach ($this->enumValues as $value) {
188
            $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...
189
        }
190 154
191 154
        return $this;
192 154
    }
193
194
    /**
195 154
     * @psalm-return non-empty-string
196 154
     */
197 154
    public function sqlStatement(DriverInterface $driver): string
198
    {
199 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

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