MdpSchemaAligner.cs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  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. await BackfillProvenSourceAsync(db, stdTables.Select(t => t.Name).Distinct(StringComparer.OrdinalIgnoreCase));
  168. }
  169. private static async Task BackfillProvenSourceAsync(ISqlSugarClient db, IEnumerable<string> stdTables)
  170. {
  171. var stg = await db.Ado.SqlQueryAsync<NameRow>(
  172. """
  173. SELECT c.TABLE_NAME AS Name
  174. FROM information_schema.COLUMNS c
  175. WHERE c.TABLE_SCHEMA=DATABASE() AND c.TABLE_NAME LIKE 'mdp_stg%' AND c.COLUMN_NAME='source_biz_key'
  176. AND EXISTS (
  177. SELECT 1 FROM information_schema.COLUMNS t
  178. WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=c.TABLE_NAME AND t.COLUMN_NAME='source_table')
  179. AND EXISTS (
  180. SELECT 1 FROM information_schema.COLUMNS t
  181. WHERE t.TABLE_SCHEMA=DATABASE() AND t.TABLE_NAME=c.TABLE_NAME AND t.COLUMN_NAME='tenant_id')
  182. """);
  183. var stgNames = stg.Select(x => x.Name).Where(IsSafeName).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
  184. if (stgNames.Count == 0) return;
  185. var union = string.Join(" UNION ALL ", stgNames.Select(n =>
  186. $"SELECT g.tenant_id, g.source_biz_key, r.source_system FROM `{n}` g JOIN mdp_source_table_registry r ON r.source_table=g.source_table"));
  187. var proven =
  188. $"""
  189. SELECT tenant_id, source_biz_key, MIN(source_system) AS source_system
  190. FROM ({union}) u
  191. GROUP BY tenant_id, source_biz_key
  192. HAVING COUNT(DISTINCT source_system)=1
  193. """;
  194. // 同一条子查询以前按中立表各跑一遍,大贴源表上每次都超时,启动被堵在监听之前。
  195. // 只物化一次;物化失败则整批跳过,不改 source_system(确证不了就不猜)。
  196. var previousTimeout = db.Ado.CommandTimeOut;
  197. db.Ado.CommandTimeOut = 20;
  198. try
  199. {
  200. await db.Ado.ExecuteCommandAsync("DROP TEMPORARY TABLE IF EXISTS tmp_mdp_proven_source");
  201. await db.Ado.ExecuteCommandAsync($"CREATE TEMPORARY TABLE tmp_mdp_proven_source AS {proven}");
  202. await db.Ado.ExecuteCommandAsync(
  203. "ALTER TABLE tmp_mdp_proven_source ADD INDEX idx_proven (tenant_id, source_biz_key)");
  204. }
  205. catch (Exception ex)
  206. {
  207. Log($"来源回填整批跳过: {ex.Message}");
  208. return;
  209. }
  210. finally
  211. {
  212. db.Ado.CommandTimeOut = previousTimeout;
  213. }
  214. foreach (var table in stdTables)
  215. {
  216. if (!IsSafeName(table)) continue;
  217. var ready = await db.Ado.GetIntAsync(
  218. """
  219. SELECT COUNT(*) FROM information_schema.COLUMNS
  220. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t
  221. AND COLUMN_NAME IN ('tenant_id','source_biz_key','source_system','written_by','id')
  222. """,
  223. new SugarParameter("@t", table));
  224. if (ready < 5) continue;
  225. try
  226. {
  227. await db.Ado.ExecuteCommandAsync(
  228. $"""
  229. UPDATE `{table}` s
  230. JOIN tmp_mdp_proven_source x ON x.tenant_id=s.tenant_id AND x.source_biz_key=s.source_biz_key
  231. LEFT JOIN `{table}` o
  232. ON o.tenant_id=s.tenant_id AND o.source_system=x.source_system
  233. AND o.source_biz_key=s.source_biz_key AND o.id<>s.id
  234. SET s.source_system=x.source_system, s.written_by='DB_SYNC'
  235. WHERE s.source_system IN ('AIDOP','','AIDOP_LEGACY')
  236. AND o.id IS NULL
  237. """);
  238. }
  239. catch (Exception ex)
  240. {
  241. Log($"来源回填跳过 {table}: {ex.Message}");
  242. }
  243. }
  244. }
  245. private static bool IsSafeName(string? name) =>
  246. !string.IsNullOrWhiteSpace(name) && Regex.IsMatch(name, "^[A-Za-z0-9_]+$");
  247. public static IReadOnlyList<string> Plan(string statement, SchemaSnapshot snapshot)
  248. {
  249. var text = StripLineComments(statement).Trim().TrimEnd(';').Trim();
  250. var head = CreateHead.Match(text);
  251. if (!head.Success || !IsNeutralTable(head.Groups["name"].Value))
  252. return [text];
  253. var table = head.Groups["name"].Value;
  254. if (head.Groups["like"].Success)
  255. return snapshot.Exists ? [] : [text];
  256. if (!snapshot.Exists)
  257. return [text];
  258. var alters = new List<string>();
  259. foreach (var part in SplitTopLevel(Body(text)))
  260. {
  261. var line = part.Trim().TrimEnd(',').Trim();
  262. if (line.Length == 0) continue;
  263. if (line.StartsWith("PRIMARY", StringComparison.OrdinalIgnoreCase)
  264. || line.StartsWith("CONSTRAINT", StringComparison.OrdinalIgnoreCase)
  265. || line.StartsWith("FULLTEXT", StringComparison.OrdinalIgnoreCase)
  266. || line.StartsWith("SPATIAL", StringComparison.OrdinalIgnoreCase))
  267. continue;
  268. var index = IndexHead.Match(line);
  269. if (index.Success)
  270. {
  271. var indexName = index.Groups["name"].Value;
  272. if (snapshot.Indexes.Contains(indexName)) continue;
  273. var unique = index.Groups["unique"].Success ? "UNIQUE KEY" : "KEY";
  274. alters.Add(
  275. $"ALTER TABLE `{table}` ADD {unique} `{indexName}` ({index.Groups["cols"].Value.Trim()})");
  276. continue;
  277. }
  278. if (line.StartsWith("UNIQUE", StringComparison.OrdinalIgnoreCase)
  279. || line.StartsWith("KEY", StringComparison.OrdinalIgnoreCase)
  280. || line.StartsWith("INDEX", StringComparison.OrdinalIgnoreCase))
  281. continue;
  282. var column = ColumnName(line);
  283. if (column.Length == 0 || snapshot.Columns.Contains(column)) continue;
  284. alters.Add($"ALTER TABLE `{table}` ADD COLUMN {line}");
  285. }
  286. return alters;
  287. }
  288. private static async Task<int> AlignOneAsync(ISqlSugarClient db, string statement)
  289. {
  290. var head = CreateHead.Match(statement.Trim());
  291. var table = head.Groups["name"].Value;
  292. var snapshot = await LoadAsync(db, table);
  293. var planned = Plan(statement, snapshot);
  294. var applied = 0;
  295. foreach (var ddl in planned)
  296. {
  297. if (ddl.Contains("DROP ", StringComparison.OrdinalIgnoreCase))
  298. throw new InvalidOperationException("中台结构对齐器禁止执行 DROP");
  299. await db.Ado.ExecuteCommandAsync(ddl);
  300. Log(ddl);
  301. applied++;
  302. }
  303. return applied;
  304. }
  305. private static async Task<SchemaSnapshot> LoadAsync(ISqlSugarClient db, string table)
  306. {
  307. var exists = await db.Ado.GetIntAsync(
  308. """
  309. SELECT COUNT(*) FROM information_schema.TABLES
  310. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t
  311. """,
  312. new SugarParameter("@t", table));
  313. if (exists == 0)
  314. return SchemaSnapshot.Missing;
  315. var columns = await db.Ado.SqlQueryAsync<NameRow>(
  316. """
  317. SELECT COLUMN_NAME AS Name FROM information_schema.COLUMNS
  318. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t
  319. """,
  320. new SugarParameter("@t", table));
  321. var indexes = await db.Ado.SqlQueryAsync<NameRow>(
  322. """
  323. SELECT DISTINCT INDEX_NAME AS Name FROM information_schema.STATISTICS
  324. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@t
  325. """,
  326. new SugarParameter("@t", table));
  327. return new SchemaSnapshot(
  328. true,
  329. columns.Select(x => x.Name),
  330. indexes.Select(x => x.Name));
  331. }
  332. private static void Log(string ddl)
  333. {
  334. try
  335. {
  336. App.GetService<ILogger<MdpSchemaAlignerLog>>()?.LogInformation("中台结构对齐 {Ddl}", ddl);
  337. }
  338. catch
  339. {
  340. // 单测与未建容器时不记日志
  341. }
  342. }
  343. internal static bool ContainsNeutralCreate(string sql) =>
  344. SplitStatements(sql).Any(IsNeutralCreate);
  345. private static bool IsNeutralCreate(string statement)
  346. {
  347. var head = CreateHead.Match(statement.Trim());
  348. return head.Success && IsNeutralTable(head.Groups["name"].Value);
  349. }
  350. internal static List<string> SplitStatements(string sql)
  351. {
  352. var list = new List<string>();
  353. var sb = new StringBuilder();
  354. var depth = 0;
  355. var quote = false;
  356. for (var i = 0; i < sql.Length; i++)
  357. {
  358. var c = sql[i];
  359. if (c == '\'')
  360. {
  361. if (quote && i + 1 < sql.Length && sql[i + 1] == '\'')
  362. {
  363. sb.Append("''");
  364. i++;
  365. continue;
  366. }
  367. quote = !quote;
  368. sb.Append(c);
  369. continue;
  370. }
  371. if (!quote && c == '(') depth++;
  372. if (!quote && c == ')' && depth > 0) depth--;
  373. if (!quote && depth == 0 && c == ';')
  374. {
  375. Add(list, sb);
  376. continue;
  377. }
  378. sb.Append(c);
  379. }
  380. Add(list, sb);
  381. return list;
  382. }
  383. private static void Add(List<string> list, StringBuilder sb)
  384. {
  385. var text = sb.ToString().Trim();
  386. if (text.Length > 0) list.Add(text);
  387. sb.Clear();
  388. }
  389. private static string Body(string create)
  390. {
  391. var start = create.IndexOf('(');
  392. var end = create.LastIndexOf(')');
  393. if (start < 0 || end <= start) return string.Empty;
  394. return create.Substring(start + 1, end - start - 1);
  395. }
  396. private static List<string> SplitTopLevel(string body)
  397. {
  398. var list = new List<string>();
  399. var sb = new StringBuilder();
  400. var depth = 0;
  401. var quote = false;
  402. foreach (var c in body)
  403. {
  404. if (c == '\'') quote = !quote;
  405. if (!quote && c == '(') depth++;
  406. if (!quote && c == ')' && depth > 0) depth--;
  407. if (!quote && depth == 0 && c == ',')
  408. {
  409. list.Add(sb.ToString());
  410. sb.Clear();
  411. continue;
  412. }
  413. sb.Append(c);
  414. }
  415. if (sb.Length > 0) list.Add(sb.ToString());
  416. return list;
  417. }
  418. private static string ColumnName(string line)
  419. {
  420. var token = line.Split([' ', '\t', '\r', '\n'], 2, StringSplitOptions.RemoveEmptyEntries)[0];
  421. return token.Trim('`');
  422. }
  423. /// <summary>
  424. /// 剥掉 SQL 行注释,必须在按逗号拆列定义之前做。
  425. /// 注释残留会让 <see cref="ColumnName"/> 取到「--」而不是真正的列名,于是已存在的列
  426. /// 被当成缺列又 ADD 一次;MySQL 执行时自己把注释吃掉,暴露出来就是
  427. /// 「Duplicate column name」。dwd_requirement_examine_detail 的 need_count 前面有一段
  428. /// 说明注释,S1 重算因此每次都在建表对齐阶段失败。
  429. /// 注释里若出现逗号、括号或单引号,还会把拆分与引号配对一起带偏,所以要先剥再拆。
  430. /// 按 MySQL 的口径,只有 <c>--</c> 后面跟空白(或到行尾)才算注释。
  431. /// </summary>
  432. internal static string StripLineComments(string sql)
  433. {
  434. var sb = new StringBuilder(sql.Length);
  435. var quote = false;
  436. for (var i = 0; i < sql.Length; i++)
  437. {
  438. var c = sql[i];
  439. if (c == '\'')
  440. {
  441. if (quote && i + 1 < sql.Length && sql[i + 1] == '\'')
  442. {
  443. sb.Append("''");
  444. i++;
  445. continue;
  446. }
  447. quote = !quote;
  448. sb.Append(c);
  449. continue;
  450. }
  451. if (!quote && c == '-' && i + 1 < sql.Length && sql[i + 1] == '-'
  452. && (i + 2 >= sql.Length || char.IsWhiteSpace(sql[i + 2])))
  453. {
  454. while (i < sql.Length && sql[i] != '\n') i++;
  455. if (i < sql.Length) sb.Append('\n');
  456. continue;
  457. }
  458. sb.Append(c);
  459. }
  460. return sb.ToString();
  461. }
  462. private sealed class NameRow
  463. {
  464. public string Name { get; set; } = string.Empty;
  465. }
  466. /// <summary>仅用于取日志泛型,避免把静态类当作日志类别。</summary>
  467. private sealed class MdpSchemaAlignerLog;
  468. }
  469. public sealed class SchemaSnapshot
  470. {
  471. public static readonly SchemaSnapshot Missing = new(false, [], []);
  472. public SchemaSnapshot(bool exists, IEnumerable<string> columns, IEnumerable<string> indexes)
  473. {
  474. Exists = exists;
  475. Columns = new HashSet<string>(columns.Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIgnoreCase);
  476. Indexes = new HashSet<string>(indexes.Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIgnoreCase);
  477. }
  478. public bool Exists { get; }
  479. public HashSet<string> Columns { get; }
  480. public HashSet<string> Indexes { get; }
  481. }