diff --git a/CODERULE.md b/CODERULE.md index d177cc2..1760f06 100644 --- a/CODERULE.md +++ b/CODERULE.md @@ -85,6 +85,7 @@ Ulthon Admin 采用独特的双层架构设计,其中 **基础核心层 (`exte 1. **设计数据库表结构** - 严格遵循【第一部分:通用基础规范】中的数据库设计规范。 + - 可以直接在数据库中创建表,也可以使用 Scheme 层代码化创建。 2. **生成基础代码 (CURD)** - 使用 `php think curd` 命令生成控制器、模型、视图等。 @@ -100,7 +101,7 @@ Ulthon Admin 采用独特的双层架构设计,其中 **基础核心层 (`exte ### 4. 常用开发工具 #### 4.1 代码生成命令 (CURD) -> 文档: [CURD 命令详解](https://doc.ulthon.com/read/augushong/ulthon_admin/619efc816472e/zh-cn/2.x.html) +> 文档: [CURD 命令详解](https://doc.ulthon.com/read/augushong/ulthon_admin/curd-command/zh-cn/2.x.html) **常用命令示例**: ```bash @@ -152,3 +153,18 @@ php think scheme:make -t test_goods # 将 app/admin/scheme/ 下的代码同步到数据库 (自动备份原表) php think scheme:sync ``` + +#### 6.4 标准 CURD 开发工作流 +Scheme 机制是 CURD 生成的前置依赖,开发流程如下: + +1. **设计表结构**: + - 方式 A:手动创建/修改数据库表结构。 + - 方式 B:手动编写/修改 `app/admin/scheme/` 下的 Scheme 类。 + +2. **同步结构(确保一致性)**: + - 若采用方式 A,执行 `php think scheme:make -t {table}` 将变更同步到 Scheme。 + - 若采用方式 B,执行 `php think scheme:sync` 将变更同步到数据库。 + - **注意**:生成 CURD 前,Scheme 与数据库结构必须完全一致,否则会被拒绝。 + +3. **生成 CURD**: + - 执行 `php think curd -t {table}` 生成业务代码。 diff --git a/extend/base/admin/service/curd/BuildCurdServiceBase.php b/extend/base/admin/service/curd/BuildCurdServiceBase.php index d63eea8..2456d92 100644 --- a/extend/base/admin/service/curd/BuildCurdServiceBase.php +++ b/extend/base/admin/service/curd/BuildCurdServiceBase.php @@ -3,6 +3,9 @@ namespace base\admin\service\curd; use app\admin\service\curd\exceptions\TableException; +use app\common\scheme\attribute\Table; +use base\common\service\scheme\SchemeToDbService; +use ReflectionClass; use think\exception\FileException; use think\facade\Db; use think\helper\Str; @@ -229,8 +232,9 @@ class BuildCurdServiceBase */ public function __construct() { - $this->tablePrefix = config('database.connections.mysql.prefix'); - $this->dbName = config('database.connections.mysql.database'); + $connection = config('database.default', 'mysql'); + $this->tablePrefix = config("database.connections.{$connection}.prefix"); + $this->dbName = config("database.connections.{$connection}.database"); $this->dir = __DIR__; $this->rootDir = root_path(); @@ -258,36 +262,58 @@ class BuildCurdServiceBase public function setTable($table) { $this->table = $table; + + $schemeClass = 'app\\admin\\scheme\\' . Str::studly($this->table); + if (!class_exists($schemeClass)) { + throw new TableException("未找到 {$schemeClass},请先执行:php think scheme:make -t {$this->table} 或手动创建 Scheme"); + } + try { - // 获取表列注释 - $colums = Db::query("SHOW FULL COLUMNS FROM {$this->tablePrefix}{$this->table}"); - - foreach ($colums as $vo) { - $colum = [ - 'type' => $vo['Type'], - 'comment' => !empty($vo['Comment']) ? $vo['Comment'] : $vo['Field'], - 'required' => $vo['Null'] == 'NO' ? true : false, - 'default' => $vo['Default'], - 'field' => $vo['Field'], - ]; - - // 格式化列数据 - $this->buildColum($colum); - - $this->tableColumns[$vo['Field']] = $colum; - - if ($vo['Field'] == 'delete_time') { - $this->delete = true; - } - } - - // 获取表名注释 - $tableSchema = Db::query("SELECT table_name,table_comment FROM information_schema.TABLES WHERE table_schema = 'ulthon_admin' AND table_name = '{$this->tablePrefix}{$this->table}'"); - $this->tableComment = (isset($tableSchema[0]['table_comment']) && !empty($tableSchema[0]['table_comment'])) ? $tableSchema[0]['table_comment'] : $this->table; - } catch (\Exception $e) { + $ref = new ReflectionClass($schemeClass); + } catch (\Throwable $e) { throw new TableException($e->getMessage()); } + $tableAttrs = $ref->getAttributes(Table::class); + if (empty($tableAttrs)) { + throw new TableException("{$schemeClass} 未设置 Table(name: ...) ,无法生成 CURD"); + } + /** @var Table $tableAttr */ + $tableAttr = $tableAttrs[0]->newInstance(); + + $expectedTableName = "{$this->tablePrefix}{$this->table}"; + if ((string)$tableAttr->name !== $expectedTableName) { + throw new TableException("Scheme 表名不匹配:{$schemeClass} => {$tableAttr->name},期望 {$expectedTableName}"); + } + + $schemeService = new SchemeToDbService(); + try { + $diffs = $schemeService->diff($schemeClass); + } catch (\Throwable $e) { + throw new TableException($e->getMessage()); + } + + if (!empty($diffs)) { + $message = "CURD 生成已拒绝:数据库结构与 Scheme 不一致({$expectedTableName})\n"; + $message .= implode("\n", array_slice($diffs, 0, 50)); + $message .= "\n\n请先执行:\n"; + $message .= "- 从数据库生成 Scheme:php think scheme:make -t {$this->table}\n"; + $message .= "- 或从 Scheme 同步数据库:php think scheme:sync\n\n"; + $message .= "详细文档请参考:https://doc.ulthon.com/read/augushong/ulthon_admin/curd-command/zh-cn/2.x.html\n"; + throw new TableException($message); + } + + $columns = $schemeService->getColumnsForCurd($schemeClass); + foreach ($columns as $field => $colum) { + $this->buildColum($colum); + $this->tableColumns[$field] = $colum; + if ($field == 'delete_time') { + $this->delete = true; + } + } + + $this->tableComment = !empty($tableAttr->comment) ? $tableAttr->comment : $this->table; + $this->controllerFilename = $this->getTableControllerName($this->table); // 初始化默认模型名 @@ -342,62 +368,92 @@ class BuildCurdServiceBase if (!isset($this->tableColumns[$foreignKey])) { throw new TableException("主表不存在外键字段:{$foreignKey}"); } + + $schemeClass = 'app\\admin\\scheme\\' . Str::studly($relationTable); + if (!class_exists($schemeClass)) { + throw new TableException("未找到 {$schemeClass},请先执行:php think scheme:make -t {$relationTable} 或手动创建 Scheme"); + } + + try { + $ref = new ReflectionClass($schemeClass); + } catch (\Throwable $e) { + throw new TableException($e->getMessage()); + } + + $tableAttrs = $ref->getAttributes(Table::class); + if (empty($tableAttrs)) { + throw new TableException("{$schemeClass} 未设置 Table(name: ...) ,无法生成 CURD"); + } + /** @var Table $tableAttr */ + $tableAttr = $tableAttrs[0]->newInstance(); + + $expectedTableName = "{$this->tablePrefix}{$relationTable}"; + if ((string)$tableAttr->name !== $expectedTableName) { + throw new TableException("关联 Scheme 表名不匹配:{$schemeClass} => {$tableAttr->name},期望 {$expectedTableName}"); + } + + $schemeService = new SchemeToDbService(); + try { + $diffs = $schemeService->diff($schemeClass); + } catch (\Throwable $e) { + throw new TableException($e->getMessage()); + } + + if (!empty($diffs)) { + $message = "CURD 生成已拒绝:数据库结构与 Scheme 不一致({$expectedTableName})\n"; + $message .= implode("\n", array_slice($diffs, 0, 50)); + $message .= "\n\n请先执行:\n"; + $message .= "- 从数据库生成 Scheme:php think scheme:make -t {$relationTable}\n"; + $message .= "- 或从 Scheme 同步数据库:php think scheme:sync\n\n"; + $message .= "详细文档请参考:https://doc.ulthon.com/read/augushong/ulthon_admin/curd-command/zh-cn/2.x.html\n"; + throw new TableException($message); + } + if (!empty($modelFilename)) { $modelFilename = str_replace('/', $this->DS, $modelFilename); } - try { - $colums = Db::query("SHOW FULL COLUMNS FROM {$this->tablePrefix}{$relationTable}"); - $formatColums = []; - $delete = false; - if (!empty($bindSelectField) && !in_array($bindSelectField, array_column($colums, 'Field'))) { - throw new TableException("关联表{$relationTable}不存在该字段: {$bindSelectField}"); - } - foreach ($colums as $vo) { - if (empty($primaryKey) && $vo['Key'] == 'PRI') { - $primaryKey = $vo['Field']; - } - if (!empty($onlyShowFileds) && !in_array($vo['Field'], $onlyShowFileds)) { - continue; - } - $colum = [ - 'type' => $vo['Type'], - 'comment' => $vo['Comment'], - 'default' => $vo['Default'], - 'field' => $vo['Field'], - ]; - $this->buildColum($colum); - - $formatColums[$vo['Field']] = $colum; - if ($vo['Field'] == 'delete_time') { - $delete = true; - } - } - - $modelFilename = empty($modelFilename) ? Str::studly($relationTable) : $modelFilename; - $modelArray = explode($this->DS, $modelFilename); - $modelName = array_pop($modelArray); - - $relation = [ - 'modelFilename' => $modelFilename, - 'modelName' => $modelName, - 'foreignKey' => $foreignKey, - 'primaryKey' => $primaryKey, - 'bindSelectField' => $bindSelectField, - 'delete' => $delete, - 'tableColumns' => $formatColums, - ]; - if (!empty($bindSelectField)) { - $relationArray = explode('\\', $modelFilename); - $this->tableColumns[$foreignKey]['bindSelectField'] = $bindSelectField; - $this->tableColumns[$foreignKey]['bindRelation'] = end($relationArray); - } - $this->relationArray[$relationTable] = $relation; - $this->selectFileds[] = $foreignKey; - } catch (\Exception $e) { - throw new TableException($e->getMessage()); + $allColumns = $schemeService->getColumnsForCurd($schemeClass); + if (!empty($bindSelectField) && !isset($allColumns[$bindSelectField])) { + throw new TableException("关联表{$relationTable}不存在该字段: {$bindSelectField}"); } + if (empty($primaryKey)) { + $primaryKey = $schemeService->getPrimaryKey($schemeClass); + } + if (empty($primaryKey)) { + throw new TableException("关联表{$relationTable}未找到主键字段"); + } + + $formatColums = $schemeService->getColumnsForCurd($schemeClass, $onlyShowFileds); + $delete = isset($allColumns['delete_time']); + + foreach ($formatColums as $field => $colum) { + $this->buildColum($colum); + $formatColums[$field] = $colum; + } + + $modelFilename = empty($modelFilename) ? Str::studly($relationTable) : $modelFilename; + $modelArray = explode($this->DS, $modelFilename); + $modelName = array_pop($modelArray); + + $relation = [ + 'modelFilename' => $modelFilename, + 'modelName' => $modelName, + 'foreignKey' => $foreignKey, + 'primaryKey' => $primaryKey, + 'bindSelectField' => $bindSelectField, + 'delete' => $delete, + 'tableColumns' => $formatColums, + ]; + if (!empty($bindSelectField)) { + $relationArray = explode('\\', $modelFilename); + $this->tableColumns[$foreignKey]['bindSelectField'] = $bindSelectField; + $this->tableColumns[$foreignKey]['bindRelation'] = end($relationArray); + } + $this->relationArray[$relationTable] = $relation; + $this->selectFileds[] = $foreignKey; + return $this; } diff --git a/extend/base/common/command/scheme/Make.php b/extend/base/common/command/scheme/Make.php index 7951f9e..ef2635d 100644 --- a/extend/base/common/command/scheme/Make.php +++ b/extend/base/common/command/scheme/Make.php @@ -5,24 +5,28 @@ namespace base\common\command\scheme; use think\console\Command; use think\console\Input; use think\console\input\Argument; +use think\console\input\Option; use think\console\Output; use think\facade\Db; use think\facade\Config; use app\common\service\scheme\DbToSchemeService; +use app\common\service\scheme\SchemeToDbService; class Make extends Command { protected function configure() { $this->setName('scheme:make') + ->addOption('table', 't', Option::VALUE_REQUIRED, "The table name (without prefix)") ->addArgument('table', Argument::OPTIONAL, "The table name (without prefix)") ->setDescription('Generate Scheme class from Database table'); } protected function execute(Input $input, Output $output) { - $table = $input->getArgument('table'); + $table = $input->getOption('table') ?: $input->getArgument('table'); $service = new DbToSchemeService(); + $compare = new SchemeToDbService(); $tables = []; if ($table) { @@ -62,6 +66,18 @@ class Make extends Command if (preg_match('/class\s+(\w+)/', $code, $matches)) { $className = $matches[1]; $path = app()->getAppPath() . 'admin/scheme/' . $className . '.php'; + + if (is_file($path)) { + require_once $path; + $schemeClass = 'app\\admin\\scheme\\' . $className; + if (class_exists($schemeClass)) { + $diffs = $compare->diff($schemeClass); + if (empty($diffs)) { + $output->writeln("Skipping (no schema changes): $path"); + continue; + } + } + } // 确保目录存在 if (!is_dir(dirname($path))) { diff --git a/extend/base/common/command/scheme/Sync.php b/extend/base/common/command/scheme/Sync.php index ea544ac..95eefeb 100644 --- a/extend/base/common/command/scheme/Sync.php +++ b/extend/base/common/command/scheme/Sync.php @@ -9,7 +9,6 @@ use think\console\Output; use think\facade\Config; use think\facade\Db; use app\common\service\scheme\SchemeToDbService; -use app\common\service\scheme\DbToSchemeService; use app\common\scheme\attribute\Table; use ReflectionClass; @@ -28,7 +27,6 @@ class Sync extends Command $skipData = $input->getOption('skip-data'); $service = new SchemeToDbService(); - $generator = new DbToSchemeService(); $schemeDir = app()->getAppPath() . 'admin/scheme/'; $ignoreTables = Config::get('scheme.ignore_tables', []); $connection = Config::get('database.default', 'mysql'); @@ -63,19 +61,21 @@ class Sync extends Command continue; } - if ($this->checkTableExists($connection, $fullTableName)) { - try { - $expectedClass = basename($file, '.php'); - $generated = $generator->generate($fullTableName, $expectedClass); - $existing = (string)file_get_contents($file); - if ($this->normalizeCode($generated) === $this->normalizeCode($existing)) { - $output->writeln("Skipping $className (no schema changes)"); - continue; - } - } catch (\Exception $e) { - $output->writeln("Check failed for $className: " . $e->getMessage() . ""); - continue; - } + try { + $diffs = $service->diff($className); + } catch (\Throwable $e) { + $output->writeln("Check failed for $className: " . $e->getMessage() . ""); + continue; + } + + if (count($diffs) === 1 && str_starts_with($diffs[0], '无法读取数据库表结构')) { + $output->writeln("Check failed for $className: {$diffs[0]}"); + continue; + } + + if (empty($diffs)) { + $output->writeln("Skipping $className (no schema changes)"); + continue; } $output->writeln("Syncing $className..."); diff --git a/extend/base/common/service/scheme/SchemeToDbService.php b/extend/base/common/service/scheme/SchemeToDbService.php index 40960ce..6ee495f 100644 --- a/extend/base/common/service/scheme/SchemeToDbService.php +++ b/extend/base/common/service/scheme/SchemeToDbService.php @@ -35,9 +35,10 @@ class SchemeToDbService } /** @var Table $tableAttr */ $tableAttr = $tableAttrs[0]->newInstance(); - + + $connection = $this->resolveConnection($tableAttr); $tableName = $tableAttr->name; - $prefix = Config::get('database.connections.' . $this->connection . '.prefix', ''); + $prefix = (string)Config::get('database.connections.' . $connection . '.prefix', ''); // 确保表名带前缀 $fullTableName = $tableName; @@ -46,7 +47,7 @@ class SchemeToDbService } // 检查表是否存在 - $tableExists = $this->checkTableExists($fullTableName); + $tableExists = $this->checkTableExists($connection, $fullTableName); $backupTableName = null; // 1. 备份 @@ -56,8 +57,8 @@ class SchemeToDbService try { // 确保没有残留的同名备份表(极端情况) - Db::connect($this->connection)->execute("DROP TABLE IF EXISTS `$backupTableName`"); - Db::connect($this->connection)->execute("RENAME TABLE `$fullTableName` TO `$backupTableName`"); + Db::connect($connection)->execute("DROP TABLE IF EXISTS `$backupTableName`"); + Db::connect($connection)->execute("RENAME TABLE `$fullTableName` TO `$backupTableName`"); } catch (\Exception $e) { // 如果备份失败,可能是权限问题或其他,抛出异常 throw new \Exception("Backup failed: " . $e->getMessage()); @@ -66,12 +67,12 @@ class SchemeToDbService // 2. 建表 $sql = $this->buildCreateTableSql($fullTableName, $tableAttr, $ref); - Db::connect($this->connection)->execute($sql); + Db::connect($connection)->execute($sql); // 3. 恢复数据 if ($tableExists && !$skipData && $backupTableName) { try { - $this->restoreData($fullTableName, $backupTableName, $ref->getProperties()); + $this->restoreData($connection, $fullTableName, $backupTableName, $ref->getProperties()); } catch (\Exception $e) { // 如果数据恢复失败,尝试回滚(这里只是简单的删除新表,重命名回旧表) // 实际生产环境可能需要更复杂的恢复机制 @@ -84,9 +85,187 @@ class SchemeToDbService return $backupTableName; } - protected function checkTableExists($tableName) + public function diff(string $className): array { - $tables = Db::connect($this->connection)->query("SHOW TABLES LIKE '$tableName'"); + if (!class_exists($className)) { + throw new \Exception("Class $className not found"); + } + + $ref = new ReflectionClass($className); + $tableAttr = $this->getTableAttribute($ref, $className); + $connection = $this->resolveConnection($tableAttr); + $prefix = (string)Config::get('database.connections.' . $connection . '.prefix', ''); + + $fullTableName = $tableAttr->name; + if ($prefix && !str_starts_with($fullTableName, $prefix)) { + $fullTableName = $prefix . $fullTableName; + } + + if (!$this->checkTableExists($connection, $fullTableName)) { + return ["表不存在:{$fullTableName}"]; + } + + try { + $dbColumnsRows = Db::connect($connection)->query("SHOW FULL COLUMNS FROM `{$fullTableName}`"); + $dbKeysRows = Db::connect($connection)->query("SHOW KEYS FROM `{$fullTableName}`"); + } catch (\Throwable $e) { + return ["无法读取数据库表结构:{$fullTableName},{$e->getMessage()}"]; + } + + $dbColumns = []; + foreach ($dbColumnsRows as $row) { + $dbColumns[$row['Field']] = $row; + } + + $schemeColumns = $this->buildSchemeColumnSignature($ref); + $schemeIndexes = $this->buildSchemeIndexSignature($ref); + $dbIndexes = $this->buildDbIndexSignature($dbKeysRows); + + $diffs = []; + + foreach ($schemeColumns as $field => $sig) { + if (!isset($dbColumns[$field])) { + $diffs[] = "缺少字段:{$field}"; + continue; + } + + $row = $dbColumns[$field]; + + $dbType = $this->normalizeDbType((string)$row['Type']); + $schemeType = $this->normalizeDbType($sig['type']); + if ($dbType !== $schemeType) { + $diffs[] = "字段类型不一致:{$field} DB={$dbType} Scheme={$schemeType}"; + } + + $dbNull = (string)$row['Null']; + $schemeNull = $sig['null']; + if ($dbNull !== $schemeNull) { + $diffs[] = "字段可空不一致:{$field} DB={$dbNull} Scheme={$schemeNull}"; + } + + $dbDefault = $row['Default']; + $schemeDefault = $sig['default']; + if (!$this->defaultEquals($dbDefault, $schemeDefault)) { + $dbStr = is_null($dbDefault) ? 'NULL' : (string)$dbDefault; + $schemeStr = is_null($schemeDefault) ? 'NULL' : (string)$schemeDefault; + $diffs[] = "字段默认值不一致:{$field} DB={$dbStr} Scheme={$schemeStr}"; + } + + $dbExtra = (string)$row['Extra']; + $schemeExtra = $sig['extra']; + if ($dbExtra !== $schemeExtra) { + $diffs[] = "字段 Extra 不一致:{$field} DB={$dbExtra} Scheme={$schemeExtra}"; + } + + $dbPrimary = (string)$row['Key'] === 'PRI'; + if ($dbPrimary !== $sig['primary']) { + $diffs[] = "字段主键不一致:{$field} DB=" . ($dbPrimary ? 'PRI' : '') . " Scheme=" . ($sig['primary'] ? 'PRI' : ''); + } + + $dbComment = (string)$row['Comment']; + $schemeComment = $sig['comment']; + if ($this->parseComment($dbComment) !== $this->parseComment($schemeComment)) { + $diffs[] = "字段注释不一致:{$field} DB={$dbComment} Scheme={$schemeComment}"; + } + } + + foreach ($dbColumns as $field => $row) { + if (!isset($schemeColumns[$field])) { + $diffs[] = "多余字段:{$field}"; + } + } + + foreach ($schemeIndexes as $name => $idx) { + if (!isset($dbIndexes[$name])) { + $diffs[] = "缺少索引:{$name}"; + continue; + } + + $dbIdx = $dbIndexes[$name]; + if ($dbIdx['type'] !== $idx['type']) { + $diffs[] = "索引类型不一致:{$name} DB={$dbIdx['type']} Scheme={$idx['type']}"; + } + + if ($dbIdx['columns'] !== $idx['columns']) { + $diffs[] = "索引字段不一致:{$name} DB=" . implode(',', $dbIdx['columns']) . " Scheme=" . implode(',', $idx['columns']); + } + } + + foreach ($dbIndexes as $name => $idx) { + if (!isset($schemeIndexes[$name])) { + $diffs[] = "多余索引:{$name}"; + } + } + + $tableCommentDiff = $this->diffTableComment($connection, $fullTableName, $tableAttr->comment); + if ($tableCommentDiff !== null) { + $diffs[] = $tableCommentDiff; + } + + return $diffs; + } + + public function getColumnsForCurd(string $className, array $onlyFields = []): array + { + if (!class_exists($className)) { + throw new \Exception("Class $className not found"); + } + + $ref = new ReflectionClass($className); + $columns = []; + + foreach ($ref->getProperties() as $prop) { + $fieldAttrs = $prop->getAttributes(Field::class); + if (empty($fieldAttrs)) { + continue; + } + + $fieldName = $prop->getName(); + if (!empty($onlyFields) && !in_array($fieldName, $onlyFields, true)) { + continue; + } + + /** @var Field $field */ + $field = $fieldAttrs[0]->newInstance(); + + $columns[$fieldName] = [ + 'type' => $this->buildMysqlTypeFromSchemeField($field), + 'comment' => $this->buildColumnComment($field, $prop), + 'required' => !$field->nullable, + 'default' => $field->default, + 'field' => $fieldName, + ]; + } + + return $columns; + } + + public function getPrimaryKey(string $className): ?string + { + if (!class_exists($className)) { + throw new \Exception("Class $className not found"); + } + + $ref = new ReflectionClass($className); + foreach ($ref->getProperties() as $prop) { + $fieldAttrs = $prop->getAttributes(Field::class); + if (empty($fieldAttrs)) { + continue; + } + + /** @var Field $field */ + $field = $fieldAttrs[0]->newInstance(); + if ($field->primary) { + return $prop->getName(); + } + } + + return null; + } + + protected function checkTableExists(string $connection, string $tableName): bool + { + $tables = Db::connect($connection)->query("SHOW TABLES LIKE '$tableName'"); return !empty($tables); } @@ -144,27 +323,7 @@ class SchemeToDbService } // Comment + Component Restore - $comment = $field->comment; - - // 检查是否有 Component 注解 - $compAttrs = $prop->getAttributes(Component::class); - if (!empty($compAttrs)) { - /** @var Component $comp */ - $comp = $compAttrs[0]->newInstance(); - $typeStr = "{{$comp->type}}"; - $optionsStr = ''; - if (!empty($comp->options)) { - $parts = []; - foreach ($comp->options as $k => $v) { - // 如果是数字索引,且连续,可能就是简单的值列表? - // 但 Ulthon 格式通常是 k:v。如果 k 是数字,也要输出。 - $parts[] = "$k:$v"; - } - $optionsStr = ' (' . implode(',', $parts) . ')'; - } - // 拼接回注释:原注释 {type} (options) - $comment = trim($comment . " $typeStr$optionsStr"); - } + $comment = $this->buildColumnComment($field, $prop); if ($comment) { $line .= " COMMENT '$comment'"; @@ -205,7 +364,7 @@ class SchemeToDbService return "CREATE TABLE `$tableName` (\n $body\n) ENGINE={$tableAttr->engine} DEFAULT CHARSET={$tableAttr->charset}$comment"; } - protected function restoreData($newTable, $oldTable, array $properties) + protected function restoreData(string $connection, $newTable, $oldTable, array $properties) { $fields = []; foreach ($properties as $prop) { @@ -218,7 +377,7 @@ class SchemeToDbService if (empty($fields)) return; try { - $oldFieldsRaw = Db::connect($this->connection)->getFields($oldTable); + $oldFieldsRaw = Db::connect($connection)->getFields($oldTable); $oldFields = array_keys($oldFieldsRaw); $commonFields = []; @@ -234,10 +393,247 @@ class SchemeToDbService $commonStr = implode(',', $commonFields); $sql = "INSERT INTO `$newTable` ($commonStr) SELECT $commonStr FROM `$oldTable`"; - Db::connect($this->connection)->execute($sql); + Db::connect($connection)->execute($sql); } catch (\Exception $e) { throw new \Exception("Data migration failed: " . $e->getMessage()); } } + + protected function resolveConnection(Table $tableAttr): string + { + return !empty($tableAttr->connection) ? (string)$tableAttr->connection : (string)$this->connection; + } + + protected function getTableAttribute(ReflectionClass $ref, string $className): Table + { + $tableAttrs = $ref->getAttributes(Table::class); + if (empty($tableAttrs)) { + throw new \Exception("Class $className missing #[Table] attribute"); + } + + /** @var Table $tableAttr */ + $tableAttr = $tableAttrs[0]->newInstance(); + + return $tableAttr; + } + + protected function buildSchemeColumnSignature(ReflectionClass $ref): array + { + $sig = []; + + foreach ($ref->getProperties() as $prop) { + $fieldAttrs = $prop->getAttributes(Field::class); + if (empty($fieldAttrs)) { + continue; + } + + $fieldName = $prop->getName(); + /** @var Field $field */ + $field = $fieldAttrs[0]->newInstance(); + + $sig[$fieldName] = [ + 'type' => $this->buildMysqlTypeFromSchemeField($field), + 'null' => $field->nullable ? 'YES' : 'NO', + 'default' => $field->default, + 'extra' => $field->autoIncrement ? 'auto_increment' : '', + 'primary' => (bool)$field->primary, + 'comment' => $this->buildColumnComment($field, $prop), + ]; + } + + return $sig; + } + + protected function buildSchemeIndexSignature(ReflectionClass $ref): array + { + $sig = []; + + $indexAttrs = $ref->getAttributes(Index::class); + foreach ($indexAttrs as $attr) { + /** @var Index $idx */ + $idx = $attr->newInstance(); + $cols = is_array($idx->columns) ? $idx->columns : [$idx->columns]; + $keyName = $idx->name ?: $cols[0]; + + $sig[$keyName] = [ + 'type' => $idx->type, + 'columns' => array_values($cols), + ]; + } + + return $sig; + } + + protected function buildDbIndexSignature(array $dbKeysRows): array + { + $idx = []; + + foreach ($dbKeysRows as $row) { + $name = $row['Key_name']; + if ($name === 'PRIMARY') { + continue; + } + + if (!isset($idx[$name])) { + $type = 'NORMAL'; + if ((int)$row['Non_unique'] === 0) { + $type = 'UNIQUE'; + } elseif (strtoupper((string)$row['Index_type']) === 'FULLTEXT') { + $type = 'FULLTEXT'; + } + + $idx[$name] = [ + 'type' => $type, + 'columns' => [], + ]; + } + + $seq = (int)$row['Seq_in_index']; + $idx[$name]['columns'][$seq] = $row['Column_name']; + } + + foreach ($idx as $name => $val) { + ksort($idx[$name]['columns']); + $idx[$name]['columns'] = array_values($idx[$name]['columns']); + } + + return $idx; + } + + protected function buildMysqlTypeFromSchemeField(Field $field): string + { + $type = strtolower($field->type); + + if ($field->length !== null) { + $type .= "({$field->length})"; + } elseif ($field->precision > 0) { + $type .= "({$field->precision},{$field->scale})"; + } + + if ($field->unsigned) { + $type .= ' unsigned'; + } + + return $type; + } + + protected function buildColumnComment(Field $field, ReflectionProperty $prop): string + { + $comment = (string)$field->comment; + $compAttrs = $prop->getAttributes(Component::class); + if (empty($compAttrs)) { + return trim($comment); + } + + /** @var Component $comp */ + $comp = $compAttrs[0]->newInstance(); + $typeStr = "{{$comp->type}}"; + $optionsStr = ''; + + if (!empty($comp->options)) { + $parts = []; + foreach ($comp->options as $k => $v) { + $parts[] = "$k:$v"; + } + $optionsStr = ' (' . implode(',', $parts) . ')'; + } + + return trim(trim($comment) . " $typeStr$optionsStr"); + } + + protected function normalizeDbType(string $type): string + { + $type = strtolower(trim($type)); + $type = preg_replace('/\\s+/', ' ', $type); + // 移除整数类型的显示宽度,例如 int(11) -> int, bigint(20) -> bigint + // 但不移除 decimal(10,2) 或 char(32) 这种有实际意义的长度 + $type = preg_replace('/(tinyint|smallint|mediumint|int|integer|bigint)\s*\(\d+\)/', '$1', $type); + $type = preg_replace('/^decimal\\((\\d+)\\)$/', 'decimal($1,0)', $type); + return $type; + } + + protected function parseComment(string $comment): array + { + $comment = trim($comment); + $result = [ + 'content' => '', + 'type' => '', + 'options' => [], + ]; + + // Extract options (...) + if (preg_match('/^(.*)\s*\((.*)\)$/', $comment, $matches)) { + $comment = trim($matches[1]); + $optsStr = $matches[2]; + $pairs = explode(',', $optsStr); + foreach ($pairs as $p) { + $kv = explode(':', $p, 2); + $k = trim($kv[0]); + $v = isset($kv[1]) ? trim($kv[1]) : ''; + if ($k !== '') { + $result['options'][$k] = $v; + } + } + } + + // Extract type {...} + if (preg_match('/^(.*)\s*\{(\w+)\}$/', $comment, $matches)) { + $comment = trim($matches[1]); + $result['type'] = strtolower($matches[2]); + } + + $result['content'] = $comment; + + // Sort options for comparison + ksort($result['options']); + + return $result; + } + + protected function defaultEquals($dbDefault, $schemeDefault): bool + { + if (is_null($dbDefault) && is_null($schemeDefault)) { + return true; + } + + if (is_null($dbDefault) xor is_null($schemeDefault)) { + return false; + } + + if (is_bool($schemeDefault)) { + $schemeDefault = $schemeDefault ? '1' : '0'; + } elseif (is_int($schemeDefault) || is_float($schemeDefault)) { + $schemeDefault = (string)$schemeDefault; + } elseif (is_string($schemeDefault)) { + $schemeDefault = (string)$schemeDefault; + } else { + $schemeDefault = (string)$schemeDefault; + } + + return (string)$dbDefault === (string)$schemeDefault; + } + + protected function diffTableComment(string $connection, string $tableName, string $schemeComment): ?string + { + $database = (string)Config::get('database.connections.' . $connection . '.database', ''); + if ($database === '') { + return null; + } + + try { + $rows = Db::connect($connection)->query( + 'SELECT table_comment FROM information_schema.TABLES WHERE table_schema = ? AND table_name = ? LIMIT 1', + [$database, $tableName] + ); + $dbComment = isset($rows[0]['table_comment']) ? (string)$rows[0]['table_comment'] : ''; + if ($dbComment !== (string)$schemeComment) { + return "表注释不一致:DB={$dbComment} Scheme={$schemeComment}"; + } + } catch (\Throwable $e) { + return null; + } + + return null; + } }