1<?php
2
3namespace Doctrine\DBAL\Schema;
4
5use Doctrine\DBAL\Exception;
6use Doctrine\DBAL\Schema\Visitor\Visitor;
7use Doctrine\DBAL\Types\Type;
8
9use function array_filter;
10use function array_merge;
11use function in_array;
12use function preg_match;
13use function strlen;
14use function strtolower;
15
16use const ARRAY_FILTER_USE_KEY;
17
18/**
19 * Object Representation of a table.
20 */
21class Table extends AbstractAsset
22{
23    /** @var Column[] */
24    protected $_columns = [];
25
26    /** @var Index[] */
27    private $implicitIndexes = [];
28
29    /** @var Index[] */
30    protected $_indexes = [];
31
32    /** @var string|false */
33    protected $_primaryKeyName = false;
34
35    /** @var ForeignKeyConstraint[] */
36    protected $_fkConstraints = [];
37
38    /** @var mixed[] */
39    protected $_options = [
40        'create_options' => [],
41    ];
42
43    /** @var SchemaConfig|null */
44    protected $_schemaConfig;
45
46    /**
47     * @param string                 $name
48     * @param Column[]               $columns
49     * @param Index[]                $indexes
50     * @param ForeignKeyConstraint[] $fkConstraints
51     * @param int                    $idGeneratorType
52     * @param mixed[]                $options
53     *
54     * @throws Exception
55     */
56    public function __construct(
57        $name,
58        array $columns = [],
59        array $indexes = [],
60        array $fkConstraints = [],
61        $idGeneratorType = 0,
62        array $options = []
63    ) {
64        if (strlen($name) === 0) {
65            throw Exception::invalidTableName($name);
66        }
67
68        $this->_setName($name);
69
70        foreach ($columns as $column) {
71            $this->_addColumn($column);
72        }
73
74        foreach ($indexes as $idx) {
75            $this->_addIndex($idx);
76        }
77
78        foreach ($fkConstraints as $constraint) {
79            $this->_addForeignKeyConstraint($constraint);
80        }
81
82        $this->_options = array_merge($this->_options, $options);
83    }
84
85    /**
86     * @return void
87     */
88    public function setSchemaConfig(SchemaConfig $schemaConfig)
89    {
90        $this->_schemaConfig = $schemaConfig;
91    }
92
93    /**
94     * @return int
95     */
96    protected function _getMaxIdentifierLength()
97    {
98        if ($this->_schemaConfig instanceof SchemaConfig) {
99            return $this->_schemaConfig->getMaxIdentifierLength();
100        }
101
102        return 63;
103    }
104
105    /**
106     * Sets the Primary Key.
107     *
108     * @param string[]     $columnNames
109     * @param string|false $indexName
110     *
111     * @return self
112     */
113    public function setPrimaryKey(array $columnNames, $indexName = false)
114    {
115        $this->_addIndex($this->_createIndex($columnNames, $indexName ?: 'primary', true, true));
116
117        foreach ($columnNames as $columnName) {
118            $column = $this->getColumn($columnName);
119            $column->setNotnull(true);
120        }
121
122        return $this;
123    }
124
125    /**
126     * @param string[]    $columnNames
127     * @param string|null $indexName
128     * @param string[]    $flags
129     * @param mixed[]     $options
130     *
131     * @return self
132     */
133    public function addIndex(array $columnNames, $indexName = null, array $flags = [], array $options = [])
134    {
135        if ($indexName === null) {
136            $indexName = $this->_generateIdentifierName(
137                array_merge([$this->getName()], $columnNames),
138                'idx',
139                $this->_getMaxIdentifierLength()
140            );
141        }
142
143        return $this->_addIndex($this->_createIndex($columnNames, $indexName, false, false, $flags, $options));
144    }
145
146    /**
147     * Drops the primary key from this table.
148     *
149     * @return void
150     */
151    public function dropPrimaryKey()
152    {
153        if ($this->_primaryKeyName === false) {
154            return;
155        }
156
157        $this->dropIndex($this->_primaryKeyName);
158        $this->_primaryKeyName = false;
159    }
160
161    /**
162     * Drops an index from this table.
163     *
164     * @param string $name The index name.
165     *
166     * @return void
167     *
168     * @throws SchemaException If the index does not exist.
169     */
170    public function dropIndex($name)
171    {
172        $name = $this->normalizeIdentifier($name);
173        if (! $this->hasIndex($name)) {
174            throw SchemaException::indexDoesNotExist($name, $this->_name);
175        }
176
177        unset($this->_indexes[$name]);
178    }
179
180    /**
181     * @param string[]    $columnNames
182     * @param string|null $indexName
183     * @param mixed[]     $options
184     *
185     * @return self
186     */
187    public function addUniqueIndex(array $columnNames, $indexName = null, array $options = [])
188    {
189        if ($indexName === null) {
190            $indexName = $this->_generateIdentifierName(
191                array_merge([$this->getName()], $columnNames),
192                'uniq',
193                $this->_getMaxIdentifierLength()
194            );
195        }
196
197        return $this->_addIndex($this->_createIndex($columnNames, $indexName, true, false, [], $options));
198    }
199
200    /**
201     * Renames an index.
202     *
203     * @param string      $oldName The name of the index to rename from.
204     * @param string|null $newName The name of the index to rename to.
205     *                                  If null is given, the index name will be auto-generated.
206     *
207     * @return self This table instance.
208     *
209     * @throws SchemaException If no index exists for the given current name
210     *                         or if an index with the given new name already exists on this table.
211     */
212    public function renameIndex($oldName, $newName = null)
213    {
214        $oldName           = $this->normalizeIdentifier($oldName);
215        $normalizedNewName = $this->normalizeIdentifier($newName);
216
217        if ($oldName === $normalizedNewName) {
218            return $this;
219        }
220
221        if (! $this->hasIndex($oldName)) {
222            throw SchemaException::indexDoesNotExist($oldName, $this->_name);
223        }
224
225        if ($this->hasIndex($normalizedNewName)) {
226            throw SchemaException::indexAlreadyExists($normalizedNewName, $this->_name);
227        }
228
229        $oldIndex = $this->_indexes[$oldName];
230
231        if ($oldIndex->isPrimary()) {
232            $this->dropPrimaryKey();
233
234            return $this->setPrimaryKey($oldIndex->getColumns(), $newName ?? false);
235        }
236
237        unset($this->_indexes[$oldName]);
238
239        if ($oldIndex->isUnique()) {
240            return $this->addUniqueIndex($oldIndex->getColumns(), $newName, $oldIndex->getOptions());
241        }
242
243        return $this->addIndex($oldIndex->getColumns(), $newName, $oldIndex->getFlags(), $oldIndex->getOptions());
244    }
245
246    /**
247     * Checks if an index begins in the order of the given columns.
248     *
249     * @param string[] $columnNames
250     *
251     * @return bool
252     */
253    public function columnsAreIndexed(array $columnNames)
254    {
255        foreach ($this->getIndexes() as $index) {
256            if ($index->spansColumns($columnNames)) {
257                return true;
258            }
259        }
260
261        return false;
262    }
263
264    /**
265     * @param string[] $columnNames
266     * @param string   $indexName
267     * @param bool     $isUnique
268     * @param bool     $isPrimary
269     * @param string[] $flags
270     * @param mixed[]  $options
271     *
272     * @return Index
273     *
274     * @throws SchemaException
275     */
276    private function _createIndex(
277        array $columnNames,
278        $indexName,
279        $isUnique,
280        $isPrimary,
281        array $flags = [],
282        array $options = []
283    ) {
284        if (preg_match('(([^a-zA-Z0-9_]+))', $this->normalizeIdentifier($indexName))) {
285            throw SchemaException::indexNameInvalid($indexName);
286        }
287
288        foreach ($columnNames as $columnName) {
289            if (! $this->hasColumn($columnName)) {
290                throw SchemaException::columnDoesNotExist($columnName, $this->_name);
291            }
292        }
293
294        return new Index($indexName, $columnNames, $isUnique, $isPrimary, $flags, $options);
295    }
296
297    /**
298     * @param string  $name
299     * @param string  $typeName
300     * @param mixed[] $options
301     *
302     * @return Column
303     */
304    public function addColumn($name, $typeName, array $options = [])
305    {
306        $column = new Column($name, Type::getType($typeName), $options);
307
308        $this->_addColumn($column);
309
310        return $column;
311    }
312
313    /**
314     * Renames a Column.
315     *
316     * @deprecated
317     *
318     * @param string $oldName
319     * @param string $name
320     *
321     * @return void
322     *
323     * @throws Exception
324     */
325    public function renameColumn($oldName, $name)
326    {
327        throw new Exception('Table#renameColumn() was removed, because it drops and recreates ' .
328            'the column instead. There is no fix available, because a schema diff cannot reliably detect if a ' .
329            'column was renamed or one column was created and another one dropped.');
330    }
331
332    /**
333     * Change Column Details.
334     *
335     * @param string  $name
336     * @param mixed[] $options
337     *
338     * @return self
339     */
340    public function changeColumn($name, array $options)
341    {
342        $column = $this->getColumn($name);
343        $column->setOptions($options);
344
345        return $this;
346    }
347
348    /**
349     * Drops a Column from the Table.
350     *
351     * @param string $name
352     *
353     * @return self
354     */
355    public function dropColumn($name)
356    {
357        $name = $this->normalizeIdentifier($name);
358        unset($this->_columns[$name]);
359
360        return $this;
361    }
362
363    /**
364     * Adds a foreign key constraint.
365     *
366     * Name is inferred from the local columns.
367     *
368     * @param Table|string $foreignTable       Table schema instance or table name
369     * @param string[]     $localColumnNames
370     * @param string[]     $foreignColumnNames
371     * @param mixed[]      $options
372     * @param string|null  $constraintName
373     *
374     * @return self
375     */
376    public function addForeignKeyConstraint(
377        $foreignTable,
378        array $localColumnNames,
379        array $foreignColumnNames,
380        array $options = [],
381        $constraintName = null
382    ) {
383        $constraintName = $constraintName ?: $this->_generateIdentifierName(
384            array_merge((array) $this->getName(), $localColumnNames),
385            'fk',
386            $this->_getMaxIdentifierLength()
387        );
388
389        return $this->addNamedForeignKeyConstraint(
390            $constraintName,
391            $foreignTable,
392            $localColumnNames,
393            $foreignColumnNames,
394            $options
395        );
396    }
397
398    /**
399     * Adds a foreign key constraint.
400     *
401     * Name is to be generated by the database itself.
402     *
403     * @deprecated Use {@link addForeignKeyConstraint}
404     *
405     * @param Table|string $foreignTable       Table schema instance or table name
406     * @param string[]     $localColumnNames
407     * @param string[]     $foreignColumnNames
408     * @param mixed[]      $options
409     *
410     * @return self
411     */
412    public function addUnnamedForeignKeyConstraint(
413        $foreignTable,
414        array $localColumnNames,
415        array $foreignColumnNames,
416        array $options = []
417    ) {
418        return $this->addForeignKeyConstraint($foreignTable, $localColumnNames, $foreignColumnNames, $options);
419    }
420
421    /**
422     * Adds a foreign key constraint with a given name.
423     *
424     * @deprecated Use {@link addForeignKeyConstraint}
425     *
426     * @param string       $name
427     * @param Table|string $foreignTable       Table schema instance or table name
428     * @param string[]     $localColumnNames
429     * @param string[]     $foreignColumnNames
430     * @param mixed[]      $options
431     *
432     * @return self
433     *
434     * @throws SchemaException
435     */
436    public function addNamedForeignKeyConstraint(
437        $name,
438        $foreignTable,
439        array $localColumnNames,
440        array $foreignColumnNames,
441        array $options = []
442    ) {
443        if ($foreignTable instanceof Table) {
444            foreach ($foreignColumnNames as $columnName) {
445                if (! $foreignTable->hasColumn($columnName)) {
446                    throw SchemaException::columnDoesNotExist($columnName, $foreignTable->getName());
447                }
448            }
449        }
450
451        foreach ($localColumnNames as $columnName) {
452            if (! $this->hasColumn($columnName)) {
453                throw SchemaException::columnDoesNotExist($columnName, $this->_name);
454            }
455        }
456
457        $constraint = new ForeignKeyConstraint(
458            $localColumnNames,
459            $foreignTable,
460            $foreignColumnNames,
461            $name,
462            $options
463        );
464        $this->_addForeignKeyConstraint($constraint);
465
466        return $this;
467    }
468
469    /**
470     * @param string $name
471     * @param mixed  $value
472     *
473     * @return self
474     */
475    public function addOption($name, $value)
476    {
477        $this->_options[$name] = $value;
478
479        return $this;
480    }
481
482    /**
483     * @return void
484     *
485     * @throws SchemaException
486     */
487    protected function _addColumn(Column $column)
488    {
489        $columnName = $column->getName();
490        $columnName = $this->normalizeIdentifier($columnName);
491
492        if (isset($this->_columns[$columnName])) {
493            throw SchemaException::columnAlreadyExists($this->getName(), $columnName);
494        }
495
496        $this->_columns[$columnName] = $column;
497    }
498
499    /**
500     * Adds an index to the table.
501     *
502     * @return self
503     *
504     * @throws SchemaException
505     */
506    protected function _addIndex(Index $indexCandidate)
507    {
508        $indexName               = $indexCandidate->getName();
509        $indexName               = $this->normalizeIdentifier($indexName);
510        $replacedImplicitIndexes = [];
511
512        foreach ($this->implicitIndexes as $name => $implicitIndex) {
513            if (! $implicitIndex->isFullfilledBy($indexCandidate) || ! isset($this->_indexes[$name])) {
514                continue;
515            }
516
517            $replacedImplicitIndexes[] = $name;
518        }
519
520        if (
521            (isset($this->_indexes[$indexName]) && ! in_array($indexName, $replacedImplicitIndexes, true)) ||
522            ($this->_primaryKeyName !== false && $indexCandidate->isPrimary())
523        ) {
524            throw SchemaException::indexAlreadyExists($indexName, $this->_name);
525        }
526
527        foreach ($replacedImplicitIndexes as $name) {
528            unset($this->_indexes[$name], $this->implicitIndexes[$name]);
529        }
530
531        if ($indexCandidate->isPrimary()) {
532            $this->_primaryKeyName = $indexName;
533        }
534
535        $this->_indexes[$indexName] = $indexCandidate;
536
537        return $this;
538    }
539
540    /**
541     * @return void
542     */
543    protected function _addForeignKeyConstraint(ForeignKeyConstraint $constraint)
544    {
545        $constraint->setLocalTable($this);
546
547        if (strlen($constraint->getName())) {
548            $name = $constraint->getName();
549        } else {
550            $name = $this->_generateIdentifierName(
551                array_merge((array) $this->getName(), $constraint->getLocalColumns()),
552                'fk',
553                $this->_getMaxIdentifierLength()
554            );
555        }
556
557        $name = $this->normalizeIdentifier($name);
558
559        $this->_fkConstraints[$name] = $constraint;
560
561        /* Add an implicit index (defined by the DBAL) on the foreign key
562           columns. If there is already a user-defined index that fulfills these
563           requirements drop the request. In the case of __construct() calling
564           this method during hydration from schema-details, all the explicitly
565           added indexes lead to duplicates. This creates computation overhead in
566           this case, however no duplicate indexes are ever added (based on
567           columns). */
568        $indexName = $this->_generateIdentifierName(
569            array_merge([$this->getName()], $constraint->getColumns()),
570            'idx',
571            $this->_getMaxIdentifierLength()
572        );
573
574        $indexCandidate = $this->_createIndex($constraint->getColumns(), $indexName, false, false);
575
576        foreach ($this->_indexes as $existingIndex) {
577            if ($indexCandidate->isFullfilledBy($existingIndex)) {
578                return;
579            }
580        }
581
582        $this->_addIndex($indexCandidate);
583        $this->implicitIndexes[$this->normalizeIdentifier($indexName)] = $indexCandidate;
584    }
585
586    /**
587     * Returns whether this table has a foreign key constraint with the given name.
588     *
589     * @param string $name
590     *
591     * @return bool
592     */
593    public function hasForeignKey($name)
594    {
595        $name = $this->normalizeIdentifier($name);
596
597        return isset($this->_fkConstraints[$name]);
598    }
599
600    /**
601     * Returns the foreign key constraint with the given name.
602     *
603     * @param string $name The constraint name.
604     *
605     * @return ForeignKeyConstraint
606     *
607     * @throws SchemaException If the foreign key does not exist.
608     */
609    public function getForeignKey($name)
610    {
611        $name = $this->normalizeIdentifier($name);
612        if (! $this->hasForeignKey($name)) {
613            throw SchemaException::foreignKeyDoesNotExist($name, $this->_name);
614        }
615
616        return $this->_fkConstraints[$name];
617    }
618
619    /**
620     * Removes the foreign key constraint with the given name.
621     *
622     * @param string $name The constraint name.
623     *
624     * @return void
625     *
626     * @throws SchemaException
627     */
628    public function removeForeignKey($name)
629    {
630        $name = $this->normalizeIdentifier($name);
631        if (! $this->hasForeignKey($name)) {
632            throw SchemaException::foreignKeyDoesNotExist($name, $this->_name);
633        }
634
635        unset($this->_fkConstraints[$name]);
636    }
637
638    /**
639     * Returns ordered list of columns (primary keys are first, then foreign keys, then the rest)
640     *
641     * @return Column[]
642     */
643    public function getColumns()
644    {
645        $primaryKey        = $this->getPrimaryKey();
646        $primaryKeyColumns = [];
647
648        if ($primaryKey !== null) {
649            $primaryKeyColumns = $this->filterColumns($primaryKey->getColumns());
650        }
651
652        return array_merge($primaryKeyColumns, $this->getForeignKeyColumns(), $this->_columns);
653    }
654
655    /**
656     * Returns foreign key columns
657     *
658     * @return Column[]
659     */
660    private function getForeignKeyColumns()
661    {
662        $foreignKeyColumns = [];
663        foreach ($this->getForeignKeys() as $foreignKey) {
664            $foreignKeyColumns = array_merge($foreignKeyColumns, $foreignKey->getColumns());
665        }
666
667        return $this->filterColumns($foreignKeyColumns);
668    }
669
670    /**
671     * Returns only columns that have specified names
672     *
673     * @param string[] $columnNames
674     *
675     * @return Column[]
676     */
677    private function filterColumns(array $columnNames)
678    {
679        return array_filter($this->_columns, static function (string $columnName) use ($columnNames) {
680            return in_array($columnName, $columnNames, true);
681        }, ARRAY_FILTER_USE_KEY);
682    }
683
684    /**
685     * Returns whether this table has a Column with the given name.
686     *
687     * @param string $name The column name.
688     *
689     * @return bool
690     */
691    public function hasColumn($name)
692    {
693        $name = $this->normalizeIdentifier($name);
694
695        return isset($this->_columns[$name]);
696    }
697
698    /**
699     * Returns the Column with the given name.
700     *
701     * @param string $name The column name.
702     *
703     * @return Column
704     *
705     * @throws SchemaException If the column does not exist.
706     */
707    public function getColumn($name)
708    {
709        $name = $this->normalizeIdentifier($name);
710        if (! $this->hasColumn($name)) {
711            throw SchemaException::columnDoesNotExist($name, $this->_name);
712        }
713
714        return $this->_columns[$name];
715    }
716
717    /**
718     * Returns the primary key.
719     *
720     * @return Index|null The primary key, or null if this Table has no primary key.
721     */
722    public function getPrimaryKey()
723    {
724        if ($this->_primaryKeyName !== false) {
725            return $this->getIndex($this->_primaryKeyName);
726        }
727
728        return null;
729    }
730
731    /**
732     * Returns the primary key columns.
733     *
734     * @return string[]
735     *
736     * @throws Exception
737     */
738    public function getPrimaryKeyColumns()
739    {
740        $primaryKey = $this->getPrimaryKey();
741
742        if ($primaryKey === null) {
743            throw new Exception('Table ' . $this->getName() . ' has no primary key.');
744        }
745
746        return $primaryKey->getColumns();
747    }
748
749    /**
750     * Returns whether this table has a primary key.
751     *
752     * @return bool
753     */
754    public function hasPrimaryKey()
755    {
756        return $this->_primaryKeyName && $this->hasIndex($this->_primaryKeyName);
757    }
758
759    /**
760     * Returns whether this table has an Index with the given name.
761     *
762     * @param string $name The index name.
763     *
764     * @return bool
765     */
766    public function hasIndex($name)
767    {
768        $name = $this->normalizeIdentifier($name);
769
770        return isset($this->_indexes[$name]);
771    }
772
773    /**
774     * Returns the Index with the given name.
775     *
776     * @param string $name The index name.
777     *
778     * @return Index
779     *
780     * @throws SchemaException If the index does not exist.
781     */
782    public function getIndex($name)
783    {
784        $name = $this->normalizeIdentifier($name);
785        if (! $this->hasIndex($name)) {
786            throw SchemaException::indexDoesNotExist($name, $this->_name);
787        }
788
789        return $this->_indexes[$name];
790    }
791
792    /**
793     * @return Index[]
794     */
795    public function getIndexes()
796    {
797        return $this->_indexes;
798    }
799
800    /**
801     * Returns the foreign key constraints.
802     *
803     * @return ForeignKeyConstraint[]
804     */
805    public function getForeignKeys()
806    {
807        return $this->_fkConstraints;
808    }
809
810    /**
811     * @param string $name
812     *
813     * @return bool
814     */
815    public function hasOption($name)
816    {
817        return isset($this->_options[$name]);
818    }
819
820    /**
821     * @param string $name
822     *
823     * @return mixed
824     */
825    public function getOption($name)
826    {
827        return $this->_options[$name];
828    }
829
830    /**
831     * @return mixed[]
832     */
833    public function getOptions()
834    {
835        return $this->_options;
836    }
837
838    /**
839     * @return void
840     */
841    public function visit(Visitor $visitor)
842    {
843        $visitor->acceptTable($this);
844
845        foreach ($this->getColumns() as $column) {
846            $visitor->acceptColumn($this, $column);
847        }
848
849        foreach ($this->getIndexes() as $index) {
850            $visitor->acceptIndex($this, $index);
851        }
852
853        foreach ($this->getForeignKeys() as $constraint) {
854            $visitor->acceptForeignKey($this, $constraint);
855        }
856    }
857
858    /**
859     * Clone of a Table triggers a deep clone of all affected assets.
860     *
861     * @return void
862     */
863    public function __clone()
864    {
865        foreach ($this->_columns as $k => $column) {
866            $this->_columns[$k] = clone $column;
867        }
868
869        foreach ($this->_indexes as $k => $index) {
870            $this->_indexes[$k] = clone $index;
871        }
872
873        foreach ($this->_fkConstraints as $k => $fk) {
874            $this->_fkConstraints[$k] = clone $fk;
875            $this->_fkConstraints[$k]->setLocalTable($this);
876        }
877    }
878
879    /**
880     * Normalizes a given identifier.
881     *
882     * Trims quotes and lowercases the given identifier.
883     *
884     * @param string|null $identifier The identifier to normalize.
885     *
886     * @return string The normalized identifier.
887     */
888    private function normalizeIdentifier($identifier)
889    {
890        if ($identifier === null) {
891            return '';
892        }
893
894        return $this->trimQuotes(strtolower($identifier));
895    }
896
897    public function setComment(?string $comment): self
898    {
899        // For keeping backward compatibility with MySQL in previous releases, table comments are stored as options.
900        $this->addOption('comment', $comment);
901
902        return $this;
903    }
904
905    public function getComment(): ?string
906    {
907        return $this->_options['comment'] ?? null;
908    }
909}
910