Passed
Pull Request — 2.x (#66)
by Aleksei
17:33
created

AbstractColumn::sqlStatementParts()   B

Complexity

Conditions 7
Paths 20

Size

Total Lines 22
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 7

Importance

Changes 0
Metric Value
cc 7
eloc 12
nc 20
nop 1
dl 0
loc 22
ccs 11
cts 11
cp 1
crap 7
rs 8.8333
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\Schema;
13
14
use DateTimeImmutable;
15
use Cycle\Database\ColumnInterface;
16
use Cycle\Database\Driver\DriverInterface;
17
use Cycle\Database\Exception\DefaultValueException;
18
use Cycle\Database\Exception\SchemaException;
19
use Cycle\Database\Injection\Fragment;
20
use Cycle\Database\Injection\FragmentInterface;
21
use Cycle\Database\Query\QueryParameters;
22
use Cycle\Database\Schema\Traits\ElementTrait;
23
24
/**
25
 * Abstract column schema with read (see ColumnInterface) and write abilities. Must be implemented
26
 * by driver to support DBMS specific syntax and creation rules.
27
 *
28
 * Shortcuts for various column types:
29
 *
30
 * @method $this|AbstractColumn primary()
31
 * @method $this|AbstractColumn bigPrimary()
32
 * @method $this|AbstractColumn boolean()
33
 * @method $this|AbstractColumn integer()
34
 * @method $this|AbstractColumn tinyInteger()
35
 * @method $this|AbstractColumn smallInteger()
36
 * @method $this|AbstractColumn bigInteger()
37
 * @method $this|AbstractColumn text()
38
 * @method $this|AbstractColumn tinyText()
39
 * @method $this|AbstractColumn longText()
40
 * @method $this|AbstractColumn double()
41
 * @method $this|AbstractColumn float()
42
 * @method $this|AbstractColumn datetime()
43
 * @method $this|AbstractColumn date()
44
 * @method $this|AbstractColumn time()
45
 * @method $this|AbstractColumn timestamp()
46
 * @method $this|AbstractColumn binary()
47
 * @method $this|AbstractColumn tinyBinary()
48
 * @method $this|AbstractColumn longBinary()
49
 * @method $this|AbstractColumn json()
50
 * @method $this|AbstractColumn uuid()
51
 */
52
abstract class AbstractColumn implements ColumnInterface, ElementInterface
53
{
54
    use ElementTrait;
55
56
    /**
57
     * Default timestamp expression (driver specific).
58
     */
59
    public const DATETIME_NOW = 'CURRENT_TIMESTAMP';
60
61
    /**
62
     * Value to be excluded from comparision.
63
     */
64
    public const EXCLUDE_FROM_COMPARE = ['timezone', 'userType'];
65
66
    /**
67
     * Normalization for time and dates.
68
     */
69
    public const DATE_FORMAT = 'Y-m-d';
70
    public const TIME_FORMAT = 'H:i:s';
71
72
    /**
73
     * Mapping between abstract type and internal database type with it's options. Multiple abstract
74
     * types can map into one database type, this implementation allows us to equalize two columns
75
     * if they have different abstract types but same database one. Must be declared by DBMS
76
     * specific implementation.
77
     *
78
     * Example:
79
     * integer => array('type' => 'int', 'size' => 1),
80
     * boolean => array('type' => 'tinyint', 'size' => 1)
81
     *
82
     * @internal
83
     */
84
    protected array $mapping = [
85
        //Primary sequences
86
        'primary'     => null,
87
        'bigPrimary'  => null,
88
89
        //Enum type (mapped via method)
90
        'enum'        => null,
91
92
        //Logical types
93
        'boolean'     => null,
94
95
        //Integer types (size can always be changed with size method), longInteger has method alias
96
        //bigInteger
97
        'integer'     => null,
98
        'tinyInteger' => null,
99
        'smallInteger'=> null,
100
        'bigInteger'  => null,
101
102
        //String with specified length (mapped via method)
103
        'string'      => null,
104
105
        //Generic types
106
        'text'        => null,
107
        'tinyText'    => null,
108
        'longText'    => null,
109
110
        //Real types
111
        'double'      => null,
112
        'float'       => null,
113
114
        //Decimal type (mapped via method)
115
        'decimal'     => null,
116
117
        //Date and Time types
118
        'datetime'    => null,
119
        'date'        => null,
120
        'time'        => null,
121
        'timestamp'   => null,
122
123
        //Binary types
124
        'binary'      => null,
125
        'tinyBinary'  => null,
126
        'longBinary'  => null,
127
128
        //Additional types
129
        'json'        => null,
130
    ];
131
132
    /**
133
     * Reverse mapping is responsible for generating abstact type based on database type and it's
134
     * options. Multiple database types can be mapped into one abstract type.
135
     *
136
     * @internal
137
     */
138
    protected array $reverseMapping = [
139
        'primary'     => [],
140
        'bigPrimary'  => [],
141
        'enum'        => [],
142
        'boolean'     => [],
143
        'integer'     => [],
144
        'tinyInteger' => [],
145
        'smallInteger'=> [],
146
        'bigInteger'  => [],
147
        'string'      => [],
148
        'text'        => [],
149
        'tinyText'    => [],
150
        'longText'    => [],
151
        'double'      => [],
152
        'float'       => [],
153
        'decimal'     => [],
154
        'datetime'    => [],
155
        'date'        => [],
156
        'time'        => [],
157
        'timestamp'   => [],
158
        'binary'      => [],
159
        'tinyBinary'  => [],
160
        'longBinary'  => [],
161
        'json'        => [],
162
    ];
163
164
    /**
165
     * User defined type. Only until actual mapping.
166
     */
167
    protected ?string $userType = null;
168
169
    /**
170
     * DBMS specific column type.
171
     */
172
    protected string $type = '';
173
174
    protected ?\DateTimeZone $timezone = null;
175
176
    /**
177
     * Indicates that column can contain null values.
178
     */
179
    protected bool $nullable = true;
180
181
    /**
182
     * Default column value, may not be applied to some datatypes (for example to primary keys),
183
     * should follow type size and other options.
184
     */
185
    protected mixed $defaultValue = null;
186
187
    /**
188
     * Column type size, can have different meanings for different datatypes.
189
     */
190
    protected int $size = 0;
191
192
    /**
193
     * Precision of column, applied only for "decimal" type.
194
     */
195
    protected int $precision = 0;
196
197
    /**
198
     * Scale of column, applied only for "decimal" type.
199
     */
200
    protected int $scale = 0;
201
202
    /**
203
     * List of allowed enum values.
204
     */
205
    protected array $enumValues = [];
206
207
    /**
208
     * Database Engine specific attributes.
209
     */
210
    protected array $attributes = [];
211
212
    /**
213
     * Abstract type aliases (for consistency).
214
     */
215
    private array $aliases = [
216
        'int'            => 'integer',
217
        'smallint'       => 'smallInteger',
218
        'bigint'         => 'bigInteger',
219
        'incremental'    => 'primary',
220
        'bigIncremental' => 'bigPrimary',
221
        'bool'           => 'boolean',
222
        'blob'           => 'binary',
223
    ];
224
225
    /**
226
     * Association list between abstract types and native PHP types. Every non listed type will be
227
     * converted into string.
228
     *
229
     * @internal
230
     */
231
    private array $phpMapping = [
232 1950
        self::INT   => ['primary', 'bigPrimary', 'integer', 'tinyInteger', 'smallInteger', 'bigInteger'],
233
        self::BOOL  => ['boolean'],
234
        self::FLOAT => ['double', 'float', 'decimal'],
235
    ];
236
237 1950
    /**
238 1950
     * @psalm-param non-empty-string $table
239
     * @psalm-param non-empty-string $name
240
     */
241
    public function __construct(
242
        protected string $table,
243
        protected string $name,
244
        \DateTimeZone $timezone = null
245 1750
    ) {
246
        $this->timezone = $timezone ?? new \DateTimeZone(date_default_timezone_get());
247 1750
    }
248
249
    /**
250 856
     * Shortcut for AbstractColumn->type() method.
251
     *
252 856
     * @psalm-param non-empty-string $type
253
     */
254
    public function __call(string $type, array $arguments = []): self
255
    {
256
        return $this->type($type);
257
    }
258 24
259
    public function __toString(): string
260 24
    {
261 24
        return $this->table . '.' . $this->getName();
262
    }
263 24
264 24
    /**
265 24
     * Simplified way to dump information.
266
     */
267
    public function __debugInfo(): array
268
    {
269 24
        $column = [
270 8
            'name' => $this->name,
271
            'type' => [
272
                'database' => $this->type,
273 24
                'schema'   => $this->getAbstractType(),
274 24
                'php'      => $this->getType(),
275
            ],
276
        ];
277 24
278 8
        if (!empty($this->size)) {
279
            $column['size'] = $this->size;
280
        }
281 24
282
        if ($this->nullable) {
283
            $column['nullable'] = true;
284
        }
285 24
286 16
        if ($this->defaultValue !== null) {
287 16
            $column['defaultValue'] = $this->getDefaultValue();
288
        }
289
290 24
        if ($this->getAbstractType() === 'enum') {
291
            $column['enumValues'] = $this->enumValues;
292
        }
293 8
294
        if ($this->getAbstractType() === 'decimal') {
295 8
            $column['precision'] = $this->precision;
296
            $column['scale'] = $this->scale;
297
        }
298 852
299
        return $column;
300 852
    }
301
302
    public function getSize(): int
303 852
    {
304
        return $this->size;
305 852
    }
306
307
    public function getPrecision(): int
308 8
    {
309
        return $this->precision;
310 8
    }
311
312
    public function getScale(): int
313 1798
    {
314
        return $this->scale;
315 1798
    }
316
317
    public function isNullable(): bool
318
    {
319
        return $this->nullable;
320
    }
321 1798
322
    public function hasDefaultValue(): bool
323 1798
    {
324 1514
        return $this->defaultValue !== null;
325
    }
326
327 1074
    /**
328
     * @throws DefaultValueException
329 368
     */
330
    public function getDefaultValue(): mixed
331
    {
332 850
        if (!$this->hasDefaultValue()) {
333 682
            return null;
334
        }
335
336 698
        if ($this->defaultValue instanceof FragmentInterface) {
337 170
            //Defined as SQL piece
338 293
            return $this->defaultValue;
339 205
        }
340 205
341 698
        if (\in_array($this->getAbstractType(), ['time', 'date', 'datetime', 'timestamp'])) {
342
            return $this->formatDatetime($this->getAbstractType(), $this->defaultValue);
343
        }
344
345
        return match ($this->getType()) {
346
            'int' => (int) $this->defaultValue,
347
            'float' => (float) $this->defaultValue,
348 60
            'bool' => \is_string($this->defaultValue) && strtolower($this->defaultValue) === 'false'
349
                ? false : (bool) $this->defaultValue,
350 60
            default => (string)$this->defaultValue
351
        };
352
    }
353
354
    public function setAttributes(array $attributes): self
355
    {
356 852
        $this->attributes = $attributes;
357
        return $this;
358 852
    }
359
360
    public function getAttributes(): array
361 856
    {
362
        return $this->attributes;
363 856
    }
364
365
    /**
366
     * Get every associated column constraint names.
367
     */
368
    public function getConstraints(): array
369 842
    {
370
        return [];
371 842
    }
372 842
373 842
    /**
374 618
     * Get allowed enum values.
375
     */
376
    public function getEnumValues(): array
377
    {
378 754
        return $this->enumValues;
379
    }
380
381
    public function getInternalType(): string
382
    {
383
        return $this->type;
384
    }
385
386
    /**
387
     * @psalm-return non-empty-string
388
     */
389
    public function getType(): string
390
    {
391
        $schemaType = $this->getAbstractType();
392
        foreach ($this->phpMapping as $phpType => $candidates) {
393
            if (in_array($schemaType, $candidates, true)) {
394
                return $phpType;
395
            }
396 1922
        }
397
398 1922
        return self::STRING;
399 1922
    }
400 1922
401 1890
    /**
402 1862
     * Returns type defined by the user, only until schema sync. Attention, this value is only preserved during the
403
     * declaration process. Value will become null after the schema fetched from database.
404
     *
405 1838
     * @internal
406
     */
407
    public function getDeclaredType(): ?string
408 1414
    {
409 1360
        return $this->userType;
410
    }
411
412 1034
    /**
413 1034
     * DBMS specific reverse mapping must map database specific type into limited set of abstract
414 1034
     * types.
415
     */
416
    public function getAbstractType(): string
417 1034
    {
418 876
        foreach ($this->reverseMapping as $type => $candidates) {
419
            foreach ($candidates as $candidate) {
420
                if (\is_string($candidate)) {
421
                    if (strtolower($candidate) === strtolower($this->type)) {
422 688
                        return $type;
423
                    }
424
425
                    continue;
426 4
                }
427
428
                if (strtolower($candidate['type']) !== strtolower($this->type)) {
429
                    continue;
430
                }
431
432
                foreach ($candidate as $option => $required) {
433
                    if ($option === 'type') {
434
                        continue;
435
                    }
436
437
                    if ($this->{$option} !== $required) {
438
                        continue 2;
439
                    }
440
                }
441
442 1932
                return $type;
443
            }
444 1932
        }
445
446
        return 'unknown';
447
    }
448
449 1932
    /**
450
     * Give column new abstract type. DBMS specific implementation must map provided type into one
451
     * of internal database values.
452 1932
     *
453
     * Attention, changing type of existed columns in some databases has a lot of restrictions like
454
     * cross type conversions and etc. Try do not change column type without a reason.
455 1932
     *
456 1932
     * @psalm-param non-empty-string $abstract Abstract or virtual type declared in mapping.
457
     *
458
     * @throws SchemaException
459 1932
     *
460 1656
     * @todo Support native database types (simply bypass abstractType)!
461
     */
462 1656
    public function type(string $abstract): self
463
    {
464
        if (isset($this->aliases[$abstract])) {
465
            //Make recursive
466 1442
            $abstract = $this->aliases[$abstract];
467 1442
        }
468
469
        isset($this->mapping[$abstract]) or throw new SchemaException("Undefined abstract/virtual type '{$abstract}'");
470 1442
471
        // Originally specified type.
472
        $this->userType = $abstract;
473
474
        // Resetting all values to default state.
475
        $this->size = $this->precision = $this->scale = 0;
476 698
        $this->enumValues = [];
477
478 698
        // Abstract type points to DBMS specific type
479
        if (\is_string($this->mapping[$abstract])) {
480 698
            $this->type = $this->mapping[$abstract];
481
482
            return $this;
483
        }
484
485
        // Configuring column properties based on abstractType preferences
486
        foreach ($this->mapping[$abstract] as $property => $value) {
487 954
            $this->{$property} = $value;
488
        }
489
490 954
        return $this;
491 578
    }
492
493
    /**
494 954
     * Set column nullable/not nullable.
495
     */
496 954
    public function nullable(bool $nullable = true): self
497
    {
498
        $this->nullable = $nullable;
499
500
        return $this;
501
    }
502
503
    /**
504
     * Change column default value (can be forbidden for some column types).
505
     * Use Database::TIMESTAMP_NOW to use driver specific NOW() function.
506
     */
507
    public function defaultValue(mixed $value): self
508
    {
509 304
        //Forcing driver specific values
510
        if ($value === self::DATETIME_NOW) {
511 304
            $value = static::DATETIME_NOW;
512 304
        }
513 304
514 304
        $this->defaultValue = $value;
515
516
        return $this;
517 304
    }
518
519
    /**
520
     * Set column as enum type and specify set of allowed values. Most of drivers will emulate enums
521
     * using column constraints.
522
     *
523
     * Examples:
524
     * $table->status->enum(['active', 'disabled']);
525
     * $table->status->enum('active', 'disabled');
526
     *
527
     * @param array|string $values Enum values (array or comma separated). String values only.
528
     */
529
    public function enum(string|array $values): self
530
    {
531
        $this->type('enum');
532
        $this->enumValues = array_map(
533
            'strval',
534 1008
            is_array($values) ? $values : func_get_args()
0 ignored issues
show
introduced by
The condition is_array($values) is always true.
Loading history...
535
        );
536 1008
537
        return $this;
538 1008
    }
539
540 1008
    /**
541
     * Set column type as string with limited size. Maximum allowed size is 255 bytes, use "text"
542 1008
     * abstract types for longer strings.
543
     *
544
     * Strings are perfect type to store email addresses as it big enough to store valid address
545
     * and
546
     * can be covered with unique index.
547
     *
548
     * @link http://stackoverflow.com/questions/386294/what-is-the-maximum-length-of-a-valid-email-address
549
     *
550 64
     * @param int $size Max string length.
551
     *
552 64
     * @throws SchemaException
553
     */
554 64
    public function string(int $size = 255): self
555
    {
556 56
        $this->type('string');
557 56
558
        $size < 0 && throw new SchemaException('Invalid string length value');
559 56
560
        $this->size = $size;
561
562 1464
        return $this;
563
    }
564 1464
565
    /**
566 1464
     * Set column type as decimal with specific precision and scale.
567
     *
568 458
     * @throws SchemaException
569 458
     */
570
    public function decimal(int $precision, int $scale = 0): self
571 1440
    {
572 42
        $this->type('decimal');
573 1410
574 878
        empty($precision) && throw new SchemaException('Invalid precision value');
575
576
        $this->precision = $precision;
577 1464
        $this->scale = $scale;
578
579 1464
        return $this;
580 634
    }
581
582
    protected function sqlStatementParts(DriverInterface $driver): array
583 1464
    {
584
        $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

584
        $statement = [$driver->/** @scrutinizer ignore-call */ identifier($this->name), $this->type];
Loading history...
585
586 1894
        if ($this->getAbstractType() === 'enum') {
587
            //Enum specific column options
588 1894
            if (!empty($enumDefinition = $this->quoteEnum($driver))) {
589
                $statement[] = $enumDefinition;
590
            }
591 1894
        } elseif (!empty($this->precision)) {
592 1838
            $statement[] = "({$this->precision}, {$this->scale})";
593
        } elseif (!empty($this->size)) {
594
            $statement[] = "({$this->size})";
595 1598
        }
596 1598
597
        $statement[] = $this->nullable ? 'NULL' : 'NOT NULL';
598 1598
599 1598
        if ($this->defaultValue !== null) {
600 1598
            $statement[] = "DEFAULT {$this->quoteDefault($driver)}";
601 1598
        }
602
603
        return $statement;
604 1598
    }
605
606 1598
    public function sqlStatement(DriverInterface $driver): string
607 380
    {
608
        return implode(' ', $this->sqlStatementParts($driver));
609 1554
    }
610 1554
611
    public function compare(self $initial): bool
612 16
    {
613
        $normalized = clone $initial;
614
615 1598
        // soft compare, todo: improve
616
        if ($this == $normalized) {
617
            return true;
618 1598
        }
619 154
620
        $columnVars = get_object_vars($this);
621
        $dbColumnVars = get_object_vars($normalized);
622
623 1598
        $difference = [];
624
        foreach ($columnVars as $name => $value) {
625
            if (\in_array($name, static::EXCLUDE_FROM_COMPARE, true)) {
626
                continue;
627
            }
628
629 152
            if ($name === 'defaultValue') {
630
                //Default values has to compared using type-casted value
631 152
                if ($this->getDefaultValue() != $initial->getDefaultValue()) {
632 152
                    $difference[] = $name;
633 152
                } elseif (
634
                    $this->getDefaultValue() !== $initial->getDefaultValue()
635
                    && (!\is_object($this->getDefaultValue()) && !\is_object($initial->getDefaultValue()))
636 152
                ) {
637
                    $difference[] = $name;
638
                }
639
640
                continue;
641
            }
642 846
643
            if ($value !== $dbColumnVars[$name]) {
644 846
                $difference[] = $name;
645 846
            }
646
        }
647
648
        return empty($difference);
649 846
    }
650 578
651 578
    /**
652 578
     * Get database specific enum type definition options.
653
     */
654
    protected function quoteEnum(DriverInterface $driver): string
655
    {
656
        $enumValues = [];
657 798
        foreach ($this->enumValues as $value) {
658 410
            $enumValues[] = $driver->quote($value);
659 293
        }
660 83
661 798
        return !empty($enumValues) ? '(' . implode(', ', $enumValues) . ')' : '';
662
    }
663
664
    /**
665
     * Must return driver specific default value.
666
     */
667
    protected function quoteDefault(DriverInterface $driver): string
668
    {
669
        $defaultValue = $this->getDefaultValue();
670
        if ($defaultValue === null) {
671
            return 'NULL';
672 682
        }
673
674
        if ($defaultValue instanceof FragmentInterface) {
675
            return $driver->getQueryCompiler()->compile(
676 682
                new QueryParameters(),
677
                '',
678 578
                $defaultValue
679
            );
680
        }
681 634
682 8
        return match ($this->getType()) {
683
            'bool' => $defaultValue ? 'TRUE' : 'FALSE',
684 634
            'float' => sprintf('%F', $defaultValue),
685
            'int' => (string) $defaultValue,
686 32
            default => $driver->quote($defaultValue)
687 32
        };
688
    }
689 632
690
    /**
691
     * Ensure that datetime fields are correctly formatted.
692
     *
693 634
     * @psalm-param non-empty-string $type
694 32
     *
695 301
     * @throws DefaultValueException
696
     */
697 634
    protected function formatDatetime(
698
        string $type,
699
        string|int|\DateTimeInterface $value
700
    ): \DateTimeInterface|FragmentInterface|string {
701
        if ($value === static::DATETIME_NOW) {
702
            //Dynamic default value
703
            return new Fragment($value);
0 ignored issues
show
Bug introduced by
It seems like $value can also be of type DateTimeInterface; however, parameter $fragment of Cycle\Database\Injection\Fragment::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

703
            return new Fragment(/** @scrutinizer ignore-type */ $value);
Loading history...
704
        }
705
706
        if ($value instanceof \DateTimeInterface) {
707
            $datetime = clone $value;
708
        } else {
709
            if (is_numeric($value)) {
710
                //Presumably timestamp
711
                $datetime = new DateTimeImmutable('now', $this->timezone);
712
                $datetime = $datetime->setTimestamp($value);
0 ignored issues
show
Bug introduced by
It seems like $value can also be of type string; however, parameter $timestamp of DateTimeImmutable::setTimestamp() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

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

712
                $datetime = $datetime->setTimestamp(/** @scrutinizer ignore-type */ $value);
Loading history...
713
            } else {
714
                $datetime = new DateTimeImmutable($value, $this->timezone);
715
            }
716
        }
717
718
        return match ($type) {
719
            'datetime', 'timestamp' => $datetime,
720
            'time' => $datetime->format(static::TIME_FORMAT),
721
            'date' => $datetime->format(static::DATE_FORMAT),
722
            default => $value
723
        };
724
    }
725
}
726