Test Failed
Pull Request — 2.x (#31)
by Aleksei
02:38
created

AbstractColumn::formatDatetime()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 26
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 15
nc 4
nop 2
dl 0
loc 26
rs 9.7666
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 AbstractColumn|$this primary()
31
 * @method AbstractColumn|$this bigPrimary()
32
 * @method AbstractColumn|$this boolean()
33
 * @method AbstractColumn|$this integer()
34
 * @method AbstractColumn|$this tinyInteger()
35
 * @method AbstractColumn|$this bigInteger()
36
 * @method AbstractColumn|$this text()
37
 * @method AbstractColumn|$this tinyText()
38
 * @method AbstractColumn|$this longText()
39
 * @method AbstractColumn|$this double()
40
 * @method AbstractColumn|$this float()
41
 * @method AbstractColumn|$this datetime()
42
 * @method AbstractColumn|$this date()
43
 * @method AbstractColumn|$this time()
44
 * @method AbstractColumn|$this timestamp()
45
 * @method AbstractColumn|$this binary()
46
 * @method AbstractColumn|$this tinyBinary()
47
 * @method AbstractColumn|$this longBinary()
48
 * @method AbstractColumn|$this json()
49
 * @method AbstractColumn|$this 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
     * Abstract type aliases (for consistency).
206
     */
207
    private array $aliases = [
208
        'int'            => 'integer',
209
        'bigint'         => 'bigInteger',
210
        'incremental'    => 'primary',
211
        'bigIncremental' => 'bigPrimary',
212
        'bool'           => 'boolean',
213
        'blob'           => 'binary',
214
    ];
215
216
    /**
217
     * Association list between abstract types and native PHP types. Every non listed type will be
218
     * converted into string.
219
     *
220
     * @internal
221
     */
222
    private array $phpMapping = [
223
        self::INT   => ['primary', 'bigPrimary', 'integer', 'tinyInteger', 'bigInteger'],
224
        self::BOOL  => ['boolean'],
225
        self::FLOAT => ['double', 'float', 'decimal'],
226
    ];
227
228
    /**
229
     * @psalm-param non-empty-string $table
230
     * @psalm-param non-empty-string $name
231
     */
232
    public function __construct(
233
        protected string $table,
234
        protected string $name,
235
        \DateTimeZone $timezone = null
236
    ) {
237
        $this->timezone = $timezone ?? new \DateTimeZone(date_default_timezone_get());
238
    }
239
240
    /**
241
     * Shortcut for AbstractColumn->type() method.
242
     * @psalm-param non-empty-string $type
243
     */
244
    public function __call(string $type, array $arguments = []): 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...
245
    {
246
        return $this->type($type);
247
    }
248
249
    public function __toString(): string
250
    {
251
        return $this->table . '.' . $this->getName();
252
    }
253
254
    /**
255
     * Simplified way to dump information.
256
     */
257
    public function __debugInfo(): array
258
    {
259
        $column = [
260
            'name' => $this->name,
261
            'type' => [
262
                'database' => $this->type,
263
                'schema'   => $this->getAbstractType(),
264
                'php'      => $this->getType(),
265
            ],
266
        ];
267
268
        if (!empty($this->size)) {
269
            $column['size'] = $this->size;
270
        }
271
272
        if ($this->nullable) {
273
            $column['nullable'] = true;
274
        }
275
276
        if ($this->defaultValue !== null) {
277
            $column['defaultValue'] = $this->getDefaultValue();
278
        }
279
280
        if ($this->getAbstractType() === 'enum') {
281
            $column['enumValues'] = $this->enumValues;
282
        }
283
284
        if ($this->getAbstractType() === 'decimal') {
285
            $column['precision'] = $this->precision;
286
            $column['scale'] = $this->scale;
287
        }
288
289
        return $column;
290
    }
291
292
    public function getSize(): int
293
    {
294
        return $this->size;
295
    }
296
297
    public function getPrecision(): int
298
    {
299
        return $this->precision;
300
    }
301
302
    public function getScale(): int
303
    {
304
        return $this->scale;
305
    }
306
307
    public function isNullable(): bool
308
    {
309
        return $this->nullable;
310
    }
311
312
    public function hasDefaultValue(): bool
313
    {
314
        return $this->defaultValue !== null;
315
    }
316
317
    /**
318
     * @throws DefaultValueException
319
     */
320
    public function getDefaultValue(): mixed
321
    {
322
        if (!$this->hasDefaultValue()) {
323
            return null;
324
        }
325
326
        if ($this->defaultValue instanceof FragmentInterface) {
327
            //Defined as SQL piece
328
            return $this->defaultValue;
329
        }
330
331
        if (\in_array($this->getAbstractType(), ['time', 'date', 'datetime', 'timestamp'])) {
332
            return $this->formatDatetime($this->getAbstractType(), $this->defaultValue);
333
        }
334
335
        return match ($this->getType()) {
336
            'int' => (int) $this->defaultValue,
337
            'float' => (float) $this->defaultValue,
338
            'bool' => \is_string($this->defaultValue) && strtolower($this->defaultValue) === 'false'
339
                ? false : (bool) $this->defaultValue,
340
            default => (string)$this->defaultValue
341
        };
342
    }
343
344
    /**
345
     * Get every associated column constraint names.
346
     */
347
    public function getConstraints(): array
348
    {
349
        return [];
350
    }
351
352
    /**
353
     * Get allowed enum values.
354
     */
355
    public function getEnumValues(): array
356
    {
357
        return $this->enumValues;
358
    }
359
360
    public function getInternalType(): string
361
    {
362
        return $this->type;
363
    }
364
365
    /**
366
     * @psalm-return non-empty-string
367
     */
368
    public function getType(): string
369
    {
370
        $schemaType = $this->getAbstractType();
371
        foreach ($this->phpMapping as $phpType => $candidates) {
372
            if (in_array($schemaType, $candidates, true)) {
373
                return $phpType;
374
            }
375
        }
376
377
        return self::STRING;
378
    }
379
380
    /**
381
     * Returns type defined by the user, only until schema sync. Attention, this value is only preserved during the
382
     * declaration process. Value will become null after the schema fetched from database.
383
     *
384
     * @internal
385
     */
386
    public function getDeclaredType(): ?string
387
    {
388
        return $this->userType;
389
    }
390
391
    /**
392
     * DBMS specific reverse mapping must map database specific type into limited set of abstract
393
     * types.
394
     */
395
    public function getAbstractType(): string
396
    {
397
        foreach ($this->reverseMapping as $type => $candidates) {
398
            foreach ($candidates as $candidate) {
399
                if (\is_string($candidate)) {
400
                    if (strtolower($candidate) === strtolower($this->type)) {
401
                        return $type;
402
                    }
403
404
                    continue;
405
                }
406
407
                if (strtolower($candidate['type']) !== strtolower($this->type)) {
408
                    continue;
409
                }
410
411
                foreach ($candidate as $option => $required) {
412
                    if ($option === 'type') {
413
                        continue;
414
                    }
415
416
                    if ($this->{$option} !== $required) {
417
                        continue 2;
418
                    }
419
                }
420
421
                return $type;
422
            }
423
        }
424
425
        return 'unknown';
426
    }
427
428
    /**
429
     * Give column new abstract type. DBMS specific implementation must map provided type into one
430
     * of internal database values.
431
     *
432
     * Attention, changing type of existed columns in some databases has a lot of restrictions like
433
     * cross type conversions and etc. Try do not change column type without a reason.
434
     *
435
     * @psalm-param non-empty-string $abstract Abstract or virtual type declared in mapping.
436
     *
437
     * @throws SchemaException
438
     * @todo Support native database types (simply bypass abstractType)!
439
     */
440
    public function type(string $abstract): AbstractColumn
441
    {
442
        if (isset($this->aliases[$abstract])) {
443
            //Make recursive
444
            $abstract = $this->aliases[$abstract];
445
        }
446
447
        isset($this->mapping[$abstract]) or throw new SchemaException("Undefined abstract/virtual type '{$abstract}'");
448
449
        // Originally specified type.
450
        $this->userType = $abstract;
451
452
        // Resetting all values to default state.
453
        $this->size = $this->precision = $this->scale = 0;
454
        $this->enumValues = [];
455
456
        // Abstract type points to DBMS specific type
457
        if (\is_string($this->mapping[$abstract])) {
458
            $this->type = $this->mapping[$abstract];
459
460
            return $this;
461
        }
462
463
        // Configuring column properties based on abstractType preferences
464
        foreach ($this->mapping[$abstract] as $property => $value) {
465
            $this->{$property} = $value;
466
        }
467
468
        return $this;
469
    }
470
471
    /**
472
     * Set column nullable/not nullable.
473
     */
474
    public function nullable(bool $nullable = true): AbstractColumn
475
    {
476
        $this->nullable = $nullable;
477
478
        return $this;
479
    }
480
481
    /**
482
     * Change column default value (can be forbidden for some column types).
483
     * Use Database::TIMESTAMP_NOW to use driver specific NOW() function.
484
     */
485
    public function defaultValue(mixed $value): AbstractColumn
486
    {
487
        //Forcing driver specific values
488
        if ($value === self::DATETIME_NOW) {
489
            $value = static::DATETIME_NOW;
490
        }
491
492
        $this->defaultValue = $value;
493
494
        return $this;
495
    }
496
497
    /**
498
     * Set column as enum type and specify set of allowed values. Most of drivers will emulate enums
499
     * using column constraints.
500
     *
501
     * Examples:
502
     * $table->status->enum(['active', 'disabled']);
503
     * $table->status->enum('active', 'disabled');
504
     *
505
     * @param string|array $values Enum values (array or comma separated). String values only.
506
     */
507
    public function enum(string|array $values): AbstractColumn
508
    {
509
        $this->type('enum');
510
        $this->enumValues = array_map(
511
            'strval',
512
            is_array($values) ? $values : func_get_args()
0 ignored issues
show
introduced by
The condition is_array($values) is always true.
Loading history...
513
        );
514
515
        return $this;
516
    }
517
518
    /**
519
     * Set column type as string with limited size. Maximum allowed size is 255 bytes, use "text"
520
     * abstract types for longer strings.
521
     *
522
     * Strings are perfect type to store email addresses as it big enough to store valid address
523
     * and
524
     * can be covered with unique index.
525
     *
526
     * @link http://stackoverflow.com/questions/386294/what-is-the-maximum-length-of-a-valid-email-address
527
     *
528
     * @param int $size Max string length.
529
     *
530
     * @throws SchemaException
531
     */
532
    public function string(int $size = 255): AbstractColumn
533
    {
534
        $this->type('string');
535
536
        $size < 0 && throw new SchemaException('Invalid string length value');
537
538
        $this->size = $size;
539
540
        return $this;
541
    }
542
543
    /**
544
     * Set column type as decimal with specific precision and scale.
545
     *
546
     * @throws SchemaException
547
     */
548
    public function decimal(int $precision, int $scale = 0): AbstractColumn
549
    {
550
        $this->type('decimal');
551
552
        empty($precision) && throw new SchemaException('Invalid precision value');
553
554
        $this->precision = $precision;
555
        $this->scale = $scale;
556
557
        return $this;
558
    }
559
560
    public function sqlStatement(DriverInterface $driver): string
561
    {
562
        $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

562
        $statement = [$driver->/** @scrutinizer ignore-call */ identifier($this->name), $this->type];
Loading history...
563
564
        if ($this->getAbstractType() === 'enum') {
565
            //Enum specific column options
566
            if (!empty($enumDefinition = $this->quoteEnum($driver))) {
567
                $statement[] = $enumDefinition;
568
            }
569
        } elseif (!empty($this->precision)) {
570
            $statement[] = "({$this->precision}, {$this->scale})";
571
        } elseif (!empty($this->size)) {
572
            $statement[] = "({$this->size})";
573
        }
574
575
        $statement[] = $this->nullable ? 'NULL' : 'NOT NULL';
576
577
        if ($this->defaultValue !== null) {
578
            $statement[] = "DEFAULT {$this->quoteDefault($driver)}";
579
        }
580
581
        return implode(' ', $statement);
582
    }
583
584
    public function compare(AbstractColumn $initial): bool
585
    {
586
        $normalized = clone $initial;
587
588
        // soft compare, todo: improve
589
        if ($this == $normalized) {
590
            return true;
591
        }
592
593
        $columnVars = get_object_vars($this);
594
        $dbColumnVars = get_object_vars($normalized);
595
596
        $difference = [];
597
        foreach ($columnVars as $name => $value) {
598
            if (\in_array($name, static::EXCLUDE_FROM_COMPARE, true)) {
599
                continue;
600
            }
601
602
            if ($name === 'defaultValue') {
603
                //Default values has to compared using type-casted value
604
                if ($this->getDefaultValue() != $initial->getDefaultValue()) {
605
                    $difference[] = $name;
606
                } elseif (
607
                    $this->getDefaultValue() !== $initial->getDefaultValue()
608
                    && (!\is_object($this->getDefaultValue()) && !\is_object($initial->getDefaultValue()))
609
                ) {
610
                    $difference[] = $name;
611
                }
612
613
                continue;
614
            }
615
616
            if ($value !== $dbColumnVars[$name]) {
617
                $difference[] = $name;
618
            }
619
        }
620
621
        return empty($difference);
622
    }
623
624
    /**
625
     * Get database specific enum type definition options.
626
     */
627
    protected function quoteEnum(DriverInterface $driver): string
628
    {
629
        $enumValues = [];
630
        foreach ($this->enumValues as $value) {
631
            $enumValues[] = $driver->quote($value);
632
        }
633
634
        return !empty($enumValues) ? '(' . implode(', ', $enumValues) . ')' : '';
635
    }
636
637
    /**
638
     * Must return driver specific default value.
639
     */
640
    protected function quoteDefault(DriverInterface $driver): string
641
    {
642
        $defaultValue = $this->getDefaultValue();
643
        if ($defaultValue === null) {
644
            return 'NULL';
645
        }
646
647
        if ($defaultValue instanceof FragmentInterface) {
648
            return $driver->getQueryCompiler()->compile(
649
                new QueryParameters(),
650
                '',
651
                $defaultValue
652
            );
653
        }
654
655
        return match ($this->getType()) {
656
            'bool' => $defaultValue ? 'TRUE' : 'FALSE',
657
            'float' => sprintf('%F', $defaultValue),
658
            'int' => (string) $defaultValue,
659
            default => $driver->quote($defaultValue)
660
        };
661
    }
662
663
    /**
664
     * Ensure that datetime fields are correctly formatted.
665
     * @psalm-param non-empty-string $type
666
     *
667
     * @throws DefaultValueException
668
     */
669
    protected function formatDatetime(
670
        string $type,
671
        string|int|\DateTimeInterface $value
672
    ): \DateTimeInterface|FragmentInterface|string {
673
        if ($value === static::DATETIME_NOW) {
674
            //Dynamic default value
675
            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

675
            return new Fragment(/** @scrutinizer ignore-type */ $value);
Loading history...
676
        }
677
678
        if ($value instanceof \DateTimeInterface) {
679
            $datetime = clone $value;
680
        } else {
681
            if (is_numeric($value)) {
682
                //Presumably timestamp
683
                $datetime = new DateTimeImmutable('now', $this->timezone);
684
                $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

684
                $datetime = $datetime->setTimestamp(/** @scrutinizer ignore-type */ $value);
Loading history...
685
            } else {
686
                $datetime = new DateTimeImmutable($value, $this->timezone);
687
            }
688
        }
689
690
        return match ($type) {
691
            'datetime', 'timestamp' => $datetime,
692
            'time' => $datetime->format(static::TIME_FORMAT),
693
            'date' => $datetime->format(static::DATE_FORMAT),
694
            default => $value
695
        };
696
    }
697
}
698