S8PurchaseDeliverySqlMappingTests.cs 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. using System.Reflection;
  2. using System.Text.RegularExpressions;
  3. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
  4. using Xunit;
  5. namespace Admin.NET.Test.S8;
  6. /// <summary>
  7. /// S8-RULE01-SQL-MAPPING-GUARD-1:SQL 结果别名与 POCO 属性的映射契约。
  8. ///
  9. /// <b>为什么需要单独一个文件</b>:本仓 <c>DbSettings.EnableUnderLine = false</c>,
  10. /// SqlSugar 不做 snake_case → PascalCase 换算,<c>Ado.SqlQueryAsync&lt;T&gt;</c> 按名字直接匹配。
  11. /// 别名一旦写成 snake_case,映射会**静默失败**:
  12. /// · 不抛异常
  13. /// · 行数照常返回
  14. /// · 但每个属性都是 null / default
  15. /// 于是 evaluator 在 <c>due == null</c> 处逐行跳过,命中恒为 0 —— 表面像"当前没有超期单",
  16. /// 实际是取数根本没落到对象上。本地全链实验中真实发生过一次(36 候选 / 0 命中)。
  17. ///
  18. /// <b>为什么既有测试抓不到</b>:Provider 映射契约测试是直接 <c>new PurchaseDeliveryRow{...}</c>
  19. /// 再喂 evaluator,整条 SqlSugar 映射被绕开;E2E 测试用的 stub Provider 同理。
  20. /// 两者都在"映射之后"开始验证,因此本文件必须从 <b>SQL 文本本身</b>入手。
  21. ///
  22. /// 不接 DB、不接 DI:解析 SQL 常量 + 反射比对属性名。
  23. /// </summary>
  24. public class S8PurchaseDeliverySqlMappingTests
  25. {
  26. /// <summary>取 SELECT 列表里所有 <c>AS &lt;alias&gt;</c> 的别名。</summary>
  27. private static string[] SelectAliases() =>
  28. Regex.Matches(S8PurchaseDeliveryDataProvider.Sql, @"\bAS\s+([A-Za-z_][A-Za-z0-9_]*)", RegexOptions.IgnoreCase)
  29. .Select(m => m.Groups[1].Value)
  30. .ToArray();
  31. private static string[] RowProperties() =>
  32. typeof(S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow)
  33. .GetProperties(BindingFlags.Public | BindingFlags.Instance)
  34. .Select(p => p.Name)
  35. .ToArray();
  36. /// <summary>
  37. /// 每个 SQL 别名都必须**逐字**等于某个属性名。
  38. /// 用 Ordinal 而非忽略大小写:SqlSugar 的匹配行为不该靠"大概能对上"来赌。
  39. /// </summary>
  40. [Fact]
  41. public void EverySelectAlias_MapsToRowProperty()
  42. {
  43. var props = RowProperties();
  44. var aliases = SelectAliases();
  45. Assert.NotEmpty(aliases);
  46. var unmapped = aliases.Where(a => !props.Contains(a, StringComparer.Ordinal)).ToArray();
  47. Assert.True(unmapped.Length == 0,
  48. "以下 SQL 别名在 PurchaseDeliveryRow 上找不到同名属性,SqlSugar 会静默映射为 null:\n "
  49. + string.Join("\n ", unmapped));
  50. }
  51. /// <summary>
  52. /// 反向:每个属性都必须被某个别名覆盖。
  53. /// 漏掉一个属性同样是静默 null —— 例如漏掉 DueAt 就直接导致命中恒为 0。
  54. /// </summary>
  55. [Fact]
  56. public void EveryRowProperty_IsProducedBySql()
  57. {
  58. var aliases = SelectAliases();
  59. var missing = RowProperties().Where(p => !aliases.Contains(p, StringComparer.Ordinal)).ToArray();
  60. Assert.True(missing.Length == 0,
  61. "以下属性没有对应的 SQL 别名,取数后将恒为 null:\n " + string.Join("\n ", missing));
  62. }
  63. /// <summary>
  64. /// 别名不得含下划线。EnableUnderLine=false 时 snake_case 别名等于"注定映射不上",
  65. /// 而失败形态是静默的,所以这条要单独钉死,而不是依赖上面两条的间接覆盖。
  66. /// </summary>
  67. [Fact]
  68. public void NoAlias_UsesSnakeCase()
  69. {
  70. var snake = SelectAliases().Where(a => a.Contains('_', StringComparison.Ordinal)).ToArray();
  71. Assert.True(snake.Length == 0,
  72. "本仓 EnableUnderLine=false,snake_case 别名会静默映射失败:\n " + string.Join("\n ", snake));
  73. }
  74. /// <summary>
  75. /// 驱动判定的四个 canonical 字段必须齐备。
  76. /// 其余诊断列缺失只是 payload 少信息,这四个缺一个就直接改变判定结果。
  77. /// </summary>
  78. [Theory]
  79. [InlineData("SourceObjectId")]
  80. [InlineData("RelatedObjectCode")]
  81. [InlineData("DueAt")]
  82. [InlineData("Status")]
  83. public void CanonicalDrivingField_IsSelected(string alias)
  84. {
  85. Assert.Contains(alias, SelectAliases(), StringComparer.Ordinal);
  86. }
  87. /// <summary>
  88. /// WHERE / FROM 仍必须用数据库真实列名(snake_case)——
  89. /// 别名只改输出侧,改错这一侧会直接让 SQL 报「Unknown column」。
  90. /// </summary>
  91. [Fact]
  92. public void PhysicalColumnNames_StayUnchangedInPredicates()
  93. {
  94. var sql = S8PurchaseDeliveryDataProvider.Sql;
  95. Assert.Contains("d.tenant_id = @TenantId", sql, StringComparison.Ordinal);
  96. Assert.Contains("d.stat_date =", sql, StringComparison.Ordinal);
  97. Assert.Contains("d.po_no <> ''", sql, StringComparison.Ordinal);
  98. Assert.Contains("d.po_line <> ''", sql, StringComparison.Ordinal);
  99. Assert.Contains("FROM dwd_supplier_delivery d", sql, StringComparison.Ordinal);
  100. }
  101. }