nextcloud/tests/Core/Command/Db/DbIndexUsageTest.php
Rodrigo Correia 729e1e6920 feat(db): add occ db:info, db:size, db:index-usage and db:locks
Implements RFC #59422. Adds four read-only diagnostic commands to
the occ CLI for administrators to inspect database health without
needing external tools:

- db:info: shows engine version and key config variables with
  health check against recommended values
- db:size: lists all tables ordered by total disk usage
- db:index-usage: reports unused indexes via performance_schema
  (MySQL) or pg_stat_user_indexes (PostgreSQL)
- db:locks: detects active blocking transactions and deadlocks

All commands support MySQL/MariaDB and PostgreSQL. A --json flag
is available for automated parsing. Includes 31 unit tests.

Closes #59422
Signed-off-by: Rodrigo Correia <rodrigo.mendes.correia@tecnico.ulisboa.pt>
Signed-off-by: Carolina Quinteiro <carolinafquinteiro@tecnico.ulisboa.pt>
Co-authored-by: Carolina Quinteiro <carolinafquinteiro@tecnico.ulisboa.pt>
2026-05-27 13:37:40 +01:00

164 lines
6.9 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Core\Command\Db;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Platforms\SqlitePlatform;
use Doctrine\DBAL\Result;
use OC\Core\Command\Db\DbIndexUsage;
use OC\DB\Connection;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\BufferedOutput;
use Test\TestCase;
class DbIndexUsageTest extends TestCase {
private Connection&MockObject $connection;
private InputInterface&MockObject $input;
private DbIndexUsage $command;
protected function setUp(): void {
parent::setUp();
$this->connection = $this->createMock(Connection::class);
$this->input = $this->createMock(InputInterface::class);
$this->command = new DbIndexUsage($this->connection);
}
private function mockMySQLRows(): array {
return [
['table' => 'oc_filecache', 'index' => 'idx_fc_name', 'reads' => 0, 'writes' => 150],
['table' => 'oc_share', 'index' => 'idx_sh_par', 'reads' => 0, 'writes' => 42],
];
}
private function mockPostgreSQLRows(): array {
return [
['table' => 'oc_filecache', 'index' => 'idx_fc_name', 'reads' => 0, 'tuples_read' => 0, 'tuples_fetched' => 0],
];
}
private function mockResult(array $rows): Result&MockObject {
$result = $this->createMock(Result::class);
$result->method('fetchAllAssociative')->willReturn($rows);
return $result;
}
public function testNoUnusedIndexesPrintsSuccessMessage(): void {
$this->connection->method('getDatabasePlatform')
->willReturn($this->createMock(MySQLPlatform::class));
$this->connection->method('executeQuery')
->willReturn($this->mockResult([]));
$this->input->method('getOption')->willReturnMap([['json', false], ['all', false]]);
$output = new BufferedOutput();
$exit = self::invokePrivate($this->command, 'execute', [$this->input, $output]);
$this->assertSame(0, $exit);
$this->assertStringContainsString('No unused indexes found', $output->fetch());
}
public function testMySQLUnusedIndexesRendersTable(): void {
$this->connection->method('getDatabasePlatform')
->willReturn($this->createMock(MySQLPlatform::class));
$this->connection->method('executeQuery')
->willReturn($this->mockResult($this->mockMySQLRows()));
$this->input->method('getOption')->willReturnMap([['json', false], ['all', false]]);
$output = new BufferedOutput();
$exit = self::invokePrivate($this->command, 'execute', [$this->input, $output]);
$this->assertSame(0, $exit);
$content = $output->fetch();
$this->assertStringContainsString('Reads', $content);
$this->assertStringContainsString('Writes', $content);
$this->assertStringContainsString('idx_fc_name', $content);
$this->assertStringContainsString('Found 2 unused index(es)', $content);
}
public function testPostgreSQLUnusedIndexesRendersTable(): void {
$this->connection->method('getDatabasePlatform')
->willReturn($this->createMock(PostgreSQLPlatform::class));
$this->connection->method('executeQuery')
->willReturn($this->mockResult($this->mockPostgreSQLRows()));
$this->input->method('getOption')->willReturnMap([['json', false], ['all', false]]);
$output = new BufferedOutput();
$exit = self::invokePrivate($this->command, 'execute', [$this->input, $output]);
$this->assertSame(0, $exit);
$content = $output->fetch();
$this->assertStringContainsString('Tuples Read', $content);
$this->assertStringContainsString('Tuples Fetched', $content);
}
public function testAllFlagSuppressesCountMessage(): void {
$this->connection->method('getDatabasePlatform')
->willReturn($this->createMock(MySQLPlatform::class));
$this->connection->method('executeQuery')
->willReturn($this->mockResult($this->mockMySQLRows()));
$this->input->method('getOption')->willReturnMap([['json', false], ['all', true]]);
$output = new BufferedOutput();
self::invokePrivate($this->command, 'execute', [$this->input, $output]);
$this->assertStringNotContainsString('Found', $output->fetch());
}
public function testDefaultFilterIncludedInQuery(): void {
$this->connection->method('getDatabasePlatform')
->willReturn($this->createMock(MySQLPlatform::class));
$this->connection->expects($this->once())
->method('executeQuery')
->with($this->stringContains('count_read = 0'))
->willReturn($this->mockResult([]));
$this->input->method('getOption')->willReturnMap([['json', false], ['all', false]]);
self::invokePrivate($this->command, 'execute', [$this->input, new BufferedOutput()]);
}
public function testAllFlagRemovesFilterFromQuery(): void {
$this->connection->method('getDatabasePlatform')
->willReturn($this->createMock(MySQLPlatform::class));
$this->connection->expects($this->once())
->method('executeQuery')
->with($this->logicalNot($this->stringContains('count_read = 0')))
->willReturn($this->mockResult([]));
$this->input->method('getOption')->willReturnMap([['json', false], ['all', true]]);
self::invokePrivate($this->command, 'execute', [$this->input, new BufferedOutput()]);
}
public function testJsonOutputWhenRowsExist(): void {
$this->connection->method('getDatabasePlatform')
->willReturn($this->createMock(MySQLPlatform::class));
$this->connection->method('executeQuery')
->willReturn($this->mockResult($this->mockMySQLRows()));
$this->input->method('getOption')->willReturnMap([['json', true], ['all', false]]);
$output = new BufferedOutput();
$exit = self::invokePrivate($this->command, 'execute', [$this->input, $output]);
$this->assertSame(0, $exit);
$data = json_decode($output->fetch(), true);
$this->assertIsArray($data);
$this->assertCount(2, $data);
$this->assertArrayHasKey('table', $data[0]);
$this->assertArrayHasKey('index', $data[0]);
}
public function testSQLiteReturnsSuccessWithMessage(): void {
$this->connection->method('getDatabasePlatform')
->willReturn($this->createMock(SqlitePlatform::class));
$this->input->method('getOption')->willReturnMap([['json', false], ['all', false]]);
$output = new BufferedOutput();
$exit = self::invokePrivate($this->command, 'execute', [$this->input, $output]);
$this->assertSame(0, $exit);
$this->assertStringContainsString('not supported for SQLite', $output->fetch());
}
}