From 88af651eb463b0d4802a98f93722d6d533019800 Mon Sep 17 00:00:00 2001
From: chen qiang <343005560@qq.com>
Date: Thu, 3 Sep 2026 13:41:59 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DBOS=E5=AE=9E=E9=99=85?=
=?UTF-8?q?=E8=A1=A8=E5=AD=97=E6=AE=B5=E8=BF=81=E7=A7=BB=E5=B9=B6=E5=8F=91?=
=?UTF-8?q?=E5=B8=831.0.20?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 10 +-
CHANGELOG.md | 7 +
henlo_migration_wpf10/README.md | 3 +-
henlo_migration_wpf10/Services/DbServices.cs | 70 ++++++++--
.../henlo_migration_wpf10.csproj | 2 +-
.../BosPhysicalColumnRegression.csproj | 12 ++
tests/BosPhysicalColumnRegression/Program.cs | 120 ++++++++++++++++++
7 files changed, 207 insertions(+), 17 deletions(-)
create mode 100644 tests/BosPhysicalColumnRegression/BosPhysicalColumnRegression.csproj
create mode 100644 tests/BosPhysicalColumnRegression/Program.cs
diff --git a/AGENTS.md b/AGENTS.md
index 757defe..1b7200e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -91,7 +91,7 @@
单字段 SQL 写入目标库时,限定值组按名称、限定值按组和值判断,只补充缺失项;引用元数据按引用表名和字段 `DBNAME` 映射,目标引用字段不存在或不唯一时直接报错,不自动创建。
-单字段读取还会查询源库 `USER_TABLES`、`USER_TAB_COLUMNS`。源对象是物理表且源字段存在时,输出文件首先检查目标库物理表和字段:目标表不存在时报错,目标字段不存在时按源字段的数据类型、长度、精度和字符语义执行 `ALTER TABLE ... ADD`。源对象是视图或 BOS 计算字段时不生成物理字段 DDL。
+BOS 单字段读取先按 `AD_TABLE.NAME` 定位 BOS 表;`REALTABLE_ID` 非空时沿 `REALTABLE_ID -> AD_TABLE.ID` 解析最终实际表名称,支持多级映射,映射记录缺失、不唯一或循环引用时报错。BOS 配置仍按原 BOS 表名导出,物理字段按最终实际表名读取和写入。同名表或映射后的实际表必须命中 `USER_TABLES`,并排除 `USER_VIEWS`、`USER_MVIEWS`(物化视图也可能出现在 `USER_TABLES` 中)。源物理字段存在时,输出文件先检查目标同名普通物理表和字段:目标表不存在或为视图/物化视图时报错,目标字段不存在时按源字段类型、长度、精度和字符语义执行 `ALTER TABLE ... ADD`,已存在则跳过物理 DDL。源对象是视图、物化视图或源物理字段不存在时仅导出 BOS 配置。独立“表字段”页签不解析 BOS 映射。
写入流程:索引项 -> 读取对应 `data/*_{后缀}.txt` -> `DbServices.ExecuteCreate` -> 在目标库执行 -> 更新写入状态。批量写入遇到第一条失败即停止,并在内存中记录断点;断点不会跨进程持久化。
@@ -147,7 +147,13 @@ dotnet build .\henlo_migration_wpf10\henlo_migration_wpf10.csproj -c Debug
dotnet build .\henlo_migration_wpf10\henlo_migration_wpf10.csproj -c Debug --no-restore
```
-当前没有测试项目。最低验证要求:
+离线 BOS 实际表/字段回归(使用模拟元数据,不启动应用、不连接数据库):
+
+```powershell
+dotnet run --project .\tests\BosPhysicalColumnRegression\BosPhysicalColumnRegression.csproj
+```
+
+最低验证要求:
1. 所有 C#/XAML 修改至少执行一次主项目构建。
2. 仅文档修改可不构建,但要重新读取文件检查 Markdown、路径和命令。
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f97708d..c7952e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# 更新记录
+## 1.0.20 - 2026-09-03
+
+### 修复
+
+- BOS 字段迁移按 `REALTABLE_ID` 解析实际表,目标实际字段已存在则跳过物理 DDL,不存在才新增;BOS 配置仍迁移到原 BOS 表。
+- 普通视图、物化视图不再生成物理字段 DDL;增加映射缺失、重复和循环引用检查及离线回归用例。
+
## 1.0.19 - 2026-08-26
### 新增
diff --git a/henlo_migration_wpf10/README.md b/henlo_migration_wpf10/README.md
index 3430367..5454393 100644
--- a/henlo_migration_wpf10/README.md
+++ b/henlo_migration_wpf10/README.md
@@ -116,7 +116,8 @@ BOS 单字段配置生成
`pre/hmig_get_table_bos_field_31.prc` 自动安装后再读取。
单字段配置写入时,目标库缺少的限定值组和限定值会自动补充;引用字段只做存在性和唯一性检查,不存在或不唯一时停止并报错。
-若源对象是物理表且存在同名物理字段,生成的 SQL 还会检查目标表字段,并在字段缺失时按源字段类型执行 `ALTER TABLE ... ADD`;目标物理表不存在时直接报错。视图字段和 BOS 计算字段不会创建物理字段。
+BOS 单字段读取会先解析 `AD_TABLE.REALTABLE_ID`:未设置时使用原 BOS 表名,已设置时沿映射找到最终实际表名(支持多级映射,缺失、不唯一或循环引用时报错)。物理字段读取和目标库写入均使用实际表名,BOS 配置仍按原 BOS 表名迁移。
+只有实际对象是普通物理表且源字段存在时,才生成 `ALTER TABLE ... ADD`:写入前检查目标实际表字段,已存在则跳过物理 DDL,不存在才新增;目标实际表不存在或为视图/物化视图时报错。源普通视图、物化视图和没有物理字段的 BOS 计算字段仅导出 BOS 配置,不生成物理 DDL。独立“表字段”页签不受此 BOS 映射规则影响。
### 3. 基本操作流程
diff --git a/henlo_migration_wpf10/Services/DbServices.cs b/henlo_migration_wpf10/Services/DbServices.cs
index 95a85de..82fc34d 100644
--- a/henlo_migration_wpf10/Services/DbServices.cs
+++ b/henlo_migration_wpf10/Services/DbServices.cs
@@ -368,10 +368,10 @@ namespace henlo_migration.Services
else
bosSql = output.Value.ToString() ?? "";
- // 旧版过程输出中的说明只针对其自身生成的 BOS 块;最终文件已在开头加入物理字段检查。
+ // 文件开头会说明物理字段的实际表来源;视图和物化视图不生成物理字段 DDL。
bosSql = bosSql.Replace(
"-- 注意:本文件不包含 Oracle 物理字段 DDL。",
- "-- BOS 字段配置块;物理字段检查位于本文件开头。",
+ "-- BOS 字段配置块;是否包含物理字段 DDL 请参见本文件开头说明。",
StringComparison.Ordinal);
return physicalColumnSql + bosSql;
}
@@ -382,26 +382,67 @@ namespace henlo_migration.Services
}
}
- /// 生成目标库物理字段存在性检查;源对象不是物理表或字段时只输出说明
+ /// 解析 BOS 实际表,仅为普通物理表生成字段检查,排除视图和物化视图
private static string BuildPhysicalColumnEnsureSql(string tableName, string columnName)
{
var db = SqlSugarHelper.DbOrig;
- int sourceTableCount = db.Ado.GetInt(
- "SELECT COUNT(*) FROM USER_TABLES WHERE TABLE_NAME = :table_name",
- new SugarParameter("table_name", tableName));
- if (sourceTableCount == 0)
- return $"-- 源库对象 {tableName} 不是物理表,不生成物理字段 DDL。\n";
+ return BuildPhysicalColumnEnsureSql(tableName, columnName,
+ (sql, parameters) => db.Ado.GetDataTable(sql, parameters));
+ }
- var columnResult = db.Ado.GetDataTable(@"
+ // 查询委托便于离线验证分类分支,避免回归测试连接真实 Oracle。
+ private static string BuildPhysicalColumnEnsureSql(string tableName, string columnName,
+ Func query)
+ {
+ var bosTables = query(
+ "SELECT ID, NAME, REALTABLE_ID FROM AD_TABLE WHERE UPPER(NAME) = :table_name",
+ new[] { new SugarParameter("table_name", tableName) });
+ if (bosTables.Rows.Count == 0)
+ throw new InvalidOperationException($"BOS表[{tableName}]不存在");
+ if (bosTables.Rows.Count != 1)
+ throw new InvalidOperationException($"BOS表[{tableName}]存在多条AD_TABLE配置");
+ string bosTableName = tableName;
+ var visited = new HashSet();
+ DataRow bosTable = bosTables.Rows[0];
+ while (true)
+ {
+ if (!visited.Add(Convert.ToDecimal(bosTable["ID"])))
+ throw new InvalidOperationException($"BOS表[{bosTableName}]的 REALTABLE_ID 存在循环引用。");
+ tableName = bosTable["NAME"]?.ToString()?.Trim().ToUpperInvariant() ?? "";
+ if (string.IsNullOrWhiteSpace(tableName))
+ throw new InvalidOperationException($"BOS表[{bosTableName}]的实际表名称为空。");
+ if (bosTable["REALTABLE_ID"] == DBNull.Value)
+ break;
+
+ var realTables = query(
+ "SELECT ID, NAME, REALTABLE_ID FROM AD_TABLE WHERE ID = :table_id",
+ new[] { new SugarParameter("table_id", bosTable["REALTABLE_ID"]) });
+ if (realTables.Rows.Count != 1)
+ throw new InvalidOperationException($"BOS表[{bosTableName}]的 REALTABLE_ID 指向的表配置不存在或不唯一。");
+ bosTable = realTables.Rows[0];
+ }
+ string sourceNote = $"-- BOS表 {bosTableName} 的物理字段来源:{tableName}.{columnName}\n";
+
+ var sourceTables = query(@"
+ SELECT COUNT(*) FROM USER_TABLES t
+ WHERE t.TABLE_NAME = :table_name
+ AND NOT EXISTS (SELECT 1 FROM USER_VIEWS v WHERE v.VIEW_NAME = t.TABLE_NAME)
+ AND NOT EXISTS (SELECT 1 FROM USER_MVIEWS m WHERE m.MVIEW_NAME = t.TABLE_NAME)",
+ new[] { new SugarParameter("table_name", tableName) });
+ int sourceTableCount = Convert.ToInt32(sourceTables.Rows[0][0]);
+ if (sourceTableCount == 0)
+ return sourceNote + $"-- 源库对象 {tableName} 不是普通物理表(可能为视图或物化视图),仅导出 BOS 字段配置。\n";
+
+ var columnResult = query(@"
SELECT DATA_TYPE, DATA_LENGTH, DATA_PRECISION, DATA_SCALE,
CHAR_LENGTH, CHAR_USED
FROM USER_TAB_COLUMNS
WHERE TABLE_NAME = :table_name
AND COLUMN_NAME = :column_name",
- new SugarParameter("table_name", tableName),
- new SugarParameter("column_name", columnName));
+ new[] { new SugarParameter("table_name", tableName),
+ new SugarParameter("column_name", columnName) });
if (columnResult.Rows.Count == 0)
- return $"-- 源库物理表 {tableName} 不存在字段 {columnName},仅导出 BOS 字段配置。\n";
+ return sourceNote + $"-- 源库物理表 {tableName} 不存在字段 {columnName},仅导出 BOS 字段配置。\n";
string columnType = BuildOracleColumnType(columnResult.Rows[0]);
string quotedTable = QuoteOracleIdentifier(tableName);
@@ -409,12 +450,15 @@ namespace henlo_migration.Services
string alterSql = $"ALTER TABLE {quotedTable} ADD ({quotedColumn} {columnType})";
var sql = new StringBuilder();
+ sql.Append(sourceNote);
sql.AppendLine($"-- 确保目标库物理字段存在:{tableName}.{columnName}");
sql.AppendLine("DECLARE");
sql.AppendLine(" v_table_count NUMBER;");
sql.AppendLine(" v_column_count NUMBER;");
sql.AppendLine("BEGIN");
- sql.AppendLine($" SELECT COUNT(*) INTO v_table_count FROM user_tables WHERE table_name = '{EscapeQuote(tableName)}';");
+ sql.AppendLine($" SELECT COUNT(*) INTO v_table_count FROM user_tables t WHERE t.table_name = '{EscapeQuote(tableName)}'");
+ sql.AppendLine(" AND NOT EXISTS (SELECT 1 FROM user_views v WHERE v.view_name = t.table_name)");
+ sql.AppendLine(" AND NOT EXISTS (SELECT 1 FROM user_mviews m WHERE m.mview_name = t.table_name);");
sql.AppendLine(" IF v_table_count = 0 THEN");
sql.AppendLine($" RAISE_APPLICATION_ERROR(-20201, '目标库缺少物理表:{EscapeQuote(tableName)}');");
sql.AppendLine(" END IF;");
diff --git a/henlo_migration_wpf10/henlo_migration_wpf10.csproj b/henlo_migration_wpf10/henlo_migration_wpf10.csproj
index ec27429..2e37c75 100644
--- a/henlo_migration_wpf10/henlo_migration_wpf10.csproj
+++ b/henlo_migration_wpf10/henlo_migration_wpf10.csproj
@@ -8,7 +8,7 @@
disable
Resources\favicon.ico
true
- 1.0.19
+ 1.0.20
diff --git a/tests/BosPhysicalColumnRegression/BosPhysicalColumnRegression.csproj b/tests/BosPhysicalColumnRegression/BosPhysicalColumnRegression.csproj
new file mode 100644
index 0000000..9959fd1
--- /dev/null
+++ b/tests/BosPhysicalColumnRegression/BosPhysicalColumnRegression.csproj
@@ -0,0 +1,12 @@
+
+
+ Exe
+ net10.0-windows
+ true
+ enable
+ enable
+
+
+
+
+
diff --git a/tests/BosPhysicalColumnRegression/Program.cs b/tests/BosPhysicalColumnRegression/Program.cs
new file mode 100644
index 0000000..cc5fd64
--- /dev/null
+++ b/tests/BosPhysicalColumnRegression/Program.cs
@@ -0,0 +1,120 @@
+using System.Data;
+using System.Reflection;
+using henlo_migration.Services;
+using SqlSugar;
+
+// Only the injected metadata reader is called. No app startup, configuration or Oracle connection.
+var builder = typeof(DbServices).GetMethod("BuildPhysicalColumnEnsureSql",
+ BindingFlags.NonPublic | BindingFlags.Static, null,
+ [typeof(string), typeof(string), typeof(Func)], null)!;
+var splitter = typeof(DbServices).GetMethod("SplitStatements", BindingFlags.NonPublic | BindingFlags.Static)!;
+var passed = 0;
+
+void Check(bool condition, string message)
+{
+ if (!condition) throw new Exception(message);
+}
+
+void Run(string label, TableInfo[] tables, string kind, string? expectedTable,
+ bool columnExists = true, string? expectedError = null)
+{
+ var columnReads = 0;
+ string? queriedTable = null;
+ DataTable Read(string sql, SugarParameter[] parameters)
+ {
+ var result = new DataTable();
+ if (sql.Contains("FROM AD_TABLE"))
+ {
+ result.Columns.Add("ID", typeof(decimal));
+ result.Columns.Add("NAME", typeof(string));
+ result.Columns.Add("REALTABLE_ID", typeof(decimal));
+ var matches = sql.Contains("UPPER(NAME)")
+ ? tables.Where(t => t.Name.Equals(parameters[0].Value.ToString(), StringComparison.OrdinalIgnoreCase))
+ : tables.Where(t => t.Id == Convert.ToDecimal(parameters[0].Value));
+ foreach (var t in matches)
+ result.Rows.Add(t.Id, t.Name, t.RealId.HasValue ? (object)t.RealId.Value : DBNull.Value);
+ }
+ else if (sql.Contains("FROM USER_TABLES"))
+ {
+ Check(sql.Contains("NOT EXISTS (SELECT 1 FROM USER_VIEWS"), "源库未排除视图");
+ Check(sql.Contains("NOT EXISTS (SELECT 1 FROM USER_MVIEWS"), "源库未排除物化视图");
+ queriedTable = parameters[0].Value.ToString();
+ result.Columns.Add("COUNT", typeof(int));
+ // Simulate the filtered dictionary result, including MVIEW's backing USER_TABLES row.
+ result.Rows.Add(kind == "TABLE" ? 1 : 0);
+ }
+ else if (sql.Contains("FROM USER_TAB_COLUMNS"))
+ {
+ columnReads++;
+ Check(parameters[0].Value.ToString() == queriedTable, "字段查询未使用实际表名");
+ Check(parameters[1].Value.ToString() == "TEST_COL", "字段名称改变");
+ foreach (var name in new[] { "DATA_TYPE", "DATA_LENGTH", "DATA_PRECISION", "DATA_SCALE", "CHAR_LENGTH", "CHAR_USED" })
+ result.Columns.Add(name, typeof(object));
+ if (columnExists) result.Rows.Add("VARCHAR2", 120, DBNull.Value, DBNull.Value, 30, "C");
+ }
+ else throw new Exception("意外查询:" + sql);
+ return result;
+ }
+
+ try
+ {
+ var sql = (string)builder.Invoke(null,
+ ["BOS_TEST", "TEST_COL", (Func)Read])!;
+ Check(expectedError == null, label + " 应报错");
+ if (expectedTable != null)
+ {
+ Check(queriedTable == expectedTable, label + " 实际表解析错误");
+ Check(columnReads == 1, label + " 未读取实际字段");
+ Check(sql.Contains($"ALTER TABLE \"{expectedTable}\" ADD (\"TEST_COL\" VARCHAR2(30 CHAR))"), label + " DDL不正确");
+ Check(sql.Contains("NOT EXISTS (SELECT 1 FROM user_mviews"), "目标库未排除物化视图");
+ Check(sql.Contains($"table_name = '{expectedTable}' AND column_name = 'TEST_COL'"), "目标字段检查错误");
+ Check(sql.IndexOf("IF v_column_count = 0 THEN", StringComparison.Ordinal)
+ < sql.IndexOf("EXECUTE IMMEDIATE", StringComparison.Ordinal), "已有字段没有跳过保护");
+ Check(sql.Contains("IF v_table_count = 0 THEN") && sql.Contains("RAISE_APPLICATION_ERROR"), "目标表缺失未报错");
+ var blocks = (string[])splitter.Invoke(null, [sql + "BEGIN NULL; END;\n/\n"])!;
+ Check(blocks.Length == 2 && blocks[0].EndsWith("END;") && blocks[1] == "BEGIN NULL; END;", "PL/SQL分隔错误");
+ }
+ else
+ {
+ Check(!sql.Contains("ALTER TABLE") && !sql.Contains("EXECUTE IMMEDIATE"), label + " 不应生成物理DDL");
+ Check(columnReads == (kind == "TABLE" ? 1 : 0), label + " 发生非预期字段读取");
+ }
+ }
+ catch (TargetInvocationException ex) when (expectedError != null)
+ {
+ Check(ex.InnerException is InvalidOperationException
+ && ex.InnerException.Message.Contains(expectedError), label + " 异常不正确:" + ex.InnerException?.Message);
+ Check(columnReads == 0, "无效映射仍读取字段");
+ }
+ Console.WriteLine("PASS " + label);
+ passed++;
+}
+
+Run("同名普通表", [new(1, "BOS_TEST", null)], "TABLE", "BOS_TEST");
+Run("REALTABLE_ID映射", [new(1, "BOS_TEST", 2), new(2, "REAL_TEST", null)], "TABLE", "REAL_TEST");
+Run("多级映射", [new(1, "BOS_TEST", 2), new(2, "MID_TEST", 3), new(3, "REAL_TEST", null)], "TABLE", "REAL_TEST");
+Run("普通视图", [new(1, "BOS_TEST", null)], "VIEW", null);
+Run("物化视图及其底层表", [new(1, "BOS_TEST", null)], "MVIEW", null);
+Run("映射到视图", [new(1, "BOS_TEST", 2), new(2, "REAL_TEST", null)], "VIEW", null);
+Run("映射到物化视图", [new(1, "BOS_TEST", 2), new(2, "REAL_TEST", null)], "MVIEW", null);
+Run("源物理对象不存在", [new(1, "BOS_TEST", null)], "MISSING", null);
+Run("BOS计算字段", [new(1, "BOS_TEST", null)], "TABLE", null, columnExists: false);
+Run("映射实际表缺少字段", [new(1, "BOS_TEST", 2), new(2, "REAL_TEST", null)], "TABLE", null, columnExists: false);
+Run("映射记录缺失", [new(1, "BOS_TEST", 2)], "TABLE", null, expectedError: "不存在或不唯一");
+Run("映射循环", [new(1, "BOS_TEST", 2), new(2, "REAL_TEST", 1)], "TABLE", null, expectedError: "循环引用");
+Run("自引用", [new(1, "BOS_TEST", 1)], "TABLE", null, expectedError: "循环引用");
+Run("BOS表不存在", [], "TABLE", null, expectedError: "不存在");
+Run("BOS表名重复", [new(1, "BOS_TEST", null), new(2, "BOS_TEST", null)], "TABLE", null, expectedError: "多条");
+Run("映射ID不唯一", [new(1, "BOS_TEST", 2), new(2, "REAL_A", null), new(2, "REAL_B", null)], "TABLE", null, expectedError: "不存在或不唯一");
+Run("实际表名称为空", [new(1, "BOS_TEST", 2), new(2, "", null)], "TABLE", null, expectedError: "名称为空");
+Run("实际表名称规范化", [new(1, "BOS_TEST", 2), new(2, "real_test", null)], "TABLE", "REAL_TEST");
+var representativeSql = "CREATE TABLE TEST_T (ID NUMBER);\n/\n"
+ + "COMMENT ON COLUMN TEST_T.ID IS '编号';\n/\n"
+ + "ALTER TABLE TEST_T ADD CONSTRAINT TEST_PK PRIMARY KEY (ID);\n/\n"
+ + "BEGIN NULL; END;\n/\nINSERT INTO TEST_T (ID) VALUES (1);\n/\n";
+var statements = (string[])splitter.Invoke(null, [representativeSql])!;
+Check(statements.Length == 5 && statements[3] == "BEGIN NULL; END;", "代表性DDL/注释/约束/PLSQL/INSERT分隔回归失败");
+Console.WriteLine("PASS DDL/注释/约束/PLSQL/INSERT分隔");
+Console.WriteLine($"通过 {passed} 个离线回归场景(未连接数据库)。");
+
+record TableInfo(decimal Id, string Name, decimal? RealId);