vendor/doctrine/dbal/src/Schema/MySQLSchemaManager.php line 83

Open in your IDE?
  1. <?php
  2. namespace Doctrine\DBAL\Schema;
  3. use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
  4. use Doctrine\DBAL\Platforms\MariaDb1027Platform;
  5. use Doctrine\DBAL\Platforms\MySQL;
  6. use Doctrine\DBAL\Platforms\MySQL\CollationMetadataProvider\CachingCollationMetadataProvider;
  7. use Doctrine\DBAL\Platforms\MySQL\CollationMetadataProvider\ConnectionCollationMetadataProvider;
  8. use Doctrine\DBAL\Result;
  9. use Doctrine\DBAL\Types\Type;
  10. use Doctrine\Deprecations\Deprecation;
  11. use function array_change_key_case;
  12. use function array_shift;
  13. use function assert;
  14. use function explode;
  15. use function implode;
  16. use function is_string;
  17. use function preg_match;
  18. use function strpos;
  19. use function strtok;
  20. use function strtolower;
  21. use function strtr;
  22. use const CASE_LOWER;
  23. /**
  24. * Schema manager for the MySQL RDBMS.
  25. *
  26. * @extends AbstractSchemaManager<AbstractMySQLPlatform>
  27. */
  28. class MySQLSchemaManager extends AbstractSchemaManager
  29. {
  30. /** @see https://mariadb.com/kb/en/library/string-literals/#escape-sequences */
  31. private const MARIADB_ESCAPE_SEQUENCES = [
  32. '\\0' => "\0",
  33. "\\'" => "'",
  34. '\\"' => '"',
  35. '\\b' => "\b",
  36. '\\n' => "\n",
  37. '\\r' => "\r",
  38. '\\t' => "\t",
  39. '\\Z' => "\x1a",
  40. '\\\\' => '\\',
  41. '\\%' => '%',
  42. '\\_' => '_',
  43. // Internally, MariaDB escapes single quotes using the standard syntax
  44. "''" => "'",
  45. ];
  46. /**
  47. * {@inheritDoc}
  48. */
  49. public function listTableNames()
  50. {
  51. return $this->doListTableNames();
  52. }
  53. /**
  54. * {@inheritDoc}
  55. */
  56. public function listTables()
  57. {
  58. return $this->doListTables();
  59. }
  60. /**
  61. * {@inheritDoc}
  62. *
  63. * @deprecated Use {@see introspectTable()} instead.
  64. */
  65. public function listTableDetails($name)
  66. {
  67. Deprecation::triggerIfCalledFromOutside(
  68. 'doctrine/dbal',
  69. 'https://github.com/doctrine/dbal/pull/5595',
  70. '%s is deprecated. Use introspectTable() instead.',
  71. __METHOD__,
  72. );
  73. return $this->doListTableDetails($name);
  74. }
  75. /**
  76. * {@inheritDoc}
  77. */
  78. public function listTableColumns($table, $database = null)
  79. {
  80. return $this->doListTableColumns($table, $database);
  81. }
  82. /**
  83. * {@inheritDoc}
  84. */
  85. public function listTableIndexes($table)
  86. {
  87. return $this->doListTableIndexes($table);
  88. }
  89. /**
  90. * {@inheritDoc}
  91. */
  92. public function listTableForeignKeys($table, $database = null)
  93. {
  94. return $this->doListTableForeignKeys($table, $database);
  95. }
  96. /**
  97. * {@inheritDoc}
  98. */
  99. protected function _getPortableViewDefinition($view)
  100. {
  101. return new View($view['TABLE_NAME'], $view['VIEW_DEFINITION']);
  102. }
  103. /**
  104. * {@inheritDoc}
  105. */
  106. protected function _getPortableTableDefinition($table)
  107. {
  108. return array_shift($table);
  109. }
  110. /**
  111. * {@inheritDoc}
  112. */
  113. protected function _getPortableTableIndexesList($tableIndexes, $tableName = null)
  114. {
  115. foreach ($tableIndexes as $k => $v) {
  116. $v = array_change_key_case($v, CASE_LOWER);
  117. if ($v['key_name'] === 'PRIMARY') {
  118. $v['primary'] = true;
  119. } else {
  120. $v['primary'] = false;
  121. }
  122. if (strpos($v['index_type'], 'FULLTEXT') !== false) {
  123. $v['flags'] = ['FULLTEXT'];
  124. } elseif (strpos($v['index_type'], 'SPATIAL') !== false) {
  125. $v['flags'] = ['SPATIAL'];
  126. }
  127. // Ignore prohibited prefix `length` for spatial index
  128. if (strpos($v['index_type'], 'SPATIAL') === false) {
  129. $v['length'] = isset($v['sub_part']) ? (int) $v['sub_part'] : null;
  130. }
  131. $tableIndexes[$k] = $v;
  132. }
  133. return parent::_getPortableTableIndexesList($tableIndexes, $tableName);
  134. }
  135. /**
  136. * {@inheritDoc}
  137. */
  138. protected function _getPortableDatabaseDefinition($database)
  139. {
  140. return $database['Database'];
  141. }
  142. /**
  143. * {@inheritDoc}
  144. */
  145. protected function _getPortableTableColumnDefinition($tableColumn)
  146. {
  147. $tableColumn = array_change_key_case($tableColumn, CASE_LOWER);
  148. $dbType = strtolower($tableColumn['type']);
  149. $dbType = strtok($dbType, '(), ');
  150. assert(is_string($dbType));
  151. $length = $tableColumn['length'] ?? strtok('(), ');
  152. $fixed = null;
  153. if (! isset($tableColumn['name'])) {
  154. $tableColumn['name'] = '';
  155. }
  156. $scale = null;
  157. $precision = null;
  158. $type = $origType = $this->_platform->getDoctrineTypeMapping($dbType);
  159. // In cases where not connected to a database DESCRIBE $table does not return 'Comment'
  160. if (isset($tableColumn['comment'])) {
  161. $type = $this->extractDoctrineTypeFromComment($tableColumn['comment'], $type);
  162. $tableColumn['comment'] = $this->removeDoctrineTypeFromComment($tableColumn['comment'], $type);
  163. }
  164. switch ($dbType) {
  165. case 'char':
  166. case 'binary':
  167. $fixed = true;
  168. break;
  169. case 'float':
  170. case 'double':
  171. case 'real':
  172. case 'numeric':
  173. case 'decimal':
  174. if (
  175. preg_match(
  176. '([A-Za-z]+\(([0-9]+),([0-9]+)\))',
  177. $tableColumn['type'],
  178. $match,
  179. ) === 1
  180. ) {
  181. $precision = $match[1];
  182. $scale = $match[2];
  183. $length = null;
  184. }
  185. break;
  186. case 'tinytext':
  187. $length = AbstractMySQLPlatform::LENGTH_LIMIT_TINYTEXT;
  188. break;
  189. case 'text':
  190. $length = AbstractMySQLPlatform::LENGTH_LIMIT_TEXT;
  191. break;
  192. case 'mediumtext':
  193. $length = AbstractMySQLPlatform::LENGTH_LIMIT_MEDIUMTEXT;
  194. break;
  195. case 'tinyblob':
  196. $length = AbstractMySQLPlatform::LENGTH_LIMIT_TINYBLOB;
  197. break;
  198. case 'blob':
  199. $length = AbstractMySQLPlatform::LENGTH_LIMIT_BLOB;
  200. break;
  201. case 'mediumblob':
  202. $length = AbstractMySQLPlatform::LENGTH_LIMIT_MEDIUMBLOB;
  203. break;
  204. case 'tinyint':
  205. case 'smallint':
  206. case 'mediumint':
  207. case 'int':
  208. case 'integer':
  209. case 'bigint':
  210. case 'year':
  211. $length = null;
  212. break;
  213. }
  214. if ($this->_platform instanceof MariaDb1027Platform) {
  215. $columnDefault = $this->getMariaDb1027ColumnDefault($this->_platform, $tableColumn['default']);
  216. } else {
  217. $columnDefault = $tableColumn['default'];
  218. }
  219. $options = [
  220. 'length' => $length !== null ? (int) $length : null,
  221. 'unsigned' => strpos($tableColumn['type'], 'unsigned') !== false,
  222. 'fixed' => (bool) $fixed,
  223. 'default' => $columnDefault,
  224. 'notnull' => $tableColumn['null'] !== 'YES',
  225. 'scale' => null,
  226. 'precision' => null,
  227. 'autoincrement' => strpos($tableColumn['extra'], 'auto_increment') !== false,
  228. 'comment' => isset($tableColumn['comment']) && $tableColumn['comment'] !== ''
  229. ? $tableColumn['comment']
  230. : null,
  231. ];
  232. if ($scale !== null && $precision !== null) {
  233. $options['scale'] = (int) $scale;
  234. $options['precision'] = (int) $precision;
  235. }
  236. $column = new Column($tableColumn['field'], Type::getType($type), $options);
  237. if (isset($tableColumn['characterset'])) {
  238. $column->setPlatformOption('charset', $tableColumn['characterset']);
  239. }
  240. if (isset($tableColumn['collation'])) {
  241. $column->setPlatformOption('collation', $tableColumn['collation']);
  242. }
  243. if (isset($tableColumn['declarationMismatch'])) {
  244. $column->setPlatformOption('declarationMismatch', $tableColumn['declarationMismatch']);
  245. }
  246. // Check underlying database type where doctrine type is inferred from DC2Type comment
  247. // and set a flag if it is not as expected.
  248. if ($type === 'json' && $origType !== $type && $this->expectedDbType($type, $options) !== $dbType) {
  249. $column->setPlatformOption('declarationMismatch', true);
  250. }
  251. return $column;
  252. }
  253. /**
  254. * Returns the database data type for a given doctrine type and column
  255. *
  256. * Note that for data types that depend on length where length is not part of the column definition
  257. * and therefore the $tableColumn['length'] will not be set, for example TEXT (which could be LONGTEXT,
  258. * MEDIUMTEXT) or BLOB (LONGBLOB or TINYBLOB), the expectedDbType cannot be inferred exactly, merely
  259. * the default type.
  260. *
  261. * This method is intended to be used to determine underlying database type where doctrine type is
  262. * inferred from a DC2Type comment.
  263. *
  264. * @param mixed[] $tableColumn
  265. */
  266. private function expectedDbType(string $type, array $tableColumn): string
  267. {
  268. $_type = Type::getType($type);
  269. $expectedDbType = strtolower($_type->getSQLDeclaration($tableColumn, $this->_platform));
  270. $expectedDbType = strtok($expectedDbType, '(), ');
  271. return $expectedDbType === false ? '' : $expectedDbType;
  272. }
  273. /**
  274. * Return Doctrine/Mysql-compatible column default values for MariaDB 10.2.7+ servers.
  275. *
  276. * - Since MariaDb 10.2.7 column defaults stored in information_schema are now quoted
  277. * to distinguish them from expressions (see MDEV-10134).
  278. * - CURRENT_TIMESTAMP, CURRENT_TIME, CURRENT_DATE are stored in information_schema
  279. * as current_timestamp(), currdate(), currtime()
  280. * - Quoted 'NULL' is not enforced by Maria, it is technically possible to have
  281. * null in some circumstances (see https://jira.mariadb.org/browse/MDEV-14053)
  282. * - \' is always stored as '' in information_schema (normalized)
  283. *
  284. * @link https://mariadb.com/kb/en/library/information-schema-columns-table/
  285. * @link https://jira.mariadb.org/browse/MDEV-13132
  286. *
  287. * @param string|null $columnDefault default value as stored in information_schema for MariaDB >= 10.2.7
  288. */
  289. private function getMariaDb1027ColumnDefault(MariaDb1027Platform $platform, ?string $columnDefault): ?string
  290. {
  291. if ($columnDefault === 'NULL' || $columnDefault === null) {
  292. return null;
  293. }
  294. if (preg_match('/^\'(.*)\'$/', $columnDefault, $matches) === 1) {
  295. return strtr($matches[1], self::MARIADB_ESCAPE_SEQUENCES);
  296. }
  297. switch ($columnDefault) {
  298. case 'current_timestamp()':
  299. return $platform->getCurrentTimestampSQL();
  300. case 'curdate()':
  301. return $platform->getCurrentDateSQL();
  302. case 'curtime()':
  303. return $platform->getCurrentTimeSQL();
  304. }
  305. return $columnDefault;
  306. }
  307. /**
  308. * {@inheritDoc}
  309. */
  310. protected function _getPortableTableForeignKeysList($tableForeignKeys)
  311. {
  312. $list = [];
  313. foreach ($tableForeignKeys as $value) {
  314. $value = array_change_key_case($value, CASE_LOWER);
  315. if (! isset($list[$value['constraint_name']])) {
  316. if (! isset($value['delete_rule']) || $value['delete_rule'] === 'RESTRICT') {
  317. $value['delete_rule'] = null;
  318. }
  319. if (! isset($value['update_rule']) || $value['update_rule'] === 'RESTRICT') {
  320. $value['update_rule'] = null;
  321. }
  322. $list[$value['constraint_name']] = [
  323. 'name' => $value['constraint_name'],
  324. 'local' => [],
  325. 'foreign' => [],
  326. 'foreignTable' => $value['referenced_table_name'],
  327. 'onDelete' => $value['delete_rule'],
  328. 'onUpdate' => $value['update_rule'],
  329. ];
  330. }
  331. $list[$value['constraint_name']]['local'][] = $value['column_name'];
  332. $list[$value['constraint_name']]['foreign'][] = $value['referenced_column_name'];
  333. }
  334. return parent::_getPortableTableForeignKeysList($list);
  335. }
  336. /**
  337. * {@inheritDoc}
  338. */
  339. protected function _getPortableTableForeignKeyDefinition($tableForeignKey): ForeignKeyConstraint
  340. {
  341. return new ForeignKeyConstraint(
  342. $tableForeignKey['local'],
  343. $tableForeignKey['foreignTable'],
  344. $tableForeignKey['foreign'],
  345. $tableForeignKey['name'],
  346. [
  347. 'onDelete' => $tableForeignKey['onDelete'],
  348. 'onUpdate' => $tableForeignKey['onUpdate'],
  349. ],
  350. );
  351. }
  352. public function createComparator(): Comparator
  353. {
  354. return new MySQL\Comparator(
  355. $this->_platform,
  356. new CachingCollationMetadataProvider(
  357. new ConnectionCollationMetadataProvider($this->_conn),
  358. ),
  359. );
  360. }
  361. protected function selectTableNames(string $databaseName): Result
  362. {
  363. $sql = <<<'SQL'
  364. SELECT TABLE_NAME
  365. FROM information_schema.TABLES
  366. WHERE TABLE_SCHEMA = ?
  367. AND TABLE_TYPE = 'BASE TABLE'
  368. ORDER BY TABLE_NAME
  369. SQL;
  370. return $this->_conn->executeQuery($sql, [$databaseName]);
  371. }
  372. protected function selectTableColumns(string $databaseName, ?string $tableName = null): Result
  373. {
  374. // @todo 4.0 - call getColumnTypeSQLSnippet() instead
  375. [$columnTypeSQL, $joinCheckConstraintSQL] = $this->_platform->getColumnTypeSQLSnippets('c', $databaseName);
  376. $sql = 'SELECT';
  377. if ($tableName === null) {
  378. $sql .= ' c.TABLE_NAME,';
  379. }
  380. $sql .= <<<SQL
  381. c.COLUMN_NAME AS field,
  382. $columnTypeSQL AS type,
  383. c.IS_NULLABLE AS `null`,
  384. c.COLUMN_KEY AS `key`,
  385. c.COLUMN_DEFAULT AS `default`,
  386. c.EXTRA,
  387. c.COLUMN_COMMENT AS comment,
  388. c.CHARACTER_SET_NAME AS characterset,
  389. c.COLLATION_NAME AS collation
  390. FROM information_schema.COLUMNS c
  391. INNER JOIN information_schema.TABLES t
  392. ON t.TABLE_NAME = c.TABLE_NAME
  393. $joinCheckConstraintSQL
  394. SQL;
  395. // The schema name is passed multiple times as a literal in the WHERE clause instead of using a JOIN condition
  396. // in order to avoid performance issues on MySQL older than 8.0 and the corresponding MariaDB versions
  397. // caused by https://bugs.mysql.com/bug.php?id=81347
  398. $conditions = ['c.TABLE_SCHEMA = ?', 't.TABLE_SCHEMA = ?', "t.TABLE_TYPE = 'BASE TABLE'"];
  399. $params = [$databaseName, $databaseName];
  400. if ($tableName !== null) {
  401. $conditions[] = 't.TABLE_NAME = ?';
  402. $params[] = $tableName;
  403. }
  404. $sql .= ' WHERE ' . implode(' AND ', $conditions) . ' ORDER BY ORDINAL_POSITION';
  405. return $this->_conn->executeQuery($sql, $params);
  406. }
  407. protected function selectIndexColumns(string $databaseName, ?string $tableName = null): Result
  408. {
  409. $sql = 'SELECT';
  410. if ($tableName === null) {
  411. $sql .= ' TABLE_NAME,';
  412. }
  413. $sql .= <<<'SQL'
  414. NON_UNIQUE AS Non_Unique,
  415. INDEX_NAME AS Key_name,
  416. COLUMN_NAME AS Column_Name,
  417. SUB_PART AS Sub_Part,
  418. INDEX_TYPE AS Index_Type
  419. FROM information_schema.STATISTICS
  420. SQL;
  421. $conditions = ['TABLE_SCHEMA = ?'];
  422. $params = [$databaseName];
  423. if ($tableName !== null) {
  424. $conditions[] = 'TABLE_NAME = ?';
  425. $params[] = $tableName;
  426. }
  427. $sql .= ' WHERE ' . implode(' AND ', $conditions) . ' ORDER BY SEQ_IN_INDEX';
  428. return $this->_conn->executeQuery($sql, $params);
  429. }
  430. protected function selectForeignKeyColumns(string $databaseName, ?string $tableName = null): Result
  431. {
  432. $sql = 'SELECT DISTINCT';
  433. if ($tableName === null) {
  434. $sql .= ' k.TABLE_NAME,';
  435. }
  436. $sql .= <<<'SQL'
  437. k.CONSTRAINT_NAME,
  438. k.COLUMN_NAME,
  439. k.REFERENCED_TABLE_NAME,
  440. k.REFERENCED_COLUMN_NAME,
  441. k.ORDINAL_POSITION /*!50116,
  442. c.UPDATE_RULE,
  443. c.DELETE_RULE */
  444. FROM information_schema.key_column_usage k /*!50116
  445. INNER JOIN information_schema.referential_constraints c
  446. ON c.CONSTRAINT_NAME = k.CONSTRAINT_NAME
  447. AND c.TABLE_NAME = k.TABLE_NAME */
  448. SQL;
  449. $conditions = ['k.TABLE_SCHEMA = ?'];
  450. $params = [$databaseName];
  451. if ($tableName !== null) {
  452. $conditions[] = 'k.TABLE_NAME = ?';
  453. $params[] = $tableName;
  454. }
  455. $conditions[] = 'k.REFERENCED_COLUMN_NAME IS NOT NULL';
  456. $sql .= ' WHERE ' . implode(' AND ', $conditions)
  457. // The schema name is passed multiple times in the WHERE clause instead of using a JOIN condition
  458. // in order to avoid performance issues on MySQL older than 8.0 and the corresponding MariaDB versions
  459. // caused by https://bugs.mysql.com/bug.php?id=81347.
  460. // Use a string literal for the database name since the internal PDO SQL parser
  461. // cannot recognize parameter placeholders inside conditional comments
  462. . ' /*!50116 AND c.CONSTRAINT_SCHEMA = ' . $this->_conn->quote($databaseName) . ' */'
  463. . ' ORDER BY k.ORDINAL_POSITION';
  464. return $this->_conn->executeQuery($sql, $params);
  465. }
  466. /**
  467. * {@inheritDoc}
  468. */
  469. protected function fetchTableOptionsByTable(string $databaseName, ?string $tableName = null): array
  470. {
  471. $sql = $this->_platform->fetchTableOptionsByTable($tableName !== null);
  472. $params = [$databaseName];
  473. if ($tableName !== null) {
  474. $params[] = $tableName;
  475. }
  476. /** @var array<string,array<string,mixed>> $metadata */
  477. $metadata = $this->_conn->executeQuery($sql, $params)
  478. ->fetchAllAssociativeIndexed();
  479. $tableOptions = [];
  480. foreach ($metadata as $table => $data) {
  481. $data = array_change_key_case($data, CASE_LOWER);
  482. $tableOptions[$table] = [
  483. 'engine' => $data['engine'],
  484. 'collation' => $data['table_collation'],
  485. 'charset' => $data['character_set_name'],
  486. 'autoincrement' => $data['auto_increment'],
  487. 'comment' => $data['table_comment'],
  488. 'create_options' => $this->parseCreateOptions($data['create_options']),
  489. ];
  490. }
  491. return $tableOptions;
  492. }
  493. /** @return string[]|true[] */
  494. private function parseCreateOptions(?string $string): array
  495. {
  496. $options = [];
  497. if ($string === null || $string === '') {
  498. return $options;
  499. }
  500. foreach (explode(' ', $string) as $pair) {
  501. $parts = explode('=', $pair, 2);
  502. $options[$parts[0]] = $parts[1] ?? true;
  503. }
  504. return $options;
  505. }
  506. }