feat(scheme): 增强 Scheme 与数据库同步机制并添加严格校验

This commit is contained in:
augushong
2026-01-12 12:37:37 +08:00
parent 2f7ec93f89
commit ee40374732
5 changed files with 612 additions and 128 deletions

View File

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