diff --git a/core/Command/Db/DbIndexUsage.php b/core/Command/Db/DbIndexUsage.php new file mode 100644 index 0000000000000..dbfe3f70916d5 --- /dev/null +++ b/core/Command/Db/DbIndexUsage.php @@ -0,0 +1,115 @@ +setName('db:index-usage') + ->setDescription('Report unused database indexes (indexes that slow writes but are never read)') + ->addOption('json', null, InputOption::VALUE_NONE, 'Output in JSON format') + ->addOption('all', null, InputOption::VALUE_NONE, 'Show all indexes, not just unused ones'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $platform = $this->connection->getDatabasePlatform(); + $asJson = $input->getOption('json'); + $showAll = $input->getOption('all'); + + if ($platform instanceof MySQLPlatform) { + // Requires performance_schema to be enabled (default in MySQL 5.6+/MariaDB 10.0+) + $unused_filter = $showAll ? '' : "WHERE s.count_read = 0 AND s.index_name IS NOT NULL AND s.index_name != 'PRIMARY'"; + $sql = " + SELECT s.object_name AS `table`, + s.index_name AS `index`, + s.count_read AS reads, + s.count_write AS writes + FROM performance_schema.table_io_waits_summary_by_index_usage s + {$unused_filter} + ORDER BY s.object_name, s.index_name + "; + } elseif ($platform instanceof PostgreSQLPlatform) { + $unused_filter = $showAll ? '' : 'AND idx_scan = 0'; + $sql = " + SELECT relname AS table, + indexrelname AS index, + idx_scan AS reads, + idx_tup_read AS tuples_read, + idx_tup_fetch AS tuples_fetched + FROM pg_stat_user_indexes + JOIN pg_index USING (indexrelid) + WHERE indisunique IS FALSE + {$unused_filter} + ORDER BY relname, indexrelname + "; + } else { + $output->writeln('db:index-usage is not supported for SQLite.'); + return Command::SUCCESS; + } + + try { + $rows = $this->connection->executeQuery($sql)->fetchAllAssociative(); + } catch (\Doctrine\DBAL\Exception $e) { + $output->writeln('Failed to query index usage statistics. The required performance tables may not be available on this database version.'); + $output->writeln('Details: ' . $e->getMessage() . ''); + return Command::FAILURE; + } + + if (empty($rows)) { + $output->writeln('No unused indexes found. Great!'); + return Command::SUCCESS; + } + + if ($asJson) { + $output->writeln(json_encode($rows, JSON_PRETTY_PRINT)); + return Command::SUCCESS; + } + + $table = new Table($output); + + if ($platform instanceof MySQLPlatform) { + $table->setHeaders(['Table', 'Index', 'Reads', 'Writes']); + foreach ($rows as $row) { + $table->addRow([$row['table'], $row['index'], $row['reads'], $row['writes']]); + } + } else { + $table->setHeaders(['Table', 'Index', 'Scans', 'Tuples Read', 'Tuples Fetched']); + foreach ($rows as $row) { + $table->addRow([$row['table'], $row['index'], $row['reads'], $row['tuples_read'], $row['tuples_fetched']]); + } + } + + $table->render(); + + if (!$showAll) { + $output->writeln(sprintf( + 'Found %d unused index(es). Consider removing them to improve write performance.', + count($rows) + )); + } + + return Command::SUCCESS; + } +} diff --git a/core/Command/Db/DbInfo.php b/core/Command/Db/DbInfo.php new file mode 100644 index 0000000000000..143ce35e624e0 --- /dev/null +++ b/core/Command/Db/DbInfo.php @@ -0,0 +1,121 @@ +setName('db:info') + ->setDescription('Show database server information and configuration health check') + ->addOption('json', null, InputOption::VALUE_NONE, 'Output in JSON format'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $platform = $this->connection->getDatabasePlatform(); + $asJson = $input->getOption('json'); + + if ($platform instanceof MySQLPlatform) { + $rows = $this->getMySQLInfo(); + } elseif ($platform instanceof PostgreSQLPlatform) { + $rows = $this->getPostgreSQLInfo(); + } elseif ($platform instanceof SqlitePlatform) { + $rows = $this->getSQLiteInfo(); + } else { + $output->writeln('Unsupported database platform.'); + return Command::FAILURE; + } + + if ($asJson) { + $output->writeln(json_encode($rows, JSON_PRETTY_PRINT)); + return Command::SUCCESS; + } + + $table = new Table($output); + $table->setHeaders(['Setting', 'Value', 'Recommended', 'Status']); + + foreach ($rows as $row) { + $status = isset($row['recommended']) + ? ($row['ok'] ? 'OK' : 'CHECK') + : ''; + $table->addRow([ + $row['setting'], + $row['value'], + $row['recommended'] ?? '—', + $status, + ]); + } + + $table->render(); + return Command::SUCCESS; + } + + private function getMySQLInfo(): array { + $result = $this->connection->executeQuery( + "SELECT VERSION() AS version, @@innodb_buffer_pool_size AS buffer_pool, + @@max_connections AS max_conn, @@character_set_database AS charset, + @@transaction_isolation AS tx_isolation" + ); + $info = $result->fetchAssociative(); + + $bufferPoolGB = round(($info['buffer_pool'] / 1024 / 1024 / 1024), 2); + + return [ + ['setting' => 'Engine', 'value' => 'MySQL/MariaDB'], + ['setting' => 'Version', 'value' => $info['version']], + ['setting' => 'Character Set', 'value' => $info['charset'], 'recommended' => 'utf8mb4', 'ok' => str_contains($info['charset'], 'utf8mb4')], + ['setting' => 'Max Connections', 'value' => $info['max_conn'], 'recommended' => '≥ 150', 'ok' => (int)$info['max_conn'] >= 150], + ['setting' => 'InnoDB Buffer Pool (GB)','value' => $bufferPoolGB, 'recommended' => '≥ 1 GB', 'ok' => $bufferPoolGB >= 1], + ['setting' => 'Transaction Isolation', 'value' => $info['tx_isolation'], 'recommended' => 'READ-COMMITTED', 'ok' => $info['tx_isolation'] === 'READ-COMMITTED'], + ]; + } + + private function getPostgreSQLInfo(): array { + $result = $this->connection->executeQuery( + "SELECT version(), + current_setting('max_connections') AS max_conn, + current_setting('shared_buffers') AS shared_buffers, + current_setting('work_mem') AS work_mem" + ); + $info = $result->fetchAssociative(); + + return [ + ['setting' => 'Engine', 'value' => 'PostgreSQL'], + ['setting' => 'Version', 'value' => $info['version']], + ['setting' => 'Max Connections', 'value' => $info['max_conn'], 'recommended' => '≥ 100', 'ok' => (int)$info['max_conn'] >= 100], + ['setting' => 'Shared Buffers', 'value' => $info['shared_buffers'],'recommended' => '128MB+', 'ok' => true], + ['setting' => 'Work Mem', 'value' => $info['work_mem'], 'recommended' => '4MB+', 'ok' => true], + ]; + } + + private function getSQLiteInfo(): array { + $result = $this->connection->executeQuery('SELECT sqlite_version() AS version'); + $info = $result->fetchAssociative(); + return [ + ['setting' => 'Engine', 'value' => 'SQLite'], + ['setting' => 'Version', 'value' => $info['version']], + ]; + } +} diff --git a/core/Command/Db/DbLocks.php b/core/Command/Db/DbLocks.php new file mode 100644 index 0000000000000..2df59f97fac31 --- /dev/null +++ b/core/Command/Db/DbLocks.php @@ -0,0 +1,103 @@ +setName('db:locks') + ->setDescription('Show active database locks, deadlocks, and long-running transactions') + ->addOption('json', null, InputOption::VALUE_NONE, 'Output in JSON format'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $platform = $this->connection->getDatabasePlatform(); + $asJson = $input->getOption('json'); + + if ($platform instanceof MySQLPlatform) { + $sql = " + SELECT r.trx_id AS waiting_trx_id, + r.trx_mysql_thread_id AS waiting_thread, + r.trx_query AS waiting_query, + b.trx_id AS blocking_trx_id, + b.trx_mysql_thread_id AS blocking_thread, + b.trx_query AS blocking_query + FROM information_schema.innodb_lock_waits w + JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id + JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id + "; + $headers = ['Waiting TRX', 'Waiting Thread', 'Waiting Query', 'Blocking TRX', 'Blocking Thread', 'Blocking Query']; + $cols = ['waiting_trx_id', 'waiting_thread', 'waiting_query', 'blocking_trx_id', 'blocking_thread', 'blocking_query']; + } elseif ($platform instanceof PostgreSQLPlatform) { + $sql = " + SELECT blocked_locks.pid AS blocked_pid, + blocked_activity.usename AS blocked_user, + blocking_locks.pid AS blocking_pid, + blocking_activity.usename AS blocking_user, + blocked_activity.query AS blocked_query, + blocking_activity.query AS blocking_query, + now() - blocked_activity.query_start AS blocked_duration + FROM pg_catalog.pg_locks blocked_locks + JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid + JOIN pg_catalog.pg_locks blocking_locks + ON blocking_locks.locktype = blocked_locks.locktype + AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation + AND blocking_locks.pid != blocked_locks.pid + JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid + WHERE NOT blocked_locks.granted + "; + $headers = ['Blocked PID', 'Blocked User', 'Blocking PID', 'Blocking User', 'Blocked Query', 'Duration']; + $cols = ['blocked_pid', 'blocked_user', 'blocking_pid', 'blocking_user', 'blocked_query', 'blocked_duration']; + } else { + $output->writeln('db:locks is not supported for SQLite (SQLite uses file-level locking).'); + return Command::SUCCESS; + } + + $rows = $this->connection->executeQuery($sql)->fetchAllAssociative(); + + if (empty($rows)) { + $output->writeln('No active locks or blocking transactions detected.'); + return Command::SUCCESS; + } + + if ($asJson) { + $output->writeln(json_encode($rows, JSON_PRETTY_PRINT)); + return Command::SUCCESS; + } + + $output->writeln(sprintf('Found %d blocking transaction(s)!', count($rows))); + $output->writeln(''); + + $table = new Table($output); + $table->setHeaders($headers); + + foreach ($rows as $row) { + $table->addRow(array_map(fn($col) => $row[$col] ?? '—', $cols)); + } + + $table->render(); + return Command::SUCCESS; + } +} diff --git a/core/Command/Db/DbSize.php b/core/Command/Db/DbSize.php new file mode 100644 index 0000000000000..89bc21532b448 --- /dev/null +++ b/core/Command/Db/DbSize.php @@ -0,0 +1,94 @@ +setName('db:size') + ->setDescription('Show disk usage of all Nextcloud database tables, ordered by size') + ->addOption('json', null, InputOption::VALUE_NONE, 'Output in JSON format'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $platform = $this->connection->getDatabasePlatform(); + $asJson = $input->getOption('json'); + + if ($platform instanceof MySQLPlatform) { + $sql = " + SELECT table_name AS `table`, + ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_mb, + ROUND(data_length / 1024 / 1024, 2) AS data_mb, + ROUND(index_length / 1024 / 1024, 2) AS index_mb, + table_rows AS rows, + IF(table_rows > 0, ROUND((data_length + index_length) / table_rows, 0), 0) AS avg_row_bytes + FROM information_schema.tables + WHERE table_schema = DATABASE() + ORDER BY (data_length + index_length) DESC + "; + $headers = ['Table', 'Total (MB)', 'Data (MB)', 'Index (MB)', 'Rows', 'Avg Row (bytes)']; + $cols = ['table', 'total_mb', 'data_mb', 'index_mb', 'rows', 'avg_row_bytes']; + } elseif ($platform instanceof PostgreSQLPlatform) { + $sql = " + SELECT relname AS table, + ROUND(pg_total_relation_size(c.oid) / 1024.0 / 1024.0, 2) AS total_mb, + ROUND(pg_relation_size(c.oid) / 1024.0 / 1024.0, 2) AS data_mb, + ROUND(pg_indexes_size(c.oid) / 1024.0 / 1024.0, 2) AS index_mb, + c.reltuples::bigint AS rows, + CASE WHEN c.reltuples > 0 THEN ROUND(pg_total_relation_size(c.oid) / c.reltuples) ELSE 0 END AS avg_row_bytes + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'r' AND n.nspname = 'public' + ORDER BY pg_total_relation_size(c.oid) DESC + "; + $headers = ['Table', 'Total (MB)', 'Data (MB)', 'Index (MB)', 'Rows (est.)', 'Avg Row (bytes)']; + $cols = ['table', 'total_mb', 'data_mb', 'index_mb', 'rows', 'avg_row_bytes']; + } else { + $output->writeln('db:size is not supported for SQLite.'); + return Command::SUCCESS; + } + + $rows = $this->connection->executeQuery($sql)->fetchAllAssociative(); + + if ($asJson) { + $output->writeln(json_encode($rows, JSON_PRETTY_PRINT)); + return Command::SUCCESS; + } + + $table = new Table($output); + $table->setHeaders($headers); + + foreach ($rows as $row) { + $table->addRow(array_map(fn($col) => $row[$col], $cols)); + } + + $table->render(); + + $totalMB = array_sum(array_column($rows, 'total_mb')); + $output->writeln(sprintf('Total database size: %.2f MB', $totalMB)); + + return Command::SUCCESS; + } +} diff --git a/core/register_command.php b/core/register_command.php index 4c9fd0fe7c213..7bb2e46d1e729 100644 --- a/core/register_command.php +++ b/core/register_command.php @@ -36,6 +36,10 @@ use OC\Core\Command\Db\ConvertFilecacheBigInt; use OC\Core\Command\Db\ConvertMysqlToMB4; use OC\Core\Command\Db\ConvertType; +use OC\Core\Command\Db\DbIndexUsage; +use OC\Core\Command\Db\DbInfo; +use OC\Core\Command\Db\DbLocks; +use OC\Core\Command\Db\DbSize; use OC\Core\Command\Db\ExpectedSchema; use OC\Core\Command\Db\ExportSchema; use OC\Core\Command\Db\Migrations\ExecuteCommand; @@ -176,6 +180,10 @@ $application->add(Server::get(AddMissingColumns::class)); $application->add(Server::get(AddMissingIndices::class)); $application->add(Server::get(AddMissingPrimaryKeys::class)); + $application->add(Server::get(DbInfo::class)); + $application->add(Server::get(DbSize::class)); + $application->add(Server::get(DbIndexUsage::class)); + $application->add(Server::get(DbLocks::class)); $application->add(Server::get(ExpectedSchema::class)); $application->add(Server::get(ExportSchema::class)); diff --git a/tests/Core/Command/Db/DbIndexUsageTest.php b/tests/Core/Command/Db/DbIndexUsageTest.php new file mode 100644 index 0000000000000..68e1b62f8da6f --- /dev/null +++ b/tests/Core/Command/Db/DbIndexUsageTest.php @@ -0,0 +1,164 @@ +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()); + } +} diff --git a/tests/Core/Command/Db/DbInfoTest.php b/tests/Core/Command/Db/DbInfoTest.php new file mode 100644 index 0000000000000..bdcc2fbcd74b1 --- /dev/null +++ b/tests/Core/Command/Db/DbInfoTest.php @@ -0,0 +1,166 @@ +connection = $this->createMock(Connection::class); + $this->input = $this->createMock(InputInterface::class); + $this->command = new DbInfo($this->connection); + } + + private function mockMySQLResult(array $overrides = []): Result&MockObject { + $result = $this->createMock(Result::class); + $result->method('fetchAssociative')->willReturn(array_merge([ + 'version' => '8.0.30', + 'buffer_pool' => 1073741824, // 1 GB + 'max_conn' => '200', + 'charset' => 'utf8mb4', + 'tx_isolation' => 'READ-COMMITTED', + ], $overrides)); + return $result; + } + + private function mockPostgreSQLResult(): Result&MockObject { + $result = $this->createMock(Result::class); + $result->method('fetchAssociative')->willReturn([ + 'version' => 'PostgreSQL 15.2 on x86_64', + 'max_conn' => '100', + 'shared_buffers' => '128MB', + 'work_mem' => '4MB', + ]); + return $result; + } + + public function testMySQLTableOutput(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(MySQLPlatform::class)); + $this->connection->method('executeQuery') + ->willReturn($this->mockMySQLResult()); + $this->input->method('getOption')->willReturnMap([['json', false]]); + + $output = new BufferedOutput(); + $exit = self::invokePrivate($this->command, 'execute', [$this->input, $output]); + + $this->assertSame(0, $exit); + $content = $output->fetch(); + $this->assertStringContainsString('Setting', $content); + $this->assertStringContainsString('MySQL/MariaDB', $content); + } + + public function testPostgreSQLTableOutput(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(PostgreSQLPlatform::class)); + $this->connection->method('executeQuery') + ->willReturn($this->mockPostgreSQLResult()); + $this->input->method('getOption')->willReturnMap([['json', false]]); + + $output = new BufferedOutput(); + $exit = self::invokePrivate($this->command, 'execute', [$this->input, $output]); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('PostgreSQL', $output->fetch()); + } + + public function testSQLiteTableOutput(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(SqlitePlatform::class)); + $result = $this->createMock(Result::class); + $result->method('fetchAssociative')->willReturn(['version' => '3.43.0']); + $this->connection->method('executeQuery')->willReturn($result); + $this->input->method('getOption')->willReturnMap([['json', false]]); + + $output = new BufferedOutput(); + $exit = self::invokePrivate($this->command, 'execute', [$this->input, $output]); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('SQLite', $output->fetch()); + } + + public function testUnsupportedPlatformReturnsFailure(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(AbstractPlatform::class)); + $this->input->method('getOption')->willReturnMap([['json', false]]); + + $output = new BufferedOutput(); + $exit = self::invokePrivate($this->command, 'execute', [$this->input, $output]); + + $this->assertSame(1, $exit); + $this->assertStringContainsString('Unsupported', $output->fetch()); + } + + public function testJsonOutputContainsSettingKeys(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(MySQLPlatform::class)); + $this->connection->method('executeQuery') + ->willReturn($this->mockMySQLResult()); + $this->input->method('getOption')->willReturnMap([['json', true]]); + + $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->assertArrayHasKey('setting', $data[0]); + $this->assertArrayHasKey('value', $data[0]); + } + + public static function dataMySQLHealthChecks(): array { + return [ + 'charset utf8mb4 → OK' => ['charset', 'utf8mb4', true, 'Character Set'], + 'charset latin1 → CHECK' => ['charset', 'latin1', false, 'Character Set'], + 'max_conn 200 → OK' => ['max_conn', '200', true, 'Max Connections'], + 'max_conn 50 → CHECK' => ['max_conn', '50', false, 'Max Connections'], + 'tx_isolation READ-COMMITTED → OK' => ['tx_isolation', 'READ-COMMITTED', true, 'Transaction Isolation'], + 'tx_isolation REPEATABLE-READ → CHECK' => ['tx_isolation', 'REPEATABLE-READ', false, 'Transaction Isolation'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataMySQLHealthChecks')] + public function testMySQLHealthCheckStatus( + string $field, + string $value, + bool $expectedOk, + string $settingLabel, + ): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(MySQLPlatform::class)); + $this->connection->method('executeQuery') + ->willReturn($this->mockMySQLResult([$field => $value])); + $this->input->method('getOption')->willReturnMap([['json', true]]); + + $output = new BufferedOutput(); + self::invokePrivate($this->command, 'execute', [$this->input, $output]); + + $data = json_decode($output->fetch(), true); + $rows = array_values(array_filter($data, fn($r) => $r['setting'] === $settingLabel)); + $this->assertNotEmpty($rows, "Setting '{$settingLabel}' not found in JSON output"); + $this->assertSame($expectedOk, $rows[0]['ok']); + } +} diff --git a/tests/Core/Command/Db/DbLocksTest.php b/tests/Core/Command/Db/DbLocksTest.php new file mode 100644 index 0000000000000..2ddc99e91ddb0 --- /dev/null +++ b/tests/Core/Command/Db/DbLocksTest.php @@ -0,0 +1,156 @@ +connection = $this->createMock(Connection::class); + $this->input = $this->createMock(InputInterface::class); + $this->command = new DbLocks($this->connection); + } + + private function mockMySQLLocks(): array { + return [[ + 'waiting_trx_id' => '12345', + 'waiting_thread' => '42', + 'waiting_query' => 'UPDATE oc_filecache SET path_hash = ?', + 'blocking_trx_id' => '12344', + 'blocking_thread' => '41', + 'blocking_query' => null, // NULL — deve ser renderizado como '—' + ]]; + } + + private function mockPostgreSQLLocks(): array { + return [[ + 'blocked_pid' => 1234, + 'blocked_user' => 'nextcloud', + 'blocking_pid' => 1233, + 'blocking_user' => 'nextcloud', + 'blocked_query' => 'SELECT * FROM oc_filecache WHERE parent = ?', + 'blocked_duration' => '00:00:05.123456', + ]]; + } + + private function mockResult(array $rows): Result&MockObject { + $result = $this->createMock(Result::class); + $result->method('fetchAllAssociative')->willReturn($rows); + return $result; + } + + public function testMySQLNoLocksShowsInfoMessage(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(MySQLPlatform::class)); + $this->connection->method('executeQuery') + ->willReturn($this->mockResult([])); + $this->input->method('getOption')->willReturnMap([['json', false]]); + + $output = new BufferedOutput(); + $exit = self::invokePrivate($this->command, 'execute', [$this->input, $output]); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('No active locks', $output->fetch()); + } + + public function testPostgreSQLNoLocksShowsInfoMessage(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(PostgreSQLPlatform::class)); + $this->connection->method('executeQuery') + ->willReturn($this->mockResult([])); + $this->input->method('getOption')->willReturnMap([['json', false]]); + + $output = new BufferedOutput(); + $exit = self::invokePrivate($this->command, 'execute', [$this->input, $output]); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('No active locks', $output->fetch()); + } + + public function testMySQLLocksFoundShowsErrorMessage(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(MySQLPlatform::class)); + $this->connection->method('executeQuery') + ->willReturn($this->mockResult($this->mockMySQLLocks())); + $this->input->method('getOption')->willReturnMap([['json', false]]); + + $output = new BufferedOutput(); + $exit = self::invokePrivate($this->command, 'execute', [$this->input, $output]); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Found 1 blocking transaction(s)', $output->fetch()); + } + + public function testPostgreSQLLocksFoundShowsErrorMessage(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(PostgreSQLPlatform::class)); + $this->connection->method('executeQuery') + ->willReturn($this->mockResult($this->mockPostgreSQLLocks())); + $this->input->method('getOption')->willReturnMap([['json', false]]); + + $output = new BufferedOutput(); + $exit = self::invokePrivate($this->command, 'execute', [$this->input, $output]); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Found 1 blocking transaction(s)', $output->fetch()); + } + + public function testJsonOutputWhenLocksExist(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(MySQLPlatform::class)); + $this->connection->method('executeQuery') + ->willReturn($this->mockResult($this->mockMySQLLocks())); + $this->input->method('getOption')->willReturnMap([['json', true]]); + + $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(1, $data); + $this->assertArrayHasKey('waiting_trx_id', $data[0]); + } + + public function testSQLiteReturnsSuccessWithMessage(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(SqlitePlatform::class)); + $this->input->method('getOption')->willReturnMap([['json', false]]); + + $output = new BufferedOutput(); + $exit = self::invokePrivate($this->command, 'execute', [$this->input, $output]); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('file-level locking', $output->fetch()); + } + + public function testNullColumnRenderedAsDash(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(MySQLPlatform::class)); + $this->connection->method('executeQuery') + ->willReturn($this->mockResult($this->mockMySQLLocks())); // blocking_query = null + $this->input->method('getOption')->willReturnMap([['json', false]]); + + $output = new BufferedOutput(); + self::invokePrivate($this->command, 'execute', [$this->input, $output]); + + $this->assertStringContainsString('—', $output->fetch()); + } +} diff --git a/tests/Core/Command/Db/DbSizeTest.php b/tests/Core/Command/Db/DbSizeTest.php new file mode 100644 index 0000000000000..cda254d87e531 --- /dev/null +++ b/tests/Core/Command/Db/DbSizeTest.php @@ -0,0 +1,117 @@ +connection = $this->createMock(Connection::class); + $this->input = $this->createMock(InputInterface::class); + $this->command = new DbSize($this->connection); + } + + private function mockRows(): array { + return [ + ['table' => 'oc_filecache', 'total_mb' => 12.50, 'data_mb' => 10.00, 'index_mb' => 2.50, 'rows' => 5000, 'avg_row_bytes' => 2560], + ['table' => 'oc_share', 'total_mb' => 3.25, 'data_mb' => 2.00, 'index_mb' => 1.25, 'rows' => 200, 'avg_row_bytes' => 16384], + ]; + } + + private function mockResult(array $rows): Result&MockObject { + $result = $this->createMock(Result::class); + $result->method('fetchAllAssociative')->willReturn($rows); + return $result; + } + + public function testMySQLOutputContainsTableAndTotal(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(MySQLPlatform::class)); + $this->connection->method('executeQuery') + ->willReturn($this->mockResult($this->mockRows())); + $this->input->method('getOption')->willReturnMap([['json', false]]); + + $output = new BufferedOutput(); + $exit = self::invokePrivate($this->command, 'execute', [$this->input, $output]); + + $this->assertSame(0, $exit); + $content = $output->fetch(); + $this->assertStringContainsString('oc_filecache', $content); + $this->assertStringContainsString('Total database size', $content); + } + + public function testPostgreSQLOutputContainsTableAndTotal(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(PostgreSQLPlatform::class)); + $this->connection->method('executeQuery') + ->willReturn($this->mockResult($this->mockRows())); + $this->input->method('getOption')->willReturnMap([['json', false]]); + + $output = new BufferedOutput(); + $exit = self::invokePrivate($this->command, 'execute', [$this->input, $output]); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Total database size', $output->fetch()); + } + + public function testSQLiteReturnsSuccessWithMessage(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(SqlitePlatform::class)); + $this->input->method('getOption')->willReturnMap([['json', 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()); + } + + public function testJsonOutputIsValidArray(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(MySQLPlatform::class)); + $this->connection->method('executeQuery') + ->willReturn($this->mockResult($this->mockRows())); + $this->input->method('getOption')->willReturnMap([['json', true]]); + + $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('total_mb', $data[0]); + } + + public function testTotalSizeCalculation(): void { + $this->connection->method('getDatabasePlatform') + ->willReturn($this->createMock(MySQLPlatform::class)); + $this->connection->method('executeQuery') + ->willReturn($this->mockResult($this->mockRows())); + $this->input->method('getOption')->willReturnMap([['json', false]]); + + $output = new BufferedOutput(); + self::invokePrivate($this->command, 'execute', [$this->input, $output]); + + // 12.50 + 3.25 = 15.75 + $this->assertStringContainsString('15.75', $output->fetch()); + } +}