feat(database): 适配多数据库维护操作

新增 DatabaseMaintenance 适配器,按数据库类型生成维护语句:MySQL/MariaDB 支持 REPAIR 与 OPTIMIZE,SQLite 使用 PRAGMA optimize 与 VACUUM,PostgreSQL 使用 VACUUM ANALYZE,SQL Server 使用索引重组与统计信息更新。

重构 DatabaseCommand,将 repair 与 optimize 统一收敛到维护适配器执行,针对不支持的数据库返回友好队列成功提示,避免 SQLite 等环境直接报错中断任务。

补充 DatabaseMaintenanceTest 覆盖各数据库语句生成、标识符转义和不支持提示,并将测试加入 phpunit.xml.dist 的套件列表。
This commit is contained in:
邹景立 2026-05-21 00:17:24 +08:00
parent 11377b6761
commit ea10f17079
4 changed files with 374 additions and 32 deletions

View File

@ -20,6 +20,7 @@
<file>plugin/think-library/tests/RequestTokenServiceTest.php</file>
<file>plugin/think-plugs-install/tests/InstallCommandTest.php</file>
<file>plugin/think-plugs-system/tests/helper/PublishTest.php</file>
<file>plugin/think-plugs-system/tests/helper/DatabaseMaintenanceTest.php</file>
<file>plugin/think-plugs-system/tests/helper/IndexNameServiceTest.php</file>
<file>plugin/think-plugs-system/tests/helper/PluginMenuServiceTest.php</file>
<file>plugin/think-plugs-system/tests/RbacAccessTest.php</file>

View File

@ -49,12 +49,9 @@ class DatabaseCommand extends Command
*/
protected function execute(Input $input, Output $output): int
{
if ($this->app->db->connect()->getConfig('type') === 'sqlite') {
$this->setQueueError('Sqlite 数据库不支持 REPAIR 和 OPTIMIZE 操作!');
}
$action = $input->getArgument('action');
if (method_exists($this, $method = "_{$action}")) {
$this->{$method}();
$action = strtolower(strval($input->getArgument('action')));
if (in_array($action, [DatabaseMaintenance::ACTION_REPAIR, DatabaseMaintenance::ACTION_OPTIMIZE], true)) {
$this->maintain($action);
} else {
$this->output->error('Wrong operation, currently allow repair|optimize');
}
@ -62,44 +59,62 @@ class DatabaseCommand extends Command
}
/**
* 修复所有数据表.
* 执行数据库维护。
* @throws Exception
*/
protected function _repair(): void
private function maintain(string $action): void
{
$connection = $this->app->db->connect();
if (!$connection instanceof PDOConnection) {
throw new Exception('当前数据库连接不支持 REPAIR 操作');
throw new Exception("当前数据库连接不支持 {$action} 操作");
}
$this->setQueueProgress('正在获取需要修复的数据表', '0');
[$tables, $total, $count] = SystemService::getTables();
$this->setQueueProgress("总共需要修复 {$total} 张数据表", '0');
foreach ($tables as $table) {
$this->setQueueMessage($total, ++$count, "正在修复数据表 {$table}");
$connection->query("REPAIR TABLE `{$table}`");
$this->setQueueMessage($total, $count, "完成修复数据表 {$table}", 1);
$adapter = new DatabaseMaintenance(strval($connection->getConfig('type') ?: ''));
if (!$adapter->supports($action)) {
$message = $adapter->unsupportedMessage($action);
$this->setQueueProgress($message, '100');
$this->setQueueSuccess($message);
return;
}
$this->setQueueSuccess("已完成对 {$total} 张数据表修复操作");
$label = DatabaseMaintenance::actionLabel($action);
$tables = [];
if ($adapter->requiresTables($action)) {
$this->setQueueProgress("正在获取需要{$label}的数据表", '0');
[$tables] = SystemService::getTables();
}
$operations = $adapter->operations($action, $tables);
$total = count($operations);
$count = 0;
if ($total < 1) {
$this->setQueueSuccess("没有需要{$label}的数据表,已跳过处理");
return;
}
$this->setQueueProgress("总共需要执行 {$total} 项数据库{$label}操作", '0');
foreach ($operations as $operation) {
$target = $operation['target'];
$this->setQueueMessage($total, ++$count, "正在{$label}{$target}");
foreach ($operation['statements'] as $statement) {
$this->runStatement($connection, $statement);
}
$this->setQueueMessage($total, $count, "完成{$label}{$target}", 1);
}
$this->setQueueSuccess("已完成 {$total} 项数据库{$label}操作");
}
/**
* 优化所有数据表.
* @throws Exception
* 执行维护语句。
*
* @param array{sql:string, mode:string} $statement
*/
protected function _optimize(): void
private function runStatement(PDOConnection $connection, array $statement): void
{
$connection = $this->app->db->connect();
if (!$connection instanceof PDOConnection) {
throw new Exception('当前数据库连接不支持 OPTIMIZE 操作');
}
$this->setQueueProgress('正在获取需要优化的数据表', '0');
[$tables, $total, $count] = SystemService::getTables();
$this->setQueueProgress("总共需要优化 {$total} 张数据表", '0');
foreach ($tables as $table) {
$this->setQueueMessage($total, ++$count, "正在优化数据表 {$table}");
$connection->query("OPTIMIZE TABLE `{$table}`");
$this->setQueueMessage($total, $count, "完成优化数据表 {$table}", 1);
}
$this->setQueueSuccess("已完成对 {$total} 张数据表优化操作");
if ($statement['mode'] === DatabaseMaintenance::MODE_QUERY) {
$connection->query($statement['sql']);
} else {
$connection->execute($statement['sql']);
}
}
}

View File

@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
/**
* +----------------------------------------------------------------------
* | ThinkAdmin Plugin
* +----------------------------------------------------------------------
* | Copyright (c) 2014~2026 ThinkAdmin [ thinkadmin.top ]
* +----------------------------------------------------------------------
* | Official Website: https://thinkadmin.top
* +----------------------------------------------------------------------
* | Licensed: https://mit-license.org
* | Disclaimer: https://thinkadmin.top/disclaimer
* | Vip Rights: https://thinkadmin.top/vip-introduce
* +----------------------------------------------------------------------
* | Gitee Repository: https://gitee.com/zoujingli/ThinkAdmin
* | Github Repository: https://github.com/zoujingli/ThinkAdmin
* +----------------------------------------------------------------------
*/
namespace plugin\system\helper\command\database;
/**
* 数据库维护语句适配器。
*/
class DatabaseMaintenance
{
public const ACTION_OPTIMIZE = 'optimize';
public const ACTION_REPAIR = 'repair';
public const MODE_EXECUTE = 'execute';
public const MODE_QUERY = 'query';
private string $type;
public function __construct(string $type)
{
$this->type = $this->normalizeType($type);
}
/**
* 操作名称。
*/
public static function actionLabel(string $action): string
{
return $action === self::ACTION_REPAIR ? '修复' : '优化';
}
/**
* 是否支持指定维护操作。
*/
public function supports(string $action): bool
{
return match ($this->type) {
'mysql', 'mariadb' => in_array($action, [self::ACTION_OPTIMIZE, self::ACTION_REPAIR], true),
'sqlite', 'pgsql', 'sqlsrv' => $action === self::ACTION_OPTIMIZE,
default => false,
};
}
/**
* 是否需要遍历数据表。
*/
public function requiresTables(string $action): bool
{
return $this->supports($action) && in_array($this->type, ['mysql', 'mariadb', 'pgsql', 'sqlsrv'], true);
}
/**
* 生成不支持时的提示。
*/
public function unsupportedMessage(string $action): string
{
return sprintf('%s 数据库暂不支持 %s 操作,已跳过处理。', strtoupper($this->type ?: 'unknown'), strtoupper($action));
}
/**
* 生成维护操作。
*
* @param array<int, string> $tables
* @return array<int, array{target:string, statements:array<int, array{sql:string, mode:string}>}>
*/
public function operations(string $action, array $tables = []): array
{
if (!$this->supports($action)) {
return [];
}
return match ($this->type) {
'mysql', 'mariadb' => $this->mysqlOperations($action, $tables),
'sqlite' => $this->sqliteOperations($action),
'pgsql' => $this->pgsqlOperations($action, $tables),
'sqlsrv' => $this->sqlsrvOperations($action, $tables),
default => [],
};
}
private function normalizeType(string $type): string
{
$type = strtolower(trim($type));
return match ($type) {
'postgres', 'postgresql' => 'pgsql',
'mssql', 'sqlserver' => 'sqlsrv',
default => $type,
};
}
/**
* @param array<int, string> $tables
* @return array<int, array{target:string, statements:array<int, array{sql:string, mode:string}>}>
*/
private function mysqlOperations(string $action, array $tables): array
{
$prefix = $action === self::ACTION_REPAIR ? 'REPAIR TABLE' : 'OPTIMIZE TABLE';
$operations = [];
foreach ($tables as $table) {
$operations[] = [
'target' => "数据表 {$table}",
'statements' => [[
'sql' => "{$prefix} {$this->quoteTable($table)}",
'mode' => self::MODE_QUERY,
]],
];
}
return $operations;
}
/**
* @return array<int, array{target:string, statements:array<int, array{sql:string, mode:string}>}>
*/
private function sqliteOperations(string $action): array
{
if ($action !== self::ACTION_OPTIMIZE) {
return [];
}
return [[
'target' => '数据库',
'statements' => [
['sql' => 'PRAGMA optimize', 'mode' => self::MODE_EXECUTE],
['sql' => 'VACUUM', 'mode' => self::MODE_EXECUTE],
],
]];
}
/**
* @param array<int, string> $tables
* @return array<int, array{target:string, statements:array<int, array{sql:string, mode:string}>}>
*/
private function pgsqlOperations(string $action, array $tables): array
{
if ($action !== self::ACTION_OPTIMIZE) {
return [];
}
$operations = [];
foreach ($tables as $table) {
$operations[] = [
'target' => "数据表 {$table}",
'statements' => [[
'sql' => "VACUUM (ANALYZE) {$this->quoteTable($table)}",
'mode' => self::MODE_EXECUTE,
]],
];
}
return $operations;
}
/**
* @param array<int, string> $tables
* @return array<int, array{target:string, statements:array<int, array{sql:string, mode:string}>}>
*/
private function sqlsrvOperations(string $action, array $tables): array
{
if ($action !== self::ACTION_OPTIMIZE) {
return [];
}
$operations = [];
foreach ($tables as $table) {
$quoted = $this->quoteTable($table);
$operations[] = [
'target' => "数据表 {$table}",
'statements' => [
['sql' => "ALTER INDEX ALL ON {$quoted} REORGANIZE", 'mode' => self::MODE_EXECUTE],
['sql' => "UPDATE STATISTICS {$quoted}", 'mode' => self::MODE_EXECUTE],
],
];
}
return $operations;
}
private function quoteTable(string $table): string
{
$parts = array_map('trim', explode('.', $table));
$parts = array_filter($parts, static fn (string $part): bool => $part !== '');
return implode('.', array_map(fn (string $part): string => $this->quoteIdentifier($part), $parts));
}
private function quoteIdentifier(string $identifier): string
{
return match ($this->type) {
'pgsql' => '"' . str_replace('"', '""', $identifier) . '"',
'sqlsrv' => '[' . str_replace(']', ']]', $identifier) . ']',
default => '`' . str_replace('`', '``', $identifier) . '`',
};
}
}

View File

@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
/**
* +----------------------------------------------------------------------
* | ThinkAdmin Plugin
* +----------------------------------------------------------------------
* | Copyright (c) 2014~2026 ThinkAdmin [ thinkadmin.top ]
* +----------------------------------------------------------------------
* | Official Website: https://thinkadmin.top
* +----------------------------------------------------------------------
* | Licensed: https://mit-license.org
* | Disclaimer: https://thinkadmin.top/disclaimer
* | Vip Rights: https://thinkadmin.top/vip-introduce
* +----------------------------------------------------------------------
* | Gitee Repository: https://gitee.com/zoujingli/ThinkAdmin
* | Github Repository: https://github.com/zoujingli/ThinkAdmin
* +----------------------------------------------------------------------
*/
namespace plugin\system\tests\helper;
use PHPUnit\Framework\TestCase;
use plugin\system\helper\command\database\DatabaseMaintenance;
/**
* @internal
* @coversNothing
*/
class DatabaseMaintenanceTest extends TestCase
{
public function testMysqlBuildsOptimizeAndRepairStatements(): void
{
$adapter = new DatabaseMaintenance('mysql');
$this->assertTrue($adapter->supports(DatabaseMaintenance::ACTION_OPTIMIZE));
$this->assertTrue($adapter->supports(DatabaseMaintenance::ACTION_REPAIR));
$this->assertTrue($adapter->requiresTables(DatabaseMaintenance::ACTION_OPTIMIZE));
$optimize = $adapter->operations(DatabaseMaintenance::ACTION_OPTIMIZE, [
'system_queue',
'admin.system_user',
'weird`name',
]);
$repair = $adapter->operations(DatabaseMaintenance::ACTION_REPAIR, ['system_queue']);
$this->assertSame('OPTIMIZE TABLE `system_queue`', $optimize[0]['statements'][0]['sql']);
$this->assertSame('OPTIMIZE TABLE `admin`.`system_user`', $optimize[1]['statements'][0]['sql']);
$this->assertSame('OPTIMIZE TABLE `weird``name`', $optimize[2]['statements'][0]['sql']);
$this->assertSame(DatabaseMaintenance::MODE_QUERY, $optimize[0]['statements'][0]['mode']);
$this->assertSame('REPAIR TABLE `system_queue`', $repair[0]['statements'][0]['sql']);
}
public function testSqliteBuildsDatabaseScopedOptimizeAndSkipsRepair(): void
{
$adapter = new DatabaseMaintenance('sqlite');
$this->assertTrue($adapter->supports(DatabaseMaintenance::ACTION_OPTIMIZE));
$this->assertFalse($adapter->supports(DatabaseMaintenance::ACTION_REPAIR));
$this->assertFalse($adapter->requiresTables(DatabaseMaintenance::ACTION_OPTIMIZE));
$operations = $adapter->operations(DatabaseMaintenance::ACTION_OPTIMIZE, ['system_queue']);
$this->assertSame('数据库', $operations[0]['target']);
$this->assertSame('PRAGMA optimize', $operations[0]['statements'][0]['sql']);
$this->assertSame('VACUUM', $operations[0]['statements'][1]['sql']);
$this->assertSame(DatabaseMaintenance::MODE_EXECUTE, $operations[0]['statements'][0]['mode']);
$this->assertSame([], $adapter->operations(DatabaseMaintenance::ACTION_REPAIR, ['system_queue']));
$this->assertSame('SQLITE 数据库暂不支持 REPAIR 操作,已跳过处理。', $adapter->unsupportedMessage(DatabaseMaintenance::ACTION_REPAIR));
}
public function testPgsqlBuildsVacuumAnalyzeWithDoubleQuotedTables(): void
{
$adapter = new DatabaseMaintenance('postgresql');
$this->assertTrue($adapter->supports(DatabaseMaintenance::ACTION_OPTIMIZE));
$this->assertFalse($adapter->supports(DatabaseMaintenance::ACTION_REPAIR));
$this->assertTrue($adapter->requiresTables(DatabaseMaintenance::ACTION_OPTIMIZE));
$operations = $adapter->operations(DatabaseMaintenance::ACTION_OPTIMIZE, [
'public.system_queue',
'weird"name',
]);
$this->assertSame('VACUUM (ANALYZE) "public"."system_queue"', $operations[0]['statements'][0]['sql']);
$this->assertSame('VACUUM (ANALYZE) "weird""name"', $operations[1]['statements'][0]['sql']);
$this->assertSame(DatabaseMaintenance::MODE_EXECUTE, $operations[0]['statements'][0]['mode']);
}
public function testSqlsrvBuildsIndexAndStatisticsStatementsWithBracketQuotedTables(): void
{
$adapter = new DatabaseMaintenance('mssql');
$this->assertTrue($adapter->supports(DatabaseMaintenance::ACTION_OPTIMIZE));
$this->assertFalse($adapter->supports(DatabaseMaintenance::ACTION_REPAIR));
$this->assertTrue($adapter->requiresTables(DatabaseMaintenance::ACTION_OPTIMIZE));
$operations = $adapter->operations(DatabaseMaintenance::ACTION_OPTIMIZE, [
'dbo.system_queue',
'weird]name',
]);
$this->assertSame('ALTER INDEX ALL ON [dbo].[system_queue] REORGANIZE', $operations[0]['statements'][0]['sql']);
$this->assertSame('UPDATE STATISTICS [dbo].[system_queue]', $operations[0]['statements'][1]['sql']);
$this->assertSame('ALTER INDEX ALL ON [weird]]name] REORGANIZE', $operations[1]['statements'][0]['sql']);
$this->assertSame(DatabaseMaintenance::MODE_EXECUTE, $operations[0]['statements'][0]['mode']);
}
public function testUnsupportedDatabaseReturnsNoOperations(): void
{
$adapter = new DatabaseMaintenance('oracle');
$this->assertFalse($adapter->supports(DatabaseMaintenance::ACTION_OPTIMIZE));
$this->assertFalse($adapter->supports(DatabaseMaintenance::ACTION_REPAIR));
$this->assertSame([], $adapter->operations(DatabaseMaintenance::ACTION_OPTIMIZE, ['system_queue']));
$this->assertSame('ORACLE 数据库暂不支持 OPTIMIZE 操作,已跳过处理。', $adapter->unsupportedMessage(DatabaseMaintenance::ACTION_OPTIMIZE));
}
}