Passed
Pull Request — 2.x (#138)
by Aleksei
18:44
created

SQLServerColumn::datetime()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 6
nc 6
nop 2
dl 0
loc 11
ccs 6
cts 6
cp 1
crap 4
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\SQLServer\Schema;
13
14
use Cycle\Database\Driver\DriverInterface;
15
use Cycle\Database\Exception\SchemaException;
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
18
class SQLServerColumn extends AbstractColumn
19
{
20
    /**
21
     * Default datetime value.
22
     */
23
    public const DATETIME_DEFAULT = '1970-01-01T00:00:00';
24
25
    /**
26
     * Default timestamp expression (driver specific).
27
     */
28
    public const DATETIME_NOW = 'getdate()';
29
    public const DATETIME_PRECISION = 7;
30
31
    /**
32
     * Private state related values.
33
     */
34
    public const EXCLUDE_FROM_COMPARE = [
35
        'userType',
36
        'timezone',
37
        'constrainedDefault',
38
        'defaultConstraint',
39
        'constrainedEnum',
40
        'enumConstraint',
41
    ];
42
43
    protected array $mapping = [
44
        //Primary sequences
45
        'primary'     => ['type' => 'int', 'identity' => true, 'nullable' => false],
46
        'bigPrimary'  => ['type' => 'bigint', 'identity' => true, 'nullable' => false],
47
48
        //Enum type (mapped via method)
49
        'enum'        => 'enum',
50
51
        //Logical types
52
        'boolean'     => 'bit',
53
54
        //Integer types (size can always be changed with size method), longInteger has method alias
55
        //bigInteger
56
        'integer'     => 'int',
57
        'tinyInteger' => 'tinyint',
58
        'smallInteger'=> 'smallint',
59
        'bigInteger'  => 'bigint',
60
61
        //String with specified length (mapped via method)
62
        'string'      => 'varchar',
63
64
        //Generic types
65
        'text'        => ['type' => 'varchar', 'size' => 0],
66
        'tinyText'    => ['type' => 'varchar', 'size' => 0],
67
        'longText'    => ['type' => 'varchar', 'size' => 0],
68
69
        //Real types
70
        'double'      => 'float',
71
        'float'       => 'real',
72
73
        //Decimal type (mapped via method)
74
        'decimal'     => 'decimal',
75
76
        //Date and Time types
77
        'datetime'    => 'datetime',
78
        'datetime2'   => 'datetime2',
79
        'date'        => 'date',
80
        'time'        => 'time',
81
        'timestamp'   => 'datetime',
82
83
        //Binary types
84
        'binary'      => ['type' => 'varbinary', 'size' => 0],
85
        'tinyBinary'  => ['type' => 'varbinary', 'size' => 0],
86
        'longBinary'  => ['type' => 'varbinary', 'size' => 0],
87
88
        //Additional types
89
        'json'        => ['type' => 'varchar', 'size' => 0],
90
        'uuid'        => ['type' => 'varchar', 'size' => 36],
91
    ];
92
93
    protected array $reverseMapping = [
94
        'primary'     => [['type' => 'int', 'identity' => true]],
95
        'bigPrimary'  => [['type' => 'bigint', 'identity' => true]],
96
        'enum'        => ['enum'],
97
        'boolean'     => ['bit'],
98
        'integer'     => ['int'],
99
        'tinyInteger' => ['tinyint'],
100
        'smallInteger'=> ['smallint'],
101
        'bigInteger'  => ['bigint'],
102
        'text'        => [['type' => 'varchar', 'size' => 0]],
103
        'string'      => ['varchar', 'char'],
104
        'double'      => ['float'],
105
        'float'       => ['real'],
106
        'decimal'     => ['decimal'],
107
        'timestamp'   => ['datetime'],
108
        'date'        => ['date'],
109
        'time'        => ['time'],
110
        'binary'      => ['varbinary'],
111
    ];
112
113
    /**
114
     * Field is table identity.
115
     */
116
    protected bool $identity = false;
117
118
    protected bool $constrainedDefault = false;
119
120
    /**
121
     * Name of default constraint.
122
     */
123
    protected string $defaultConstraint = '';
124
125
    protected bool $constrainedEnum = false;
126
127 44
    /**
128
     * Name of enum constraint.
129 44
     */
130
    protected string $enumConstraint = '';
131 44
132 16
    public function getConstraints(): array
133
    {
134
        $constraints = parent::getConstraints();
135 44
136 6
        if ($this->constrainedDefault) {
137
            $constraints[] = $this->defaultConstraint;
138
        }
139 44
140
        if ($this->constrainedEnum) {
141
            $constraints[] = $this->enumConstraint;
142 480
        }
143
144 480
        return $constraints;
145
    }
146
147 152
    public function getAbstractType(): string
148
    {
149 152
        return !empty($this->enumValues) ? 'enum' : parent::getAbstractType();
150 152
    }
151
152 152
    public function enum(mixed $values): AbstractColumn
153 152
    {
154 152
        $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...
155
        sort($this->enumValues);
156
157 152
        $this->type = 'varchar';
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...
158
        foreach ($this->enumValues as $value) {
159
            $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...
160
        }
161
162
        return $this;
163
    }
164
165
    public function datetime(int $size = 0, mixed ...$attributes): self
166 480
    {
167
        $size === 0 ? $this->type('datetime') : $this->type('datetime2');
168 480
        $this->fillAttributes($attributes);
169 152
170
        ($size < 0 || $size > static::DATETIME_PRECISION) && throw new SchemaException(
171
            \sprintf('Invalid %s precision value.', $this->getAbstractType())
172 480
        );
173
        $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...
174 480
175 14
        return $this;
176 470
    }
177 266
178 446
    /**
179 224
     * @param bool $withEnum When true enum constrain will be included into definition. Set to false
180
     *                       if you want to create constrain separately.
181
     *
182 480
     * @psalm-return non-empty-string
183 334
     */
184
    public function sqlStatement(DriverInterface $driver, bool $withEnum = true): string
185
    {
186 480
        if ($withEnum && $this->getAbstractType() === 'enum') {
187
            return "{$this->sqlStatement($driver, false)} {$this->enumStatement($driver)}";
188 480
        }
189 212
190
        $statement = [$driver->identifier($this->getName()), $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->getName()), $this->type];
Loading history...
191
192 480
        if (!empty($this->precision)) {
193
            $statement[] = "({$this->precision}, {$this->scale})";
194
        } elseif (!empty($this->size)) {
195
            $statement[] = "({$this->size})";
196
        } elseif ($this->type === 'varchar' || $this->type === 'varbinary') {
197
            $statement[] = '(max)';
198
        }
199
200
        if ($this->identity) {
201 38
            $statement[] = 'IDENTITY(1,1)';
202
        }
203 38
204
        $statement[] = $this->nullable ? 'NULL' : 'NOT NULL';
205 38
206 38
        if ($this->hasDefaultValue()) {
207 38
            $statement[] = "DEFAULT {$this->quoteDefault($driver)}";
208 38
        }
209 38
210 38
        return implode(' ', $statement);
211
    }
212
213 38
    /**
214 38
     * Generate set of operations need to change column. We are expecting that column constrains
215 38
     * will be dropped separately.
216 38
     *
217 38
     * @return string[]
218 38
     */
219
    public function alterOperations(DriverInterface $driver, AbstractColumn $initial): array
220
    {
221 38
        $operations = [];
222 20
223
        $currentType = [
224
            $this->type,
225
            $this->size,
226
            $this->precision,
227
            $this->scale,
228
            $this->nullable,
229
        ];
230
231
        $initType = [
232 20
            $initial->type,
233
            $initial->size,
234 20
            $initial->precision,
235 10
            $initial->scale,
236 12
            $initial->nullable,
237 2
        ];
238 10
239
        if ($currentType !== $initType) {
240
            if ($this->getAbstractType() === 'enum') {
241
                //Getting longest value
242 20
                $enumSize = $this->size;
243
                foreach ($this->enumValues as $value) {
244
                    $enumSize = max($enumSize, strlen($value));
245
                }
246
247 38
                $type = "ALTER COLUMN {$driver->identifier($this->getName())} varchar($enumSize)";
248 14
                $operations[] = $type . ' ' . ($this->nullable ? 'NULL' : 'NOT NULL');
249 14
            } else {
250 14
                $type = "ALTER COLUMN {$driver->identifier($this->getName())} {$this->type}";
251
252
                if (!empty($this->size)) {
253
                    $type .= "($this->size)";
254 38
                } elseif ($this->type === 'varchar' || $this->type === 'varbinary') {
255 4
                    $type .= '(max)';
256
                } elseif (!empty($this->precision)) {
257
                    $type .= "($this->precision, $this->scale)";
258 38
                }
259
260
                $operations[] = $type . ' ' . ($this->nullable ? 'NULL' : 'NOT NULL');
261
            }
262
        }
263
264
        //Constraint should be already removed it this moment (see doColumnChange in TableSchema)
265
        if ($this->hasDefaultValue()) {
266 480
            $operations[] = "ADD CONSTRAINT {$this->defaultConstrain()} "
267
                . "DEFAULT {$this->quoteDefault($driver)} "
268
                . "FOR {$driver->identifier($this->getName())}";
269
        }
270
271 480
        //Constraint should be already removed it this moment (see alterColumn in SQLServerHandler)
272
        if ($this->getAbstractType() === 'enum') {
273 480
            $operations[] = "ADD {$this->enumStatement($driver)}";
274 480
        }
275 480
276
        return $operations;
277 480
    }
278
279 480
    /**
280 480
     * @psalm-param non-empty-string $table Table name.
281 224
     *
282
     * @param DriverInterface $driver SQLServer columns are bit more complex.
283
     */
284 480
    public static function createInstance(
285 14
        string $table,
286 14
        array $schema,
287
        DriverInterface $driver
288
    ): self {
289
        $column = new self($table, $schema['COLUMN_NAME'], $driver->getTimezone());
290 480
291
        $column->type = $schema['DATA_TYPE'];
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...
292
        $column->nullable = strtoupper($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...
293
        $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...
294
295
        $column->identity = (bool)$schema['is_identity'];
296
297 480
        $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...
298
        if ($column->size === -1) {
299 212
            $column->size = 0;
300 212
        }
301
302 212
        if ($column->type === 'decimal') {
303
            $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...
304 212
            $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...
305
        }
306 212
307 212
        if ($column->type === 'datetime2') {
308
            $column->size = (int) $schema['DATETIME_PRECISION'];
309
        }
310
311
        //Normalizing default value
312 480
        $column->normalizeDefault();
313 266
314
        /*
315
        * We have to fetch all column constrains cos default and enum check will be included into
316 480
        * them, plus column drop is not possible without removing all constraints.
317
        */
318
319
        if (!empty($schema['default_object_id'])) {
320
            //Looking for default constrain id
321
            $column->defaultConstraint = $driver->query(
322 212
                'SELECT [name] FROM [sys].[default_constraints] WHERE [object_id] = ?',
323
                [
324 212
                    $schema['default_object_id'],
325 212
                ]
326 136
            )->fetchColumn();
327
328
            if (!empty($column->defaultConstraint)) {
329 212
                $column->constrainedDefault = true;
330
            }
331
        }
332
333
        //Potential enum
334
        if ($column->type === 'varchar' && !empty($column->size)) {
335 152
            self::resolveEnum($driver, $schema, $column);
336
        }
337 152
338 152
        return $column;
339
    }
340
341 152
    /**
342
     * @psalm-return non-empty-string
343
     */
344
    protected function quoteDefault(DriverInterface $driver): string
345
    {
346
        $defaultValue = parent::quoteDefault($driver);
347 14
        if ($this->getAbstractType() === 'boolean') {
348
            $defaultValue = (string) ((int)$this->defaultValue);
349 14
        }
350
351
        return $defaultValue;
352
    }
353 14
354
    /**
355
     * Get/generate name of enum constraint.
356
     */
357
    protected function enumConstraint(): string
358
    {
359
        if (empty($this->enumConstraint)) {
360
            $this->enumConstraint = $this->table . '_' . $this->getName() . '_enum_' . uniqid();
361 152
        }
362
363 152
        return $this->enumConstraint;
364 152
    }
365 152
366
    /**
367
     * Get/generate name of default constrain.
368 152
     */
369 152
    protected function defaultConstrain(): string
370 152
    {
371
        if (empty($this->defaultConstraint)) {
372 152
            $this->defaultConstraint = $this->table . '_' . $this->getName() . '_default_' . uniqid();
373
        }
374
375
        return $this->defaultConstraint;
376
    }
377
378 480
    protected static function isJson(AbstractColumn $column): ?bool
379
    {
380 480
        // In SQL Server, we cannot determine if a column has a JSON type.
381 412
        return $column->getAbstractType() === 'text' ? null : false;
382
    }
383
384 212
    /**
385
     * In SQLServer we can emulate enums similar way as in Postgres via column constrain.
386 212
     *
387
     * @psalm-return non-empty-string
388
     */
389 212
    private function enumStatement(DriverInterface $driver): string
390 178
    {
391
        $enumValues = [];
392
        foreach ($this->enumValues as $value) {
393
            $enumValues[] = $driver->quote($value);
394 212
        }
395
396 154
        $constrain = $driver->identifier($this->enumConstraint());
397 212
        $column = $driver->identifier($this->getName());
398
        $enumValues = implode(', ', $enumValues);
399
400
        return "CONSTRAINT {$constrain} CHECK ({$column} IN ({$enumValues}))";
401 154
    }
402
403 212
    /**
404
     * Normalize default value.
405
     */
406
    private function normalizeDefault(): void
407
    {
408 266
        if (!$this->hasDefaultValue()) {
409
            return;
410
        }
411
412
        if ($this->defaultValue[0] === '(' && $this->defaultValue[strlen($this->defaultValue) - 1] === ')') {
413 266
            //Cut braces
414
            $this->defaultValue = substr($this->defaultValue, 1, -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...
415
        }
416
417
        if (preg_match('/^[\'"].*?[\'"]$/', $this->defaultValue)) {
418 266
            $this->defaultValue = substr($this->defaultValue, 1, -1);
419
        }
420 266
421 152
        if (
422 152
            $this->getType() !== 'string'
423
            && (
424 152
                $this->defaultValue[0] === '('
425
                && $this->defaultValue[strlen($this->defaultValue) - 1] === ')'
426
            )
427
        ) {
428 152
            //Cut another braces
429 152
            $this->defaultValue = substr($this->defaultValue, 1, -1);
430 152
        }
431
    }
432
433
    /**
434
     * Resolve enum values if any.
435 152
     */
436 152
    private static function resolveEnum(
437
        DriverInterface $driver,
438
        array $schema,
439 266
        self $column
440
    ): void {
441
        $query = 'SELECT object_definition([o].[object_id]) AS [definition], '
442
            . "OBJECT_NAME([o].[object_id]) AS [name]\nFROM [sys].[objects] AS [o]\n"
443
            . "JOIN [sys].[sysconstraints] AS [c] ON [o].[object_id] = [c].[constid]\n"
444
            . "WHERE [type_desc] = 'CHECK_CONSTRAINT' AND [parent_object_id] = ? AND [c].[colid] = ?";
445
446
        $constraints = $driver->query($query, [$schema['object_id'], $schema['column_id']]);
447
448
        foreach ($constraints as $constraint) {
449
            $column->enumConstraint = $constraint['name'];
450
            $column->constrainedEnum = true;
451
452
            $name = preg_quote($driver->identifier($column->getName()));
453
454
            // we made some assumptions here...
455
            if (
456
                preg_match_all(
457
                    '/' . $name . '=[\']?([^\']+)[\']?/i',
458
                    $constraint['definition'],
459
                    $matches
460
                )
461
            ) {
462
                //Fetching enum values
463
                $column->enumValues = $matches[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...
464
                sort($column->enumValues);
465
            }
466
        }
467
    }
468
}
469