From 865af21b3a6f7fb807e20ae406db1931cb25a288 Mon Sep 17 00:00:00 2001 From: Nicolai Ehrhardt <245527909+predictor2718@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:53:57 +0200 Subject: [PATCH] Implement #19613 - Support invisible indexes (MySQL 8.0+) Signed-off-by: Nicolai Ehrhardt <245527909+predictor2718@users.noreply.github.com> --- resources/js/modules/indexes.ts | 2 + .../database/data_dictionary/index.twig | 2 + resources/templates/indexes.twig | 2 + resources/templates/table/index_form.twig | 16 ++++ .../table/structure/display_structure.twig | 2 + src/Controllers/Table/IndexesController.php | 8 +- src/Indexes/Index.php | 26 +++++- src/Table/Indexes.php | 19 ++++ .../DataDictionary-testController.html | 2 + .../Table/IndexRenameControllerTest.php | 2 +- tests/unit/Indexes/IndexTest.php | 13 +++ tests/unit/Table/IndexesTest.php | 88 +++++++++++++++++++ 12 files changed, 178 insertions(+), 4 deletions(-) diff --git a/resources/js/modules/indexes.ts b/resources/js/modules/indexes.ts index 223d76c60e..3ef73b852b 100644 --- a/resources/js/modules/indexes.ts +++ b/resources/js/modules/indexes.ts @@ -163,6 +163,7 @@ function addColumnToIndex (sourceArray, arrayIndex, indexChoice, colIndex): void const keyBlockSize = $('input[name="index[Key_block_size]"]').val(); const parser = $('input[name="index[Parser]"]').val(); const indexType = $('select[name="index[Index_type]"]').val(); + const indexVisible = $('select[name="index[Visible]"]').val(); const columns = []; $('#index_columns').find('tbody').find('tr').each(function () { // Get columns in particular order. @@ -182,6 +183,7 @@ function addColumnToIndex (sourceArray, arrayIndex, indexChoice, colIndex): void 'Key_block_size': keyBlockSize, 'Parser': parser, 'Index_type': indexType, + 'Visible': indexVisible, 'columns': columns }; diff --git a/resources/templates/database/data_dictionary/index.twig b/resources/templates/database/data_dictionary/index.twig index c2e3f8ccd4..3c81329b26 100644 --- a/resources/templates/database/data_dictionary/index.twig +++ b/resources/templates/database/data_dictionary/index.twig @@ -75,6 +75,7 @@ {{ t('Type') }} {{ t('Unique') }} {{ t('Packed') }} + {{ t('Visible') }} {{ t('Column') }} {{ t('Cardinality') }} {{ t('Collation') }} @@ -91,6 +92,7 @@ {{ index.getType()|default(index.getChoice()) }} {{ index.isUnique() ? t('Yes') : t('No') }} {{ index.isPacked()|raw }} + {{ index.isVisible() ? t('Yes') : t('No') }} {% for column in index.getColumns() %} {% if column.getSeqInIndex() > 1 %} diff --git a/resources/templates/indexes.twig b/resources/templates/indexes.twig index 0a94eb63d6..20fbc8cd67 100644 --- a/resources/templates/indexes.twig +++ b/resources/templates/indexes.twig @@ -18,6 +18,7 @@ {{ t('Type') }} {{ t('Unique') }} {{ t('Packed') }} + {{ t('Visible') }} {{ t('Column') }} {{ t('Cardinality') }} {{ t('Collation') }} @@ -65,6 +66,7 @@ {{ index.getType()|default(index.getChoice()) }} {{ index.isUnique() ? t('Yes') : t('No') }} {{ index.isPacked()|raw }} + {{ index.isVisible() ? t('Yes') : t('No') }} {% for column in index.getColumns() %} {% if column.getSeqInIndex() > 1 %} diff --git a/resources/templates/table/index_form.twig b/resources/templates/table/index_form.twig index 4af034db24..116e4098f0 100644 --- a/resources/templates/table/index_form.twig +++ b/resources/templates/table/index_form.twig @@ -127,6 +127,22 @@ value="{{ index.getComment() }}"> +
+
+ + + +
+ + +
+ {% if default_sliders_state != 'disabled' %} {% endif %} diff --git a/resources/templates/table/structure/display_structure.twig b/resources/templates/table/structure/display_structure.twig index b5e8d6479a..f3b752a3d2 100644 --- a/resources/templates/table/structure/display_structure.twig +++ b/resources/templates/table/structure/display_structure.twig @@ -473,6 +473,7 @@ {{ t('Type') }} {{ t('Unique') }} {{ t('Packed') }} + {{ t('Visible') }} {{ t('Column') }} {{ t('Cardinality') }} {{ t('Collation') }} @@ -528,6 +529,7 @@ {{ index.getType()|default(index.getChoice()) }} {{ index.isUnique() ? t('Yes') : t('No') }} {{ index.isPacked()|raw }} + {{ index.isVisible() ? t('Yes') : t('No') }} {% for column in index.getColumns() %} {% if column.getSeqInIndex() > 1 %} diff --git a/src/Controllers/Table/IndexesController.php b/src/Controllers/Table/IndexesController.php index 8e4852bf17..d908f8e8c9 100644 --- a/src/Controllers/Table/IndexesController.php +++ b/src/Controllers/Table/IndexesController.php @@ -132,7 +132,13 @@ final readonly class IndexesController implements InvocableController return $this->response->response(); } - $this->dbi->query($sqlQuery); + // The SQL may contain multiple statements (e.g. ALTER TABLE ...; ALTER TABLE ... ALTER INDEX ... VISIBLE;) + // when toggling index visibility, so use multi-query and drain any + // additional result sets so subsequent queries on the same connection don't fail. + $this->dbi->tryMultiQuery($sqlQuery); + do { + $next = $this->dbi->nextResult(); + } while ($next !== false); if ($request->isAjax()) { $message = Message::success( diff --git a/src/Indexes/Index.php b/src/Indexes/Index.php index 7f54281e95..b980aa82f2 100644 --- a/src/Indexes/Index.php +++ b/src/Indexes/Index.php @@ -85,6 +85,13 @@ class Index */ private string $parser = ''; + /** + * Whether the index is visible to the optimizer. + * Invisible indexes (MySQL 8.0+) are not used by the optimizer unless + * explicitly forced with USE/FORCE INDEX. + */ + private bool $visible = true; + /** @param mixed[] $params parameters */ public function __construct(array $params = []) { @@ -323,11 +330,17 @@ class Index $this->keyBlockSize = (int) $params['Key_block_size']; } - if (! isset($params['Parser'])) { + if (isset($params['Parser'])) { + $this->parser = $params['Parser']; + } + + if (! isset($params['Visible'])) { return; } - $this->parser = $params['Parser']; + // SHOW INDEXES on MySQL 8.0+ returns 'YES' / 'NO'. + // The form posts the same string values from the dropdown. + $this->visible = $params['Visible'] !== 'NO'; } /** @@ -376,6 +389,15 @@ class Index return $this->parser; } + /** + * Whether the index is visible to the optimizer (MySQL 8.0+). + * Defaults to true; only false when the index was created with INVISIBLE. + */ + public function isVisible(): bool + { + return $this->visible; + } + /** * Returns concatenated remarks and comment * diff --git a/src/Table/Indexes.php b/src/Table/Indexes.php index b9b20cffa8..b05060484c 100644 --- a/src/Table/Indexes.php +++ b/src/Table/Indexes.php @@ -153,6 +153,25 @@ final class Indexes $sqlQuery .= ';'; + // For non-PRIMARY indexes, append a second statement to set the + // index's visibility explicitly. We can't reliably set it inline in + // the compound `DROP INDEX x, ADD INDEX x ...` above: MySQL silently + // ignores `VISIBLE` in that position (and depending on option order + // even `INVISIBLE` may be ignored), so the new index would inherit + // the visibility of the dropped one. A separate `ALTER TABLE ... + // ALTER INDEX x VISIBLE/INVISIBLE` always applies cleanly. Primary + // keys cannot be invisible, so we never emit the keyword for them. + $indexName = $index->getName(); + if ($index->getChoice() !== 'PRIMARY' && $indexName !== '') { + $sqlQuery .= sprintf( + ' ALTER TABLE %s.%s ALTER INDEX %s %s;', + Util::backquote($dbName), + Util::backquote($tableName), + Util::backquote($indexName), + $index->isVisible() ? 'VISIBLE' : 'INVISIBLE', + ); + } + return $sqlQuery; } diff --git a/tests/unit/Controllers/Database/Fixtures/DataDictionary-testController.html b/tests/unit/Controllers/Database/Fixtures/DataDictionary-testController.html index 3a84693645..2a9a2d5382 100644 --- a/tests/unit/Controllers/Database/Fixtures/DataDictionary-testController.html +++ b/tests/unit/Controllers/Database/Fixtures/DataDictionary-testController.html @@ -72,6 +72,7 @@ Type Unique Packed + Visible Column Cardinality Collation @@ -86,6 +87,7 @@ PRIMARY Yes No + Yes id diff --git a/tests/unit/Controllers/Table/IndexRenameControllerTest.php b/tests/unit/Controllers/Table/IndexRenameControllerTest.php index 89ae6b86d9..a0b95cbd4e 100644 --- a/tests/unit/Controllers/Table/IndexRenameControllerTest.php +++ b/tests/unit/Controllers/Table/IndexRenameControllerTest.php @@ -91,7 +91,7 @@ class IndexRenameControllerTest extends AbstractTestCase // phpcs:disable Generic.Files.LineLength.TooLong $expected = <<<'HTML'
-
ALTER TABLE `test_db`.`test_table_index_rename` DROP INDEX `old_name`, ADD INDEX `new_name` (`name`) USING BTREE;
+
ALTER TABLE `test_db`.`test_table_index_rename` DROP INDEX `old_name`, ADD INDEX `new_name` (`name`) USING BTREE; ALTER TABLE `test_db`.`test_table_index_rename` ALTER INDEX `new_name` VISIBLE;
HTML; diff --git a/tests/unit/Indexes/IndexTest.php b/tests/unit/Indexes/IndexTest.php index e197ec3fb7..16b8f951fe 100644 --- a/tests/unit/Indexes/IndexTest.php +++ b/tests/unit/Indexes/IndexTest.php @@ -128,6 +128,19 @@ class IndexTest extends AbstractTestCase ); } + public function testIsVisibleDefaultsToTrue(): void + { + $index = new Index(); + self::assertTrue($index->isVisible()); + } + + public function testIsVisibleReadsFromShowIndexes(): void + { + // SHOW INDEXES on MySQL 8.0+ returns 'YES' or 'NO' for the Visible column. + self::assertFalse((new Index(['Visible' => 'NO']))->isVisible()); + self::assertTrue((new Index(['Visible' => 'YES']))->isVisible()); + } + /** * Test for get Name & set Name */ diff --git a/tests/unit/Table/IndexesTest.php b/tests/unit/Table/IndexesTest.php index 8de0baf554..005613a691 100644 --- a/tests/unit/Table/IndexesTest.php +++ b/tests/unit/Table/IndexesTest.php @@ -86,4 +86,92 @@ class IndexesTest extends AbstractTestCase $indexes->getSqlQueryForIndexCreateOrEdit('PRIMARY', $index, $db, $table); self::assertInstanceOf(Message::class, $indexes->getError()); } + + public function testGetSqlQueryForInvisibleIndex(): void + { + $table = $this->getMockBuilder(Table::class) + ->disableOriginalConstructor() + ->getMock(); + $table->expects(self::any())->method('isEngine')->willReturn(false); + $this->dbi->expects(self::any())->method('getTable')->willReturn($table); + $indexes = new Indexes($this->dbi); + + $index = new Index([ + 'Key_name' => 'idx_email', + 'Index_choice' => 'INDEX', + 'Visible' => 'NO', + 'columns' => [['Column_name' => 'email']], + ]); + + $expected = 'ALTER TABLE `pma_db`.`pma_table` ADD INDEX `idx_email` (`email`);' + . ' ALTER TABLE `pma_db`.`pma_table` ALTER INDEX `idx_email` INVISIBLE;'; + self::assertSame($expected, $indexes->getSqlQueryForIndexCreateOrEdit(null, $index, 'pma_db', 'pma_table')); + } + + public function testGetSqlQueryForEditIndexToInvisible(): void + { + $table = $this->getMockBuilder(Table::class) + ->disableOriginalConstructor() + ->getMock(); + $table->expects(self::any())->method('isEngine')->willReturn(false); + $this->dbi->expects(self::any())->method('getTable')->willReturn($table); + $indexes = new Indexes($this->dbi); + + $index = new Index([ + 'Key_name' => 'idx_email', + 'Index_choice' => 'INDEX', + 'Visible' => 'NO', + 'columns' => [['Column_name' => 'email']], + ]); + + $expected = 'ALTER TABLE `pma_db`.`pma_table` DROP INDEX `idx_email`,' + . ' ADD INDEX `idx_email` (`email`);' + . ' ALTER TABLE `pma_db`.`pma_table` ALTER INDEX `idx_email` INVISIBLE;'; + $sql = $indexes->getSqlQueryForIndexCreateOrEdit('idx_email', $index, 'pma_db', 'pma_table'); + self::assertSame($expected, $sql); + } + + public function testGetSqlQueryForVisibleIndexEmitsVisible(): void + { + // The visibility keyword is set via a separate `ALTER INDEX` statement + // because MySQL silently ignores `VISIBLE` inside compound + // `DROP INDEX x, ADD INDEX x` clauses (the new index inherits the + // previous visibility otherwise). + $table = $this->getMockBuilder(Table::class) + ->disableOriginalConstructor() + ->getMock(); + $table->expects(self::any())->method('isEngine')->willReturn(false); + $this->dbi->expects(self::any())->method('getTable')->willReturn($table); + $indexes = new Indexes($this->dbi); + + $index = new Index([ + 'Key_name' => 'idx_email', + 'Index_choice' => 'INDEX', + 'columns' => [['Column_name' => 'email']], + ]); + + $expected = 'ALTER TABLE `pma_db`.`pma_table` DROP INDEX `idx_email`,' + . ' ADD INDEX `idx_email` (`email`);' + . ' ALTER TABLE `pma_db`.`pma_table` ALTER INDEX `idx_email` VISIBLE;'; + $sql = $indexes->getSqlQueryForIndexCreateOrEdit('idx_email', $index, 'pma_db', 'pma_table'); + self::assertSame($expected, $sql); + } + + public function testGetSqlQueryForPrimaryDoesNotEmitVisibilityKeyword(): void + { + // Primary keys cannot be invisible — never emit the keyword for them. + $table = $this->getMockBuilder(Table::class) + ->disableOriginalConstructor() + ->getMock(); + $this->dbi->expects(self::any())->method('getTable')->willReturn($table); + $indexes = new Indexes($this->dbi); + + $index = new Index([ + 'Key_name' => 'PRIMARY', + 'columns' => [['Column_name' => 'id']], + ]); + + $sql = $indexes->getSqlQueryForIndexCreateOrEdit(null, $index, 'pma_db', 'pma_table'); + self::assertSame('ALTER TABLE `pma_db`.`pma_table` ADD PRIMARY KEY (`id`);', $sql); + } }