feat: add BOS single-field migration

This commit is contained in:
chen qiang
2026-08-26 08:45:04 +08:00
parent 227f92c45b
commit a1a0f58ae1
14 changed files with 976 additions and 83 deletions
+22 -16
View File
@@ -40,8 +40,8 @@
### 启动与界面
- `App.xaml`:应用资源、HandyControl 主题和 `MainViewModel` 资源注册;启动页是 `Views/HomeWindow.xaml`。
- `App.xaml.cs`:Velopack 初始化、后台/手动更新、更新前后 `data/` 与 `pre/` 备份恢复、全局异常日志。
- `Views/HomeWindow.xaml`:单窗口主界面,包含四个迁移 Tab、连接状态、SQL 编辑区和批量操作。
- `App.xaml.cs`:Velopack 初始化、后台/手动更新、更新前后 `config.ini`、`data/` 与 `pre/` 备份恢复、全局异常日志。
- `Views/HomeWindow.xaml`:单窗口主界面,包含五个迁移 Tab、连接状态、SQL 编辑区和批量操作。
- `Views/HomeWindow.xaml.cs`:只处理窗口关闭和手动检查更新等窗口事件。
- `Views/ConnectionDialog.*`:源库/目标库连接配置与测试。
- `Views/ErrorDialog.*`:错误详情展示和复制。
@@ -49,7 +49,7 @@
### 状态与编排
- `ViewModels/MainViewModel.cs`:主状态容器和命令编排;初始化四个 `TabServices`,管理数据库连接、单行/批量读写、SQL 编辑与断点续写。
- `ViewModels/MainViewModel.cs`:主状态容器和命令编排;初始化五个 `TabServices`,管理数据库连接、单行/批量读写、SQL 编辑与断点续写。
- `Models/TableRowModel.cs`:每个迁移对象的名称、所属 Tab、读写状态。
- `Enums/`:Tab、状态、工作类型和输出扩展名枚举。
@@ -60,34 +60,41 @@
- `Services/DbServices.cs`:Oracle SQL 的提取与执行核心:
- `GetTableSql`:读取表 DDL、注释、主外键、唯一键和索引;
- `GetTableBos`:读取 BOS 配置;
- `GetTableBosField`:按 `TABLE.COLUMN` 读取单个 BOS 字段配置,生成目标物理字段补充 SQL,并包含关联限定值/引用元数据;
- `GetPrcSql` / `GetAllPrc`:读取存储过程或函数源码;
- `GetTableData`:生成表数据 INSERT SQL;
- `ExecuteCreate`:按 `\n/` 分隔 SQL,在目标库逐条执行并检查 `USER_ERRORS`;
- `PreProcess`:在源库执行 `pre/` 下全部脚本。
- `ExecuteCreate`:按 `\n/` 分隔 SQL,在目标库逐条执行并检查 `USER_ERRORS`。
- `Common/SqlSugarHelper.cs`:维护 `orig`(源库)和 `dest`(目标库)两个 SqlSugar 连接。
- `Common/SysConfig.cs`:读取和写入运行目录下的 `config.ini`。
- `Common/CommonHelper.cs`:运行目录、`data/`、`data/from/`、`pre/` 路径及文本读写。
## 真实数据流
四个 Tab 的映射由 `MainViewModel` 构造函数定义:
五个 Tab 的映射由 `MainViewModel` 构造函数定义:
| Tab | 索引文件 | 内容文件 | 源库读取方法 |
| --- | --- | --- | --- |
| 表配置 | `data/from/table.txt` | `data/{对象名}_creat.txt` | `DbServices.GetTableSql` |
| BOS 配置 | `data/from/bos.txt` | `data/{对象名}_bos.txt` | `DbServices.GetTableBos` |
| BOS 字段配置 | `data/from/bos_field.txt` | `data/{表名}.{字段名}_bos_field.txt` | `DbServices.GetTableBosField` |
| 存储过程 | `data/from/prc.txt` | `data/{对象名}_prc.txt` | `DbServices.GetPrcSql` |
| 表数据 | `data/from/data.txt` | `data/{对象名}_data.txt` | `DbServices.GetTableData` |
读取流程:索引项 -> 调用源库读取委托 -> 生成 SQL 文本 -> 保存到 `data/` -> 更新读取状态并显示内容。
读取 BOS 配置前会检查源库当前用户下的辅助过程。整表读取使用 `HMIG_GET_TABLE_BOS_31`,单字段读取使用 `HMIG_GET_TABLE_BOS_FIELD_31`;若过程不存在,程序从运行目录的同名 `pre/*.prc` 脚本安装,检查 `USER_ERRORS` 和对象状态后再读取。单字段列表项必须采用 `TABLE.COLUMN` 格式。界面不提供手动“预处理”入口。
单字段 SQL 写入目标库时,限定值组按名称、限定值按组和值判断,只补充缺失项;引用元数据按引用表名和字段 `DBNAME` 映射,目标引用字段不存在或不唯一时直接报错,不自动创建。
单字段读取还会查询源库 `USER_TABLES`、`USER_TAB_COLUMNS`。源对象是物理表且源字段存在时,输出文件首先检查目标库物理表和字段:目标表不存在时报错,目标字段不存在时按源字段的数据类型、长度、精度和字符语义执行 `ALTER TABLE ... ADD`。源对象是视图或 BOS 计算字段时不生成物理字段 DDL。
写入流程:索引项 -> 读取对应 `data/*_{后缀}.txt` -> `DbServices.ExecuteCreate` -> 在目标库执行 -> 更新写入状态。批量写入遇到第一条失败即停止,并在内存中记录断点;断点不会跨进程持久化。
删除列表项会同时删除对应内容文件。调整顺序会立即覆盖索引文件。新增对象名会被转为小写,数据库读取时再按需要转大写。
## 配置和运行时文件
- `config.ini` 保存源库和目标库的 IP/服务名、用户名、密码。当前实现是明文读写,不要把注释或 UI 文案误当成加密能力。
- `config.ini` 保存源库和目标库的 IP/服务名、用户名、密码。当前实现是明文读写,首次运行缺失时创建空配置;发布包不得携带本机配置。
- 不要在日志、回答、补丁或测试输出中展示真实连接串和密码。
- 修改或发布时不要用样例配置覆盖用户现有 `config.ini`。
- `data/` 和 `pre/` 是用户可编辑、更新时需要保留的运行数据;不要无故清空、批量改名或删除。
@@ -113,11 +120,11 @@
源库和目标库不是等价环境:
- `Get*` 方法通常查询源库;`ExecuteCreate` 明确写目标库。
- `PreProcess` 会在源库执行 `pre/` 下的 SQL/PLSQL,虽然名为预处理,但不是只读操作。
- 首次读取整表或单字段 BOS 配置时可能在源库自动创建对应的 `HMIG_GET_TABLE_BOS_31` 或 `HMIG_GET_TABLE_BOS_FIELD_31`;这不是只读操作,源库账号需要相应的 `CREATE PROCEDURE` 权限。
- 连接按钮会把界面中的连接信息写回 `config.ini`,并重建同时包含源库和目标库的 SqlSugarScope。
- 界面的“断开”当前只更新连接状态,并未释放或关闭底层连接对象。
除非用户明确要求并确认数据库、对象和影响范围,否则不要连接真实数据库、执行预处理、执行写入、清理数据或测试破坏性 SQL。需要验证数据库写入逻辑时优先使用代码审查、可控测试库或事务化的最小复现。真实目标库写入前必须提醒备份并确认权限。
除非用户明确要求并确认数据库、对象和影响范围,否则不要连接真实数据库、触发 BOS 辅助过程安装、执行目标库写入、清理数据或测试破坏性 SQL。需要验证数据库写入逻辑时优先使用代码审查、可控测试库或事务化的最小复现。真实目标库写入前必须提醒备份并确认权限。
## 构建与验证
@@ -138,7 +145,7 @@ dotnet build .\henlo_migration_wpf10\henlo_migration_wpf10.csproj -c Debug --no-
1. 所有 C#/XAML 修改至少执行一次主项目构建。
2. 仅文档修改可不构建,但要重新读取文件检查 Markdown、路径和命令。
3. XAML/绑定修改除构建外,应在 Windows 上启动应用,检查四个 Tab、连接弹窗、按钮可用性和错误弹窗;不要因此连接生产库。
3. XAML/绑定修改除构建外,应在 Windows 上启动应用,检查五个 Tab、连接弹窗、按钮可用性和错误弹窗;不要因此连接生产库。
4. SQL 生成/拆分修改需用不含真实数据的代表性 SQL 验证表 DDL、注释、约束、PL/SQL 和 INSERT。
5. 更新逻辑修改需分别考虑普通 `dotnet run`(非 Velopack 安装)和 Velopack 安装态。
@@ -154,12 +161,11 @@ dotnet run --project .\henlo_migration_wpf10\henlo_migration_wpf10.csproj
## 版本与发布
- 版本号只修改 `.csproj` 中的 `<Version>`,必须单调递增;显示版本来自程序集元数据。
- 正式发布入口是 `henlo_migration_wpf10/pack.bat`。脚本会清理 `publish/`、执行 `dotnet publish -c Release --self-contained false -r win-x64`,再调用 `vpk pack --noInst` 输出到 `releases/`。
- `pack.bat` 依赖本机 `dotnet`、`vpk` 和 Windows 批处理环境;不要把“构建通过”等同于“打包通过”。
- 发布是显式任务。未经要求不要运行打包、删除发布目录、修改版本号或上传更新服务器。
- 打包后核对便携 ZIP、full/delta nupkg、`RELEASES`/JSON 元数据以及实际版本号。上传服务器属于外部写操作,必须另行获得明确授权。
- 若修改运行时必需文件,检查 `.csproj` 的 `CopyToOutputDirectory` 规则和 `App.xaml.cs` 的更新备份/恢复范围。
- 仓库根目录的 `RELEASE.md` 是当前发版流程、产物、上传和自动更新风险的权威说明。凡任务涉及版本号、Release 构建、Velopack、打包、便携包、自动更新、服务器部署或上传,必须先完整读取 `RELEASE.md`,再执行或给出结论。
- 正式版本的用户可见变更记录维护在仓库根目录 `CHANGELOG.md`,发版时必须新增对应版本条目。
- 版本号只修改 `.csproj` 中的 `<Version>`,必须单调递增;正式发布入口是 `henlo_migration_wpf10/pack.bat`。
- 发布是显式任务。未经要求不要运行打包、删除发布目录、修改版本号或上传更新服务器;构建通过不等于打包或自动更新验证通过。
- 若代码、脚本和 `RELEASE.md` 不一致,以实际代码和 `pack.bat` 为准,并在同一任务中同步修正文档。
## 完成任务时的交付说明
+22
View File
@@ -0,0 +1,22 @@
# 更新记录
## 1.0.17 - 2026-08-26
### 新增
- 新增“BOS字段配置”页签,使用 `TABLE.COLUMN` 单独迁移一个 BOS 字段配置。
- 单字段迁移包含限定值组及限定值:目标库不存在时自动补充,已有数据不覆盖。
- 单字段迁移支持引用元数据重新映射;目标引用字段不存在或不唯一时明确报错。
- 单字段写入前检查目标物理表字段;目标字段缺失时按源库字段类型、长度、精度和字符语义自动新增。
- 新增 `HMIG_GET_TABLE_BOS_FIELD_31` 辅助过程,并使用 Oracle CLOB 返回完整迁移 SQL。
### 调整
- 取消界面“预处理”按钮。读取整表或单字段 BOS 配置时,缺少对应辅助过程才从 `pre/` 自动安装并检查编译状态。
- 整理项目、构建和发版约定,新增仓库级 `AGENTS.md` 与 `RELEASE.md`。
- 发布包不再携带本机 `config.ini` 和 `data/from/*.txt`;首次运行创建空配置和运行目录,后续更新备份并恢复用户的 `config.ini`、`data/` 和 `pre/`。
### 兼容性
- 保留原有整表 BOS 配置、表结构、存储过程/函数和表数据迁移流程。
- 仍为 Windows x64、.NET 10 framework-dependent 便携发布,不包含安装器。
+142
View File
@@ -0,0 +1,142 @@
# 恒诺迁移工具构建与发版说明
本文是 `henlo_migration_wpf10/` 的构建、Velopack 打包、更新服务器部署和客户端自动更新说明。涉及版本、发布或更新的任务必须先完整读取本文;若本文与实际代码冲突,以 `.csproj`、`pack.bat` 和 `App.xaml.cs` 为准,并同步更新本文。
## 权限边界
- 普通 Debug/Release 构建可以用于本地验证。
- 修改版本号、运行 `pack.bat`、删除或覆盖发布产物、上传更新服务器都属于显式发布动作,必须有用户当前任务的明确授权。
- 打包脚本不会上传任何文件;服务器部署是独立的外部写操作,需要另行授权。
- 不要把“项目构建成功”描述成“发版成功”或“自动更新已验证”。
## 版本来源
- 唯一版本来源是 `henlo_migration_wpf10/henlo_migration_wpf10.csproj` 的 `<Version>`。
- 正式发版前必须把版本号单调递增,不要重新打包已存在的版本号。
- 程序显示版本来自程序集元数据,不在脚本或文档中另设版本常量。
## 日常构建
在仓库根目录执行:
```powershell
dotnet restore .\henlo_migration_wpf10\henlo_migration_wpf10.csproj
dotnet build .\henlo_migration_wpf10\henlo_migration_wpf10.csproj -c Debug
```
依赖已经恢复且不希望访问包源时:
```powershell
dotnet build .\henlo_migration_wpf10\henlo_migration_wpf10.csproj -c Debug --no-restore
```
Debug 产物位于 `henlo_migration_wpf10/bin/Debug/net10.0-windows/`,只用于开发验证,不是 Velopack 发布包。项目现有 `CS8632` 和 `NU1701` 警告不等于构建错误,但不得新增编译错误。
## 正式打包流程
唯一正式入口:
```powershell
cd .\henlo_migration_wpf10
.\pack.bat
```
`pack.bat` 当前执行顺序:
1. 从 `.csproj` 读取 `<Version>`。
2. 删除并重建 `publish/`。
3. 执行:
```powershell
dotnet publish henlo_migration_wpf10.csproj -c Release -o publish --self-contained false -r win-x64
```
4. 删除 `publish/config.ini`,确保本机数据库配置不会进入产物。
5. 确保 `publish/data/`、`publish/data/from/`、`publish/pre/` 存在。
6. 执行:
```powershell
vpk pack -u henlo_migration -v <Version> -p publish -e "恒诺迁移工具.exe" -o releases --noInst
```
7. 保留 `releases/` 中的历史包,供 Velopack 生成差量包;脚本只清理 `publish/`,不会清理 `releases/`。
打包依赖 Windows、对应 .NET SDK、可调用的 `dotnet` 和 `vpk`。不要使用 `_build*`、`_make_pack.bat`、`_write_pack.ps1` 等历史辅助文件作为正式入口。
## 当前发布形态
- 目标平台:Windows x64。
- 发布方式:framework-dependent,`--self-contained false`;目标机器需要匹配的 .NET 10 Desktop Runtime。
- Velopack 参数包含 `--noInst`,因此不生成 Setup 安装器,只生成便携包和更新源文件。
- `Properties/PublishProfiles/FolderProfile.pubxml` 虽配置 `PublishSingleFile=true`,但 `pack.bat` 没有使用该 Profile;正式产物不是单文件,会包含 EXE、DLL 和 runtimeconfig/deps 文件。
## 运行时文件进入发布包的规则
- `.csproj` 只复制 `pre/*.txt|*.fnc|*.prc` 等程序辅助脚本,不复制本机 `config.ini` 或 `data/from/*.txt`;`pack.bat` 还会在打包前再次删除 `publish/config.ini`,并创建空的 `data/from/` 目录。
- `pre/hmig_get_table_bos_31.prc`、`pre/hmig_get_table_bos_field_31.prc` 等运行时辅助过程必须出现在发布输出中。
- 新安装的索引列表为空;程序添加第一条对应配置时会在 `data/from/` 创建 `table.txt`、`bos.txt`、`bos_field.txt`、`prc.txt` 或 `data.txt`。
- 新安装首次运行时会创建值为空的 `config.ini`,由用户在界面配置;不得把真实或样例账号密码放入发布产物。
## Velopack 产物
正常情况下 `releases/` 至少包含:
```text
henlo_migration-win-Portable.zip
henlo_migration-<Version>-full.nupkg
henlo_migration-<Version>-delta.nupkg
RELEASES
releases.win.json
assets.win.json
```
便携 ZIP 内应包含 `.portable`、根启动器、`Update.exe`、`current/` 应用文件以及需要的 `data/`、`pre/` 内容。差量包是否生成取决于 `releases/` 中是否有可用的历史版本。
## 更新服务器部署
客户端更新地址硬编码在 `App.xaml.cs`:
```text
http://zjhenlo.henlo.net:8878/henlo_migration/auto_update/
```
当前没有自动上传脚本。获得上传授权后,应部署并核对:
- 新版本 full nupkg;
- 新版本 delta nupkg(若生成);
- `RELEASES`;
- `releases.win.json`;
- `assets.win.json`;
- 需要提供便携下载时再上传 `henlo_migration-win-Portable.zip`。
上传后至少验证服务器能直接访问元数据文件和其中引用的包,文件大小及版本号与本地产物一致。不要只上传 nupkg 而遗漏更新后的元数据。
## 客户端自动更新流程
`App.xaml.cs` 当前行为:
1. UI 启动前调用 `VelopackApp.Build().Run()`。
2. 从系统临时目录恢复上次更新前备份的 `config.ini`、`data/`、`pre/`。
3. 创建指向更新服务器的 `UpdateManager`;非 Velopack 环境创建失败时禁用自动更新。
4. 后台静默检查并下载更新;手动“检查更新”使用相同更新源。
5. 用户同意立即重启时,先备份 `config.ini`、`data/`、`pre/`,再调用 `ApplyUpdatesAndRestart()`。
6. 新版本首次启动时恢复备份并删除临时备份目录。
当前限制和未验证项:
- 更新备份范围包含 `config.ini`、`data/` 和 `pre/`;恢复失败时会记录到 `error.log`,发版验收仍需验证用户配置未丢失。
- 用户下载完成后选择“不立即重启”时,代码没有明确实现下次启动自动应用已下载更新的流程。
- 直接运行普通 `publish/` 中的 EXE 时通常不属于 Velopack 安装态,程序会禁用更新或把检查异常写入 `error.log`。
- `--noInst` 生成的是便携模式;便携包的检查、下载、应用和重启更新链路尚未完成实际端到端回归,不能声称自动更新可靠可用。
## 发版验收清单
1. 确认工作区改动和准备发布的提交范围,避免把无关或敏感文件带入。
2. 递增并核对 `.csproj` 版本号。
3. 先执行主项目构建,确保 `0` 个错误。
4. 获得明确授权后运行 `pack.bat`,确认 publish 和 vpk 两阶段都成功。
5. 检查便携 ZIP、full/delta 包、`RELEASES` 和两个 JSON 的版本、大小及时间。
6. 检查发布包中的 `pre/` 和必要运行文件,并确认产物中不存在 `config.ini` 及开发机 `data/from/*.txt`。
7. 在未安装态测试普通 EXE/便携包启动;若任务涉及自动更新,再在对应 Velopack 运行形态下单独验证更新。
8. 只有获得上传授权后才能部署服务器;上传后验证 URL 和包完整性。
9. 交付说明必须区分:构建验证、打包验证、便携运行验证、自动更新验证和服务器部署验证。
+10 -2
View File
@@ -164,8 +164,9 @@ namespace henlo_migration
var rootDir = AppDomain.CurrentDomain.BaseDirectory;
var dataDir = Path.Combine(rootDir, "data");
var preDir = Path.Combine(rootDir, "pre");
var configPath = Path.Combine(rootDir, "config.ini");
if (!Directory.Exists(dataDir) && !Directory.Exists(preDir)) return;
if (!Directory.Exists(dataDir) && !Directory.Exists(preDir) && !File.Exists(configPath)) return;
if (Directory.Exists(BackupDir))
Directory.Delete(BackupDir, true);
@@ -176,6 +177,9 @@ namespace henlo_migration
if (Directory.Exists(preDir))
CopyDirectory(preDir, Path.Combine(BackupDir, "pre"));
if (File.Exists(configPath))
File.Copy(configPath, Path.Combine(BackupDir, "config.ini"), true);
}
catch (Exception ex)
{
@@ -200,6 +204,10 @@ namespace henlo_migration
if (Directory.Exists(backupPre))
CopyDirectory(backupPre, Path.Combine(rootDir, "pre"));
var backupConfig = Path.Combine(BackupDir, "config.ini");
if (File.Exists(backupConfig))
File.Copy(backupConfig, Path.Combine(rootDir, "config.ini"), true);
Directory.Delete(BackupDir, true);
}
catch (Exception ex)
@@ -227,4 +235,4 @@ namespace henlo_migration
catch { }
}
}
}
}
+8 -8
View File
@@ -17,14 +17,14 @@ namespace henlo_migration.Common
if (!System.IO.File.Exists(ConfigPath))
{
System.IO.File.WriteAllText(ConfigPath, @"[oracle]
orig_ip=127.0.0.1
orig_exam=orcl
orig_user=system
orig_password=system
desc_ip=127.0.0.1
desc_exam=orcl
desc_user=system
desc_password=system
orig_ip=
orig_exam=
orig_user=
orig_password=
desc_ip=
desc_exam=
desc_user=
desc_password=
");
}
_init = parser.ReadFile(ConfigPath);
+1
View File
@@ -3,6 +3,7 @@ namespace henlo_migration.Enums
public enum CodeExt
{
bos,
bos_field,
creat,
data,
prc
+3 -2
View File
@@ -4,7 +4,8 @@ namespace henlo_migration.Enums
{
表 = 0,
BOS表配置 = 1,
存储过程 = 2,
表数据 = 3
BOS字段配置 = 2,
存储过程 = 3,
表数据 = 4
}
}
+10 -3
View File
@@ -13,6 +13,7 @@
| **表结构迁移** | 读取源库表结构(DDL)、表注释、字段注释、主键/外键/索引,写入目标库 |
| **存储过程迁移** | 读取/写入存储过程和函数源码 |
| **BOS 配置迁移** | 读取 BOS 表配置写入目标库 |
| **BOS 字段配置迁移** | 按 `TABLE.COLUMN` 补充目标物理字段并迁移字段配置、限定值和引用元数据 |
| **表数据迁移** | 支持表数据的导出/导入 |
| **自动更新** | 支持通过私有服务器 Velopack 自动检查更新 |
| **断点续写** | 批量写入失败后,下次从失败行继续,无需从头开始 |
@@ -57,7 +58,7 @@ henlo_migration_wpf10/
│ ├── HomeWindow.xaml # 主窗口
│ └── ConnectionDialog.xaml # 连接配置
├── data/from/ # 索引文件(table.txt 等)
├── pre/ # 预处理脚本
├── pre/ # 运行时辅助过程/函数脚本(BOS 读取缺失时自动安装)
├── pack.bat # Velopack 打包脚本
└── henlo_migration_wpf10.csproj
```
@@ -89,7 +90,7 @@ henlo_migration_wpf10/
- **源库**:读取数据的数据库
- **目标库**:写入数据的数据库
连接信息保存在 `config.ini` 中。
连接信息保存在运行目录的 `config.ini` 中。发布包不携带该文件,首次运行会创建空配置。
### 2. 索引文件
@@ -99,10 +100,16 @@ henlo_migration_wpf10/
|------|------|
| `table.txt` | 表结构列表 |
| `bos.txt` | BOS 配置列表 |
| `bos_field.txt` | BOS 单字段配置列表,每行使用 `TABLE.COLUMN` 格式 |
| `prc.txt` | 存储过程/函数列表 |
| `data.txt` | 表数据列表 |
每行一个对象名称,读取数据库时会自动更新内容。
每行一个对象名称,读取数据库时会自动更新内容。BOS 单字段配置生成
`data/{表名}.{字段名}_bos_field.txt`;源库缺少单字段读取过程时,程序会从
`pre/hmig_get_table_bos_field_31.prc` 自动安装后再读取。
单字段配置写入时,目标库缺少的限定值组和限定值会自动补充;引用字段只做存在性和唯一性检查,不存在或不唯一时停止并报错。
若源对象是物理表且存在同名物理字段,生成的 SQL 还会检查目标表字段,并在字段缺失时按源字段类型执行 `ALTER TABLE ... ADD`;目标物理表不存在时直接报错。视图字段和 BOS 计算字段不会创建物理字段。
### 3. 基本操作流程
+228 -14
View File
@@ -1,4 +1,6 @@
using SqlSugar;
using Oracle.ManagedDataAccess.Client;
using Oracle.ManagedDataAccess.Types;
using System;
using System.Collections.Generic;
using System.Data;
@@ -132,12 +134,77 @@ namespace henlo_migration.Services
private static string EscapeQuote(string s)
=> s?.Replace("'", "''") ?? "";
/// <summary>获取BOS表配置 - 调用 hmig_get_table_bos_31 存储过程获取 r_table,再查 tabletext</summary>
private const string BosReaderProcedureName = "HMIG_GET_TABLE_BOS_31";
private const string BosReaderProcedureFileName = "hmig_get_table_bos_31.prc";
private const string BosFieldReaderProcedureName = "HMIG_GET_TABLE_BOS_FIELD_31";
private const string BosFieldReaderProcedureFileName = "hmig_get_table_bos_field_31.prc";
/// <summary>确保源库存在且启用了指定辅助过程;缺失时从 pre 目录安装</summary>
private static void EnsureReaderProcedure(string procedureName, string procedureFileName)
{
var db = SqlSugarHelper.DbOrig;
var objectResult = db.Ado.GetDataTable(
"SELECT STATUS FROM USER_OBJECTS WHERE OBJECT_NAME = :name AND OBJECT_TYPE = 'PROCEDURE'",
new SugarParameter("name", procedureName));
if (objectResult.Rows.Count > 0)
{
if (string.Equals(objectResult.Rows[0]["STATUS"]?.ToString(), "VALID", StringComparison.OrdinalIgnoreCase))
return;
ThrowProcedureErrors(procedureName, $"源库过程 {procedureName} 状态无效");
throw new InvalidOperationException($"源库过程 {procedureName} 状态无效,请检查后重试。");
}
string scriptPath = Path.Combine(CommonHelper.PrePath, procedureFileName);
if (!File.Exists(scriptPath))
throw new FileNotFoundException($"缺少 BOS 配置读取过程脚本:{scriptPath}", scriptPath);
string script = File.ReadAllText(scriptPath).TrimEnd();
// 兼容带 SQL*Plus 结束符的脚本;ODP.NET 执行时不能包含末尾的独立斜杠。
if (script.EndsWith("/", StringComparison.Ordinal))
script = script.Substring(0, script.Length - 1).TrimEnd();
if (string.IsNullOrWhiteSpace(script))
throw new InvalidDataException($"BOS 配置读取过程脚本为空:{scriptPath}");
db.Ado.ExecuteCommand(script);
ThrowProcedureErrors(procedureName, $"自动安装 {procedureName} 后存在编译错误");
var installedResult = db.Ado.GetDataTable(
"SELECT STATUS FROM USER_OBJECTS WHERE OBJECT_NAME = :name AND OBJECT_TYPE = 'PROCEDURE'",
new SugarParameter("name", procedureName));
if (installedResult.Rows.Count == 0 ||
!string.Equals(installedResult.Rows[0]["STATUS"]?.ToString(), "VALID", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"自动安装 {procedureName} 失败,源库中未找到有效过程。");
}
}
private static void ThrowProcedureErrors(string procedureName, string title)
{
var errors = SqlSugarHelper.DbOrig.Ado.GetDataTable(
"SELECT LINE, POSITION, TEXT FROM USER_ERRORS WHERE NAME = :name AND TYPE = 'PROCEDURE' ORDER BY SEQUENCE",
new SugarParameter("name", procedureName));
if (errors.Rows.Count == 0)
return;
var message = new StringBuilder();
message.AppendLine(title + ":");
foreach (DataRow row in errors.Rows)
message.AppendLine($"Line {row["LINE"]}, Pos {row["POSITION"]}: {row["TEXT"]}");
throw new InvalidOperationException(message.ToString());
}
/// <summary>获取BOS表配置 - 确保过程存在后,调用过程获取 r_table,再查 tabletext</summary>
public static string GetTableBos(string tableName)
{
var db = SqlSugarHelper.DbOrig;
string t = tableName.Trim().ToUpperInvariant();
EnsureReaderProcedure(BosReaderProcedureName, BosReaderProcedureFileName);
// 1. 调用存储过程,获取 r_id 和 r_table
var pRId = new SugarParameter("r_id", null, true) { DbType = System.Data.DbType.Decimal };
var pRTable = new SugarParameter("r_table", null, true) { DbType = System.Data.DbType.String, Size = 200 };
@@ -162,6 +229,165 @@ namespace henlo_migration.Services
return textResult.Rows.Count > 0 ? textResult.Rows[0][colName]?.ToString() ?? "" : "";
}
/// <summary>解析 TABLE.COLUMN 格式的 BOS 字段名称</summary>
public static bool TryParseBosFieldName(string qualifiedFieldName, out string tableName, out string columnName)
{
tableName = "";
columnName = "";
if (string.IsNullOrWhiteSpace(qualifiedFieldName))
return false;
string value = qualifiedFieldName.Trim();
int separator = value.IndexOf('.');
if (separator <= 0 || separator != value.LastIndexOf('.') || separator >= value.Length - 1)
return false;
tableName = value.Substring(0, separator).Trim().ToUpperInvariant();
columnName = value.Substring(separator + 1).Trim().ToUpperInvariant();
return tableName.Length > 0 && columnName.Length > 0;
}
/// <summary>获取单个 BOS 字段配置,返回可在目标库执行的完整 PL/SQL</summary>
public static string GetTableBosField(string qualifiedFieldName)
{
if (!TryParseBosFieldName(qualifiedFieldName, out string tableName, out string columnName))
throw new ArgumentException("BOS 字段配置必须使用 TABLE.COLUMN 格式。", nameof(qualifiedFieldName));
string physicalColumnSql = BuildPhysicalColumnEnsureSql(tableName, columnName);
EnsureReaderProcedure(BosFieldReaderProcedureName, BosFieldReaderProcedureFileName);
var connection = SqlSugarHelper.DbOrig.Ado.Connection as OracleConnection
?? throw new InvalidOperationException("源数据库连接不是 OracleConnection,无法读取 CLOB 输出参数。");
bool closeAfterRead = connection.State != ConnectionState.Open;
try
{
if (closeAfterRead)
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"BEGIN hmig_get_table_bos_field_31(:p_table_name, :p_column_name, :r_sql); END;";
command.Parameters.Add(new OracleParameter("p_table_name", OracleDbType.Varchar2)
{
Direction = ParameterDirection.Input,
Value = tableName
});
command.Parameters.Add(new OracleParameter("p_column_name", OracleDbType.Varchar2)
{
Direction = ParameterDirection.Input,
Value = columnName
});
var output = new OracleParameter("r_sql", OracleDbType.Clob)
{
Direction = ParameterDirection.Output
};
command.Parameters.Add(output);
command.ExecuteNonQuery();
string bosSql;
if (output.Value == null || output.Value == DBNull.Value)
bosSql = "";
else if (output.Value is OracleClob clob)
bosSql = clob.IsNull ? "" : clob.Value;
else
bosSql = output.Value.ToString() ?? "";
// 旧版过程输出中的说明只针对其自身生成的 BOS 块;最终文件已在开头加入物理字段检查。
bosSql = bosSql.Replace(
"-- 注意:本文件不包含 Oracle 物理字段 DDL。",
"-- BOS 字段配置块;物理字段检查位于本文件开头。",
StringComparison.Ordinal);
return physicalColumnSql + bosSql;
}
finally
{
if (closeAfterRead && connection.State != ConnectionState.Closed)
connection.Close();
}
}
/// <summary>生成目标库物理字段存在性检查;源对象不是物理表或字段时只输出说明</summary>
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";
var columnResult = db.Ado.GetDataTable(@"
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));
if (columnResult.Rows.Count == 0)
return $"-- 源库物理表 {tableName} 不存在字段 {columnName},仅导出 BOS 字段配置。\n";
string columnType = BuildOracleColumnType(columnResult.Rows[0]);
string quotedTable = QuoteOracleIdentifier(tableName);
string quotedColumn = QuoteOracleIdentifier(columnName);
string alterSql = $"ALTER TABLE {quotedTable} ADD ({quotedColumn} {columnType})";
var sql = new StringBuilder();
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(" IF v_table_count = 0 THEN");
sql.AppendLine($" RAISE_APPLICATION_ERROR(-20201, '目标库缺少物理表:{EscapeQuote(tableName)}');");
sql.AppendLine(" END IF;");
sql.AppendLine($" SELECT COUNT(*) INTO v_column_count FROM user_tab_columns WHERE table_name = '{EscapeQuote(tableName)}' AND column_name = '{EscapeQuote(columnName)}';");
sql.AppendLine(" IF v_column_count = 0 THEN");
sql.AppendLine($" EXECUTE IMMEDIATE '{EscapeQuote(alterSql)}';");
sql.AppendLine(" END IF;");
sql.AppendLine("END;");
sql.AppendLine("/");
return sql.ToString();
}
private static string BuildOracleColumnType(DataRow row)
{
string dataType = row["DATA_TYPE"]?.ToString()?.ToUpperInvariant() ?? "";
if (string.IsNullOrWhiteSpace(dataType))
throw new InvalidDataException("无法读取源库物理字段的数据类型。");
long dataLength = Convert.ToInt64(row["DATA_LENGTH"]);
long? precision = row["DATA_PRECISION"] == DBNull.Value
? null
: Convert.ToInt64(row["DATA_PRECISION"]);
long? scale = row["DATA_SCALE"] == DBNull.Value
? null
: Convert.ToInt64(row["DATA_SCALE"]);
long charLength = row["CHAR_LENGTH"] == DBNull.Value
? dataLength
: Convert.ToInt64(row["CHAR_LENGTH"]);
string charUsed = row["CHAR_USED"]?.ToString()?.ToUpperInvariant() ?? "";
return dataType switch
{
"CHAR" or "VARCHAR2" =>
$"{dataType}({(charUsed == "C" ? charLength : dataLength)} {(charUsed == "C" ? "CHAR" : "BYTE")})",
"NCHAR" or "NVARCHAR2" => $"{dataType}({charLength})",
"NUMBER" when precision.HasValue && scale.HasValue => $"NUMBER({precision.Value},{scale.Value})",
"NUMBER" when precision.HasValue => $"NUMBER({precision.Value})",
"FLOAT" when precision.HasValue => $"FLOAT({precision.Value})",
"RAW" => $"RAW({dataLength})",
"UROWID" => $"UROWID({dataLength})",
_ => dataType
};
}
private static string QuoteOracleIdentifier(string value)
=> $"\"{value.Replace("\"", "\"\"")}\"";
/// <summary>获取所有存储过程和函数列表 - 查 USER_OBJECTS,返回纯名称(不带前缀),不依赖存储过程</summary>
public static string GetAllPrc()
{
@@ -396,18 +622,6 @@ namespace henlo_migration.Services
}
}
/// <summary>执行预处理存储过程</summary>
public static void PreProcess()
{
var dbOrig = SqlSugarHelper.DbOrig;
foreach (var fname in Directory.GetFiles(CommonHelper.PrePath))
{
string name = Path.GetFileNameWithoutExtension(fname);
string content = File.ReadAllText(fname);
dbOrig.Ado.ExecuteCommand(content);
}
}
private static string[] SplitStatements(string sql)
{
// Oracle PL/SQL 块用 \n/ 分隔
@@ -423,4 +637,4 @@ namespace henlo_migration.Services
return result.ToArray();
}
}
}
}
@@ -16,9 +16,10 @@ namespace henlo_migration.ViewModels
{
public class MainViewModel : INotifyPropertyChanged
{
// 四个 Tab 的数据
// 五个 Tab 的数据
public TabServices TableTab { get; }
public TabServices BosTab { get; }
public TabServices BosFieldTab { get; }
public TabServices PrcTab { get; }
public TabServices DataTab { get; }
@@ -33,6 +34,7 @@ namespace henlo_migration.ViewModels
OnPropertyChanged();
OnPropertyChanged(nameof(TablesData));
OnPropertyChanged(nameof(BosTablesData));
OnPropertyChanged(nameof(BosFieldsData));
OnPropertyChanged(nameof(StoredProcsData));
OnPropertyChanged(nameof(CurrentDataData));
}
@@ -50,13 +52,15 @@ namespace henlo_migration.ViewModels
{
0 => TableTab,
1 => BosTab,
2 => PrcTab,
3 => DataTab,
2 => BosFieldTab,
3 => PrcTab,
4 => DataTab,
_ => TableTab
};
OnPropertyChanged();
OnPropertyChanged(nameof(TablesData));
OnPropertyChanged(nameof(BosTablesData));
OnPropertyChanged(nameof(BosFieldsData));
OnPropertyChanged(nameof(StoredProcsData));
OnPropertyChanged(nameof(CurrentDataData));
OnPropertyChanged(nameof(CurrentTabName));
@@ -64,9 +68,10 @@ namespace henlo_migration.ViewModels
}
}
// 四个Tab数据源别名(供DataGrid绑定)
// 五个Tab数据源别名(供DataGrid绑定)
public ObservableCollection<TableRowModel> TablesData => TableTab.List;
public ObservableCollection<TableRowModel> BosTablesData => BosTab.List;
public ObservableCollection<TableRowModel> BosFieldsData => BosFieldTab.List;
public ObservableCollection<TableRowModel> StoredProcsData => PrcTab.List;
public ObservableCollection<TableRowModel> CurrentDataData => DataTab.List;
@@ -74,8 +79,9 @@ namespace henlo_migration.ViewModels
{
0 => "表配置",
1 => "BOS配置",
2 => "存储过程",
3 => "表数据",
2 => "BOS字段配置",
3 => "存储过程",
4 => "表数据",
_ => ""
};
@@ -240,7 +246,6 @@ namespace henlo_migration.ViewModels
public ICommand DisconnectDestCommand { get; }
public ICommand AddRowCommand { get; }
public ICommand OpenDataFolderCommand { get; }
public ICommand PreProcessCommand { get; }
public ICommand BatchReadCommand { get; }
public ICommand BatchWriteCommand { get; }
@@ -264,13 +269,16 @@ namespace henlo_migration.ViewModels
DestUser = SysConfig.GetDestUser();
DestPwd = SysConfig.GetDestPwd();
// 初始化四个 TabServices(索引文件在 from/,内容文件在 data/)
// 初始化五个 TabServices(索引文件在 from/,内容文件在 data/)
TableTab = new TabServices(new TabConfig(TabsEnum.表, CommonHelper.DataFromPath, CommonHelper.DataPath, "table.txt", CodeExt.creat,
DbServices.GetTableSql, (p, c) => SqlCode = c));
BosTab = new TabServices(new TabConfig(TabsEnum.BOS表配置, CommonHelper.DataFromPath, CommonHelper.DataPath, "bos.txt", CodeExt.bos,
DbServices.GetTableBos, (p, c) => SqlCode = c));
BosFieldTab = new TabServices(new TabConfig(TabsEnum.BOS字段配置, CommonHelper.DataFromPath, CommonHelper.DataPath, "bos_field.txt", CodeExt.bos_field,
DbServices.GetTableBosField, (p, c) => SqlCode = c));
PrcTab = new TabServices(new TabConfig(TabsEnum.存储过程, CommonHelper.DataFromPath, CommonHelper.DataPath, "prc.txt", CodeExt.prc,
name => DbServices.GetPrcSql(name), (p, c) => SqlCode = c));
@@ -278,7 +286,7 @@ namespace henlo_migration.ViewModels
DbServices.GetTableData, (p, c) => SqlCode = c));
// Tab行点击 -> 显示SQL
foreach (var tab in new[] { TableTab, BosTab, PrcTab, DataTab })
foreach (var tab in new[] { TableTab, BosTab, BosFieldTab, PrcTab, DataTab })
{
tab.RowClickEvent += (path, code) =>
{
@@ -297,7 +305,6 @@ namespace henlo_migration.ViewModels
DisconnectDestCommand = new RelayCommand(DisconnectDest, () => DestConnected);
AddRowCommand = new RelayCommand(AddRow, () => CurrentTab != null && !string.IsNullOrWhiteSpace(InputText));
OpenDataFolderCommand = new RelayCommand(OpenDataFolder);
PreProcessCommand = new RelayCommand(PreProcess);
BatchReadCommand = new RelayCommand(async () => await BatchReadAsync());
BatchWriteCommand = new RelayCommand(async () => await BatchWriteAsync());
@@ -372,7 +379,18 @@ namespace henlo_migration.ViewModels
{
if (!string.IsNullOrWhiteSpace(InputText))
{
CurrentTab?.AddRow(InputText.Trim().ToLower());
string itemName = InputText.Trim();
if (CurrentTab == BosFieldTab)
{
if (!DbServices.TryParseBosFieldName(itemName, out string tableName, out string columnName))
{
HandyControl.Controls.Growl.Warning("BOS 字段配置必须使用 TABLE.COLUMN 格式");
return;
}
itemName = $"{tableName}.{columnName}";
}
CurrentTab?.AddRow(itemName.ToLowerInvariant());
InputText = "";
}
}
@@ -396,12 +414,6 @@ namespace henlo_migration.ViewModels
Process.Start("explorer.exe", CommonHelper.DataPath);
}
private void PreProcess()
{
try { DbServices.PreProcess(); }
catch { }
}
private async Task BatchReadAsync()
{
if (CurrentTab == null) return;
+73 -4
View File
@@ -55,7 +55,7 @@
<!-- 输入框 -->
<hc:TextBox Grid.Column="0"
hc:InfoElement.Placeholder="输入表名或存储过程名,按回车添加..."
hc:InfoElement.Placeholder="输入表名、TABLE.COLUMN 或存储过程名,按回车添加..."
Text="{Binding InputText, UpdateSourceTrigger=PropertyChanged}"
Height="32" VerticalContentAlignment="Center">
<hc:TextBox.InputBindings>
@@ -86,9 +86,6 @@
</TextBlock>
</Button.Content>
</Button>
<Button Content="预处理" Width="70" Height="32" Margin="8,0,0,0"
Style="{StaticResource ButtonWarning}"
Command="{Binding PreProcessCommand}"/>
<Button Content="打开数据目录" Width="100" Height="32" Margin="8,0,0,0"
Style="{StaticResource ButtonDefault}"
Command="{Binding OpenDataFolderCommand}"/>
@@ -420,6 +417,78 @@
</DataGrid>
</TabItem>
<!-- BOS字段配置 -->
<TabItem Header="BOS字段配置">
<DataGrid x:Name="BosFieldGrid" ItemsSource="{Binding BosFieldsData}"
SelectedItem="{Binding SelectedRow}"
RowStyle="{StaticResource DataGridRowStyleFix}"
CellStyle="{StaticResource DataGridCellStyleFix}"
AutoGenerateColumns="False" CanUserAddRows="False"
IsReadOnly="True" HeadersVisibility="Column"
GridLinesVisibility="Horizontal" BorderThickness="1"
BorderBrush="#d0d7de">
<DataGrid.Columns>
<DataGridTemplateColumn Header="表名.字段名" Width="*" MinWidth="180">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}" Style="{StaticResource CopyableNameWhenSelected}" b:ClickToCopy.Selectable="True" VerticalAlignment="Center"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="状态" Width="160">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Border Style="{StaticResource StatusBadge}">
<Border.Background>
<MultiBinding Converter="{StaticResource ReadStatusColorConverter}">
<Binding Path="RStatus"/>
</MultiBinding>
</Border.Background>
<TextBlock Text="{Binding ReadStatusText}" Style="{StaticResource StatusBadgeText}"/>
</Border>
<Border Style="{StaticResource StatusBadge}" Margin="6,0,0,0">
<Border.Background>
<MultiBinding Converter="{StaticResource WriteStatusColorConverter}">
<Binding Path="WStatus"/>
</MultiBinding>
</Border.Background>
<TextBlock Text="{Binding WriteStatusText}" Style="{StaticResource StatusBadgeText}"/>
</Border>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="操作" Width="320">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<Button Content="读取" Style="{StaticResource SmallBtn}"
Command="{Binding DataContext.ReadCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}"
CommandParameter="{Binding}"/>
<Button Content="写入" Style="{StaticResource SmallSuccessBtn}"
Command="{Binding DataContext.WriteCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}"
CommandParameter="{Binding}"/>
<Button Content="删除" Style="{StaticResource SmallWarnBtn}"
Command="{Binding DataContext.DeleteCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}"
CommandParameter="{Binding}"/>
<Button Content="↑" Style="{StaticResource SmallDefaultBtn}" MinWidth="36"
Command="{Binding DataContext.MoveUpCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}"
CommandParameter="{Binding}"/>
<Button Content="↓" Style="{StaticResource SmallDefaultBtn}" MinWidth="36"
Command="{Binding DataContext.MoveDownCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}"
CommandParameter="{Binding}"/>
<Button Content="Top" Style="{StaticResource SmallDefaultBtn}" MinWidth="40"
Command="{Binding DataContext.MoveTopCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}"
CommandParameter="{Binding}"/>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</TabItem>
<!-- 存储过程 -->
<TabItem Header="存储过程">
<DataGrid x:Name="PrcGrid" ItemsSource="{Binding StoredProcsData}"
@@ -8,7 +8,7 @@
<Nullable>disable</Nullable>
<ApplicationIcon>Resources\favicon.ico</ApplicationIcon>
<Deterministic>true</Deterministic>
<Version>1.0.16</Version>
<Version>1.0.17</Version>
</PropertyGroup>
<ItemGroup>
@@ -22,21 +22,6 @@
</ItemGroup>
<ItemGroup>
<None Update="config.ini">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="data\from\table.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="data\from\bos.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="data\from\prc.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="data\from\data.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="pre\*.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
@@ -51,4 +36,4 @@
<ItemGroup>
<Resource Include="Resources\favicon.ico" />
</ItemGroup>
</Project>
</Project>
+3
View File
@@ -35,6 +35,9 @@ if errorlevel 1 (
exit /b 1
)
REM Never ship the developer's local Oracle connection configuration.
if exist "!PUBLISH_DIR!\config.ini" del /q "!PUBLISH_DIR!\config.ini"
echo [2.5/3] Create data directories in publish (user runtime data)...
if not exist "!PUBLISH_DIR!\data" mkdir "!PUBLISH_DIR!\data"
echo. 2>nul > "!PUBLISH_DIR!\data\.gitkeep"
@@ -0,0 +1,423 @@
CREATE OR REPLACE PROCEDURE hmig_get_table_bos_field_31(
p_table_name IN VARCHAR2,
p_column_name IN VARCHAR2,
r_sql OUT CLOB
) AS
/*
按 BOS 表名 + 字段 DBNAME 导出单个 AD_COLUMN 配置。
输出是可在目标库执行的 PL/SQL:
1. 在目标库按 AD_TABLE.NAME 重新解析 AD_TABLE_ID;
2. 按 AD_TABLE_ID + DBNAME 判断字段是否已经存在;
3. 已存在则保留目标字段 ID 并更新配置;
4. 不存在则通过 get_sequences('AD_COLUMN') 生成新 ID 后插入。
限定值处理:
- 通过 AD_LIMITVALUE_GROUP.NAME 在目标库定位限定值组;
- 组不存在时新增;
- 组内的 AD_LIMITVALUE.VALUE 不存在时逐项新增。
引用字段处理:
- 将源 REF_COLUMN_ID 解析为 AD_TABLE.NAME + AD_COLUMN.DBNAME;
- 在目标库按名称重新定位;
- 引用字段不存在或不唯一时直接报错,不自动创建。
当前版本不包含 Oracle 物理表字段 DDL。
SQL Developer 验证示例:
DECLARE
v_sql CLOB;
BEGIN
hmig_get_table_bos_field_31('M_RETAIL', 'DOCNO', v_sql);
DBMS_OUTPUT.PUT_LINE(DBMS_LOB.SUBSTR(v_sql, 32767, 1));
END;
/
*/
c_source_table CONSTANT VARCHAR2(30) := 'AD_COLUMN';
v_table_name VARCHAR2(128) := UPPER(TRIM(p_table_name));
v_column_name VARCHAR2(128) := UPPER(TRIM(p_column_name));
v_source_table_id NUMBER;
v_source_column_id NUMBER;
v_source_limit_group_id NUMBER;
v_source_ref_column_id NUMBER;
v_limit_group_name VARCHAR2(200);
v_limit_group_comments VARCHAR2(4000);
v_limit_group_valuetype VARCHAR2(200);
v_limit_group_ownerid NUMBER;
v_ref_table_name VARCHAR2(128);
v_ref_column_name VARCHAR2(128);
v_value VARCHAR2(32767);
v_value_sql VARCHAR2(32767);
v_select_sql VARCHAR2(32767);
v_insert_columns VARCHAR2(32767);
v_insert_values VARCHAR2(32767);
v_update_values VARCHAR2(32767);
v_lob_length NUMBER;
v_separator VARCHAR2(2);
PROCEDURE append_text(p_text IN VARCHAR2) AS
BEGIN
IF p_text IS NOT NULL THEN
DBMS_LOB.WRITEAPPEND(r_sql, LENGTH(p_text), p_text);
END IF;
END append_text;
FUNCTION quoted(p_value IN VARCHAR2) RETURN VARCHAR2 AS
BEGIN
IF p_value IS NULL THEN
RETURN 'NULL';
END IF;
RETURN '''' || REPLACE(p_value, '''', '''''') || '''';
END quoted;
FUNCTION number_literal(p_value IN NUMBER) RETURN VARCHAR2 AS
BEGIN
IF p_value IS NULL THEN
RETURN 'NULL';
END IF;
RETURN TO_CHAR(p_value, 'TM9', 'NLS_NUMERIC_CHARACTERS=''.,''');
END number_literal;
BEGIN
IF v_table_name IS NULL THEN
RAISE_APPLICATION_ERROR(-20201, 'BOS表名不能为空');
END IF;
IF v_column_name IS NULL THEN
RAISE_APPLICATION_ERROR(-20201, 'BOS字段DBNAME不能为空');
END IF;
BEGIN
SELECT id
INTO v_source_table_id
FROM ad_table
WHERE UPPER(name) = v_table_name;
EXCEPTION
WHEN NO_DATA_FOUND THEN
RAISE_APPLICATION_ERROR(-20201, 'BOS表[' || v_table_name || ']不存在');
WHEN TOO_MANY_ROWS THEN
RAISE_APPLICATION_ERROR(-20201, 'BOS表[' || v_table_name || ']存在多条AD_TABLE配置');
END;
BEGIN
SELECT id
INTO v_source_column_id
FROM ad_column
WHERE ad_table_id = v_source_table_id
AND UPPER(dbname) = v_column_name;
EXCEPTION
WHEN NO_DATA_FOUND THEN
RAISE_APPLICATION_ERROR(
-20201,
'BOS字段[' || v_table_name || '.' || v_column_name || ']不存在'
);
WHEN TOO_MANY_ROWS THEN
RAISE_APPLICATION_ERROR(
-20201,
'BOS字段[' || v_table_name || '.' || v_column_name || ']存在多条AD_COLUMN配置'
);
END;
SELECT ad_limitvalue_group_id, ref_column_id
INTO v_source_limit_group_id, v_source_ref_column_id
FROM ad_column
WHERE id = v_source_column_id;
IF v_source_limit_group_id IS NOT NULL THEN
BEGIN
SELECT name, comments, valuetype, ownerid
INTO v_limit_group_name,
v_limit_group_comments,
v_limit_group_valuetype,
v_limit_group_ownerid
FROM ad_limitvalue_group
WHERE id = v_source_limit_group_id;
IF v_limit_group_name IS NULL THEN
RAISE_APPLICATION_ERROR(
-20201,
'字段关联的限定值组名称为空,ID=' || v_source_limit_group_id
);
END IF;
EXCEPTION
WHEN NO_DATA_FOUND THEN
RAISE_APPLICATION_ERROR(
-20201,
'字段关联的限定值组不存在,ID=' || v_source_limit_group_id
);
END;
END IF;
IF v_source_ref_column_id IS NOT NULL THEN
BEGIN
SELECT UPPER(t.name), UPPER(c.dbname)
INTO v_ref_table_name, v_ref_column_name
FROM ad_column c
JOIN ad_table t ON t.id = c.ad_table_id
WHERE c.id = v_source_ref_column_id;
EXCEPTION
WHEN NO_DATA_FOUND THEN
RAISE_APPLICATION_ERROR(
-20201,
'字段关联的引用元数据不存在,REF_COLUMN_ID=' || v_source_ref_column_id
);
END;
END IF;
DBMS_LOB.CREATETEMPORARY(r_sql, TRUE);
/*
动态遍历 AD_COLUMN 的实际字段,避免在不同 BOS 版本间硬编码列清单。
ID、AD_TABLE_ID、AD_LIMITVALUE_GROUP_ID 和 REF_COLUMN_ID 在目标库重新映射;
其余字段按源值输出。
*/
FOR col IN (
SELECT column_name, data_type
FROM user_tab_columns
WHERE table_name = c_source_table
ORDER BY column_id
) LOOP
IF col.column_name = 'ID' THEN
v_value_sql := 'v_target_column_id';
ELSIF col.column_name = 'AD_TABLE_ID' THEN
v_value_sql := 'v_target_table_id';
ELSIF col.column_name = 'AD_LIMITVALUE_GROUP_ID' THEN
IF v_source_limit_group_id IS NULL THEN
v_value_sql := 'NULL';
ELSE
v_value_sql := 'v_target_limit_group_id';
END IF;
ELSIF col.column_name = 'REF_COLUMN_ID' THEN
IF v_source_ref_column_id IS NULL THEN
v_value_sql := 'NULL';
ELSE
v_value_sql := 'v_target_ref_column_id';
END IF;
ELSE
v_value := NULL;
v_lob_length := NULL;
IF col.data_type IN ('NUMBER', 'FLOAT', 'BINARY_FLOAT', 'BINARY_DOUBLE') THEN
v_select_sql :=
'SELECT CASE WHEN "' || col.column_name || '" IS NULL THEN NULL ' ||
'ELSE TO_CHAR("' || col.column_name || '", ''TM9'', ' ||
'''NLS_NUMERIC_CHARACTERS=''''.,'''''') END ' ||
'FROM AD_COLUMN WHERE ID = :id';
EXECUTE IMMEDIATE v_select_sql INTO v_value USING v_source_column_id;
v_value_sql := NVL(v_value, 'NULL');
ELSIF col.data_type = 'DATE' THEN
v_select_sql :=
'SELECT TO_CHAR("' || col.column_name || '", ''YYYY-MM-DD HH24:MI:SS'') ' ||
'FROM AD_COLUMN WHERE ID = :id';
EXECUTE IMMEDIATE v_select_sql INTO v_value USING v_source_column_id;
IF v_value IS NULL THEN
v_value_sql := 'NULL';
ELSE
v_value_sql := 'TO_DATE(' || quoted(v_value) ||
', ''YYYY-MM-DD HH24:MI:SS'')';
END IF;
ELSIF col.data_type LIKE 'TIMESTAMP%WITH TIME ZONE' THEN
v_select_sql :=
'SELECT TO_CHAR("' || col.column_name ||
'", ''YYYY-MM-DD HH24:MI:SS.FF6 TZH:TZM'') ' ||
'FROM AD_COLUMN WHERE ID = :id';
EXECUTE IMMEDIATE v_select_sql INTO v_value USING v_source_column_id;
IF v_value IS NULL THEN
v_value_sql := 'NULL';
ELSE
v_value_sql := 'TO_TIMESTAMP_TZ(' || quoted(v_value) ||
', ''YYYY-MM-DD HH24:MI:SS.FF6 TZH:TZM'')';
END IF;
ELSIF col.data_type LIKE 'TIMESTAMP%' THEN
v_select_sql :=
'SELECT TO_CHAR("' || col.column_name ||
'", ''YYYY-MM-DD HH24:MI:SS.FF6'') ' ||
'FROM AD_COLUMN WHERE ID = :id';
EXECUTE IMMEDIATE v_select_sql INTO v_value USING v_source_column_id;
IF v_value IS NULL THEN
v_value_sql := 'NULL';
ELSE
v_value_sql := 'TO_TIMESTAMP(' || quoted(v_value) ||
', ''YYYY-MM-DD HH24:MI:SS.FF6'')';
END IF;
ELSIF col.data_type = 'RAW' THEN
v_select_sql :=
'SELECT RAWTOHEX("' || col.column_name || '") ' ||
'FROM AD_COLUMN WHERE ID = :id';
EXECUTE IMMEDIATE v_select_sql INTO v_value USING v_source_column_id;
IF v_value IS NULL THEN
v_value_sql := 'NULL';
ELSE
v_value_sql := 'HEXTORAW(' || quoted(v_value) || ')';
END IF;
ELSIF col.data_type = 'CLOB' THEN
v_select_sql :=
'SELECT DBMS_LOB.GETLENGTH("' || col.column_name || '"), ' ||
'DBMS_LOB.SUBSTR("' || col.column_name || '", 32000, 1) ' ||
'FROM AD_COLUMN WHERE ID = :id';
EXECUTE IMMEDIATE v_select_sql
INTO v_lob_length, v_value
USING v_source_column_id;
IF v_lob_length > 32000 THEN
RAISE_APPLICATION_ERROR(
-20201,
'字段配置列AD_COLUMN.' || col.column_name || '超过32000字符,暂不支持导出'
);
END IF;
IF v_value IS NULL THEN
v_value_sql := 'NULL';
ELSE
v_value_sql := 'TO_CLOB(' || quoted(v_value) || ')';
END IF;
ELSIF col.data_type IN ('BLOB', 'LONG', 'LONG RAW') THEN
RAISE_APPLICATION_ERROR(
-20201,
'字段配置列AD_COLUMN.' || col.column_name ||
'的数据类型' || col.data_type || '暂不支持导出'
);
ELSE
v_select_sql :=
'SELECT TO_CHAR("' || col.column_name || '") ' ||
'FROM AD_COLUMN WHERE ID = :id';
EXECUTE IMMEDIATE v_select_sql INTO v_value USING v_source_column_id;
v_value_sql := quoted(v_value);
END IF;
END IF;
IF v_insert_columns IS NULL THEN
v_insert_columns := col.column_name;
v_insert_values := v_value_sql;
ELSE
v_insert_columns := v_insert_columns || ', ' || col.column_name;
v_insert_values := v_insert_values || ', ' || v_value_sql;
END IF;
IF col.column_name NOT IN ('ID', 'AD_TABLE_ID') THEN
IF v_update_values IS NULL THEN
v_separator := '';
ELSE
v_separator := ', ';
END IF;
v_update_values := v_update_values || v_separator ||
col.column_name || ' = ' || v_value_sql;
END IF;
END LOOP;
append_text('-- BOS单字段配置:' || v_table_name || '.' || v_column_name || CHR(10));
append_text('-- 来源:AD_COLUMN.ID=' || v_source_column_id || CHR(10));
append_text('-- 注意:本文件不包含 Oracle 物理字段 DDL。' || CHR(10));
append_text('DECLARE' || CHR(10));
append_text(' v_target_table_id NUMBER;' || CHR(10));
append_text(' v_target_column_id NUMBER;' || CHR(10));
append_text(' v_target_limit_group_id NUMBER;' || CHR(10));
append_text(' v_target_ref_column_id NUMBER;' || CHR(10));
append_text(' v_count NUMBER;' || CHR(10));
append_text('BEGIN' || CHR(10));
append_text(' SELECT id INTO v_target_table_id FROM ad_table' || CHR(10));
append_text(' WHERE UPPER(name) = ' || quoted(v_table_name) || ';' || CHR(10));
append_text(CHR(10));
IF v_source_limit_group_id IS NOT NULL THEN
append_text(' -- 限定值组按 NAME 匹配,只补充目标库缺失的数据。' || CHR(10));
append_text(' SELECT COUNT(*), MAX(id)' || CHR(10));
append_text(' INTO v_count, v_target_limit_group_id' || CHR(10));
append_text(' FROM ad_limitvalue_group' || CHR(10));
append_text(' WHERE UPPER(name) = UPPER(' || quoted(v_limit_group_name) || ');' || CHR(10));
append_text(' IF v_count > 1 THEN' || CHR(10));
append_text(' RAISE_APPLICATION_ERROR(-20201, ' ||
quoted('目标库存在多条同名限定值组:' || v_limit_group_name) || ');' || CHR(10));
append_text(' ELSIF v_count = 0 THEN' || CHR(10));
append_text(' v_target_limit_group_id := get_sequences(''AD_LIMITVALUE_GROUP'');' || CHR(10));
append_text(' INSERT INTO ad_limitvalue_group' || CHR(10));
append_text(' (id, name, comments, valuetype, creationdate, ownerid)' || CHR(10));
append_text(' VALUES' || CHR(10));
append_text(' (v_target_limit_group_id, ' || quoted(v_limit_group_name) || ', ' ||
quoted(v_limit_group_comments) || ', ' || quoted(v_limit_group_valuetype) ||
', SYSDATE, ' || number_literal(v_limit_group_ownerid) || ');' || CHR(10));
append_text(' END IF;' || CHR(10));
append_text(CHR(10));
FOR lv IN (
SELECT value, description, orderno, comments, valuetype, ownerid
FROM ad_limitvalue
WHERE ad_limitvalue_group_id = v_source_limit_group_id
ORDER BY orderno, id
) LOOP
append_text(' SELECT COUNT(*) INTO v_count' || CHR(10));
append_text(' FROM ad_limitvalue' || CHR(10));
append_text(' WHERE ad_limitvalue_group_id = v_target_limit_group_id' || CHR(10));
append_text(' AND (value = ' || quoted(lv.value) ||
' OR (value IS NULL AND ' || quoted(lv.value) || ' IS NULL));' || CHR(10));
append_text(' IF v_count = 0 THEN' || CHR(10));
append_text(' INSERT INTO ad_limitvalue' || CHR(10));
append_text(' (id, ad_limitvalue_group_id, value, description, orderno,' || CHR(10));
append_text(' comments, valuetype, creationdate, ownerid)' || CHR(10));
append_text(' VALUES' || CHR(10));
append_text(' (get_sequences(''AD_LIMITVALUE''), v_target_limit_group_id, ' ||
quoted(lv.value) || ', ' || quoted(lv.description) || ', ' ||
number_literal(lv.orderno) || ', ' || quoted(lv.comments) || ', ' ||
quoted(lv.valuetype) || ', SYSDATE, ' || number_literal(lv.ownerid) || ');' || CHR(10));
append_text(' END IF;' || CHR(10));
append_text(CHR(10));
END LOOP;
END IF;
IF v_source_ref_column_id IS NOT NULL THEN
append_text(' -- 引用元数据只校验,不自动创建引用字段。' || CHR(10));
append_text(' SELECT COUNT(*), MAX(c.id)' || CHR(10));
append_text(' INTO v_count, v_target_ref_column_id' || CHR(10));
append_text(' FROM ad_column c' || CHR(10));
append_text(' JOIN ad_table t ON t.id = c.ad_table_id' || CHR(10));
append_text(' WHERE UPPER(t.name) = ' || quoted(v_ref_table_name) || CHR(10));
append_text(' AND UPPER(c.dbname) = ' || quoted(v_ref_column_name) || ';' || CHR(10));
append_text(' IF v_count = 0 THEN' || CHR(10));
append_text(' RAISE_APPLICATION_ERROR(-20201, ' ||
quoted('目标库缺少引用字段:' || v_ref_table_name || '.' || v_ref_column_name) || ');' || CHR(10));
append_text(' ELSIF v_count > 1 THEN' || CHR(10));
append_text(' RAISE_APPLICATION_ERROR(-20201, ' ||
quoted('目标库引用字段不唯一:' || v_ref_table_name || '.' || v_ref_column_name) || ');' || CHR(10));
append_text(' END IF;' || CHR(10));
append_text(CHR(10));
END IF;
append_text(' SELECT COUNT(*), MAX(id)' || CHR(10));
append_text(' INTO v_count, v_target_column_id' || CHR(10));
append_text(' FROM ad_column' || CHR(10));
append_text(' WHERE ad_table_id = v_target_table_id' || CHR(10));
append_text(' AND UPPER(dbname) = ' || quoted(v_column_name) || ';' || CHR(10));
append_text(CHR(10));
append_text(' IF v_count > 1 THEN' || CHR(10));
append_text(' RAISE_APPLICATION_ERROR(-20201, ''目标库存在多条同名BOS字段配置'');' || CHR(10));
append_text(' ELSIF v_count = 0 THEN' || CHR(10));
append_text(' v_target_column_id := get_sequences(''AD_COLUMN'');' || CHR(10));
append_text(' INSERT INTO ad_column (' || v_insert_columns || ')' || CHR(10));
append_text(' VALUES (' || v_insert_values || ');' || CHR(10));
append_text(' ELSE' || CHR(10));
append_text(' UPDATE ad_column' || CHR(10));
append_text(' SET ' || v_update_values || CHR(10));
append_text(' WHERE id = v_target_column_id;' || CHR(10));
append_text(' END IF;' || CHR(10));
append_text(CHR(10));
append_text(' COMMIT;' || CHR(10));
append_text('END;' || CHR(10));
append_text('/' || CHR(10));
EXCEPTION
WHEN OTHERS THEN
IF DBMS_LOB.ISTEMPORARY(r_sql) = 1 THEN
DBMS_LOB.FREETEMPORARY(r_sql);
END IF;
RAISE_APPLICATION_ERROR(
-20201,
'导出BOS字段配置失败[' || v_table_name || '.' || v_column_name || ']:' || SQLERRM
);
END hmig_get_table_bos_field_31;
/