Passed
Pull Request — 2.x (#66)
by Alexander
19:42
created

AbstractColumn::getDefaultValue()   A

Complexity

Conditions 6
Paths 4

Size

Total Lines 21
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 6

Importance

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

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

699
            return new Fragment(/** @scrutinizer ignore-type */ $value);
Loading history...
700
        }
701
702
        if ($value instanceof \DateTimeInterface) {
703
            $datetime = clone $value;
704
        } else {
705
            if (is_numeric($value)) {
706
                //Presumably timestamp
707
                $datetime = new DateTimeImmutable('now', $this->timezone);
708
                $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

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