Completed
Branch feature/pre-split (60f5c0)
by Anton
03:19
created

AbstractTable::save()   B

Complexity

Conditions 5
Paths 7

Size

Total Lines 39
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 19
nc 7
nop 3
dl 0
loc 39
rs 8.439
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 Psr\Log\LoggerInterface;
10
use Spiral\Database\Entities\AbstractHandler as Behaviour;
11
use Spiral\Database\Entities\Driver;
12
use Spiral\Database\Exceptions\HandlerException;
13
use Spiral\Database\Exceptions\SchemaException;
14
use Spiral\Database\Schemas\StateComparator;
15
use Spiral\Database\Schemas\TableInterface;
16
use Spiral\Database\Schemas\TableState;
17
18
/**
19
 * AbstractTable class used to describe and manage state of specified table. It provides ability to
20
 * get table introspection, update table schema and automatically generate set of diff operations.
21
 *
22
 * Most of table operation like column, index or foreign key creation/altering will be applied when
23
 * save() method will be called.
24
 *
25
 * Column configuration shortcuts:
26
 *
27
 * @method AbstractColumn primary($column)
28
 * @method AbstractColumn bigPrimary($column)
29
 * @method AbstractColumn enum($column, array $values)
30
 * @method AbstractColumn string($column, $length = 255)
31
 * @method AbstractColumn decimal($column, $precision, $scale)
32
 * @method AbstractColumn boolean($column)
33
 * @method AbstractColumn integer($column)
34
 * @method AbstractColumn tinyInteger($column)
35
 * @method AbstractColumn bigInteger($column)
36
 * @method AbstractColumn text($column)
37
 * @method AbstractColumn tinyText($column)
38
 * @method AbstractColumn longText($column)
39
 * @method AbstractColumn double($column)
40
 * @method AbstractColumn float($column)
41
 * @method AbstractColumn datetime($column)
42
 * @method AbstractColumn date($column)
43
 * @method AbstractColumn time($column)
44
 * @method AbstractColumn timestamp($column)
45
 * @method AbstractColumn binary($column)
46
 * @method AbstractColumn tinyBinary($column)
47
 * @method AbstractColumn longBinary($column)
48
 */
49
abstract class AbstractTable implements TableInterface
50
{
51
    /**
52
     * Table states.
53
     */
54
    const STATUS_NEW     = 0;
55
    const STATUS_EXISTS  = 1;
56
    const STATUS_DROPPED = 2;
57
58
    /**
59
     * Indication that table is exists and current schema is fetched from database.
60
     *
61
     * @var int
62
     */
63
    private $status = self::STATUS_NEW;
64
65
    /**
66
     * Database specific tablePrefix. Required for table renames.
67
     *
68
     * @var string
69
     */
70
    private $prefix = '';
71
72
    /**
73
     * @invisible
74
     *
75
     * @var Driver
76
     */
77
    protected $driver = null;
78
79
    /**
80
     * Initial table state.
81
     *
82
     * @invisible
83
     * @var TableState
84
     */
85
    protected $initial = null;
86
87
    /**
88
     * Currently defined table state.
89
     *
90
     * @invisible
91
     * @var TableState
92
     */
93
    protected $current = null;
94
95
    /**
96
     * @param Driver $driver Parent driver.
97
     * @param string $name   Table name, must include table prefix.
98
     * @param string $prefix Database specific table prefix.
99
     */
100
    public function __construct(Driver $driver, string $name, string $prefix)
101
    {
102
        $this->driver = $driver;
103
        $this->prefix = $prefix;
104
105
        //Initializing states
106
        $this->initial = new TableState($this->prefix . $name);
107
        $this->current = new TableState($this->prefix . $name);
108
109
        if ($this->driver->hasTable($this->getName())) {
110
            $this->status = self::STATUS_EXISTS;
111
        }
112
113
        if ($this->exists()) {
114
            //Initiating table schema
115
            $this->initSchema($this->initial);
116
        }
117
118
        $this->setState($this->initial);
119
    }
120
121
    /**
122
     * Get instance of associated driver.
123
     *
124
     * @return Driver
125
     */
126
    public function getDriver(): Driver
127
    {
128
        return $this->driver;
129
    }
130
131
    /**
132
     * Return database specific table prefix.
133
     *
134
     * @return string
135
     */
136
    public function getPrefix(): string
137
    {
138
        return $this->prefix;
139
    }
140
141
    /**
142
     * @return StateComparator
143
     */
144
    public function getComparator(): StateComparator
145
    {
146
        return new StateComparator($this->initial, $this->current);
147
    }
148
149
    /**
150
     * Check if table schema has been modified since synchronization.
151
     *
152
     * @return bool
153
     */
154
    protected function hasChanges(): bool
155
    {
156
        return $this->getComparator()->hasChanges();
157
    }
158
159
    /**
160
     * {@inheritdoc}
161
     */
162
    public function exists(): bool
163
    {
164
        return $this->status == self::STATUS_EXISTS || $this->status == self::STATUS_DROPPED;
165
    }
166
167
    /**
168
     * Table status (see codes above).
169
     *
170
     * @return int
171
     */
172
    public function getStatus(): int
173
    {
174
        return $this->status;
175
    }
176
177
    /**
178
     * Sets table name. Use this function in combination with save to rename table.
179
     *
180
     * @param string $name
181
     *
182
     * @return string Prefixed table name.
183
     */
184
    public function setName(string $name): string
185
    {
186
        $this->current->setName($this->prefix . $name);
187
188
        return $this->getName();
189
    }
190
191
    /**
192
     * {@inheritdoc}
193
     */
194
    public function getName(): string
195
    {
196
        return $this->current->getName();
197
    }
198
199
    /**
200
     * Table name before rename.
201
     *
202
     * @return string
203
     */
204
    public function getInitialName(): string
205
    {
206
        return $this->initial->getName();
207
    }
208
209
    /**
210
     * Declare table as dropped, you have to sync table using "save" method in order to apply this
211
     * change.
212
     */
213
    public function declareDropped()
214
    {
215
        if ($this->status == self::STATUS_NEW) {
216
            throw new SchemaException("Unable to drop non existed table");
217
        }
218
219
        //Declaring as dropper
220
        $this->status = self::STATUS_DROPPED;
221
    }
222
223
    /**
224
     * Set table primary keys. Operation can only be applied for newly created tables. Now every
225
     * database might support compound indexes.
226
     *
227
     * @param array $columns
228
     *
229
     * @return self
230
     */
231
    public function setPrimaryKeys(array $columns): AbstractTable
232
    {
233
        //Originally i were forcing an exception when primary key were changed, now we should
234
        //force it when table will be synced
235
236
        //Updating primary keys in current state
237
        $this->current->setPrimaryKeys($columns);
238
239
        return $this;
240
    }
241
242
    /**
243
     * {@inheritdoc}
244
     */
245
    public function getPrimaryKeys(): array
246
    {
247
        return $this->current->getPrimaryKeys();
248
    }
249
250
    /**
251
     * {@inheritdoc}
252
     */
253
    public function hasColumn(string $name): bool
254
    {
255
        return $this->current->hasColumn($name);
256
    }
257
258
    /**
259
     * {@inheritdoc}
260
     *
261
     * @return AbstractColumn[]
262
     */
263
    public function getColumns(): array
264
    {
265
        return $this->current->getColumns();
266
    }
267
268
    /**
269
     * {@inheritdoc}
270
     */
271
    public function hasIndex(array $columns = []): bool
272
    {
273
        return $this->current->hasIndex($columns);
274
    }
275
276
    /**
277
     * {@inheritdoc}
278
     *
279
     * @return AbstractIndex[]
280
     */
281
    public function getIndexes(): array
282
    {
283
        return $this->current->getIndexes();
284
    }
285
286
    /**
287
     * {@inheritdoc}
288
     */
289
    public function hasForeign(string $column): bool
290
    {
291
        return $this->current->hasForeign($column);
292
    }
293
294
    /**
295
     * {@inheritdoc}
296
     *
297
     * @return AbstractReference[]
298
     */
299
    public function getForeigns(): array
300
    {
301
        return $this->current->getForeigns();
302
    }
303
304
    /**
305
     * {@inheritdoc}
306
     */
307
    public function getDependencies(): array
308
    {
309
        $tables = [];
310
        foreach ($this->current->getForeigns() as $foreign) {
311
            $tables[] = $foreign->getForeignTable();
312
        }
313
314
        return $tables;
315
    }
316
317
    /**
318
     * Get/create instance of AbstractColumn associated with current table.
319
     *
320
     * Attention, renamed column will be available by it's old name until being synced!
321
     *
322
     * Examples:
323
     * $table->column('name')->string();
324
     *
325
     * @param string $name
326
     *
327
     * @return AbstractColumn
328
     */
329
    public function column(string $name): AbstractColumn
330
    {
331
        if ($this->current->hasColumn($name)) {
332
            //Column already exists
333
            return $this->current->findColumn($name);
334
        }
335
336
        $column = $this->createColumn($name);
337
        $this->current->registerColumn($column);
338
339
        return $column;
340
    }
341
342
    /**
343
     * Shortcut for column() method.
344
     *
345
     * @param string $column
346
     *
347
     * @return AbstractColumn
348
     */
349
    public function __get(string $column)
350
    {
351
        return $this->column($column);
352
    }
353
354
    /**
355
     * Column creation/altering shortcut, call chain is identical to:
356
     * AbstractTable->column($name)->$type($arguments).
357
     *
358
     * Example:
359
     * $table->string("name");
360
     * $table->text("some_column");
361
     *
362
     * @param string $type
363
     * @param array  $arguments Type specific parameters.
364
     *
365
     * @return AbstractColumn
366
     */
367
    public function __call(string $type, array $arguments)
368
    {
369
        return call_user_func_array(
370
            [$this->column($arguments[0]), $type],
371
            array_slice($arguments, 1)
372
        );
373
    }
374
375
    /**
376
     * Get/create instance of AbstractIndex associated with current table based on list of forming
377
     * column names.
378
     *
379
     * Example:
380
     * $table->index(['key']);
381
     * $table->index(['key', 'key2']);
382
     *
383
     * @param array $columns List of index columns.
384
     *
385
     * @return AbstractIndex
386
     *
387
     * @throws SchemaException
388
     */
389
    public function index(array $columns): AbstractIndex
390
    {
391
        foreach ($columns as $column) {
392
            if (!$this->hasColumn($column)) {
393
                throw new SchemaException("Undefined column '{$column}' in '{$this->getName()}'");
394
            }
395
        }
396
397
        if ($this->hasIndex($columns)) {
398
            return $this->current->findIndex($columns);
399
        }
400
401
        $index = $this->createIndex($this->createIdentifier('index', $columns));
402
        $index->columns($columns);
403
        $this->current->registerIndex($index);
404
405
        return $index;
406
    }
407
408
    /**
409
     * Get/create instance of AbstractReference associated with current table based on local column
410
     * name.
411
     *
412
     * @param string $column
413
     *
414
     * @return AbstractReference
415
     *
416
     * @throws SchemaException
417
     */
418
    public function foreign(string $column): AbstractReference
419
    {
420
        if (!$this->hasColumn($column)) {
421
            throw new SchemaException("Undefined column '{$column}' in '{$this->getName()}'");
422
        }
423
424
        if ($this->hasForeign($column)) {
425
            return $this->current->findForeign($column);
426
        }
427
428
        $foreign = $this->createForeign($this->createIdentifier('foreign', [$column]));
429
        $foreign->column($column);
430
431
        $this->current->registerReference($foreign);
432
433
        //Let's ensure index existence to performance and compatibility reasons
434
        $this->index([$column]);
435
436
        return $foreign;
437
    }
438
439
    /**
440
     * Rename column (only if column exists).
441
     *
442
     * @param string $column
443
     * @param string $name New column name.
444
     *
445
     * @return self
446
     *
447
     * @throws SchemaException
448
     */
449
    public function renameColumn(string $column, string $name): AbstractTable
450
    {
451
        if (!$this->hasColumn($column)) {
452
            throw new SchemaException("Undefined column '{$column}' in '{$this->getName()}'");
453
        }
454
455
        //Rename operation is simple about declaring new name
456
        $this->column($column)->setName($name);
457
458
        return $this;
459
    }
460
461
    /**
462
     * Rename index (only if index exists).
463
     *
464
     * @param array  $columns Index forming columns.
465
     * @param string $name    New index name.
466
     *
467
     * @return self
468
     *
469
     * @throws SchemaException
470
     */
471
    public function renameIndex(array $columns, string $name): AbstractTable
472
    {
473
        if ($this->hasIndex($columns)) {
474
            throw new SchemaException(
475
                "Undefined index ['" . join("', '", $columns) . "'] in '{$this->getName()}'"
476
            );
477
        }
478
479
        //Declaring new index name
480
        $this->index($columns)->setName($name);
481
482
        return $this;
483
    }
484
485
    /**
486
     * Drop column by it's name.
487
     *
488
     * @param string $column
489
     *
490
     * @return self
491
     *
492
     * @throws SchemaException
493
     */
494
    public function dropColumn(string $column): AbstractTable
495
    {
496
        if (empty($schema = $this->current->findColumn($column))) {
497
            throw new SchemaException("Undefined column '{$column}' in '{$this->getName()}'");
498
        }
499
500
        //Dropping column from current schema
501
        $this->current->forgetColumn($schema);
502
503
        return $this;
504
    }
505
506
    /**
507
     * Drop index by it's forming columns.
508
     *
509
     * @param array $columns
510
     *
511
     * @return self
512
     *
513
     * @throws SchemaException
514
     */
515
    public function dropIndex(array $columns): AbstractTable
516
    {
517
        if (empty($schema = $this->current->findIndex($columns))) {
518
            throw new SchemaException(
519
                "Undefined index ['" . join("', '", $columns) . "'] in '{$this->getName()}'"
520
            );
521
        }
522
523
        //Dropping index from current schema
524
        $this->current->forgetIndex($schema);
525
526
        return $this;
527
    }
528
529
    /**
530
     * Drop foreign key by it's name.
531
     *
532
     * @param string $column
533
     *
534
     * @return self
535
     *
536
     * @throws SchemaException
537
     */
538
    public function dropForeign($column): AbstractTable
539
    {
540
        if (empty($schema = $this->current->findForeign($column))) {
541
            throw new SchemaException(
542
                "Undefined FK on '{$column}' in '{$this->getName()}'"
543
            );
544
        }
545
546
        //Dropping foreign from current schema
547
        $this->current->forgetForeign($schema);
548
549
        return $this;
550
    }
551
552
    /**
553
     * Reset table state to new form.
554
     *
555
     * @param TableState $state Use null to flush table schema.
556
     *
557
     * @return self|$this
558
     */
559
    public function setState(TableState $state = null): AbstractTable
560
    {
561
        $this->current = new TableState($this->initial->getName());
562
563
        if (!empty($state)) {
564
            $this->current->setName($state->getName());
565
            $this->current->syncState($state);
566
        }
567
568
        return $this;
569
    }
570
571
    /**
572
     * Reset table state to it initial form.
573
     *
574
     * @return self|$this
575
     */
576
    public function resetState(): AbstractTable
577
    {
578
        $this->setState($this->initial);
579
580
        return $this;
581
    }
582
583
    /**
584
     * Save table schema including every column, index, foreign key creation/altering. If table
585
     * does not exist it must be created. If table declared as dropped it will be removed from
586
     * the database.
587
     *
588
     * @param int             $behaviour Operation to be performed while table being saved. In some
589
     *                                   cases (when multiple tables are being updated) it is
590
     *                                   reasonable to drop foreing keys and indexes prior to
591
     *                                   dropping related columns. See sync bus class to get more
592
     *                                   details.
593
     * @param LoggerInterface $logger    Optional, aggregates messages for data syncing.
594
     * @param bool            $reset     When true schema will be marked as synced.
595
     *
596
     * @throws HandlerException
597
     *
598
     * @throws SchemaException
599
     */
600
    public function save(
601
        int $behaviour = Behaviour::DO_ALL,
602
        LoggerInterface $logger = null,
603
        bool $reset = true
604
    ) {
605
        //We need an instance of Handler of dbal operations
606
        $handler = $this->driver->getHandler($logger);
607
608
        if ($this->status == self::STATUS_DROPPED) {
609
            //We don't need syncer for this operation
610
            $handler->dropTable($this);
611
612
            //Flushing status
613
            $this->status = self::STATUS_NEW;
614
615
            return;
616
        }
617
618
        //Ensure that columns references to valid indexes and et
619
        $prepared = $this->prepareSchema();
620
621
        if ($this->status == self::STATUS_NEW) {
622
            //Executing table creation
623
            $handler->createTable($prepared);
624
            $this->status = self::STATUS_EXISTS;
625
        } else {
626
            //Executing table syncing
627
            if ($this->hasChanges()) {
628
                $handler->syncTable($prepared, $behaviour);
629
            }
630
631
            $prepared->status = self::STATUS_EXISTS;
632
        }
633
634
        //Syncing our schemas
635
        if ($reset) {
636
            $this->initial->syncState($prepared->current);
637
        }
638
    }
639
640
    /**
641
     * Ensure that no wrong indexes left in table.
642
     *
643
     * @return AbstractTable
644
     */
645
    protected function prepareSchema()
646
    {
647
        //To make sure that no pre-sync modifications will be reflected on current table
648
        $target = clone $this;
649
650
        /*
651
         * In cases where columns are removed we have to automatically remove related indexes and
652
         * foreign keys.
653
         */
654
        foreach ($this->getComparator()->droppedColumns() as $column) {
655
            foreach ($target->getIndexes() as $index) {
656
                if (in_array($column->getName(), $index->getColumns())) {
657
                    $target->current->forgetIndex($index);
658
                }
659
            }
660
661
            foreach ($target->getForeigns() as $foreign) {
662
                if ($column->getName() == $foreign->getColumn()) {
663
                    $target->current->forgetForeign($foreign);
664
                }
665
            }
666
        }
667
668
        //We also have to adjusts indexes and foreign keys
669
        foreach ($this->getComparator()->alteredColumns() as $pair) {
670
            /**
671
             * @var AbstractColumn $initial
672
             * @var AbstractColumn $name
673
             */
674
            list($name, $initial) = $pair;
675
676
            foreach ($target->getIndexes() as $index) {
677
                if (in_array($initial->getName(), $index->getColumns())) {
678
                    $columns = $index->getColumns();
679
680
                    //Replacing column name
681
                    foreach ($columns as &$column) {
682
                        if ($column == $initial->getName()) {
683
                            $column = $name->getName();
684
                        }
685
686
                        unset($column);
687
                    }
688
689
                    $index->columns($columns);
690
                }
691
            }
692
693
            foreach ($target->getForeigns() as $foreign) {
694
                if ($initial->getName() == $foreign->getColumn()) {
695
                    $foreign->column($name->getName());
696
                }
697
            }
698
        }
699
700
        return $target;
701
    }
702
703
    /**
704
     * @return AbstractColumn|string
705
     */
706
    public function __toString(): string
707
    {
708
        return $this->getName();
709
    }
710
711
    /**
712
     * Cloning schemas as well.
713
     */
714
    public function __clone()
715
    {
716
        $this->initial = clone $this->initial;
717
        $this->current = clone $this->current;
718
    }
719
720
    /**
721
     * @return array
722
     */
723
    public function __debugInfo()
724
    {
725
        return [
726
            'name'        => $this->getName(),
727
            'primaryKeys' => $this->getPrimaryKeys(),
728
            'columns'     => array_values($this->getColumns()),
729
            'indexes'     => array_values($this->getIndexes()),
730
            'references'  => array_values($this->getForeigns()),
731
        ];
732
    }
733
734
    /**
735
     * Populate table schema with values from database.
736
     *
737
     * @param TableState $state
738
     */
739
    protected function initSchema(TableState $state)
740
    {
741
        foreach ($this->fetchColumns() as $column) {
742
            $state->registerColumn($column);
743
        }
744
745
        foreach ($this->fetchIndexes() as $index) {
746
            $state->registerIndex($index);
747
        }
748
749
        foreach ($this->fetchReferences() as $foreign) {
750
            $state->registerReference($foreign);
751
        }
752
753
        $state->setPrimaryKeys($this->fetchPrimaryKeys());
754
755
        //DBMS specific initialization can be placed here
756
    }
757
758
    /**
759
     * Fetch index declarations from database.
760
     *
761
     * @return AbstractColumn[]
762
     */
763
    abstract protected function fetchColumns(): array;
764
765
    /**
766
     * Fetch index declarations from database.
767
     *
768
     * @return AbstractIndex[]
769
     */
770
    abstract protected function fetchIndexes(): array;
771
772
    /**
773
     * Fetch references declaration from database.
774
     *
775
     * @return AbstractReference[]
776
     */
777
    abstract protected function fetchReferences(): array;
778
779
    /**
780
     * Fetch names of primary keys from table.
781
     *
782
     * @return array
783
     */
784
    abstract protected function fetchPrimaryKeys(): array;
785
786
    /**
787
     * Create column with a given name.
788
     *
789
     * @param string $name
790
     *
791
     * @return AbstractColumn
792
     */
793
    abstract protected function createColumn(string $name): AbstractColumn;
794
795
    /**
796
     * Create index for a given set of columns.
797
     *
798
     * @param string $name
799
     *
800
     * @return AbstractIndex
801
     */
802
    abstract protected function createIndex(string $name): AbstractIndex;
803
804
    /**
805
     * Create reference on a given column set.
806
     *
807
     * @param string $name
808
     *
809
     * @return AbstractReference
810
     */
811
    abstract protected function createForeign(string $name): AbstractReference;
812
813
    /**
814
     * Generate unique name for indexes and foreign keys.
815
     *
816
     * @param string $type
817
     * @param array  $columns
818
     *
819
     * @return string
820
     */
821
    protected function createIdentifier(string $type, array $columns): string
822
    {
823
        $name = $this->getName() . '_' . $type . '_' . join('_', $columns) . '_' . uniqid();
824
825
        if (strlen($name) > 64) {
826
            //Many DBMS has limitations on identifier length
827
            $name = md5($name);
828
        }
829
830
        return $name;
831
    }
832
}