MdpSchemaAligner.cs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. using System.Text;
  2. using System.Text.RegularExpressions;
  3. using Admin.NET.Plugin.AiDOP.DataPlatform;
  4. using Microsoft.Extensions.Logging;
  5. namespace Admin.NET.Plugin.AiDOP.DataPlatform.Schema;
  6. /// <summary>
  7. /// 中台表(mdp_std_* / mdp_stg_* / dwd_*)的增量对齐。
  8. /// 表不存在则按声明建表;已存在则只 ADD COLUMN / ADD KEY。不 DROP、不改类型、不改已有索引。
  9. /// </summary>
  10. public static class MdpSchemaAligner
  11. {
  12. private static readonly Regex CreateHead = new(
  13. @"^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+`?(?<name>[A-Za-z0-9_]+)`?(?:\s+LIKE\s+`?(?<like>[A-Za-z0-9_]+)`?)?",
  14. RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline);
  15. private static readonly Regex IndexHead = new(
  16. @"^(?<unique>UNIQUE\s+)?(?:KEY|INDEX)\s+`?(?<name>[A-Za-z0-9_]+)`?\s*\((?<cols>[^)]+)\)",
  17. RegexOptions.IgnoreCase | RegexOptions.Compiled);
  18. public static bool IsNeutralTable(string name) =>
  19. name.StartsWith("mdp_std_", StringComparison.OrdinalIgnoreCase)
  20. || name.StartsWith("mdp_stg_", StringComparison.OrdinalIgnoreCase)
  21. || name.StartsWith("dwd_", StringComparison.OrdinalIgnoreCase);
  22. /// <summary>
  23. /// 非中立建表语句原样交给数据库;中立建表改为增量对齐,避免 CREATE IF NOT EXISTS 在表已存在时不补列。
  24. /// </summary>
  25. public static async Task<int> ExecuteAsync(ISqlSugarClient db, string sql, params SugarParameter[] parameters)
  26. {
  27. if (!ContainsNeutralCreate(sql))
  28. return await db.Ado.ExecuteCommandAsync(sql, parameters);
  29. var applied = 0;
  30. foreach (var statement in SplitStatements(sql))
  31. {
  32. if (IsNeutralCreate(statement))
  33. applied += await AlignOneAsync(db, statement);
  34. else
  35. applied += await db.Ado.ExecuteCommandAsync(statement, parameters);
  36. }
  37. return applied;
  38. }
  39. public static Task<int> ExecuteAsync(ISqlSugarClient db, string sql, List<SugarParameter> parameters) =>
  40. ExecuteAsync(db, sql, parameters?.ToArray() ?? []);
  41. public static async Task EnsureWrittenByColumnAsync(ISqlSugarClient db, string table)
  42. {
  43. if (!IsSafeName(table))
  44. throw new InvalidOperationException($"非法表名:{table}");
  45. var hasWritten = await db.Ado.GetIntAsync(
  46. """
  47. SELECT COUNT(*) FROM information_schema.COLUMNS
  48. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t AND COLUMN_NAME='written_by'
  49. """,
  50. new SugarParameter("@t", table));
  51. if (hasWritten > 0) return;
  52. var ddl =
  53. $"ALTER TABLE `{table}` ADD COLUMN written_by VARCHAR(32) NOT NULL DEFAULT 'LEGACY_UNVERIFIED' COMMENT 'DB_SYNC/API_INBOUND/PLATFORM_FORM/SEED/LEGACY_UNVERIFIED'";
  54. await db.Ado.ExecuteCommandAsync(ddl);
  55. Log(ddl);
  56. }
  57. /// <summary>
  58. /// 给已有 source_system 的 mdp_std_* 补 written_by,并按贴源表名登记真实来源。
  59. /// 确证不了的行不改 source_system,只保持 written_by=LEGACY_UNVERIFIED。
  60. /// </summary>
  61. public static async Task EnsureSourceIdentityAsync(ISqlSugarClient db)
  62. {
  63. await db.Ado.ExecuteCommandAsync(
  64. """
  65. CREATE TABLE IF NOT EXISTS mdp_source_table_registry (
  66. id BIGINT NOT NULL AUTO_INCREMENT,
  67. source_table VARCHAR(64) NOT NULL COMMENT '贴源记录的原始表名',
  68. source_system VARCHAR(50) NOT NULL COMMENT '真实来源系统码',
  69. remark VARCHAR(255) NULL,
  70. PRIMARY KEY (id),
  71. UNIQUE KEY uk_source_table (source_table)
  72. ) COMMENT='原始表名 → 真实来源系统 的唯一对照'
  73. """);
  74. var stgTables = await db.Ado.SqlQueryAsync<NameRow>(
  75. """
  76. SELECT TABLE_NAME AS Name FROM information_schema.TABLES
  77. WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'mdp_stg%'
  78. """);
  79. foreach (var table in stgTables)
  80. {
  81. if (!IsSafeName(table.Name)) continue;
  82. var hasSourceTable = await db.Ado.GetIntAsync(
  83. """
  84. SELECT COUNT(*) FROM information_schema.COLUMNS
  85. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t AND COLUMN_NAME='source_table'
  86. """,
  87. new SugarParameter("@t", table.Name));
  88. if (hasSourceTable == 0) continue;
  89. var names = await db.Ado.SqlQueryAsync<NameRow>(
  90. $"SELECT DISTINCT source_table AS Name FROM `{table.Name}` WHERE source_table IS NOT NULL AND TRIM(source_table)<>''");
  91. foreach (var row in names)
  92. {
  93. var system = MdpSourceIdentity.ClassifySourceTable(row.Name);
  94. if (system == null)
  95. {
  96. Log($"来源表未登记(命名规则无法确证,不猜测): {row.Name}");
  97. continue;
  98. }
  99. await db.Ado.ExecuteCommandAsync(
  100. """
  101. INSERT INTO mdp_source_table_registry (source_table, source_system, remark)
  102. SELECT @t, @s, '1.0.565 按表名规则登记'
  103. FROM DUAL
  104. WHERE NOT EXISTS (SELECT 1 FROM mdp_source_table_registry WHERE source_table=@t)
  105. """,
  106. new SugarParameter("@t", row.Name.Trim()),
  107. new SugarParameter("@s", system));
  108. }
  109. }
  110. var stdTables = await db.Ado.SqlQueryAsync<NameRow>(
  111. """
  112. SELECT TABLE_NAME AS Name FROM information_schema.COLUMNS
  113. WHERE TABLE_SCHEMA=DATABASE() AND COLUMN_NAME='source_system' AND TABLE_NAME LIKE 'mdp_std%'
  114. """);
  115. foreach (var table in stdTables.Select(t => t.Name).Distinct(StringComparer.OrdinalIgnoreCase))
  116. {
  117. if (!IsSafeName(table)) continue;
  118. var hasWritten = await db.Ado.GetIntAsync(
  119. """
  120. SELECT COUNT(*) FROM information_schema.COLUMNS
  121. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t AND COLUMN_NAME='written_by'
  122. """,
  123. new SugarParameter("@t", table));
  124. if (hasWritten == 0)
  125. {
  126. var ddl =
  127. $"ALTER TABLE `{table}` ADD COLUMN written_by VARCHAR(32) NOT NULL DEFAULT 'LEGACY_UNVERIFIED' COMMENT 'DB_SYNC/API_INBOUND/PLATFORM_FORM/SEED/LEGACY_UNVERIFIED'";
  128. await db.Ado.ExecuteCommandAsync(ddl);
  129. Log(ddl);
  130. }
  131. var defaults = await db.Ado.SqlQueryAsync<NameRow>(
  132. """
  133. SELECT COLUMN_DEFAULT AS Name FROM information_schema.COLUMNS
  134. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t AND COLUMN_NAME='source_system'
  135. """,
  136. new SugarParameter("@t", table));
  137. var currentDefault = defaults.FirstOrDefault()?.Name?.Trim().Trim('\'') ?? "";
  138. if (currentDefault == "AIDOP")
  139. {
  140. var ddl = $"ALTER TABLE `{table}` ALTER COLUMN source_system SET DEFAULT ''";
  141. await db.Ado.ExecuteCommandAsync(ddl);
  142. Log(ddl);
  143. }
  144. await db.Ado.ExecuteCommandAsync(
  145. $"""
  146. UPDATE `{table}` SET written_by='DB_SYNC'
  147. WHERE written_by='LEGACY_UNVERIFIED'
  148. AND source_system IN ('DOPDEMORQ_SQLSERVER','T8','AIDOPDEV_MYSQL')
  149. """);
  150. await db.Ado.ExecuteCommandAsync(
  151. $"""
  152. UPDATE `{table}` SET written_by='SEED'
  153. WHERE written_by='LEGACY_UNVERIFIED'
  154. AND source_system IN ('UAT_GENERATOR','UAT','DEMO')
  155. """);
  156. await db.Ado.ExecuteCommandAsync(
  157. $"""
  158. UPDATE `{table}` SET written_by='API_INBOUND'
  159. WHERE written_by='LEGACY_UNVERIFIED' AND source_system='API'
  160. """);
  161. await db.Ado.ExecuteCommandAsync(
  162. $"""
  163. UPDATE `{table}` SET written_by='PLATFORM_FORM'
  164. WHERE written_by='LEGACY_UNVERIFIED' AND source_system='AIDOP_NATIVE'
  165. """);
  166. }
  167. }
  168. /// <summary>
  169. /// 启动后执行历史来源回填。该过程可能扫描大量贴源数据,禁止在 Startup.Configure 中同步调用。
  170. /// 任一贴源表扫描失败时整批不更新,避免遗漏来源后把多来源业务键误判为唯一来源。
  171. /// </summary>
  172. public static async Task BackfillProvenSourceAsync(ISqlSugarClient db)
  173. {
  174. var stdTables = await db.Ado.SqlQueryAsync<NameRow>(
  175. """
  176. SELECT TABLE_NAME AS Name FROM information_schema.COLUMNS
  177. WHERE TABLE_SCHEMA=DATABASE() AND COLUMN_NAME='source_system' AND TABLE_NAME LIKE 'mdp_std%'
  178. """);
  179. // 来源追溯是历史数据修复,不应在每次启动都扫描全部贴源表。
  180. // 先确认确实存在待修复的标准层行;没有遗留值时直接结束。
  181. var pendingTables = new List<string>();
  182. foreach (var table in stdTables.Select(x => x.Name).Where(IsSafeName).Distinct(StringComparer.OrdinalIgnoreCase))
  183. {
  184. var ready = await db.Ado.GetIntAsync(
  185. """
  186. SELECT COUNT(*) FROM information_schema.COLUMNS
  187. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t
  188. AND COLUMN_NAME IN ('tenant_id','source_biz_key','source_system','written_by','id')
  189. """,
  190. new SugarParameter("@t", table));
  191. if (ready < 5) continue;
  192. var pending = await db.Ado.GetIntAsync(
  193. $"""
  194. SELECT EXISTS(
  195. SELECT 1 FROM `{table}`
  196. WHERE source_system IN ('AIDOP','','AIDOP_LEGACY')
  197. LIMIT 1
  198. )
  199. """);
  200. if (pending > 0) pendingTables.Add(table);
  201. }
  202. if (pendingTables.Count == 0)
  203. {
  204. Log("来源回填跳过:无待修复的标准层历史行");
  205. return;
  206. }
  207. var stg = await db.Ado.SqlQueryAsync<NameRow>(
  208. """
  209. SELECT c.TABLE_NAME AS Name
  210. FROM information_schema.COLUMNS c
  211. WHERE c.TABLE_SCHEMA=DATABASE() AND c.TABLE_NAME LIKE 'mdp_stg%' AND c.COLUMN_NAME='source_biz_key'
  212. AND EXISTS (
  213. SELECT 1 FROM information_schema.COLUMNS t
  214. WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=c.TABLE_NAME AND t.COLUMN_NAME='source_table')
  215. AND EXISTS (
  216. SELECT 1 FROM information_schema.COLUMNS t
  217. WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=c.TABLE_NAME AND t.COLUMN_NAME='tenant_id')
  218. """);
  219. var stgNames = stg.Select(x => x.Name).Where(IsSafeName).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
  220. if (stgNames.Count == 0) return;
  221. // 独立客户端保证 TEMPORARY TABLE 的建表、填充、更新始终使用同一物理连接。
  222. // 分表累积替代 70+ 表 UNION ALL;单表失败即整批放弃,确保不会因漏扫而误判唯一来源。
  223. using var isolated = db.CopyNew();
  224. var previousTimeout = db.Ado.CommandTimeOut;
  225. isolated.Ado.CommandTimeOut = 60;
  226. try
  227. {
  228. // SqlSugar 默认会在每条命令后归还连接;事务把后续命令固定到同一连接,
  229. // 否则下一条语句看不到上一条命令创建的 TEMPORARY TABLE。
  230. isolated.Ado.BeginTran();
  231. await isolated.Ado.ExecuteCommandAsync("DROP TEMPORARY TABLE IF EXISTS tmp_mdp_proven_source");
  232. await isolated.Ado.ExecuteCommandAsync(
  233. """
  234. CREATE TEMPORARY TABLE tmp_mdp_proven_source (
  235. tenant_id BIGINT NOT NULL,
  236. source_biz_key VARCHAR(255) NOT NULL,
  237. source_system VARCHAR(50) NOT NULL,
  238. conflicted TINYINT NOT NULL DEFAULT 0,
  239. PRIMARY KEY (tenant_id, source_biz_key)
  240. ) ENGINE=InnoDB
  241. """);
  242. foreach (var table in stgNames)
  243. {
  244. await isolated.Ado.ExecuteCommandAsync(
  245. $"""
  246. INSERT INTO tmp_mdp_proven_source
  247. (tenant_id, source_biz_key, source_system, conflicted)
  248. SELECT g.tenant_id, g.source_biz_key, MIN(r.source_system),
  249. IF(COUNT(DISTINCT r.source_system) = 1, 0, 1)
  250. FROM `{table}` g
  251. JOIN mdp_source_table_registry r ON r.source_table=g.source_table
  252. WHERE g.tenant_id IS NOT NULL
  253. AND g.source_biz_key IS NOT NULL AND g.source_biz_key<>''
  254. GROUP BY g.tenant_id, g.source_biz_key
  255. ON DUPLICATE KEY UPDATE
  256. conflicted=IF(
  257. conflicted=1 OR VALUES(conflicted)=1
  258. OR source_system<>VALUES(source_system),
  259. 1, 0)
  260. """);
  261. }
  262. }
  263. catch (Exception ex)
  264. {
  265. try { isolated.Ado.RollbackTran(); } catch { }
  266. Log($"来源回填整批跳过: {ex.Message}");
  267. return;
  268. }
  269. foreach (var table in pendingTables)
  270. {
  271. if (!IsSafeName(table)) continue;
  272. var ready = await db.Ado.GetIntAsync(
  273. """
  274. SELECT COUNT(*) FROM information_schema.COLUMNS
  275. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t
  276. AND COLUMN_NAME IN ('tenant_id','source_biz_key','source_system','written_by','id')
  277. """,
  278. new SugarParameter("@t", table));
  279. if (ready < 5) continue;
  280. try
  281. {
  282. await isolated.Ado.ExecuteCommandAsync(
  283. $"""
  284. UPDATE `{table}` s
  285. JOIN tmp_mdp_proven_source x ON x.tenant_id=s.tenant_id AND x.source_biz_key=s.source_biz_key
  286. LEFT JOIN `{table}` o
  287. ON o.tenant_id=s.tenant_id AND o.source_system=x.source_system
  288. AND o.source_biz_key=s.source_biz_key AND o.id<>s.id
  289. SET s.source_system=x.source_system, s.written_by='DB_SYNC'
  290. WHERE s.source_system IN ('AIDOP','','AIDOP_LEGACY')
  291. AND x.conflicted=0
  292. AND o.id IS NULL
  293. """);
  294. }
  295. catch (Exception ex)
  296. {
  297. Log($"来源回填跳过 {table}: {ex.Message}");
  298. }
  299. }
  300. await isolated.Ado.ExecuteCommandAsync("DROP TEMPORARY TABLE IF EXISTS tmp_mdp_proven_source");
  301. isolated.Ado.CommitTran();
  302. isolated.Ado.CommandTimeOut = previousTimeout;
  303. }
  304. private static bool IsSafeName(string? name) =>
  305. !string.IsNullOrWhiteSpace(name) && Regex.IsMatch(name, "^[A-Za-z0-9_]+$");
  306. public static IReadOnlyList<string> Plan(string statement, SchemaSnapshot snapshot)
  307. {
  308. var text = StripLineComments(statement).Trim().TrimEnd(';').Trim();
  309. var head = CreateHead.Match(text);
  310. if (!head.Success || !IsNeutralTable(head.Groups["name"].Value))
  311. return [text];
  312. var table = head.Groups["name"].Value;
  313. if (head.Groups["like"].Success)
  314. return snapshot.Exists ? [] : [text];
  315. if (!snapshot.Exists)
  316. return [text];
  317. var alters = new List<string>();
  318. foreach (var part in SplitTopLevel(Body(text)))
  319. {
  320. var line = part.Trim().TrimEnd(',').Trim();
  321. if (line.Length == 0) continue;
  322. if (line.StartsWith("PRIMARY", StringComparison.OrdinalIgnoreCase)
  323. || line.StartsWith("CONSTRAINT", StringComparison.OrdinalIgnoreCase)
  324. || line.StartsWith("FULLTEXT", StringComparison.OrdinalIgnoreCase)
  325. || line.StartsWith("SPATIAL", StringComparison.OrdinalIgnoreCase))
  326. continue;
  327. var index = IndexHead.Match(line);
  328. if (index.Success)
  329. {
  330. var indexName = index.Groups["name"].Value;
  331. if (snapshot.Indexes.Contains(indexName)) continue;
  332. var unique = index.Groups["unique"].Success ? "UNIQUE KEY" : "KEY";
  333. alters.Add(
  334. $"ALTER TABLE `{table}` ADD {unique} `{indexName}` ({index.Groups["cols"].Value.Trim()})");
  335. continue;
  336. }
  337. if (line.StartsWith("UNIQUE", StringComparison.OrdinalIgnoreCase)
  338. || line.StartsWith("KEY", StringComparison.OrdinalIgnoreCase)
  339. || line.StartsWith("INDEX", StringComparison.OrdinalIgnoreCase))
  340. continue;
  341. var column = ColumnName(line);
  342. if (column.Length == 0 || snapshot.Columns.Contains(column)) continue;
  343. alters.Add($"ALTER TABLE `{table}` ADD COLUMN {line}");
  344. }
  345. return alters;
  346. }
  347. private static async Task<int> AlignOneAsync(ISqlSugarClient db, string statement)
  348. {
  349. var head = CreateHead.Match(statement.Trim());
  350. var table = head.Groups["name"].Value;
  351. var snapshot = await LoadAsync(db, table);
  352. var planned = Plan(statement, snapshot);
  353. var applied = 0;
  354. foreach (var ddl in planned)
  355. {
  356. if (ddl.Contains("DROP ", StringComparison.OrdinalIgnoreCase))
  357. throw new InvalidOperationException("中台结构对齐器禁止执行 DROP");
  358. await db.Ado.ExecuteCommandAsync(ddl);
  359. Log(ddl);
  360. applied++;
  361. }
  362. return applied;
  363. }
  364. private static async Task<SchemaSnapshot> LoadAsync(ISqlSugarClient db, string table)
  365. {
  366. var exists = await db.Ado.GetIntAsync(
  367. """
  368. SELECT COUNT(*) FROM information_schema.TABLES
  369. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t
  370. """,
  371. new SugarParameter("@t", table));
  372. if (exists == 0)
  373. return SchemaSnapshot.Missing;
  374. var columns = await db.Ado.SqlQueryAsync<NameRow>(
  375. """
  376. SELECT COLUMN_NAME AS Name FROM information_schema.COLUMNS
  377. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t
  378. """,
  379. new SugarParameter("@t", table));
  380. var indexes = await db.Ado.SqlQueryAsync<NameRow>(
  381. """
  382. SELECT DISTINCT INDEX_NAME AS Name FROM information_schema.STATISTICS
  383. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t
  384. """,
  385. new SugarParameter("@t", table));
  386. return new SchemaSnapshot(
  387. true,
  388. columns.Select(x => x.Name),
  389. indexes.Select(x => x.Name));
  390. }
  391. private static void Log(string ddl)
  392. {
  393. try
  394. {
  395. App.GetService<ILogger<MdpSchemaAlignerLog>>()?.LogInformation("中台结构对齐 {Ddl}", ddl);
  396. }
  397. catch
  398. {
  399. // 单测与未建容器时不记日志
  400. }
  401. }
  402. internal static bool ContainsNeutralCreate(string sql) =>
  403. SplitStatements(sql).Any(IsNeutralCreate);
  404. private static bool IsNeutralCreate(string statement)
  405. {
  406. var head = CreateHead.Match(statement.Trim());
  407. return head.Success && IsNeutralTable(head.Groups["name"].Value);
  408. }
  409. internal static List<string> SplitStatements(string sql)
  410. {
  411. var list = new List<string>();
  412. var sb = new StringBuilder();
  413. var depth = 0;
  414. var quote = false;
  415. for (var i = 0; i < sql.Length; i++)
  416. {
  417. var c = sql[i];
  418. if (c == '\'')
  419. {
  420. if (quote && i + 1 < sql.Length && sql[i + 1] == '\'')
  421. {
  422. sb.Append("''");
  423. i++;
  424. continue;
  425. }
  426. quote = !quote;
  427. sb.Append(c);
  428. continue;
  429. }
  430. if (!quote && c == '(') depth++;
  431. if (!quote && c == ')' && depth > 0) depth--;
  432. if (!quote && depth == 0 && c == ';')
  433. {
  434. Add(list, sb);
  435. continue;
  436. }
  437. sb.Append(c);
  438. }
  439. Add(list, sb);
  440. return list;
  441. }
  442. private static void Add(List<string> list, StringBuilder sb)
  443. {
  444. var text = sb.ToString().Trim();
  445. if (text.Length > 0) list.Add(text);
  446. sb.Clear();
  447. }
  448. private static string Body(string create)
  449. {
  450. var start = create.IndexOf('(');
  451. var end = create.LastIndexOf(')');
  452. if (start < 0 || end <= start) return string.Empty;
  453. return create.Substring(start + 1, end - start - 1);
  454. }
  455. private static List<string> SplitTopLevel(string body)
  456. {
  457. var list = new List<string>();
  458. var sb = new StringBuilder();
  459. var depth = 0;
  460. var quote = false;
  461. foreach (var c in body)
  462. {
  463. if (c == '\'') quote = !quote;
  464. if (!quote && c == '(') depth++;
  465. if (!quote && c == ')' && depth > 0) depth--;
  466. if (!quote && depth == 0 && c == ',')
  467. {
  468. list.Add(sb.ToString());
  469. sb.Clear();
  470. continue;
  471. }
  472. sb.Append(c);
  473. }
  474. if (sb.Length > 0) list.Add(sb.ToString());
  475. return list;
  476. }
  477. private static string ColumnName(string line)
  478. {
  479. var token = line.Split([' ', '\t', '\r', '\n'], 2, StringSplitOptions.RemoveEmptyEntries)[0];
  480. return token.Trim('`');
  481. }
  482. /// <summary>
  483. /// 剥掉 SQL 行注释,必须在按逗号拆列定义之前做。
  484. /// 注释残留会让 <see cref="ColumnName"/> 取到「--」而不是真正的列名,于是已存在的列
  485. /// 被当成缺列又 ADD 一次;MySQL 执行时自己把注释吃掉,暴露出来就是
  486. /// 「Duplicate column name」。dwd_requirement_examine_detail 的 need_count 前面有一段
  487. /// 说明注释,S1 重算因此每次都在建表对齐阶段失败。
  488. /// 注释里若出现逗号、括号或单引号,还会把拆分与引号配对一起带偏,所以要先剥再拆。
  489. /// 按 MySQL 的口径,只有 <c>--</c> 后面跟空白(或到行尾)才算注释。
  490. /// </summary>
  491. internal static string StripLineComments(string sql)
  492. {
  493. var sb = new StringBuilder(sql.Length);
  494. var quote = false;
  495. for (var i = 0; i < sql.Length; i++)
  496. {
  497. var c = sql[i];
  498. if (c == '\'')
  499. {
  500. if (quote && i + 1 < sql.Length && sql[i + 1] == '\'')
  501. {
  502. sb.Append("''");
  503. i++;
  504. continue;
  505. }
  506. quote = !quote;
  507. sb.Append(c);
  508. continue;
  509. }
  510. if (!quote && c == '-' && i + 1 < sql.Length && sql[i + 1] == '-'
  511. && (i + 2 >= sql.Length || char.IsWhiteSpace(sql[i + 2])))
  512. {
  513. while (i < sql.Length && sql[i] != '\n') i++;
  514. if (i < sql.Length) sb.Append('\n');
  515. continue;
  516. }
  517. sb.Append(c);
  518. }
  519. return sb.ToString();
  520. }
  521. private sealed class NameRow
  522. {
  523. public string Name { get; set; } = string.Empty;
  524. }
  525. /// <summary>仅用于取日志泛型,避免把静态类当作日志类别。</summary>
  526. private sealed class MdpSchemaAlignerLog;
  527. }
  528. public sealed class SchemaSnapshot
  529. {
  530. public static readonly SchemaSnapshot Missing = new(false, [], []);
  531. public SchemaSnapshot(bool exists, IEnumerable<string> columns, IEnumerable<string> indexes)
  532. {
  533. Exists = exists;
  534. Columns = new HashSet<string>(columns.Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIgnoreCase);
  535. Indexes = new HashSet<string>(indexes.Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIgnoreCase);
  536. }
  537. public bool Exists { get; }
  538. public HashSet<string> Columns { get; }
  539. public HashSet<string> Indexes { get; }
  540. }