|
|
@@ -34,6 +34,8 @@ public class S0DimContractTests
|
|
|
"LABEL_TYPE", "SOP_FILE_TYPE", "QUALITY_BASE_TYPE",
|
|
|
"SAMPLING_SCHEME", "INSPECTION_ITEM", "INSPECTION_METHOD",
|
|
|
"INSPECTION_FREQUENCY", "INSPECTION_INSTRUMENT",
|
|
|
+ // C1 扩展
|
|
|
+ "ITEM",
|
|
|
// C3
|
|
|
"EMP_WORK_DUTY", "LINE_POST", "LINE_POST_SKILL"
|
|
|
],
|
|
|
@@ -698,6 +700,244 @@ public class S0DimContractTests
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // ════════════════════════════════════════════════════════════════════════════
|
|
|
+ // 同库集合式贴源快路径(S0DimSameDbStagingLoader / BuildSameDbStagingInsertSql)
|
|
|
+ //
|
|
|
+ // 这些断言钉死的是「快路径与旧逐行 MdpStagingWriter 的语义等价」。每一条对应一个
|
|
|
+ // 在 aidopdev 上实测过的差异点;改动这些表达式前先看注释里的实测值。
|
|
|
+ // ════════════════════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ /// <summary>构造一列源列描述,便于各用例复用。</summary>
|
|
|
+ private static S0DimSourceColumn Col(string name, string dataType, string? columnType = null, bool nullable = true)
|
|
|
+ => new(name, dataType, columnType ?? dataType, nullable);
|
|
|
+
|
|
|
+ private static readonly IReadOnlySet<string> StgCols =
|
|
|
+ new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
|
+ { "tenant_id", "factory_id", "source_system", "source_table", "source_row_id",
|
|
|
+ "source_biz_key", "raw_data", "sync_batch_id", "sync_time", "process_status", "create_time", "update_time" };
|
|
|
+
|
|
|
+ private static string SameDbSql(params S0DimSourceColumn[] cols) =>
|
|
|
+ S0DimSqlBuilder.BuildSameDbStagingInsertSql(
|
|
|
+ S0DimCatalog.Item, cols, StgCols, ["RecID"]);
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void SameDb_unsigned_integer_json_type_label_is_downstream_irrelevant()
|
|
|
+ {
|
|
|
+ // 已知差异(如实登记,非缺陷):bit(1) 与 `tinyint(1) unsigned zerofill` 在
|
|
|
+ // 旧路径的 raw_data 里 JSON_TYPE = INTEGER,快路径则是 UNSIGNED INTEGER。
|
|
|
+ //
|
|
|
+ // 为什么不去消除它:downstream 只经 MdpJsonSql 读值,而它一律走
|
|
|
+ // JSON_UNQUOTE(JSON_EXTRACT(...)) 再 CAST —— 两种标签下取出的文本完全相同。
|
|
|
+ // 实证:同一批 16150 行真实数据,旧路径与快路径产出的 dim 校验和**逐位相同**
|
|
|
+ // (9215906568012255809101),即该标签差异对 dim 契约零影响。
|
|
|
+ //
|
|
|
+ // 本断言钉死「MdpJsonSql 不依赖 JSON_TYPE」这一前提 —— 一旦有人改成按类型分支,
|
|
|
+ // 上述等价性即不再成立,此测试会失败并把人引到这段注释。
|
|
|
+ foreach (var expr in new[]
|
|
|
+ {
|
|
|
+ MdpJsonSql.Str("s", "X"), MdpJsonSql.Int("s", "X"),
|
|
|
+ MdpJsonSql.Dec("s", "X", 18, 5), MdpJsonSql.DateTimeSec("s", "X"),
|
|
|
+ MdpJsonSql.BoolTrue("s", "X"), MdpJsonSql.Raw("s", "X")
|
|
|
+ })
|
|
|
+ Assert.DoesNotContain("JSON_TYPE", expr, Ord);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void SameDb_source_column_must_be_sqlsugar_mappable()
|
|
|
+ {
|
|
|
+ // 🔴 SqlSugar 的 IDataReaderEntityBuilder.CreateBuilder 走反射构造,
|
|
|
+ // 对只有主构造函数的 positional record 会抛 ArgumentNullException (Parameter 'con')。
|
|
|
+ // 该异常会被 S0DimSameDbStagingLoader 吞掉并静默回退逐行路径 ——
|
|
|
+ // **表现为「变慢」而不是「报错」**,实测踩过一次。此断言让回归变成编译期/单测期可见。
|
|
|
+ var t = typeof(S0DimSourceColumn);
|
|
|
+ Assert.NotNull(t.GetConstructor(Type.EmptyTypes));
|
|
|
+ foreach (var prop in new[] { "Name", "DataType", "ColumnType", "IsNullable" })
|
|
|
+ {
|
|
|
+ var p = t.GetProperty(prop);
|
|
|
+ Assert.NotNull(p);
|
|
|
+ Assert.True(p!.CanWrite, $"{prop} 必须可写,否则 SqlSugar 无法回填");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void SameDb_tinyint1_must_emit_json_boolean_not_integer()
|
|
|
+ {
|
|
|
+ // 旧路径:MySqlConnector 的 TreatTinyAsBoolean 把 tinyint(1) 变成 CLR bool
|
|
|
+ // → JsonSerializer 产出 JSON BOOLEAN(实测 mdp_stg_s0_item.$.IsActive = BOOLEAN "true")。
|
|
|
+ // JSON_OBJECT 直出会是 INTEGER 1 —— 必须用 CAST(... AS JSON) 纠正。
|
|
|
+ var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("IsActive", "tinyint", "tinyint(1)"));
|
|
|
+ Assert.Contains("CAST(IF(a.`IsActive` IS NULL, NULL, IF(a.`IsActive` <> 0, 'true', 'false')) AS JSON)", sql, Ord);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void SameDb_zerofill_tinyint1_must_stay_integer()
|
|
|
+ {
|
|
|
+ // 🔴 TreatTinyAsBoolean 对 `tinyint(1) unsigned zerofill` **不生效**
|
|
|
+ // (实测 $.enable_bom_version = INTEGER 0,而 $.IsActive = BOOLEAN)。
|
|
|
+ // 判定必须比对 COLUMN_TYPE 全串而非 DATA_TYPE,否则这一列会被错误地转成布尔。
|
|
|
+ var sql = SameDbSql(Col("RecID", "bigint", nullable: false),
|
|
|
+ Col("enable_bom_version", "tinyint", "tinyint(1) unsigned zerofill"));
|
|
|
+ Assert.DoesNotContain("`enable_bom_version` IS NULL, NULL, IF(", sql, Ord);
|
|
|
+ Assert.Contains("'enable_bom_version', a.`enable_bom_version`", sql, Ord);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void SameDb_bit_must_cast_unsigned_never_char()
|
|
|
+ {
|
|
|
+ // bit(1) 在旧路径是 INTEGER(实测 $.SuppWarranty = INTEGER 0)。
|
|
|
+ // 若直出,JSON_OBJECT 会得到裸字节;CAST AS CHAR 更糟(0x01 而非 '1',见 S0DimValueKind.Int 注释)。
|
|
|
+ var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("SuppWarranty", "bit", "bit(1)"));
|
|
|
+ Assert.Contains("'SuppWarranty', CAST(a.`SuppWarranty` AS UNSIGNED)", sql, Ord);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void SameDb_decimal_must_emit_double_not_decimal()
|
|
|
+ {
|
|
|
+ // 旧路径 decimal → CLR decimal → JSON DOUBLE «0.0»;JSON_OBJECT 直出是 DECIMAL «0.00000»。
|
|
|
+ // JSON_UNQUOTE 后字符串不同 → staging 侧任何按原文比对都会差异。
|
|
|
+ var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("NetWeight", "decimal", "decimal(18,5)"));
|
|
|
+ Assert.Contains("'NetWeight', CAST(a.`NetWeight` AS DOUBLE)", sql, Ord);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void SameDb_datetime_must_emit_mysql_literal_string()
|
|
|
+ {
|
|
|
+ // 旧路径用 MysqlLiteralDateTimeConverter 写成 STRING "yyyy-MM-dd HH:mm:ss.ffffff"
|
|
|
+ // (实测 $.UpdateTime = STRING "2023-12-18 13:57:30.000000");JSON_OBJECT 直出是 JSON DATETIME。
|
|
|
+ var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("UpdateTime", "datetime"));
|
|
|
+ Assert.Contains("'UpdateTime', DATE_FORMAT(a.`UpdateTime`, '%Y-%m-%d %H:%i:%s.%f')", sql, Ord);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void SameDb_string_columns_must_pass_through_untouched()
|
|
|
+ {
|
|
|
+ // 空串必须保持空串(不能被当成 NULL);字符类直出即 JSON STRING,与旧路径一致。
|
|
|
+ var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("Descr", "varchar", "varchar(200)"));
|
|
|
+ Assert.Contains("'Descr', a.`Descr`", sql, Ord);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void SameDb_biz_key_must_fall_back_to_row_id_when_any_part_is_null_or_blank()
|
|
|
+ {
|
|
|
+ // 🔴 MdpStagingWriter.BuildBizKey:任一分量 null/空串 → 整个 biz key 作废 → 回落 source_row_id。
|
|
|
+ // 直接写 CONCAT_WS 会**跳过** NULL 分量而产出「看起来合法」的短键,与旧路径不等价,
|
|
|
+ // 且要到下一轮四向集合比对才炸。
|
|
|
+ var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("ItemNum", "varchar", "varchar(100)"));
|
|
|
+ Assert.Contains("CASE WHEN (a.`ItemNum` IS NOT NULL AND CAST(a.`ItemNum` AS CHAR) <> '')", sql, Ord);
|
|
|
+ Assert.Contains("ELSE IF(a.`RecID` IS NULL OR TRIM(CAST(a.`RecID` AS CHAR)) = '', NULL, CAST(a.`RecID` AS CHAR)) END", sql, Ord);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void SameDb_row_id_candidate_order_must_match_shared_pull_executor()
|
|
|
+ {
|
|
|
+ // 顺序与 MdpDbPullExecutor.ResolveSourceRowId 逐字一致;错序会让同一源行在两条路径下
|
|
|
+ // 得到不同 source_row_id,贴源唯一键随之漂移。
|
|
|
+ Assert.Equal(
|
|
|
+ ["RecID", "RecId", "recid", "Recid", "id", "Id", "ID", "noid", "billno", "BillNo", "djbh", "Djbh"],
|
|
|
+ S0DimSqlBuilder.SourceRowIdCandidates);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void SameDb_sql_must_never_contain_nondeterministic_uuid()
|
|
|
+ {
|
|
|
+ // 旧路径在全部候选都空白时兜底 Guid.NewGuid()。快路径不复刻该分支,
|
|
|
+ // 而是由 Loader 要求「首个候选是 NOT NULL 整数」后判定适用,从源头排除。
|
|
|
+ var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("ItemNum", "varchar", "varchar(100)"));
|
|
|
+ Assert.DoesNotContain("UUID(", sql, StringComparison.OrdinalIgnoreCase);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void SameDb_sql_must_carry_structural_tenant_gate()
|
|
|
+ {
|
|
|
+ // 租户硬门必须结构性存在,且绝不允许出现 ctx 兜底常量
|
|
|
+ var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("ItemNum", "varchar", "varchar(100)"));
|
|
|
+ Assert.Contains("WHERE a.`tenant_id` = @TenantId", sql, Ord);
|
|
|
+ Assert.Contains("`tenant_id`", sql, Ord);
|
|
|
+ Assert.DoesNotContain("tenant_id = 0", sql, Ord);
|
|
|
+ Assert.DoesNotContain("IFNULL(a.`tenant_id`", sql, Ord);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void SameDb_supported_types_must_be_an_allowlist_excluding_binary_and_json()
|
|
|
+ {
|
|
|
+ // 白名单而非黑名单:未知类型一律回退旧路径,不猜其序列化形态。
|
|
|
+ // blob 在旧路径是 byte[]→base64,JSON_OBJECT 会直出原始字节,静默失真。
|
|
|
+ foreach (var ok in new[] { "varchar", "bigint", "datetime", "tinyint", "decimal", "int", "bit" })
|
|
|
+ Assert.Contains(ok, S0DimSqlBuilder.SameDbSupportedDataTypes);
|
|
|
+ foreach (var no in new[] { "blob", "varbinary", "binary", "json", "enum", "set", "time", "geometry" })
|
|
|
+ Assert.DoesNotContain(no, S0DimSqlBuilder.SameDbSupportedDataTypes);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void SameDb_must_preserve_full_semantics_with_upsert()
|
|
|
+ {
|
|
|
+ // 旧 MdpStagingWriter 是 upsert;保留 ON DUPLICATE KEY UPDATE 才能在源侧出现重复
|
|
|
+ // source_row_id 这类异常数据下两条路径同构,而不是一条报错一条 last-wins。
|
|
|
+ var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("ItemNum", "varchar", "varchar(100)"));
|
|
|
+ Assert.Contains("ON DUPLICATE KEY UPDATE", sql, Ord);
|
|
|
+ Assert.Contains("`process_status`='PENDING'", sql, Ord);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void AntiJoin_must_not_use_nested_loop_left_join_shape()
|
|
|
+ {
|
|
|
+ // 🔴 LEFT JOIN ... ON y.bk <=> x.bk 在「右侧是 CONCAT_WS 计算列」的方向上退化成 nested loop:
|
|
|
+ // 16150 行的 stg\src 实测 130.3s(rows=32,762,720),撞穿 CommandTimeOut=30;
|
|
|
+ // 而对称的 src\stg 只要 0.5s —— 缺陷只在一半方向显形,小表永远发现不了。
|
|
|
+ var sql = S0DimSqlBuilder.BuildAntiJoinCountSql("SELECT 1 AS bk", "SELECT 2 AS bk");
|
|
|
+ Assert.DoesNotContain("LEFT JOIN", sql, Ord);
|
|
|
+ Assert.Contains("UNION ALL", sql, Ord);
|
|
|
+ Assert.Contains("GROUP BY u.bk", sql, Ord);
|
|
|
+ // 必须按左行计数(SUM(l_cnt))而非去重键数,才与旧 COUNT(*) 语义一致
|
|
|
+ Assert.Contains("SUM(CASE WHEN g.r_cnt = 0 THEN g.l_cnt ELSE 0 END)", sql, Ord);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void Item_identity_must_be_tenant_and_item_code_only()
|
|
|
+ {
|
|
|
+ // 旧 UK uk_ItemMaster_factory_item 含 factory,不予继承:factory_ref_id 连 f(tenant) 都不是
|
|
|
+ // (838257186181189 有 3 个取值含 0),且 8010 被三个租户共用。
|
|
|
+ var d = S0DimCatalog.Item;
|
|
|
+ Assert.Equal(["tenant_id", "item_code"], d.BusinessKeyColumns);
|
|
|
+ Assert.Equal(["ItemNum"], d.SourceBizKeyColumns);
|
|
|
+ foreach (var legacy in new[] { "domain_code", "company_id", "factory_id", "legacy_source_id" })
|
|
|
+ Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
|
|
|
+ Assert.Null(d.ParentDimTable);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void Item_legacy_source_id_is_technical_attribute_only()
|
|
|
+ {
|
|
|
+ // 供 C3 的 ComponentMaterialId 这类 legacy 代理键做对账解析;绝不是身份。
|
|
|
+ // 也绝不能用 dim 的代理 id 建外键 —— 每轮 FULL REPLACE 后 dim_item.id 都会变。
|
|
|
+ var col = S0DimCatalog.Item.Columns.Single(c => c.TargetColumn == "legacy_source_id");
|
|
|
+ Assert.Equal("RecID", col.JsonPath);
|
|
|
+ Assert.Equal(S0DimValueKind.Int, col.Kind);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void Item_must_not_materialize_bit_or_nullable_bool_or_shadow_columns()
|
|
|
+ {
|
|
|
+ // SuppWarranty 是 bit(1) 且含 2 行 NULL —— Int 会让对账必挂、BoolTrue 会滤掉那 2 行。
|
|
|
+ // DEAControlled 是可空 tinyint(1),同样会被 BoolTrue 守卫滤行。
|
|
|
+ // expire_alarm_day 是 0 填充的影子列,真列为 ExpireAlarmDays。
|
|
|
+ var paths = S0DimCatalog.Item.Columns.Select(c => c.JsonPath ?? "").ToList();
|
|
|
+ foreach (var forbidden in new[] { "SuppWarranty", "DEAControlled", "expire_alarm_day" })
|
|
|
+ Assert.DoesNotContain(forbidden, paths);
|
|
|
+ Assert.Contains("ExpireAlarmDays", paths);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void Item_must_precede_c3_definitions_in_catalog_order()
|
|
|
+ {
|
|
|
+ // 后续 6 张 C3 都要挂 dim_item 作父,父必须先就绪
|
|
|
+ var keys = S0DimCatalog.All.Select(d => d.Key).ToList();
|
|
|
+ var item = keys.IndexOf("ITEM");
|
|
|
+ Assert.True(item >= 0);
|
|
|
+ foreach (var child in new[] { "EMP_WORK_DUTY", "LINE_POST", "LINE_POST_SKILL" })
|
|
|
+ Assert.True(item < keys.IndexOf(child), $"ITEM 必须排在 {child} 之前");
|
|
|
+ }
|
|
|
+
|
|
|
[Fact]
|
|
|
public void Batch4_must_not_reuse_dopdemorq_staging_tables()
|
|
|
{
|