Passed
Pull Request — 2.x (#86)
by Maxim
18:17
created

PostgresColumn::enum()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 3

Importance

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

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