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