Passed
Pull Request — 2.x (#88)
by Aleksei
19:20
created

AbstractColumn::getPrecision()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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

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

714
            return new Fragment(/** @scrutinizer ignore-type */ $value);
Loading history...
715
        }
716
717
        if ($value instanceof \DateTimeInterface) {
718
            $datetime = clone $value;
719
        } else {
720
            if (is_numeric($value)) {
721
                //Presumably timestamp
722
                $datetime = new DateTimeImmutable('now', $this->timezone);
723
                $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

723
                $datetime = $datetime->setTimestamp(/** @scrutinizer ignore-type */ $value);
Loading history...
724
            } else {
725
                $datetime = new DateTimeImmutable($value, $this->timezone);
726
            }
727
        }
728
729
        return match ($type) {
730
            'datetime', 'timestamp' => $datetime,
731
            'time' => $datetime->format(static::TIME_FORMAT),
732
            'date' => $datetime->format(static::DATE_FORMAT),
733
            default => $value
734
        };
735
    }
736
}
737