Completed
Branch feature/pre-split (42159e)
by Anton
05:36
created

AbstractColumn::compare()   C

Complexity

Conditions 7
Paths 7

Size

Total Lines 33
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 17
nc 7
nop 1
dl 0
loc 33
rs 6.7272
c 0
b 0
f 0
1
<?php
2
/**
3
 * Spiral Framework, Core Components
4
 *
5
 * @author    Wolfy-J
6
 */
7
namespace Spiral\Database\Schemas\Prototypes;
8
9
use Spiral\Database\Entities\Driver;
10
use Spiral\Database\Exceptions\DefaultValueException;
11
use Spiral\Database\Exceptions\SchemaException;
12
use Spiral\Database\Injections\Fragment;
13
use Spiral\Database\Injections\FragmentInterface;
14
use Spiral\Database\Schemas\ColumnInterface;
15
16
/**
17
 * Abstract column schema with read (see ColumnInterface) and write abilities. Must be implemented
18
 * by driver to support DBMS specific syntax and creation rules.
19
 *
20
 * Shortcuts for various column types:
21
 *
22
 * @method AbstractColumn|$this primary()
23
 * @method AbstractColumn|$this bigPrimary()
24
 * @method AbstractColumn|$this boolean()
25
 * @method AbstractColumn|$this integer()
26
 * @method AbstractColumn|$this tinyInteger()
27
 * @method AbstractColumn|$this bigInteger()
28
 * @method AbstractColumn|$this text()
29
 * @method AbstractColumn|$this tinyText()
30
 * @method AbstractColumn|$this longText()
31
 * @method AbstractColumn|$this double()
32
 * @method AbstractColumn|$this float()
33
 * @method AbstractColumn|$this datetime()
34
 * @method AbstractColumn|$this date()
35
 * @method AbstractColumn|$this time()
36
 * @method AbstractColumn|$this timestamp()
37
 * @method AbstractColumn|$this binary()
38
 * @method AbstractColumn|$this tinyBinary()
39
 * @method AbstractColumn|$this longBinary()
40
 * @method AbstractColumn|$this json()
41
 */
42
abstract class AbstractColumn extends AbstractElement implements ColumnInterface
43
{
44
    /**
45
     * Default timestamp expression (driver specific).
46
     */
47
    const DATETIME_NOW = 'CURRENT_TIMESTAMP';
48
49
    /**
50
     * Value to be excluded from comparation.
51
     */
52
    const EXCLUDE_FROM_COMPARE = ['timezone'];
53
54
    /**
55
     * Normalization for time and dates.
56
     */
57
    const DATE_FORMAT = 'Y-m-d';
58
    const TIME_FORMAT = 'H:i:s';
59
60
    /**
61
     * Abstract type aliases (for consistency).
62
     *
63
     * @var array
64
     */
65
    private $aliases = [
66
        'int'            => 'integer',
67
        'bigint'         => 'bigInteger',
68
        'incremental'    => 'primary',
69
        'bigIncremental' => 'bigPrimary',
70
        'bool'           => 'boolean',
71
        'blob'           => 'binary',
72
    ];
73
74
    /**
75
     * Association list between abstract types and native PHP types. Every non listed type will be
76
     * converted into string.
77
     *
78
     * @invisible
79
     *
80
     * @var array
81
     */
82
    private $phpMapping = [
83
        self::INT   => ['primary', 'bigPrimary', 'integer', 'tinyInteger', 'bigInteger'],
84
        self::BOOL  => ['boolean'],
85
        self::FLOAT => ['double', 'float', 'decimal'],
86
    ];
87
88
    /**
89
     * Mapping between abstract type and internal database type with it's options. Multiple abstract
90
     * types can map into one database type, this implementation allows us to equalize two columns
91
     * if they have different abstract types but same database one. Must be declared by DBMS
92
     * specific implementation.
93
     *
94
     * Example:
95
     * integer => array('type' => 'int', 'size' => 1),
96
     * boolean => array('type' => 'tinyint', 'size' => 1)
97
     *
98
     * @invisible
99
     *
100
     * @var array
101
     */
102
    protected $mapping = [
103
        //Primary sequences
104
        'primary'     => null,
105
        'bigPrimary'  => null,
106
107
        //Enum type (mapped via method)
108
        'enum'        => null,
109
110
        //Logical types
111
        'boolean'     => null,
112
113
        //Integer types (size can always be changed with size method), longInteger has method alias
114
        //bigInteger
115
        'integer'     => null,
116
        'tinyInteger' => null,
117
        'bigInteger'  => null,
118
119
        //String with specified length (mapped via method)
120
        'string'      => null,
121
122
        //Generic types
123
        'text'        => null,
124
        'tinyText'    => null,
125
        'longText'    => null,
126
127
        //Real types
128
        'double'      => null,
129
        'float'       => null,
130
131
        //Decimal type (mapped via method)
132
        'decimal'     => null,
133
134
        //Date and Time types
135
        'datetime'    => null,
136
        'date'        => null,
137
        'time'        => null,
138
        'timestamp'   => null,
139
140
        //Binary types
141
        'binary'      => null,
142
        'tinyBinary'  => null,
143
        'longBinary'  => null,
144
145
        //Additional types
146
        'json'        => null,
147
    ];
148
149
    /**
150
     * Reverse mapping is responsible for generating abstact type based on database type and it's
151
     * options. Multiple database types can be mapped into one abstract type.
152
     *
153
     * @invisible
154
     *
155
     * @var array
156
     */
157
    protected $reverseMapping = [
158
        'primary'     => [],
159
        'bigPrimary'  => [],
160
        'enum'        => [],
161
        'boolean'     => [],
162
        'integer'     => [],
163
        'tinyInteger' => [],
164
        'bigInteger'  => [],
165
        'string'      => [],
166
        'text'        => [],
167
        'tinyText'    => [],
168
        'longText'    => [],
169
        'double'      => [],
170
        'float'       => [],
171
        'decimal'     => [],
172
        'datetime'    => [],
173
        'date'        => [],
174
        'time'        => [],
175
        'timestamp'   => [],
176
        'binary'      => [],
177
        'tinyBinary'  => [],
178
        'longBinary'  => [],
179
        'json'        => [],
180
    ];
181
182
    /**
183
     * DBMS specific column type.
184
     *
185
     * @var string
186
     */
187
    protected $type = '';
188
189
    /**
190
     * @var \DateTimeZone
191
     */
192
    protected $timezone = null;
193
194
    /**
195
     * Indicates that column can contain null values.
196
     *
197
     * @var bool
198
     */
199
    protected $nullable = true;
200
201
    /**
202
     * Default column value, may not be applied to some datatypes (for example to primary keys),
203
     * should follow type size and other options.
204
     *
205
     * @var mixed
206
     */
207
    protected $defaultValue = null;
208
209
    /**
210
     * Column type size, can have different meanings for different datatypes.
211
     *
212
     * @var int
213
     */
214
    protected $size = 0;
215
216
    /**
217
     * Precision of column, applied only for "decimal" type.
218
     *
219
     * @var int
220
     */
221
    protected $precision = 0;
222
223
    /**
224
     * Scale of column, applied only for "decimal" type.
225
     *
226
     * @var int
227
     */
228
    protected $scale = 0;
229
230
    /**
231
     * List of allowed enum values.
232
     *
233
     * @var array
234
     */
235
    protected $enumValues = [];
236
237
    /**
238
     * @param string        $table
239
     * @param string        $name
240
     * @param \DateTimeZone $timezone
241
     */
242
    public function __construct(string $table, string $name, \DateTimeZone $timezone)
243
    {
244
        parent::__construct($table, $name);
245
        $this->timezone = $timezone;
246
    }
247
248
    /**
249
     * {@inheritdoc}
250
     */
251
    public function getType(): string
252
    {
253
        return $this->type;
254
    }
255
256
    /**
257
     * {@inheritdoc}
258
     */
259
    public function phpType(): string
260
    {
261
        $schemaType = $this->abstractType();
262
        foreach ($this->phpMapping as $phpType => $candidates) {
263
            if (in_array($schemaType, $candidates)) {
264
                return $phpType;
265
            }
266
        }
267
268
        return self::STRING;
269
    }
270
271
    /**
272
     * {@inheritdoc}
273
     */
274
    public function getSize(): int
275
    {
276
        return $this->size;
277
    }
278
279
    /**
280
     * {@inheritdoc}
281
     */
282
    public function getPrecision(): int
283
    {
284
        return $this->precision;
285
    }
286
287
    /**
288
     * {@inheritdoc}
289
     */
290
    public function getScale(): int
291
    {
292
        return $this->scale;
293
    }
294
295
    /**
296
     * {@inheritdoc}
297
     */
298
    public function isNullable(): bool
299
    {
300
        return $this->nullable;
301
    }
302
303
    /**
304
     * {@inheritdoc}
305
     */
306
    public function hasDefaultValue(): bool
307
    {
308
        return !is_null($this->defaultValue);
309
    }
310
311
    /**
312
     * {@inheritdoc}
313
     *
314
     * @throws DefaultValueException
315
     */
316
    public function getDefaultValue()
317
    {
318
        if (!$this->hasDefaultValue()) {
319
            return null;
320
        }
321
322
        if ($this->defaultValue instanceof FragmentInterface) {
323
            //Defined as SQL piece
324
            return $this->defaultValue;
325
        }
326
327
        if (in_array($this->abstractType(), ['time', 'date', 'datetime', 'timestamp'])) {
328
            return $this->normalizeDatetime($this->abstractType(), $this->defaultValue);
329
        }
330
331
        switch ($this->phpType()) {
332
            case 'int':
333
                return (int)$this->defaultValue;
334
            case 'float':
335
                return (float)$this->defaultValue;
336
            case 'bool':
337
                if (strtolower($this->defaultValue) == 'false') {
338
                    return false;
339
                }
340
341
                return (bool)$this->defaultValue;
342
        }
343
344
        return (string)$this->defaultValue;
345
    }
346
347
    /**
348
     * Get every associated column constraint names.
349
     *
350
     * @return array
351
     */
352
    public function getConstraints(): array
353
    {
354
        return [];
355
    }
356
357
    /**
358
     * Get allowed enum values.
359
     *
360
     * @return array
361
     */
362
    public function getEnumValues(): array
363
    {
364
        return $this->enumValues;
365
    }
366
367
    /**
368
     * DBMS specific reverse mapping must map database specific type into limited set of abstract
369
     * types.
370
     *
371
     * @return string
372
     */
373
    public function abstractType(): string
374
    {
375
        foreach ($this->reverseMapping as $type => $candidates) {
376
            foreach ($candidates as $candidate) {
377
                if (is_string($candidate)) {
378
                    if (strtolower($candidate) == strtolower($this->type)) {
379
                        return $type;
380
                    }
381
382
                    continue;
383
                }
384
385
                if (strtolower($candidate['type']) != strtolower($this->type)) {
386
                    continue;
387
                }
388
389
                foreach ($candidate as $option => $required) {
390
                    if ($option == 'type') {
391
                        continue;
392
                    }
393
394
                    if ($this->{$option} != $required) {
395
                        continue 2;
396
                    }
397
                }
398
399
                return $type;
400
            }
401
        }
402
403
        return 'unknown';
404
    }
405
406
    /**
407
     * Give column new abstract type. DBMS specific implementation must map provided type into one
408
     * of internal database values.
409
     *
410
     * Attention, changing type of existed columns in some databases has a lot of restrictions like
411
     * cross type conversions and etc. Try do not change column type without a reason.
412
     *
413
     * @param string $abstract Abstract or virtual type declared in mapping.
414
     *
415
     * @return self|$this
416
     *
417
     * @throws SchemaException
418
     */
419
    public function setType(string $abstract): AbstractColumn
420
    {
421
        if (isset($this->aliases[$abstract])) {
422
            $abstract = $this->aliases[$abstract];
423
        }
424
425
        if (!isset($this->mapping[$abstract])) {
426
            throw new SchemaException("Undefined abstract/virtual type '{$abstract}'");
427
        }
428
429
        //Resetting all values to default state.
430
        $this->size = $this->precision = $this->scale = 0;
431
        $this->enumValues = [];
432
433
        if (is_string($this->mapping[$abstract])) {
434
            $this->type = $this->mapping[$abstract];
435
436
            return $this;
437
        }
438
439
        //Additional type options
440
        foreach ($this->mapping[$abstract] as $property => $value) {
441
            $this->{$property} = $value;
442
        }
443
444
        return $this;
445
    }
446
447
    /**
448
     * Set column nullable/not nullable.
449
     *
450
     * @param bool $nullable
451
     *
452
     * @return self|$this
453
     */
454
    public function nullable(bool $nullable = true): AbstractColumn
455
    {
456
        $this->nullable = $nullable;
457
458
        return $this;
459
    }
460
461
    /**
462
     * Change column default value (can be forbidden for some column types).
463
     * Use Database::TIMESTAMP_NOW to use driver specific NOW() function.
464
     *
465
     * @param mixed $value
466
     *
467
     * @return self|$this
468
     */
469
    public function defaultValue($value): AbstractColumn
470
    {
471
        //Forcing driver specific values
472
        if ($value === self::DATETIME_NOW) {
473
            $value = static::DATETIME_NOW;
474
        }
475
476
        $this->defaultValue = $value;
477
478
        return $this;
479
    }
480
481
    /**
482
     * Set column as enum type and specify set of allowed values. Most of drivers will emulate enums
483
     * using column constraints.
484
     *
485
     * Examples:
486
     * $table->status->enum(['active', 'disabled']);
487
     * $table->status->enum('active', 'disabled');
488
     *
489
     * @param string|array $values Enum values (array or comma separated). String values only.
490
     *
491
     * @return self
492
     */
493
    public function enum($values): AbstractColumn
494
    {
495
        $this->setType('enum');
496
        $this->enumValues = array_map('strval', is_array($values) ? $values : func_get_args());
497
498
        return $this;
499
    }
500
501
    /**
502
     * Set column type as string with limited size. Maximum allowed size is 255 bytes, use "text"
503
     * abstract types for longer strings.
504
     *
505
     * Strings are perfect type to store email addresses as it big enough to store valid address
506
     * and
507
     * can be covered with unique index.
508
     *
509
     * @link http://stackoverflow.com/questions/386294/what-is-the-maximum-length-of-a-valid-email-address
510
     *
511
     * @param int $size Max string length.
512
     *
513
     * @return self|$this
514
     *
515
     * @throws SchemaException
516
     */
517
    public function string(int $size = 255): AbstractColumn
518
    {
519
        $this->setType('string');
520
521
        if ($size > 255) {
522
            throw new SchemaException("String size can't exceed 255 characters. Use text instead");
523
        }
524
525
        if ($size < 0) {
526
            throw new SchemaException('Invalid string length value');
527
        }
528
529
        $this->size = (int)$size;
530
531
        return $this;
532
    }
533
534
    /**
535
     * Set column type as decimal with specific precision and scale.
536
     *
537
     * @param int $precision
538
     * @param int $scale
539
     *
540
     * @return self|$this
541
     *
542
     * @throws SchemaException
543
     */
544
    public function decimal(int $precision, int $scale = 0): AbstractColumn
545
    {
546
        $this->setType('decimal');
547
548
        if (empty($precision)) {
549
            throw new SchemaException('Invalid precision value');
550
        }
551
552
        $this->precision = (int)$precision;
553
        $this->scale = (int)$scale;
554
555
        return $this;
556
    }
557
558
    /**
559
     * Shortcut for AbstractColumn->type() method.
560
     *
561
     * @param string $type      Abstract type.
562
     * @param array  $arguments Not used.
563
     *
564
     * @return self
565
     */
566
    public function __call(string $type, array $arguments = []): AbstractColumn
567
    {
568
        return $this->setType($type);
569
    }
570
571
    /**
572
     * {@inheritdoc}
573
     */
574
    public function compare(ColumnInterface $initial): bool
575
    {
576
        $normalized = clone $initial;
577
578
        if ($this == $normalized) {
579
            return true;
580
        }
581
582
        $columnVars = get_object_vars($this);
583
        $dbColumnVars = get_object_vars($normalized);
584
585
        $difference = [];
586
        foreach ($columnVars as $name => $value) {
587
            if (in_array($name, static::EXCLUDE_FROM_COMPARE)) {
588
                continue;
589
            }
590
591
            if ($name == 'defaultValue') {
592
                //Default values has to compared using type-casted value
593
                if ($this->getDefaultValue() != $initial->getDefaultValue()) {
594
                    $difference[] = $name;
595
                }
596
597
                continue;
598
            }
599
600
            if ($value != $dbColumnVars[$name]) {
601
                $difference[] = $name;
602
            }
603
        }
604
605
        return empty($difference);
606
    }
607
608
    /**
609
     * {@inheritdoc}
610
     */
611
    public function sqlStatement(Driver $driver): string
612
    {
613
        $statement = [$driver->identifier($this->name), $this->type];
614
615
        if ($this->abstractType() == 'enum') {
616
            //Enum specific column options
617
            if (!empty($enumDefinition = $this->quoteEnum($driver))) {
618
                $statement[] = $enumDefinition;
619
            }
620
        } elseif (!empty($this->precision)) {
621
            $statement[] = "({$this->precision}, {$this->scale})";
622
        } elseif (!empty($this->size)) {
623
            $statement[] = "({$this->size})";
624
        }
625
626
        $statement[] = $this->nullable ? 'NULL' : 'NOT NULL';
627
628
        if ($this->defaultValue !== null) {
629
            $statement[] = "DEFAULT {$this->quoteDefault($driver)}";
630
        }
631
632
        return implode(' ', $statement);
633
    }
634
635
    /**
636
     * @return string
637
     */
638
    public function __toString()
639
    {
640
        return $this->table . '.' . $this->getName();
641
    }
642
643
    /**
644
     * Simplified way to dump information.
645
     *
646
     * @return array
647
     */
648
    public function __debugInfo()
649
    {
650
        $column = [
651
            'name' => $this->name,
652
            'type' => [
653
                'database' => $this->type,
654
                'schema'   => $this->abstractType(),
655
                'php'      => $this->phpType(),
656
            ],
657
        ];
658
659
        if (!empty($this->size)) {
660
            $column['size'] = $this->size;
661
        }
662
663
        if ($this->nullable) {
664
            $column['nullable'] = true;
665
        }
666
667
        if ($this->defaultValue !== null) {
668
            $column['defaultValue'] = $this->getDefaultValue();
669
        }
670
671
        if ($this->abstractType() == 'enum') {
672
            $column['enumValues'] = $this->enumValues;
673
        }
674
675
        if ($this->abstractType() == 'decimal') {
676
            $column['precision'] = $this->precision;
677
            $column['scale'] = $this->scale;
678
        }
679
680
        return $column;
681
    }
682
683
    /**
684
     * Get database specific enum type definition options.
685
     *
686
     * @param Driver $driver
687
     *
688
     * @return string
689
     */
690
    protected function quoteEnum(Driver $driver): string
691
    {
692
        $enumValues = [];
693
        foreach ($this->enumValues as $value) {
694
            $enumValues[] = $driver->quote($value);
695
        }
696
697
        if (!empty($enumValues)) {
698
            return '(' . implode(', ', $enumValues) . ')';
699
        }
700
701
        return '';
702
    }
703
704
    /**
705
     * Must return driver specific default value.
706
     *
707
     * @param Driver $driver
708
     *
709
     * @return string
710
     */
711
    protected function quoteDefault(Driver $driver): string
712
    {
713
        $defaultValue = $this->getDefaultValue();
714
        if ($defaultValue === null) {
715
            return 'NULL';
716
        }
717
718
        if ($defaultValue instanceof FragmentInterface) {
719
            return $defaultValue->sqlStatement();
720
        }
721
722
        if ($this->phpType() == 'bool') {
723
            return $defaultValue ? 'TRUE' : 'FALSE';
724
        }
725
726
        if ($this->phpType() == 'float') {
727
            return sprintf('%F', $defaultValue);
728
        }
729
730
        if ($this->phpType() == 'int') {
731
            return $defaultValue;
732
        }
733
734
        return $driver->quote($defaultValue);
735
    }
736
737
    /**
738
     * Ensure that datetime fields are correctly formatted.
739
     *
740
     * @param string $type
741
     * @param string $value
742
     *
743
     * @return string|FragmentInterface|\DateTime
744
     *
745
     * @throws DefaultValueException
746
     */
747
    protected function normalizeDatetime(string $type, $value)
748
    {
749
        if ($value === static::DATETIME_NOW) {
750
            //Dynamic default value
751
            return new Fragment($value);
752
        }
753
754
        if ($value instanceof \DateTimeInterface) {
755
            $datetime = clone $value;
756
        } else {
757
            if (is_numeric($value)) {
758
                //Presumably timestamp
759
                $datetime = new \DateTime('now', $this->timezone);
760
                $datetime->setTimestamp($value);
761
            } else {
762
                $datetime = new \DateTime($value, $this->timezone);
763
            }
764
        }
765
766
        switch ($type) {
767
            case 'datetime':
768
                //no break
769
            case 'timestamp':
770
                //Driver should handle conversion automatically in this case
771
                return $datetime;
772
            case 'time':
773
                return $datetime->format(static::TIME_FORMAT);
774
            case 'date':
775
                return $datetime->format(static::DATE_FORMAT);
776
        }
777
778
        return $value;
779
    }
780
}