Completed
Branch feature/pre-split (1effb8)
by Anton
03:04
created

PostgresColumn::getConstraints()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 0
dl 0
loc 10
rs 9.4285
c 0
b 0
f 0
1
<?php
2
/**
3
 * components
4
 *
5
 * @author    Wolfy-J
6
 */
7
namespace Spiral\Database\Drivers\Postgres\Schemas;
8
9
use Spiral\Database\Entities\Driver;
10
use Spiral\Database\Injections\Fragment;
11
use Spiral\Database\Schemas\Prototypes\AbstractColumn;
12
13
class PostgresColumn extends AbstractColumn
14
{
15
    /**
16
     * {@inheritdoc}
17
     */
18
    protected $mapping = [
19
        //Primary sequences
20
        'primary'     => ['type' => 'serial', 'autoIncrement' => true, 'nullable' => false],
21
        'bigPrimary'  => ['type' => 'bigserial', 'autoIncrement' => true, 'nullable' => false],
22
23
        //Enum type (mapped via method)
24
        'enum'        => 'enum',
25
26
        //Logical types
27
        'boolean'     => 'boolean',
28
29
        //Integer types (size can always be changed with size method), longInteger has method alias
30
        //bigInteger
31
        'integer'     => 'integer',
32
        'tinyInteger' => 'smallint',
33
        'bigInteger'  => 'bigint',
34
35
        //String with specified length (mapped via method)
36
        'string'      => 'character varying',
37
38
        //Generic types
39
        'text'        => 'text',
40
        'tinyText'    => 'text',
41
        'longText'    => 'text',
42
43
        //Real types
44
        'double'      => 'double precision',
45
        'float'       => 'real',
46
47
        //Decimal type (mapped via method)
48
        'decimal'     => 'numeric',
49
50
        //Date and Time types
51
        'datetime'    => 'timestamp without time zone',
52
        'date'        => 'date',
53
        'time'        => 'time',
54
        'timestamp'   => 'timestamp without time zone',
55
56
        //Binary types
57
        'binary'      => 'bytea',
58
        'tinyBinary'  => 'bytea',
59
        'longBinary'  => 'bytea',
60
61
        //Additional types
62
        'json'        => 'json',
63
    ];
64
65
    /**
66
     * {@inheritdoc}
67
     */
68
    protected $reverseMapping = [
69
        'primary'     => ['serial'],
70
        'bigPrimary'  => ['bigserial'],
71
        'enum'        => ['enum'],
72
        'boolean'     => ['boolean'],
73
        'integer'     => ['int', 'integer', 'int4'],
74
        'tinyInteger' => ['smallint'],
75
        'bigInteger'  => ['bigint', 'int8'],
76
        'string'      => ['character varying', 'character'],
77
        'text'        => ['text'],
78
        'double'      => ['double precision'],
79
        'float'       => ['real', 'money'],
80
        'decimal'     => ['numeric'],
81
        'date'        => ['date'],
82
        'time'        => ['time', 'time with time zone', 'time without time zone'],
83
        'timestamp'   => ['timestamp', 'timestamp with time zone', 'timestamp without time zone'],
84
        'binary'      => ['bytea'],
85
        'json'        => ['json'],
86
    ];
87
88
    /**
89
     * Field is auto incremental.
90
     *
91
     * @var bool
92
     */
93
    protected $autoIncrement = false;
94
95
    /**
96
     * Indication that column have enum constrain.
97
     *
98
     * @var bool
99
     */
100
    protected $constrained = false;
101
102
    /**
103
     * Name of enum constraint associated with field.
104
     *
105
     * @var string
106
     */
107
    protected $constrainName = '';
108
109
    /**
110
     * {@inheritdoc}
111
     */
112
    public function getConstraints(): array
113
    {
114
        $constraints = parent::getConstraints();
115
116
        if ($this->constrained) {
117
            $constraints[] = $this->constrainName;
118
        }
119
120
        return $constraints;
121
    }
122
123
    /**
124
     * {@inheritdoc}
125
     */
126
    public function abstractType(): string
127
    {
128
        if (!empty($this->enumValues)) {
129
            return 'enum';
130
        }
131
132
        return parent::abstractType();
133
    }
134
135
    /**
136
     * Overriding default magically happen method for custom logic.
137
     *
138
     * @return $this|AbstractColumn
139
     */
140
    public function primary(): AbstractColumn
141
    {
142
        if (!empty($this->type) && $this->type != 'serial') {
143
            //Change type of already existed column (we can't use "serial" alias here)
144
            $this->type = 'integer';
145
146
            return $this;
147
        }
148
149
        return $this->setType('primary');
150
    }
151
152
    /**
153
     * Overriding default magically happen method for custom logic.
154
     *
155
     * @return $this|AbstractColumn
156
     */
157
    public function bigPrimary(): AbstractColumn
158
    {
159
        if (!empty($this->type) && $this->type != 'bigserial') {
160
            //Change type of already existed column (we can't use "serial" alias here)
161
            $this->type = 'bigint';
162
163
            return $this;
164
        }
165
166
        return $this->setType('bigPrimary');
167
    }
168
169
    /**
170
     * Postgres requires custom logic for enum values.
171
     *
172
     * @param array|string $values
173
     *
174
     * @return AbstractColumn
175
     */
176
    public function enum($values): AbstractColumn
177
    {
178
        $this->enumValues = array_map('strval', is_array($values) ? $values : func_get_args());
179
180
        $this->type = 'character';
181
        foreach ($this->enumValues as $value) {
182
            $this->size = max((int)$this->size, strlen($value));
183
        }
184
185
        return $this;
186
    }
187
188
    /**
189
     * {@inheritdoc}
190
     */
191
    public function sqlStatement(Driver $driver): string
192
    {
193
        $statement = parent::sqlStatement($driver);
194
195
        if ($this->abstractType() != 'enum') {
196
            //Nothing special
197
            return $statement;
198
        }
199
200
        //We have add constraint for enum type
201
        $enumValues = [];
202
        foreach ($this->enumValues as $value) {
203
            $enumValues[] = $driver->quote($value);
204
        }
205
206
        $constrain = $driver->identifier($this->enumConstraint());
207
        $column = $driver->identifier($this->getName());
208
        $values = implode(', ', $enumValues);
209
210
        return "{$statement} CONSTRAINT {$constrain} CHECK ($column IN ({$values}))";
211
    }
212
213
    /**
214
     * Generate set of operations need to change column.
215
     *
216
     * @param Driver         $driver
217
     * @param AbstractColumn $initial
218
     *
219
     * @return array
220
     */
221
    public function alterOperations(Driver $driver, AbstractColumn $initial): array
222
    {
223
        $operations = [];
224
225
        //To simplify comparation
226
        $currentType = [$this->type, $this->size, $this->precision, $this->scale];
227
        $initialType = [$initial->type, $initial->size, $initial->precision, $initial->scale];
228
229
        $identifier = $driver->identifier($this->getName());
230
231
        /*
232
         * This block defines column type and all variations.
233
         */
234
        if ($currentType != $initialType) {
235
            if ($this->abstractType() == 'enum') {
236
                //Getting longest value
237
                $enumSize = $this->size;
238
                foreach ($this->enumValues as $value) {
239
                    $enumSize = max($enumSize, strlen($value));
240
                }
241
242
                $type = "ALTER COLUMN {$identifier} TYPE character($enumSize)";
243
                $operations[] = $type;
244
            } else {
245
                $type = "ALTER COLUMN {$identifier} TYPE {$this->type}";
246
247
                if (!empty($this->size)) {
248
                    $type .= "($this->size)";
249
                } elseif (!empty($this->precision)) {
250
                    $type .= "($this->precision, $this->scale)";
251
                }
252
253
                //Required to perform cross conversion
254
                $operations[] = "{$type} USING {$identifier}::{$this->type}";
255
            }
256
        }
257
258
        //Dropping enum constrain before any operation
259
        if ($initial->abstractType() == 'enum' && $this->constrained) {
260
            $operations[] = 'DROP CONSTRAINT ' . $driver->identifier($this->enumConstraint());
261
        }
262
263
        //Default value set and dropping
264
        if ($initial->defaultValue != $this->defaultValue) {
265
            if (is_null($this->defaultValue)) {
266
                $operations[] = "ALTER COLUMN {$identifier} DROP DEFAULT";
267
            } else {
268
                $operations[] = "ALTER COLUMN {$identifier} SET DEFAULT {$this->prepareDefault($driver)}";
269
            }
270
        }
271
272
        //Nullable option
273
        if ($initial->nullable != $this->nullable) {
274
            $operations[] = "ALTER COLUMN {$identifier} " . (!$this->nullable ? 'SET' : 'DROP') . ' NOT NULL';
275
        }
276
277
        if ($this->abstractType() == 'enum') {
278
            $enumValues = [];
279
            foreach ($this->enumValues as $value) {
280
                $enumValues[] = $driver->quote($value);
281
            }
282
283
            $operations[] = "ADD CONSTRAINT {$driver->identifier($this->enumConstraint())} "
284
                . "CHECK ({$identifier} IN (" . implode(', ', $enumValues) . '))';
285
        }
286
287
        return $operations;
288
    }
289
290
    /**
291
     * {@inheritdoc}
292
     */
293
    protected function prepareEnum(Driver $driver): string
294
    {
295
        //Postgres enums are just constrained strings
296
        return '(' . $this->size . ')';
297
    }
298
299
    /**
300
     * Get/generate name for enum constraint.
301
     *
302
     * @todo investigate potential issue with entity non handling enum correctly when multiple
303
     * @todo column changes happen in one session (who the hell will do that?)
304
     *
305
     * @return string
306
     */
307
    private function enumConstraint(): string
308
    {
309
        if (empty($this->constrainName)) {
310
            $this->constrainName = $this->table . '_' . $this->getName() . '_enum_' . uniqid();
311
        }
312
313
        return $this->constrainName;
314
    }
315
316
    /**
317
     * Normalize default value.
318
     */
319
    private function normalizeDefault()
320
    {
321
        if ($this->hasDefaultValue()) {
322
            if (preg_match("/^'?(.*?)'?::(.+)/", $this->defaultValue, $matches)) {
323
                //In database: 'value'::TYPE
324
                $this->defaultValue = $matches[1];
325
            } elseif ($this->type == 'bit') {
326
                $this->defaultValue = bindec(
327
                    substr($this->defaultValue, 2, strpos($this->defaultValue, '::') - 3)
328
                );
329
            } elseif ($this->type == 'boolean') {
330
                $this->defaultValue = (strtolower($this->defaultValue) == 'true');
331
            }
332
        }
333
    }
334
335
    /**
336
     * @param string $table  Table name.
337
     * @param array  $schema
338
     * @param Driver $driver Postgres columns are bit more complex.
339
     *
340
     * @return PostgresColumn
341
     */
342
    public static function createInstance(
343
        string $table,
344
        array $schema,
345
        Driver $driver
346
    ): self {
347
        $column = new self($table, $schema['column_name']);
348
349
        $column->type = $schema['data_type'];
350
        $column->defaultValue = $schema['column_default'];
351
        $column->nullable = $schema['is_nullable'] == 'YES';
352
353
        if (
354
            in_array($column->type, ['int', 'bigint', 'integer'])
355
            && preg_match('/nextval(.*)/', $column->defaultValue)
356
        ) {
357
            $column->type = ($column->type == 'bigint' ? 'bigserial' : 'serial');
358
            $column->autoIncrement = true;
359
360
            $column->defaultValue = new Fragment($column->defaultValue);
361
362
            return $column;
363
        }
364
365
        if (strpos($column->type, 'char') !== false && $schema['character_maximum_length']) {
366
            $column->size = $schema['character_maximum_length'];
367
        }
368
369
        if ($column->type == 'numeric') {
370
            $column->precision = $schema['numeric_precision'];
371
            $column->scale = $schema['numeric_scale'];
372
        }
373
374
        if ($column->type == 'USER-DEFINED' && $schema['typtype'] == 'e') {
375
            $column->type = $schema['typname'];
376
377
            /**
378
             * Attention, this is not default spiral enum type emulated via CHECK. This is real
379
             * Postgres enum type.
380
             */
381
            self::resolveEnum($driver, $column);
382
        }
383
384
        if (strpos($column->type, 'char') !== false && !empty($column->size)) {
385
            //Potential enum with manually created constraint (check in)
386
            self::resolveConstrains($driver, $schema['tableOID'], $column);
387
        }
388
389
        $column->normalizeDefault();
390
391
        return $column;
392
    }
393
394
    /**
395
     * Resolving enum constrain and converting it into proper enum values set.
396
     *
397
     * @param Driver         $driver
398
     * @param string|int     $tableOID
399
     * @param PostgresColumn $column
400
     */
401
    private static function resolveConstrains(Driver $driver, $tableOID, PostgresColumn $column)
402
    {
403
        $query = "SELECT conname, consrc FROM pg_constraint WHERE conrelid = ? AND contype = 'c' AND (consrc LIKE ? OR consrc LIKE ?)";
404
405
        $constraints = $driver->query($query, [
406
            $tableOID,
407
            '(' . $column->name . '%',
408
            '("' . $column->name . '%'
409
        ]);
410
411
        foreach ($constraints as $constraint) {
412
            if (preg_match('/ARRAY\[([^\]]+)\]/', $constraint['consrc'], $matches)) {
413
                $enumValues = explode(',', $matches[1]);
414 View Code Duplication
                foreach ($enumValues as &$value) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
415
                    if (preg_match("/^'?(.*?)'?::(.+)/", trim($value), $matches)) {
416
                        //In database: 'value'::TYPE
417
                        $value = $matches[1];
418
                    }
419
420
                    unset($value);
421
                }
422
423
                $column->enumValues = $enumValues;
424
                $column->constrainName = $constraint['conname'];
425
                $column->constrained = true;
426
            }
427
        }
428
    }
429
430
    /**
431
     * Resolve native ENUM type if presented.
432
     *
433
     * @param Driver         $driver
434
     * @param PostgresColumn $column
435
     */
436
    private static function resolveEnum(Driver $driver, PostgresColumn $column)
437
    {
438
        $range = $driver->query('SELECT enum_range(NULL::' . $column->type . ')')->fetchColumn(0);
439
440
        $column->enumValues = explode(',', substr($range, 1, -1));
441
442
        if (!empty($column->defaultValue)) {
443
            //In database: 'value'::enumType
444
            $column->defaultValue = substr(
445
                $column->defaultValue,
446
                1,
447
                strpos($column->defaultValue, $column->type) - 4
448
            );
449
        }
450
    }
451
}