| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114 |
- using System.Reflection;
- using System.Text.RegularExpressions;
- using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
- using Xunit;
- namespace Admin.NET.Test.S8;
- /// <summary>
- /// S8-RULE01-SQL-MAPPING-GUARD-1:SQL 结果别名与 POCO 属性的映射契约。
- ///
- /// <b>为什么需要单独一个文件</b>:本仓 <c>DbSettings.EnableUnderLine = false</c>,
- /// SqlSugar 不做 snake_case → PascalCase 换算,<c>Ado.SqlQueryAsync<T></c> 按名字直接匹配。
- /// 别名一旦写成 snake_case,映射会**静默失败**:
- /// · 不抛异常
- /// · 行数照常返回
- /// · 但每个属性都是 null / default
- /// 于是 evaluator 在 <c>due == null</c> 处逐行跳过,命中恒为 0 —— 表面像"当前没有超期单",
- /// 实际是取数根本没落到对象上。本地全链实验中真实发生过一次(36 候选 / 0 命中)。
- ///
- /// <b>为什么既有测试抓不到</b>:Provider 映射契约测试是直接 <c>new PurchaseDeliveryRow{...}</c>
- /// 再喂 evaluator,整条 SqlSugar 映射被绕开;E2E 测试用的 stub Provider 同理。
- /// 两者都在"映射之后"开始验证,因此本文件必须从 <b>SQL 文本本身</b>入手。
- ///
- /// 不接 DB、不接 DI:解析 SQL 常量 + 反射比对属性名。
- /// </summary>
- public class S8PurchaseDeliverySqlMappingTests
- {
- /// <summary>取 SELECT 列表里所有 <c>AS <alias></c> 的别名。</summary>
- private static string[] SelectAliases() =>
- Regex.Matches(S8PurchaseDeliveryDataProvider.Sql, @"\bAS\s+([A-Za-z_][A-Za-z0-9_]*)", RegexOptions.IgnoreCase)
- .Select(m => m.Groups[1].Value)
- .ToArray();
- private static string[] RowProperties() =>
- typeof(S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow)
- .GetProperties(BindingFlags.Public | BindingFlags.Instance)
- .Select(p => p.Name)
- .ToArray();
- /// <summary>
- /// 每个 SQL 别名都必须**逐字**等于某个属性名。
- /// 用 Ordinal 而非忽略大小写:SqlSugar 的匹配行为不该靠"大概能对上"来赌。
- /// </summary>
- [Fact]
- public void EverySelectAlias_MapsToRowProperty()
- {
- var props = RowProperties();
- var aliases = SelectAliases();
- Assert.NotEmpty(aliases);
- var unmapped = aliases.Where(a => !props.Contains(a, StringComparer.Ordinal)).ToArray();
- Assert.True(unmapped.Length == 0,
- "以下 SQL 别名在 PurchaseDeliveryRow 上找不到同名属性,SqlSugar 会静默映射为 null:\n "
- + string.Join("\n ", unmapped));
- }
- /// <summary>
- /// 反向:每个属性都必须被某个别名覆盖。
- /// 漏掉一个属性同样是静默 null —— 例如漏掉 DueAt 就直接导致命中恒为 0。
- /// </summary>
- [Fact]
- public void EveryRowProperty_IsProducedBySql()
- {
- var aliases = SelectAliases();
- var missing = RowProperties().Where(p => !aliases.Contains(p, StringComparer.Ordinal)).ToArray();
- Assert.True(missing.Length == 0,
- "以下属性没有对应的 SQL 别名,取数后将恒为 null:\n " + string.Join("\n ", missing));
- }
- /// <summary>
- /// 别名不得含下划线。EnableUnderLine=false 时 snake_case 别名等于"注定映射不上",
- /// 而失败形态是静默的,所以这条要单独钉死,而不是依赖上面两条的间接覆盖。
- /// </summary>
- [Fact]
- public void NoAlias_UsesSnakeCase()
- {
- var snake = SelectAliases().Where(a => a.Contains('_', StringComparison.Ordinal)).ToArray();
- Assert.True(snake.Length == 0,
- "本仓 EnableUnderLine=false,snake_case 别名会静默映射失败:\n " + string.Join("\n ", snake));
- }
- /// <summary>
- /// 驱动判定的四个 canonical 字段必须齐备。
- /// 其余诊断列缺失只是 payload 少信息,这四个缺一个就直接改变判定结果。
- /// </summary>
- [Theory]
- [InlineData("SourceObjectId")]
- [InlineData("RelatedObjectCode")]
- [InlineData("DueAt")]
- [InlineData("Status")]
- public void CanonicalDrivingField_IsSelected(string alias)
- {
- Assert.Contains(alias, SelectAliases(), StringComparer.Ordinal);
- }
- /// <summary>
- /// WHERE / FROM 仍必须用数据库真实列名(snake_case)——
- /// 别名只改输出侧,改错这一侧会直接让 SQL 报「Unknown column」。
- /// </summary>
- [Fact]
- public void PhysicalColumnNames_StayUnchangedInPredicates()
- {
- var sql = S8PurchaseDeliveryDataProvider.Sql;
- Assert.Contains("d.tenant_id = @TenantId", sql, StringComparison.Ordinal);
- Assert.Contains("d.stat_date =", sql, StringComparison.Ordinal);
- Assert.Contains("d.po_no <> ''", sql, StringComparison.Ordinal);
- Assert.Contains("d.po_line <> ''", sql, StringComparison.Ordinal);
- Assert.Contains("FROM dwd_supplier_delivery d", sql, StringComparison.Ordinal);
- }
- }
|