{% 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);
+ }
}