DbMigrator.java 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827
  1. import java.io.IOException;
  2. import java.nio.charset.StandardCharsets;
  3. import java.nio.file.Files;
  4. import java.nio.file.Path;
  5. import java.nio.file.StandardOpenOption;
  6. import java.sql.*;
  7. import java.util.*;
  8. import java.util.regex.Matcher;
  9. import java.util.regex.Pattern;
  10. import java.util.stream.Collectors;
  11. /**
  12. * MySQL -> 达梦(DM8) 通用结构+数据迁移工具(JDBC 驱动层,零 SQL 文本转义)。
  13. *
  14. * 设计要点(兼容规则来自 DM8 8.1.2.128 实测):
  15. * - 结构:解析源库 SHOW CREATE TABLE(结构化元数据,非 dump 文本),生成达梦 DDL;
  16. * - 数据:ResultSet -> PreparedStatement 批量直搬,类型转换全部由 JDBC 驱动完成;
  17. * - 次序:建表(PK/CHECK/普通UK) -> 索引 -> 数据 -> 外键 -> 注释,外键后置消除表间依赖;
  18. * - 幂等:--drop-existing 先删目标模式全部外键与表,可反复重跑;
  19. * - 韧性:批次失败自动降级为逐行重试,坏行记入错误日志不中断整体;
  20. * - 通用:不针对特定表名/列名;类型映射覆盖 MySQL 常用类型族;分页策略适配任意主键形态。
  21. *
  22. * 达梦兼容实测规则:
  23. * 1) AUTO_INCREMENT -> IDENTITY(1,1);自增列插显式值需 SET IDENTITY_INSERT ON + 显式列清单;
  24. * 2) 生成列 GENERATED ALWAYS AS (IF(cond,1,NULL)) VIRTUAL -> TINYINT AS (CASE WHEN cond THEN 1 ELSE NULL END);
  25. * 3) 含生成列的内联 UNIQUE 不被达梦接受(-2034),改后置 CREATE UNIQUE INDEX;
  26. * NULL 判重语义差异:MySQL 任一键列 NULL 即不判重,达梦(Oracle 语义)仅全 NULL 键不判重——
  27. * 对齐方式为全部键成分套同一守卫 CASE,非判重行整体置全 NULL 键;单标记生成列唯一键用单成分 CASE;
  28. * 4) ON DELETE RESTRICT 不被支持,剥离(默认 NO ACTION 语义等价);
  29. * 5) 达梦无 FULLTEXT/SPATIAL 索引与前缀索引,跳过/降级并告警。
  30. *
  31. * 用法(JDK17 单文件运行,cp 需 MySQL 与达梦 JDBC 驱动 jar):
  32. * java -Dfile.encoding=UTF-8 -cp "mysql.jar;dm.jar" DbMigrator.java migrate \
  33. * --source-url "jdbc:mysql://host:3306/db?useSSL=false&characterEncoding=utf8" \
  34. * --source-user u --source-pwd p \
  35. * --target-url jdbc:dm://host:5236 --target-user U --target-pwd P --schema SCHEMA \
  36. * [--create-schema] [--drop-existing] [--tables t1,t2] [--exclude t3] \
  37. * [--batch 1000] [--no-verify] [--err-log migrate_errors.log]
  38. * ... verify <同 migrate 参数> 仅校验行数与主键水位
  39. * ... drop-schema --target-url U --target-user u --target-pwd p --schema S
  40. */
  41. public class DbMigrator {
  42. // ==================== 数据模型 ====================
  43. record Col(String name, String rawType, boolean notNull, String def,
  44. boolean autoInc, String genExpr, String comment) {}
  45. static final class Idx {
  46. final String name; final List<String> cols = new ArrayList<>();
  47. final List<String> prefixes = new ArrayList<>(); // 前缀长度,null=整列
  48. boolean unique; boolean fulltext; boolean spatial;
  49. Idx(String name) { this.name = name; }
  50. }
  51. record Fk(String name, List<String> cols, String refTable, List<String> refCols, String onDelete) {}
  52. record Chk(String name, String expr) {}
  53. static final class TableDef {
  54. String name; final List<Col> cols = new ArrayList<>();
  55. final List<String> pk = new ArrayList<>();
  56. final List<Idx> indexes = new ArrayList<>();
  57. final List<Fk> fks = new ArrayList<>();
  58. final List<Chk> checks = new ArrayList<>();
  59. String comment; boolean hasIdentity;
  60. List<String> dataCols() { // 可搬运列 = 非生成列
  61. return cols.stream().filter(c -> c.genExpr() == null).map(Col::name).toList();
  62. }
  63. List<Col> generatedCols() {
  64. return cols.stream().filter(c -> c.genExpr() != null).toList();
  65. }
  66. Col findCol(String name) {
  67. return cols.stream().filter(c -> c.name().equalsIgnoreCase(name)).findFirst().orElse(null);
  68. }
  69. boolean singleNumericPk() {
  70. if (pk.size() != 1) return false;
  71. return cols.stream().filter(c -> c.name().equals(pk.get(0))).findFirst()
  72. .map(c -> c.rawType().matches("(big|int|medium)int.*")).orElse(false);
  73. }
  74. }
  75. // ==================== 入口 ====================
  76. public static void main(String[] args) throws Exception {
  77. if (args.length == 0) { usage(); System.exit(2); }
  78. String mode = args[0];
  79. Map<String, String> opt = parseOpts(args);
  80. switch (mode) {
  81. case "migrate", "verify" -> run(mode, opt);
  82. case "drop-schema" -> {
  83. String tUrl = require(opt, "target-url"), tUser = require(opt, "target-user"), tPwd = require(opt, "target-pwd");
  84. String schema = require(opt, "schema").toUpperCase();
  85. try (Connection dst = DriverManager.getConnection(tUrl, tUser, tPwd)) {
  86. dropSchemaObjects(dst, schema);
  87. }
  88. }
  89. default -> { usage(); System.exit(2); }
  90. }
  91. }
  92. static void usage() {
  93. System.err.println("""
  94. 用法:
  95. DbMigrator migrate --source-url U --source-user u --source-pwd p \\
  96. --target-url U --target-user u --target-pwd p --schema S \\
  97. [--create-schema] [--drop-existing] [--tables a,b] [--exclude c] \\
  98. [--batch 1000] [--no-verify] [--err-log FILE]
  99. DbMigrator verify <同 migrate 参数,仅校验>
  100. DbMigrator drop-schema --target-url U --target-user u --target-pwd p --schema S""");
  101. }
  102. static Map<String, String> parseOpts(String[] args) {
  103. Map<String, String> m = new HashMap<>();
  104. for (int i = 1; i < args.length; i++) {
  105. String k = args[i];
  106. if (!k.startsWith("--")) continue;
  107. String v = i + 1 < args.length && !args[i + 1].startsWith("--") ? args[++i] : "true";
  108. m.put(k.substring(2), v);
  109. }
  110. return m;
  111. }
  112. static void run(String mode, Map<String, String> opt) throws Exception {
  113. String sUrl = require(opt, "source-url"), sUser = require(opt, "source-user"), sPwd = require(opt, "source-pwd");
  114. String tUrl = require(opt, "target-url"), tUser = require(opt, "target-user"), tPwd = require(opt, "target-pwd");
  115. String schema = require(opt, "schema").toUpperCase();
  116. int batch = Integer.parseInt(opt.getOrDefault("batch", "1000"));
  117. Path errLog = Path.of(opt.getOrDefault("err-log", "migrate_errors.log"));
  118. if (sUrl.startsWith("jdbc:mysql") && !sUrl.contains("characterEncoding")) {
  119. sUrl += (sUrl.contains("?") ? "&" : "?") + "characterEncoding=utf8";
  120. }
  121. long t0 = System.currentTimeMillis();
  122. try (Connection src = DriverManager.getConnection(sUrl, sUser, sPwd);
  123. Connection dst = DriverManager.getConnection(tUrl, tUser, tPwd)) {
  124. System.out.println("[源] " + src.getMetaData().getDatabaseProductVersion());
  125. System.out.println("[目标] " + dst.getMetaData().getDatabaseProductVersion() + " 模式: " + schema);
  126. if (opt.containsKey("create-schema") && mode.equals("migrate")) createSchema(dst, schema);
  127. if (opt.containsKey("drop-existing") && mode.equals("migrate")) dropSchemaObjects(dst, schema);
  128. String catalog = src.getCatalog();
  129. Set<String> only = opt.containsKey("tables")
  130. ? Arrays.stream(opt.get("tables").split(",")).map(String::trim).collect(Collectors.toSet()) : null;
  131. Set<String> excl = opt.containsKey("exclude")
  132. ? Arrays.stream(opt.get("exclude").split(",")).map(String::trim).collect(Collectors.toSet()) : null;
  133. List<String> tables = listTables(src, catalog).stream()
  134. .filter(t -> (only == null || only.contains(t)) && (excl == null || !excl.contains(t)))
  135. .sorted().toList();
  136. if (tables.isEmpty()) { System.out.println("无待迁移表"); return; }
  137. Map<String, TableDef> defs = new LinkedHashMap<>();
  138. for (String t : tables) defs.put(t, parseShowCreate(src, t));
  139. System.out.println("待迁移表 " + defs.size() + " 张: " + tables);
  140. List<String> warns = new ArrayList<>();
  141. if (mode.equals("migrate")) {
  142. dst.setAutoCommit(true);
  143. for (TableDef d : defs.values()) {
  144. for (String ddl : buildCreateTable(d, schema, warns))
  145. exec(dst, ddl, "结构 " + d.name);
  146. }
  147. dst.setAutoCommit(false);
  148. for (TableDef d : defs.values()) copyData(src, dst, d, schema, batch, errLog, warns);
  149. dst.setAutoCommit(true);
  150. for (TableDef d : defs.values())
  151. for (Fk fk : d.fks)
  152. exec(dst, buildFk(d, fk, schema), "外键 " + d.name + "." + fk.name());
  153. for (TableDef d : defs.values())
  154. for (String c : buildComments(d, schema)) execQuiet(dst, c);
  155. }
  156. if (opt.containsKey("no-verify")) {
  157. System.out.println("(跳过校验)");
  158. } else {
  159. System.out.println("==================== 校验 ====================");
  160. int bad = 0;
  161. for (TableDef d : defs.values()) if (!verifyTable(src, dst, d, schema)) bad++;
  162. if (!warns.isEmpty()) {
  163. System.out.println("==================== 告警 ====================");
  164. warns.forEach(w -> System.out.println(" [WARN] " + w));
  165. }
  166. System.out.println("=============================================");
  167. System.out.println("表: " + defs.size() + " | 校验不一致: " + bad + " | 告警: " + warns.size()
  168. + " | 耗时 " + (System.currentTimeMillis() - t0) / 1000 + "s"
  169. + (Files.exists(errLog) ? " | 错误日志: " + errLog.toAbsolutePath() : ""));
  170. if (bad > 0) System.exit(1);
  171. }
  172. }
  173. }
  174. static String require(Map<String, String> opt, String key) {
  175. String v = opt.get(key);
  176. if (v == null || v.isBlank()) { System.err.println("缺少参数 --" + key); usage(); System.exit(2); }
  177. return v;
  178. }
  179. // ==================== 源库元数据 ====================
  180. static List<String> listTables(Connection c, String catalog) throws SQLException {
  181. List<String> out = new ArrayList<>();
  182. try (ResultSet rs = c.getMetaData().getTables(catalog, null, "%", new String[]{"TABLE"})) {
  183. while (rs.next()) out.add(rs.getString("TABLE_NAME"));
  184. }
  185. return out;
  186. }
  187. /** 解析 SHOW CREATE TABLE 输出为结构化定义。 */
  188. static TableDef parseShowCreate(Connection src, String table) throws SQLException {
  189. String ddl;
  190. try (Statement st = src.createStatement(); ResultSet rs = st.executeQuery("SHOW CREATE TABLE `" + table + "`")) {
  191. rs.next();
  192. ddl = rs.getString(2);
  193. }
  194. TableDef d = new TableDef();
  195. d.name = table;
  196. String body = ddl.substring(ddl.indexOf('(') + 1, ddl.lastIndexOf(')'));
  197. for (String seg : splitTop(body)) {
  198. String s = seg.trim();
  199. if (s.isEmpty()) continue;
  200. if (s.startsWith("`")) parseColumn(d, s);
  201. else if (s.regionMatches(true, 0, "PRIMARY KEY", 0, 11)) d.pk.addAll(identList(cutParen(s)));
  202. else if (s.regionMatches(true, 0, "UNIQUE KEY", 0, 10)) parseIndex(d, s, true);
  203. else if (s.regionMatches(true, 0, "FULLTEXT", 0, 8) || s.regionMatches(true, 0, "SPATIAL", 0, 7)) parseIndex(d, s, false);
  204. else if (s.regionMatches(true, 0, "KEY", 0, 3)) parseIndex(d, s, false);
  205. else if (s.regionMatches(true, 0, "CONSTRAINT", 0, 10)) parseConstraint(d, s);
  206. else warns0.add(table + ": 未识别片段(忽略) " + abbrev(s));
  207. }
  208. Matcher m = Pattern.compile("\\)\\s*(?:ENGINE|AUTO_INCREMENT).*?(?:COMMENT='([^']*)')?", Pattern.DOTALL).matcher(ddl);
  209. if (m.find() && m.group(1) != null) d.comment = m.group(1);
  210. return d;
  211. }
  212. // parseShowCreate 早期告警通道(结构解析阶段无 warns 列表)
  213. static final List<String> warns0 = new ArrayList<>();
  214. static void parseColumn(TableDef d, String s) {
  215. Matcher head = Pattern.compile("^`([^`]+)`\\s+(.*)$", Pattern.DOTALL).matcher(s);
  216. if (!head.matches()) return;
  217. String name = head.group(1), rest = head.group(2);
  218. String type = firstTypeToken(rest);
  219. String tail = rest.substring(type.length());
  220. // 生成列: GENERATED ALWAYS AS (...) VIRTUAL|STORED —— 括号配对提取
  221. String gen = null;
  222. Matcher g = Pattern.compile("\\bGENERATED\\s+ALWAYS\\s+AS\\s*\\(", Pattern.CASE_INSENSITIVE).matcher(tail);
  223. if (g.find()) {
  224. int open = g.end() - 1, depth = 0, end = -1;
  225. for (int i = open; i < tail.length(); i++) {
  226. char ch = tail.charAt(i);
  227. if (ch == '(') depth++;
  228. else if (ch == ')') { depth--; if (depth == 0) { end = i; break; } }
  229. }
  230. if (end > 0) gen = tail.substring(open + 1, end);
  231. }
  232. boolean notNull = gen == null && Pattern.compile("\\bNOT\\s+NULL\\b").matcher(tail).find();
  233. String def = null;
  234. Matcher dm = Pattern.compile("\\bDEFAULT\\s+(NULL|'(?:[^']|'')*'|[\\w.()+-]+)", Pattern.CASE_INSENSITIVE).matcher(tail);
  235. if (gen == null && dm.find()) {
  236. String v = dm.group(1);
  237. def = v.equalsIgnoreCase("NULL") ? null : v;
  238. }
  239. boolean auto = gen == null
  240. && Pattern.compile("\\bAUTO_INCREMENT\\b", Pattern.CASE_INSENSITIVE).matcher(tail).find();
  241. String comment = null;
  242. Matcher cm = Pattern.compile("\\bCOMMENT\\s+'((?:[^']|'')*)'").matcher(tail);
  243. if (cm.find()) comment = cm.group(1).replace("''", "'");
  244. if (auto) d.hasIdentity = true;
  245. d.cols.add(new Col(name, type.toLowerCase(), notNull, def, auto, gen, comment));
  246. }
  247. /** 提取类型词:双词类型走白名单(避免吞掉 NOT/DEFAULT/GENERATED 等后续关键字),并归并 unsigned/zerofill。 */
  248. static String firstTypeToken(String rest) {
  249. Matcher m = Pattern.compile(
  250. "^(?:double\\s+precision|character\\s+varying|[a-zA-Z]+)(?:\\s*\\([^)]*\\))?(?:\\s+(?:unsigned|zerofill))*",
  251. Pattern.CASE_INSENSITIVE).matcher(rest);
  252. return m.find() ? m.group() : rest;
  253. }
  254. static void parseIndex(TableDef d, String s, boolean unique) {
  255. Matcher m = Pattern.compile("^(?:UNIQUE\\s+|FULLTEXT\\s+|SPATIAL\\s+)?KEY\\s+`([^`]+)`\\s*\\((.*)\\)$",
  256. Pattern.DOTALL).matcher(s.trim());
  257. if (!m.matches()) { warns0.add(d.name + ": 索引未解析(忽略) " + abbrev(s)); return; }
  258. Idx idx = new Idx(m.group(1));
  259. idx.unique = unique;
  260. idx.fulltext = s.trim().regionMatches(true, 0, "FULLTEXT", 0, 8);
  261. idx.spatial = s.trim().regionMatches(true, 0, "SPATIAL", 0, 7);
  262. for (String part : splitTop(m.group(2))) {
  263. Matcher p = Pattern.compile("^`([^`]+)`(?:\\((\\d+)\\))?$").matcher(part.trim());
  264. if (p.matches()) { idx.cols.add(p.group(1)); idx.prefixes.add(p.group(2)); }
  265. }
  266. d.indexes.add(idx);
  267. }
  268. static void parseConstraint(TableDef d, String s) {
  269. Matcher m = Pattern.compile("^CONSTRAINT\\s+`([^`]+)`\\s+(.*)$", Pattern.DOTALL).matcher(s.trim());
  270. if (!m.matches()) return;
  271. String name = m.group(1), body = m.group(2);
  272. if (body.regionMatches(true, 0, "FOREIGN KEY", 0, 11)) {
  273. // 列清单取第一个括号对(cutParen 的 first/last 会跨越 REFERENCES 段)
  274. List<String> cols = identList(cutOuterParen(body.substring(body.indexOf('('))));
  275. Matcher ref = Pattern.compile("REFERENCES\\s+`([^`]+)`\\s*\\(([^)]*)\\)(?:\\s+ON\\s+DELETE\\s+(\\w+(?:\\s+\\w+)?))?",
  276. Pattern.CASE_INSENSITIVE).matcher(body);
  277. if (!ref.find()) { warns0.add(d.name + ": 外键未解析 " + name); return; }
  278. d.fks.add(new Fk(name, cols, ref.group(1), identList(ref.group(2)), ref.group(3)));
  279. } else if (body.regionMatches(true, 0, "CHECK", 0, 5)) {
  280. d.checks.add(new Chk(name, cutOuterParen(body.substring(5).trim())));
  281. }
  282. }
  283. static String cutParen(String s) { return s.substring(s.indexOf('(') + 1, s.lastIndexOf(')')); }
  284. static String cutOuterParen(String s) {
  285. s = s.trim();
  286. int depth = 0;
  287. for (int i = 0; i < s.length(); i++) {
  288. if (s.charAt(i) == '(') depth++;
  289. else if (s.charAt(i) == ')') { depth--; if (depth == 0) return s.substring(1, i); }
  290. }
  291. return s;
  292. }
  293. static List<String> identList(String s) {
  294. return splitTop(s).stream().map(x -> {
  295. Matcher m = Pattern.compile("^`([^`]+)`").matcher(x.trim());
  296. return m.matches() ? m.group(1) : x.trim();
  297. }).toList();
  298. }
  299. /** 顶层逗号切分(跳过括号嵌套与单/双引号、反引号字符串,含成对转义)。 */
  300. static List<String> splitTop(String s) {
  301. List<String> out = new ArrayList<>();
  302. StringBuilder cur = new StringBuilder();
  303. int depth = 0;
  304. for (int i = 0; i < s.length(); i++) {
  305. char c = s.charAt(i);
  306. if (c == '\'' || c == '"' || c == '`') {
  307. char quote = c;
  308. cur.append(c);
  309. for (i++; i < s.length(); i++) {
  310. cur.append(s.charAt(i));
  311. if (s.charAt(i) == quote) {
  312. if (i + 1 < s.length() && s.charAt(i + 1) == quote) { cur.append(quote); i++; }
  313. else break;
  314. }
  315. }
  316. } else if (c == '(') { depth++; cur.append(c); }
  317. else if (c == ')') { depth--; cur.append(c); }
  318. else if (c == ',' && depth == 0) { out.add(cur.toString()); cur.setLength(0); }
  319. else cur.append(c);
  320. }
  321. out.add(cur.toString());
  322. return out;
  323. }
  324. // ==================== 达梦 DDL 生成 ====================
  325. static final Set<String> DM_RESERVED = Set.of(
  326. "USER", "VERSION", "TYPE", "STATUS", "LEVEL", "COMMENT", "VALUES", "OPTION",
  327. "PASSWORD", "ROLE", "TABLE", "COLUMN", "INDEX", "DEFAULT", "CHECK", "CONSTRAINT",
  328. "REFERENCES", "PRIMARY", "FOREIGN", "UNIQUE", "VIEW", "SEQUENCE", "TRIGGER",
  329. "FUNCTION", "PROCEDURE", "DECLARE", "BEGIN", "END", "IF", "ELSE", "WHILE", "LOOP",
  330. "FOR", "SELECT", "INSERT", "UPDATE", "DELETE", "FROM", "WHERE", "GROUP", "ORDER",
  331. "BY", "HAVING", "DISTINCT", "ALL", "ANY", "EXISTS", "BETWEEN", "LIKE", "IS",
  332. "NULL", "NOT", "AND", "OR", "AS", "ON", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER",
  333. "FULL", "CROSS", "UNION", "INTERSECT", "MINUS", "CASE", "WHEN", "THEN", "GRANT",
  334. "REVOKE", "COMMIT", "ROLLBACK", "SAVEPOINT", "SET", "INTO", "ROW", "ROWS",
  335. "DATE", "TIME", "TIMESTAMP", "INTERVAL", "PUBLIC", "SESSION", "SYSTEM", "AUDIT");
  336. static String ident(String name) {
  337. String u = name.toUpperCase();
  338. return (DM_RESERVED.contains(u) || !u.matches("[A-Z_][A-Z0-9_$#]*")) ? '"' + u + '"' : u;
  339. }
  340. /** MySQL 类型 -> 达梦类型;返回 null 表示不支持(调用方告警跳过列)。 */
  341. static String mapType(String mysqlType, List<String> warns, String ctx) {
  342. String t = mysqlType.toLowerCase().replaceAll("\\s+", " ").trim();
  343. Matcher m = Pattern.compile("^([a-z ]+?)(?:\\((\\d+)(?:,(\\d+))?\\))?( unsigned| zerofill)?$").matcher(t);
  344. if (!m.matches()) { warns.add(ctx + ": 无法识别类型 " + mysqlType); return null; }
  345. String base = m.group(1).trim();
  346. String len = m.group(2), scale = m.group(3);
  347. boolean uns = m.group(4) != null && m.group(4).contains("unsigned");
  348. if (uns && !base.equals("smallint") && !base.equals("tinyint"))
  349. warns.add(ctx + ": UNSIGNED 不被达梦支持,已忽略(" + mysqlType + ")");
  350. return switch (base) {
  351. case "bigint" -> "BIGINT";
  352. case "int", "integer", "mediumint" -> "INT";
  353. case "smallint" -> uns ? "INT" : "SMALLINT"; // unsigned 超界升 INT
  354. case "tinyint" -> uns ? "SMALLINT" : "TINYINT"; // (n) 为显示宽度,不代表值域
  355. case "decimal", "numeric" -> scale != null ? "DECIMAL(" + len + "," + scale + ")"
  356. : len != null ? "DECIMAL(" + len + ",0)" : "DECIMAL(65,30)";
  357. case "float" -> "FLOAT";
  358. case "double", "double precision", "real" -> "DOUBLE";
  359. case "bit" -> len != null && Integer.parseInt(len) > 1
  360. ? unsupport(warns, ctx, mysqlType) : "BIT";
  361. case "date" -> "DATE";
  362. case "time" -> "TIME";
  363. case "year" -> { warns.add(ctx + ": YEAR 映射为 SMALLINT"); yield "SMALLINT"; }
  364. case "datetime", "timestamp" -> len != null ? "TIMESTAMP(" + len + ")" : "TIMESTAMP";
  365. case "char", "character" -> "CHAR(" + (len == null ? 1 : len) + ")";
  366. case "varchar", "character varying" -> {
  367. if (len == null) yield "VARCHAR(255)";
  368. int n = Integer.parseInt(len);
  369. if (n > 8188) { warns.add(ctx + ": VARCHAR(" + n + ") 超达梦页上限,降级 TEXT"); yield "TEXT"; }
  370. yield "VARCHAR(" + n + ")";
  371. }
  372. case "tinytext", "text" -> "TEXT";
  373. case "mediumtext", "longtext" -> "CLOB";
  374. case "json" -> "TEXT";
  375. case "enum", "set" -> { warns.add(ctx + ": " + base.toUpperCase()
  376. + " 映射为 VARCHAR(255),枚举元素约束不迁移"); yield "VARCHAR(255)"; }
  377. case "binary" -> "BINARY(" + (len == null ? 1 : len) + ")";
  378. case "varbinary" -> "VARBINARY(" + (len == null ? 255 : len) + ")";
  379. case "tinyblob", "blob", "mediumblob", "longblob" -> "BLOB";
  380. default -> unsupport(warns, ctx, mysqlType);
  381. };
  382. }
  383. static String unsupport(List<String> warns, String ctx, String t) {
  384. warns.add(ctx + ": 不支持的类型 " + t + ",列已跳过,请手工处理");
  385. return null;
  386. }
  387. /** 生成建表语句 + 后置索引语句(首元素 CREATE TABLE,其余 CREATE [UNIQUE] INDEX)。 */
  388. static List<String> buildCreateTable(TableDef d, String schema, List<String> warns) {
  389. Set<String> genNames = d.generatedCols().stream().map(c -> c.name().toUpperCase()).collect(Collectors.toSet());
  390. List<String> out = new ArrayList<>();
  391. StringBuilder sb = new StringBuilder("CREATE TABLE ").append(schema).append('.').append(ident(d.name)).append(" (\n");
  392. List<String> parts = new ArrayList<>();
  393. for (Col c : d.cols) {
  394. String ctx = d.name + "." + c.name();
  395. String colDdl;
  396. if (c.genExpr() != null) {
  397. colDdl = buildGeneratedColumn(c, ctx, warns);
  398. } else {
  399. String type = mapType(c.rawType(), warns, ctx);
  400. if (type == null) continue;
  401. StringBuilder cb = new StringBuilder(" ").append(ident(c.name())).append(' ').append(type);
  402. if (c.notNull()) cb.append(" NOT NULL");
  403. if (c.autoInc()) cb.append(" IDENTITY(1,1)");
  404. if (c.def() != null) {
  405. String dv = mapDefault(c.def(), warns, ctx);
  406. if (dv != null) cb.append(" DEFAULT ").append(dv);
  407. }
  408. colDdl = cb.toString();
  409. }
  410. if (colDdl != null) parts.add(colDdl);
  411. }
  412. if (!d.pk.isEmpty())
  413. parts.add(" PRIMARY KEY (" + d.pk.stream().map(DbMigrator::ident).collect(Collectors.joining(",")) + ")");
  414. // 不含生成列的唯一约束 inline;含生成列的转后置(达梦 -2034)
  415. Set<String> deferredUk = new LinkedHashSet<>();
  416. for (Idx idx : d.indexes) {
  417. if (!idx.unique) continue;
  418. boolean hasGen = idx.cols.stream().anyMatch(cn -> genNames.contains(cn.toUpperCase()));
  419. if (!hasGen) {
  420. if (idx.cols.size() > 1 && idx.cols.stream().map(d::findCol).filter(Objects::nonNull)
  421. .anyMatch(bc -> bc.genExpr() == null && !bc.notNull()))
  422. warns.add(d.name + "." + idx.name + ": 多列唯一键含可空列,达梦对部分NULL键判重(MySQL任一NULL不判重),NULL组合重复的数据将冲突");
  423. parts.add(" CONSTRAINT " + ident(idx.name) + " UNIQUE ("
  424. + idx.cols.stream().map(DbMigrator::ident).collect(Collectors.joining(",")) + ")");
  425. } else deferredUk.add(idx.name);
  426. }
  427. for (Chk chk : d.checks)
  428. parts.add(" CONSTRAINT " + ident(chk.name()) + " CHECK (" + exprToDm(chk.expr()) + ")");
  429. sb.append(String.join(",\n", parts)).append("\n)");
  430. out.add(sb.toString());
  431. for (Idx idx : d.indexes) {
  432. if (idx.unique) {
  433. if (!deferredUk.contains(idx.name)) continue;
  434. Col marker = findMarkerCol(d, idx, genNames);
  435. String bizCol = idx.cols.stream()
  436. .filter(cn -> !genNames.contains(cn.toUpperCase())).findFirst().orElse(null);
  437. if (bizCol == null) {
  438. // 单标记生成列唯一键("全局唯一默认项"形态):单成分 CASE 守卫即可对齐 MySQL 部分唯一语义
  439. if (idx.cols.size() == 1 && marker != null) {
  440. out.add("CREATE UNIQUE INDEX " + ident(idx.name) + " ON " + schema + '.' + ident(d.name)
  441. + " ((CASE WHEN " + markerCond(marker) + " THEN 1 END))");
  442. } else {
  443. warns.add(d.name + "." + idx.name + ": 唯一键全部由生成列构成,已跳过");
  444. }
  445. continue;
  446. }
  447. String key;
  448. if (marker != null) {
  449. // NULL 判重语义对齐:MySQL 任一键列 NULL 即不判重;达梦(Oracle 语义)仅全 NULL 键不判重、
  450. // 部分 NULL 键的 NULL 视为相等参与判重。对齐方式:全部键成分套同一守卫 CASE——
  451. // 判重条件不满足时整行键置全 NULL(达梦对全 NULL 键不判重),满足时展开原值。
  452. String guard = idx.cols.stream()
  453. .filter(cn -> !genNames.contains(cn.toUpperCase()))
  454. .map(d::findCol).filter(Objects::nonNull)
  455. .filter(bc -> !bc.notNull())
  456. .map(bc -> ident(bc.name()) + " IS NOT NULL")
  457. .collect(Collectors.joining(" AND "));
  458. String cond = guard.isEmpty() ? markerCond(marker)
  459. : guard + " AND " + markerCond(marker);
  460. key = idx.cols.stream()
  461. .map(cn -> genNames.contains(cn.toUpperCase())
  462. ? "(CASE WHEN " + cond + " THEN 1 END)"
  463. : "(CASE WHEN " + cond + " THEN " + ident(cn) + " END)")
  464. .collect(Collectors.joining(","));
  465. } else {
  466. key = idx.cols.stream().filter(cn -> !genNames.contains(cn.toUpperCase()))
  467. .map(DbMigrator::ident).collect(Collectors.joining(","));
  468. warns.add(d.name + "." + idx.name + ": 唯一键含生成列,已剔除生成列降级为普通唯一索引");
  469. }
  470. out.add("CREATE UNIQUE INDEX " + ident(idx.name) + " ON " + schema + '.' + ident(d.name)
  471. + " (" + key + ")");
  472. } else if (idx.fulltext || idx.spatial) {
  473. warns.add(d.name + "." + idx.name + ": 达梦无 FULLTEXT/SPATIAL 索引,已跳过");
  474. } else {
  475. if (idx.prefixes.stream().anyMatch(Objects::nonNull))
  476. warns.add(d.name + "." + idx.name + ": 达梦不支持前缀索引,已按整列建索引");
  477. out.add("CREATE INDEX " + ident(idx.name) + " ON " + schema + '.' + ident(d.name) + " ("
  478. + idx.cols.stream().map(DbMigrator::ident).collect(Collectors.joining(",")) + ")");
  479. }
  480. }
  481. return out;
  482. }
  483. /** 生成列 -> 达梦 AS(...) 列:IF(a,b,c) -> CASE WHEN a THEN b ELSE c END。 */
  484. static String buildGeneratedColumn(Col c, String ctx, List<String> warns) {
  485. String type = mapType(c.rawType(), warns, ctx);
  486. if (type == null) type = "INT";
  487. if (type.equals("INT") || type.equals("BIGINT")) type = "TINYINT"; // 1/NULL 标记形态实测 TINYINT
  488. String expr = exprToDm(c.genExpr());
  489. if (expr == null || expr.isEmpty()) {
  490. warns.add(ctx + ": 生成列表达式无法转译(" + c.genExpr() + "),列已跳过");
  491. return null;
  492. }
  493. if (Pattern.compile("\\bIF\\s*\\(").matcher(expr).find())
  494. warns.add(ctx + ": 生成列表达式含未完全转译的 IF,请人工核验");
  495. return " " + ident(c.name()) + " " + type + " AS (" + expr + ")";
  496. }
  497. /** 唯一键中的 1/NULL 活跃标记生成列(IF((cond),1,NULL) 形态)。 */
  498. static Col findMarkerCol(TableDef d, Idx idx, Set<String> genNames) {
  499. for (String cn : idx.cols) {
  500. if (!genNames.contains(cn.toUpperCase())) continue;
  501. Col gc = d.cols.stream().filter(x -> x.name().equalsIgnoreCase(cn)).findFirst().orElse(null);
  502. if (gc != null && markerCond(gc) != null) return gc;
  503. }
  504. return null;
  505. }
  506. /** 提取生成列 IF((cond),1,NULL) 的 cond(达梦表达式);非该形态返回 null。 */
  507. static String markerCond(Col gc) {
  508. Matcher m = Pattern.compile("^if\\s*\\(\\((.*)\\)\\s*,\\s*1\\s*,\\s*null\\s*\\)$",
  509. Pattern.CASE_INSENSITIVE | Pattern.DOTALL).matcher(gc.genExpr().trim());
  510. return m.matches() ? exprToDm(m.group(1)) : null;
  511. }
  512. static String mapDefault(String def, List<String> warns, String ctx) {
  513. String d = def.trim();
  514. if (d.regionMatches(true, 0, "CURRENT_TIMESTAMP", 0, 17)) return "CURRENT_TIMESTAMP";
  515. if (d.startsWith("_utf8mb4")) d = d.substring(8);
  516. if (d.startsWith("'")) return d;
  517. if (d.matches("-?\\d+(\\.\\d+)?")) return d;
  518. warns.add(ctx + ": 默认值 " + def + " 无法映射,已丢弃");
  519. return null;
  520. }
  521. /** 表达式转译:_utf8mb4 剥离、`x` -> X、IF(a,b,c) -> CASE WHEN、CURRENT_TIMESTAMP(n) 归一。 */
  522. static String exprToDm(String e) {
  523. String out = e.replace("_utf8mb4", "");
  524. out = Pattern.compile("`([^`]+)`").matcher(out).replaceAll(r -> Matcher.quoteReplacement(ident(r.group(1))));
  525. out = out.replaceAll("CURRENT_TIMESTAMP\\(\\d+\\)", "CURRENT_TIMESTAMP");
  526. Matcher ifm = Pattern.compile("^if\\s*\\((.*)\\)$", Pattern.CASE_INSENSITIVE | Pattern.DOTALL).matcher(out.trim());
  527. if (ifm.matches()) {
  528. List<String> args = splitTop(ifm.group(1));
  529. if (args.size() == 3)
  530. out = "CASE WHEN " + args.get(0).trim() + " THEN " + args.get(1).trim()
  531. + " ELSE " + args.get(2).trim() + " END";
  532. }
  533. return out.trim();
  534. }
  535. static String buildFk(TableDef d, Fk fk, String schema) {
  536. String onDel = fk.onDelete() == null ? "" : fk.onDelete().trim().toUpperCase();
  537. if (onDel.equals("RESTRICT")) onDel = ""; // 达梦不支持,NO ACTION 等价
  538. return "ALTER TABLE " + schema + '.' + ident(d.name) + " ADD CONSTRAINT " + ident(fk.name())
  539. + " FOREIGN KEY (" + fk.cols().stream().map(DbMigrator::ident).collect(Collectors.joining(",")) + ")"
  540. + " REFERENCES " + schema + '.' + ident(fk.refTable())
  541. + " (" + fk.refCols().stream().map(DbMigrator::ident).collect(Collectors.joining(",")) + ")"
  542. + (onDel.isEmpty() ? "" : " ON DELETE " + onDel);
  543. }
  544. static List<String> buildComments(TableDef d, String schema) {
  545. List<String> out = new ArrayList<>();
  546. if (d.comment != null)
  547. out.add("COMMENT ON TABLE " + schema + '.' + ident(d.name) + " IS '" + d.comment.replace("'", "''") + "'");
  548. for (Col c : d.cols)
  549. if (c.comment() != null)
  550. out.add("COMMENT ON COLUMN " + schema + '.' + ident(d.name) + '.' + ident(c.name())
  551. + " IS '" + c.comment().replace("'", "''") + "'");
  552. return out;
  553. }
  554. // ==================== 模式管理 ====================
  555. static void createSchema(Connection dst, String schema) throws SQLException {
  556. try (Statement st = dst.createStatement()) {
  557. st.execute("CREATE SCHEMA " + schema + " AUTHORIZATION \"" + dst.getMetaData().getUserName() + "\"");
  558. System.out.println("[建模式] " + schema + " OK");
  559. } catch (SQLException e) {
  560. try (ResultSet rs = dst.getMetaData().getSchemas()) {
  561. while (rs.next())
  562. if (rs.getString(1).equalsIgnoreCase(schema)) {
  563. System.out.println("[建模式] " + schema + " 已存在,跳过");
  564. return;
  565. }
  566. }
  567. throw e;
  568. }
  569. }
  570. /** 幂等清理:先删目标模式全部外键(解除依赖),再删全部表。 */
  571. static void dropSchemaObjects(Connection dst, String schema) throws SQLException {
  572. List<String[]> fks = new ArrayList<>();
  573. try (ResultSet rs = dst.createStatement().executeQuery(
  574. "SELECT CONSTRAINT_NAME, TABLE_NAME FROM ALL_CONSTRAINTS WHERE OWNER='" + schema
  575. + "' AND CONSTRAINT_TYPE='R'")) {
  576. while (rs.next()) fks.add(new String[]{rs.getString(1), rs.getString(2)});
  577. }
  578. int fkOk = 0;
  579. for (String[] fk : fks)
  580. try (Statement st = dst.createStatement()) {
  581. st.execute("ALTER TABLE " + schema + ".\"" + fk[1] + "\" DROP CONSTRAINT \"" + fk[0] + "\"");
  582. fkOk++;
  583. } catch (SQLException e) {
  584. System.out.println(" [外键删除跳过] " + fk[1] + "." + fk[0] + ": " + e.getMessage().trim());
  585. }
  586. List<String> tables = new ArrayList<>();
  587. try (ResultSet rs = dst.getMetaData().getTables(null, schema, "%", new String[]{"TABLE"})) {
  588. while (rs.next()) tables.add(rs.getString("TABLE_NAME"));
  589. }
  590. int tOk = 0;
  591. for (String t : tables)
  592. try (Statement st = dst.createStatement()) {
  593. st.execute("DROP TABLE " + schema + ".\"" + t + "\"");
  594. tOk++;
  595. } catch (SQLException e) {
  596. System.out.println(" [表删除失败] " + t + ": " + e.getMessage().trim());
  597. }
  598. System.out.println("[清理] 外键删除 " + fkOk + "/" + fks.size() + ",表删除 " + tOk + "/" + tables.size());
  599. }
  600. static void exec(Connection c, String sql, String label) {
  601. try (Statement st = c.createStatement()) {
  602. st.execute(sql);
  603. System.out.println("[" + label + "] OK");
  604. } catch (SQLException e) {
  605. System.out.println("[" + label + "] 失败: " + e.getErrorCode() + " " + abbrev(e.getMessage()));
  606. System.out.println(" SQL: " + abbrev(sql));
  607. throw new RuntimeException(label + " 失败", e);
  608. }
  609. }
  610. static void execQuiet(Connection c, String sql) {
  611. try (Statement st = c.createStatement()) { st.execute(sql); } catch (SQLException ignored) { }
  612. }
  613. // ==================== 数据搬运 ====================
  614. /**
  615. * 单表搬运:单列数值主键走窗口分页(WHERE pk > ? ORDER BY pk LIMIT n,内存友好),
  616. * 其余(复合/非数值/无主键)一次性流式读。失败自动降级逐行抢救。
  617. */
  618. static void copyData(Connection src, Connection dst, TableDef d, String schema,
  619. int batch, Path errLog, List<String> warns) {
  620. List<String> cols = d.dataCols();
  621. if (cols.isEmpty()) { System.out.println("[数据] " + d.name + ": 无可搬运列,跳过"); return; }
  622. boolean windowed = d.singleNumericPk();
  623. if (!windowed) warns.add(d.name + ": 非单列数值主键,整表加载(大表请评估内存)");
  624. String srcCols = cols.stream().map(cn -> '`' + cn + '`').collect(Collectors.joining(","));
  625. String target = schema + '.' + ident(d.name);
  626. String ins = "INSERT INTO " + target + " (" + cols.stream().map(DbMigrator::ident)
  627. .collect(Collectors.joining(",")) + ") VALUES (" + String.join(",", Collections.nCopies(cols.size(), "?")) + ")";
  628. long t0 = System.currentTimeMillis();
  629. try {
  630. dst.setAutoCommit(false);
  631. if (d.hasIdentity) execDdl(dst, "SET IDENTITY_INSERT " + target + " ON");
  632. long copied = 0;
  633. if (windowed) {
  634. String pk = d.pk.get(0);
  635. long last = Long.MIN_VALUE;
  636. while (true) {
  637. int n;
  638. try (PreparedStatement q = src.prepareStatement(
  639. "SELECT " + srcCols + " FROM `" + d.name + "` WHERE `" + pk + "` > ? ORDER BY `"
  640. + pk + "` LIMIT " + batch)) {
  641. q.setLong(1, last);
  642. try (ResultSet rs = q.executeQuery();
  643. PreparedStatement ps = dst.prepareStatement(ins)) {
  644. n = 0;
  645. while (rs.next()) {
  646. for (int i = 1; i <= cols.size(); i++) ps.setObject(i, rs.getObject(i));
  647. last = Math.max(last, rs.getLong(pk));
  648. ps.addBatch();
  649. if (++n >= batch) break;
  650. }
  651. ps.executeBatch();
  652. }
  653. }
  654. if (n == 0) break;
  655. copied += n;
  656. dst.commit();
  657. }
  658. } else {
  659. try (PreparedStatement q = src.prepareStatement(
  660. "SELECT " + srcCols + " FROM `" + d.name + "`"
  661. + (d.pk.isEmpty() ? "" : " ORDER BY " + d.pk.stream()
  662. .map(p -> '`' + p + '`').collect(Collectors.joining(","))));
  663. ResultSet rs = q.executeQuery();
  664. PreparedStatement ps = dst.prepareStatement(ins)) {
  665. int n = 0;
  666. while (rs.next()) {
  667. for (int i = 1; i <= cols.size(); i++) ps.setObject(i, rs.getObject(i));
  668. ps.addBatch();
  669. copied++;
  670. if (++n >= batch) { ps.executeBatch(); dst.commit(); n = 0; }
  671. }
  672. if (n > 0) { ps.executeBatch(); dst.commit(); }
  673. }
  674. }
  675. if (d.hasIdentity) execDdl(dst, "SET IDENTITY_INSERT " + target + " OFF");
  676. System.out.printf("[数据] %-30s %8d 行 %.1fs%n", d.name, copied,
  677. (System.currentTimeMillis() - t0) / 1000.0);
  678. } catch (Exception e) {
  679. try { dst.rollback(); } catch (SQLException ignored) { }
  680. try { if (d.hasIdentity) execDdl(dst, "SET IDENTITY_INSERT " + target + " OFF"); } catch (SQLException ignored) { }
  681. long salvaged = copyRowByRow(src, dst, d, schema, cols, errLog);
  682. System.out.printf("[数据] %s 批量失败(%s),逐行抢救完成 %d 行%n", d.name, abbrev(e.getMessage()), salvaged);
  683. } finally {
  684. try { dst.setAutoCommit(true); } catch (SQLException ignored) { }
  685. }
  686. }
  687. static long totalRows(Connection src, TableDef d) throws SQLException {
  688. try (Statement st = src.createStatement(); ResultSet rs = st.executeQuery(
  689. "SELECT COUNT(*) FROM `" + d.name + "`")) { rs.next(); return rs.getLong(1); }
  690. }
  691. /** 批次失败后的逐行抢救:成功行逐行提交,坏行写错误日志并跳过。 */
  692. static long copyRowByRow(Connection src, Connection dst, TableDef d, String schema,
  693. List<String> cols, Path errLog) {
  694. String srcCols = cols.stream().map(cn -> '`' + cn + '`').collect(Collectors.joining(","));
  695. String target = schema + '.' + ident(d.name);
  696. String ins = "INSERT INTO " + target + " (" + cols.stream().map(DbMigrator::ident)
  697. .collect(Collectors.joining(",")) + ") VALUES (" + String.join(",", Collections.nCopies(cols.size(), "?")) + ")";
  698. long ok = 0;
  699. List<String> bad = new ArrayList<>();
  700. try {
  701. dst.setAutoCommit(true);
  702. if (d.hasIdentity) execDdl(dst, "SET IDENTITY_INSERT " + target + " ON");
  703. try (PreparedStatement q = src.prepareStatement("SELECT " + srcCols + " FROM `" + d.name + "`");
  704. ResultSet rs = q.executeQuery();
  705. PreparedStatement ps = dst.prepareStatement(ins)) {
  706. while (rs.next()) {
  707. try {
  708. for (int i = 1; i <= cols.size(); i++) ps.setObject(i, rs.getObject(i));
  709. ps.executeUpdate();
  710. ok++;
  711. } catch (SQLException e) {
  712. bad.add("[行丢弃] " + d.name + " pk≈" + safePkValue(rs, d) + " : "
  713. + e.getErrorCode() + " " + e.getMessage().trim());
  714. }
  715. }
  716. }
  717. if (d.hasIdentity) execDdl(dst, "SET IDENTITY_INSERT " + target + " OFF");
  718. } catch (Exception fatal) {
  719. throw new RuntimeException("逐行抢救失败: " + fatal.getMessage(), fatal);
  720. }
  721. if (!bad.isEmpty()) {
  722. try {
  723. Files.write(errLog, bad, StandardCharsets.UTF_8,
  724. StandardOpenOption.CREATE, StandardOpenOption.APPEND);
  725. } catch (IOException io) { bad.forEach(System.out::println); }
  726. System.out.println(" 坏行 " + bad.size() + " 行已记录到 " + errLog.toAbsolutePath());
  727. }
  728. return ok;
  729. }
  730. static String safePkValue(ResultSet rs, TableDef d) {
  731. if (d.pk.size() != 1) return "?";
  732. try { return String.valueOf(rs.getObject(d.pk.get(0))); } catch (SQLException e) { return "?"; }
  733. }
  734. static void execDdl(Connection c, String sql) throws SQLException {
  735. try (Statement st = c.createStatement()) { st.execute(sql); }
  736. }
  737. // ==================== 校验 ====================
  738. static boolean verifyTable(Connection src, Connection dst, TableDef d, String schema) {
  739. try {
  740. long a, b;
  741. try (Statement st = src.createStatement(); ResultSet rs = st.executeQuery(
  742. "SELECT COUNT(*) FROM `" + d.name + "`")) { rs.next(); a = rs.getLong(1); }
  743. try (Statement st = dst.createStatement(); ResultSet rs = st.executeQuery(
  744. "SELECT COUNT(*) FROM " + schema + '.' + ident(d.name))) { rs.next(); b = rs.getLong(1); }
  745. String extra = "";
  746. if (d.singleNumericPk()) {
  747. String pk = d.pk.get(0);
  748. try (Statement st = src.createStatement(); ResultSet rs = st.executeQuery(
  749. "SELECT COALESCE(MAX(`" + pk + "`),0) FROM `" + d.name + "`")) {
  750. rs.next(); extra = " maxPk 源=" + rs.getLong(1);
  751. }
  752. try (Statement st = dst.createStatement(); ResultSet rs = st.executeQuery(
  753. "SELECT COALESCE(MAX(" + ident(pk) + "),0) FROM " + schema + '.' + ident(d.name))) {
  754. rs.next(); extra += " 目标=" + rs.getLong(1);
  755. }
  756. }
  757. boolean same = a == b;
  758. System.out.printf(" %-30s 源=%-8d 目标=%-8d %s%s%n", d.name, a, b, same ? "OK" : "!!不一致", extra);
  759. return same;
  760. } catch (SQLException e) {
  761. System.out.println(" " + d.name + " 校验失败: " + e.getMessage().trim());
  762. return false;
  763. }
  764. }
  765. static String abbrev(String s) {
  766. String one = s.replaceAll("\\s+", " ");
  767. return one.length() <= 200 ? one : one.substring(0, 200) + " ...(" + one.length() + " chars)";
  768. }
  769. }