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;
+ }
}