feat(scheme): 新增数据库表结构同步方案

This commit is contained in:
augushong
2026-01-09 21:08:51 +08:00
parent 1a39354287
commit 8de6b99bb3
24 changed files with 944 additions and 3 deletions

View File

@@ -0,0 +1,219 @@
<?php
namespace base\common\service\scheme;
use think\facade\Db;
use think\facade\Config;
use think\helper\Str;
class DbToSchemeService
{
protected string $module = 'admin';
protected string $connection;
public function __construct()
{
$this->connection = Config::get('database.default', 'mysql');
}
public function generate(string $tableName, string $className = null): string
{
$prefix = Config::get('database.connections.' . $this->connection . '.prefix', '');
$fullTableName = $tableName;
// 如果表名不包含前缀,且配置了前缀,则添加
if ($prefix && !str_starts_with($tableName, $prefix)) {
$fullTableName = $prefix . $tableName;
}
// 尝试获取表信息
try {
// 获取列信息TP通用方法
$columns = Db::connect($this->connection)->getFields($fullTableName);
} catch (\Exception $e) {
// 尝试直接使用tableName
$fullTableName = $tableName;
$columns = Db::connect($this->connection)->getFields($fullTableName);
}
// 获取表注释
$tableComment = $this->getTableComment($fullTableName);
// 获取索引信息
$indices = $this->getTableIndices($fullTableName);
// 生成类名
if (empty($className)) {
// 去除前缀
$shortName = $tableName;
if ($prefix && str_starts_with($tableName, $prefix)) {
$shortName = substr($tableName, strlen($prefix));
}
$className = Str::studly($shortName);
}
// 构建类内容
return $this->buildClass($className, $fullTableName, $tableComment, $columns, $indices);
}
protected function getTableComment($tableName): string
{
// 简单适配 MySQL
try {
$config = Config::get('database.connections.' . $this->connection);
$database = $config['database'];
// 暂时只支持MySQL的表注释获取其他返回空
$sql = "SELECT table_comment FROM information_schema.TABLES WHERE table_schema = ? AND table_name = ?";
$res = Db::connect($this->connection)->query($sql, [$database, $tableName]);
return $res[0]['table_comment'] ?? '';
} catch (\Exception $e) {
return '';
}
}
protected function getTableIndices($tableName): array
{
try {
// SHOW KEYS FROM table
$keys = Db::connect($this->connection)->query("SHOW KEYS FROM `$tableName`");
$indices = [];
foreach ($keys as $key) {
$name = $key['Key_name'];
if ($name === 'PRIMARY') continue; // 主键在 Field 中处理
if (!isset($indices[$name])) {
$indices[$name] = [
'name' => $name,
'columns' => [],
'type' => $key['Non_unique'] == 0 ? 'UNIQUE' : ($key['Index_type'] == 'FULLTEXT' ? 'FULLTEXT' : 'NORMAL')
];
}
$indices[$name]['columns'][] = $key['Column_name'];
}
return array_values($indices);
} catch (\Exception $e) {
return [];
}
}
protected function buildClass($className, $tableName, $tableComment, $columns, $indices): string
{
$fieldsCode = [];
foreach ($columns as $field) {
$fieldName = $field['name'];
$type = $field['type']; // varchar(255)
$comment = $field['comment'] ?? '';
$default = $field['default'] ?? null;
$notNull = $field['notnull'] ?? false;
$primary = $field['primary'] ?? false;
$autoinc = $field['autoinc'] ?? false;
// 解析类型和长度
preg_match('/(\w+)(?:\((\d+)(?:,(\d+))?\))?/', $type, $matches);
$dbType = $matches[1] ?? 'varchar';
$length = isset($matches[2]) ? (int)$matches[2] : null;
$precision = isset($matches[2]) ? (int)$matches[2] : 0; // For decimal
$scale = isset($matches[3]) ? (int)$matches[3] : 0;
// 解析 Ulthon 组件语法
$componentAttr = '';
$cleanComment = $comment;
// 匹配 {type} (options)
// 改进:允许 options 中含有冒号,且 key:value 之间可能有空格,或者没有括号
// 例子:{radio} (0:禁用,1:启用) 或 {image} 或 {relation} (table:mall_cate,relationBindSelect:title)
if (preg_match('/\{(.*?)\}\s*(\((.*?)\))?/', $comment, $cmatch)) {
$compType = $cmatch[1];
$compOptionsStr = $cmatch[3] ?? '';
$cleanComment = trim(str_replace($cmatch[0], '', $comment));
// 解析选项 (1:A, 2:B) 或 (table:mall_cate,relationBindSelect:title)
$optionsCode = '[]';
if ($compOptionsStr) {
$options = [];
// 简单的 explode 可能有问题,如果值里面有逗号。但目前假设选项格式比较简单。
$parts = explode(',', $compOptionsStr);
foreach ($parts as $p) {
// 尝试分割 key:value
$kv = explode(':', trim($p), 2); // Limit to 2 parts
if (count($kv) >= 2) {
$k = trim($kv[0]);
$v = trim($kv[1]);
$options[$k] = $v;
} else {
$options[] = trim($p);
}
}
$optionsCode = $this->exportArray($options);
}
$componentAttr = "\n #[Component(type: '$compType', options: $optionsCode)]";
}
// 构建 Field 注解
$defaultStr = is_null($default) ? 'null' : (is_string($default) ? "'$default'" : $default);
$nullable = $notNull ? 'false' : 'true';
$unsigned = str_contains($type, 'unsigned') ? 'true' : 'false';
$primaryStr = $primary ? 'true' : 'false';
$autoincStr = $autoinc ? 'true' : 'false';
// 如果是整型且未指定长度默认11 (兼容旧习俗)
if (str_contains($dbType, 'int') && !$length) {
$length = 11;
}
$lengthStr = is_null($length) ? 'null' : $length;
$fieldsCode[] = <<<PHP
#[Field(type: '$dbType', length: $lengthStr, precision: $precision, scale: $scale, nullable: $nullable, default: $defaultStr, comment: '$cleanComment', unsigned: $unsigned, autoIncrement: $autoincStr, primary: $primaryStr)]$componentAttr
public \$$fieldName;
PHP;
}
$fieldsBody = implode("\n\n", $fieldsCode);
// 构建 Index 注解
$indexAttrs = '';
foreach ($indices as $idx) {
$cols = $this->exportArray($idx['columns']);
$indexAttrs .= "\n#[Index(columns: $cols, name: '{$idx['name']}', type: '{$idx['type']}')]";
}
return <<<PHP
<?php
namespace app\\{$this->module}\scheme;
use app\common\scheme\BaseScheme;
use app\common\scheme\attribute\Table;
use app\common\scheme\attribute\Field;
use app\common\scheme\attribute\Component;
use app\common\scheme\attribute\Index;
#[Table(name: '$tableName', comment: '$tableComment')]$indexAttrs
class $className extends BaseScheme
{
$fieldsBody
}
PHP;
}
protected function exportArray(array $array): string
{
// 简易数组导出
$items = [];
// 判断是否是关联数组
$isAssoc = array_keys($array) !== range(0, count($array) - 1);
foreach ($array as $k => $v) {
$val = is_string($v) ? "'$v'" : $v;
if ($isAssoc) {
$key = is_int($k) ? $k : "'$k'";
$items[] = "$key => $val";
} else {
$items[] = $val;
}
}
return '[' . implode(', ', $items) . ']';
}
}

View File

@@ -0,0 +1,243 @@
<?php
namespace base\common\service\scheme;
use think\facade\Db;
use think\facade\Config;
use app\common\scheme\attribute\Table;
use app\common\scheme\attribute\Field;
use app\common\scheme\attribute\Component;
use app\common\scheme\attribute\Index;
use ReflectionClass;
use ReflectionProperty;
class SchemeToDbService
{
protected $connection;
public function __construct()
{
$this->connection = Config::get('database.default', 'mysql');
}
public function sync(string $className, bool $skipData = false)
{
if (!class_exists($className)) {
throw new \Exception("Class $className not found");
}
$ref = new ReflectionClass($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();
$tableName = $tableAttr->name;
$prefix = Config::get('database.connections.' . $this->connection . '.prefix', '');
// 确保表名带前缀
$fullTableName = $tableName;
if ($prefix && !str_starts_with($tableName, $prefix)) {
$fullTableName = $prefix . $tableName;
}
// 检查表是否存在
$tableExists = $this->checkTableExists($fullTableName);
$backupTableName = null;
// 1. 备份
if ($tableExists) {
$backupPrefix = Config::get('scheme.backup_prefix', 'backup');
$backupTableName = $prefix . $backupPrefix . '_' . date('YmdHis') . '_' . $fullTableName;
try {
// 确保没有残留的同名备份表(极端情况)
Db::connect($this->connection)->execute("DROP TABLE IF EXISTS `$backupTableName`");
Db::connect($this->connection)->execute("RENAME TABLE `$fullTableName` TO `$backupTableName`");
} catch (\Exception $e) {
// 如果备份失败,可能是权限问题或其他,抛出异常
throw new \Exception("Backup failed: " . $e->getMessage());
}
}
// 2. 建表
$sql = $this->buildCreateTableSql($fullTableName, $tableAttr, $ref);
Db::connect($this->connection)->execute($sql);
// 3. 恢复数据
if ($tableExists && !$skipData && $backupTableName) {
try {
$this->restoreData($fullTableName, $backupTableName, $ref->getProperties());
} catch (\Exception $e) {
// 如果数据恢复失败,尝试回滚(这里只是简单的删除新表,重命名回旧表)
// 实际生产环境可能需要更复杂的恢复机制
// Db::connect($this->connection)->execute("DROP TABLE IF EXISTS `$fullTableName`");
// Db::connect($this->connection)->execute("RENAME TABLE `$backupTableName` TO `$fullTableName`");
throw $e;
}
}
return $backupTableName;
}
protected function checkTableExists($tableName)
{
$tables = Db::connect($this->connection)->query("SHOW TABLES LIKE '$tableName'");
return !empty($tables);
}
protected function buildCreateTableSql($tableName, Table $tableAttr, ReflectionClass $ref)
{
$properties = $ref->getProperties();
$lines = [];
$primaryKeys = [];
foreach ($properties as $prop) {
$fieldAttrs = $prop->getAttributes(Field::class);
if (empty($fieldAttrs)) {
continue;
}
/** @var Field $field */
$field = $fieldAttrs[0]->newInstance();
$fieldName = $prop->getName();
$line = "`$fieldName` {$field->type}";
// Length/Precision
if ($field->length !== null) {
$line .= "({$field->length})";
} elseif ($field->precision > 0) {
$line .= "({$field->precision},{$field->scale})";
}
// Unsigned
if ($field->unsigned) {
$line .= " UNSIGNED";
}
// Nullable
if (!$field->nullable) {
$line .= " NOT NULL";
} else {
$line .= " DEFAULT NULL";
}
// AutoIncrement
if ($field->autoIncrement) {
$line .= " AUTO_INCREMENT";
}
// Default
if (!is_null($field->default)) {
$def = $field->default;
if (is_string($def)) {
$line .= " DEFAULT '$def'";
} elseif (is_bool($def)) {
$line .= " DEFAULT " . ($def ? 1 : 0);
} else {
$line .= " DEFAULT $def";
}
}
// 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");
}
if ($comment) {
$line .= " COMMENT '$comment'";
}
$lines[] = $line;
if ($field->primary) {
$primaryKeys[] = "`$fieldName`";
}
}
if (!empty($primaryKeys)) {
$lines[] = "PRIMARY KEY (" . implode(',', $primaryKeys) . ")";
}
// 处理 Index 注解
$indexAttrs = $ref->getAttributes(Index::class);
foreach ($indexAttrs as $attr) {
/** @var Index $idx */
$idx = $attr->newInstance();
$cols = is_array($idx->columns) ? $idx->columns : [$idx->columns];
$colStr = "`" . implode("`,`", $cols) . "`";
$keyName = $idx->name ?: $cols[0]; // 默认使用第一列名
if ($idx->type === 'UNIQUE') {
$lines[] = "UNIQUE KEY `$keyName` ($colStr)";
} elseif ($idx->type === 'FULLTEXT') {
$lines[] = "FULLTEXT KEY `$keyName` ($colStr)";
} else {
$lines[] = "KEY `$keyName` ($colStr)";
}
}
$body = implode(",\n ", $lines);
$comment = $tableAttr->comment ? " COMMENT='{$tableAttr->comment}'" : "";
return "CREATE TABLE `$tableName` (\n $body\n) ENGINE={$tableAttr->engine} DEFAULT CHARSET={$tableAttr->charset}$comment";
}
protected function restoreData($newTable, $oldTable, array $properties)
{
$fields = [];
foreach ($properties as $prop) {
$fieldAttrs = $prop->getAttributes(Field::class);
if (!empty($fieldAttrs)) {
$fields[] = "`" . $prop->getName() . "`";
}
}
if (empty($fields)) return;
try {
$oldFieldsRaw = Db::connect($this->connection)->getFields($oldTable);
$oldFields = array_keys($oldFieldsRaw);
$commonFields = [];
foreach ($properties as $prop) {
$name = $prop->getName();
if (in_array($name, $oldFields)) {
$commonFields[] = "`$name`";
}
}
if (empty($commonFields)) return;
$commonStr = implode(',', $commonFields);
$sql = "INSERT INTO `$newTable` ($commonStr) SELECT $commonStr FROM `$oldTable`";
Db::connect($this->connection)->execute($sql);
} catch (\Exception $e) {
throw new \Exception("Data migration failed: " . $e->getMessage());
}
}
}