using System.Text.RegularExpressions;
namespace Admin.NET.Plugin.AiDOP.DataPlatform.S0Dim;
///
/// 一个 S0 主数据维度的完整声明:源表 → staging → dim 的全部映射与身份口径。
///
/// 本类型是**唯一的业务知识落点**: /
/// 中不得出现任何 if (key == "WORK_CENTER") 之类的分支。
///
/// 对外标识(refresh/{key}),大写下划线。
/// mdp_entity.entity_code。
/// 源物理表名;同时是 staging 的 source_table 过滤值。
/// mdp_source.source_code;同时是 staging 的 source_system 过滤值。
/// 贴源表名(可被同模块多个 definition 共用,靠 SourceTable 区分)。
/// 维度表名。**必须由本 definition 独占**(FULL Replace 按 tenant 整体删除该表)。
/// dim 侧业务键列,首列必须是 tenant_id。
/// 源侧业务键**物理列**,须与 mdp_entity.biz_key_expr 逐字一致(不含 tenant)。
/// 列映射。
/// 额外唯一性断言(镜像源 UK,用于抗源侧索引口径变化)。
/// 父维度表(子维度专用)。
/// 父引用列(同时存在于本 dim 与父 dim)。
public sealed record S0DimDefinition(
string Key,
string EntityCode,
string SourceTable,
string SourceSystem,
string StagingTable,
string DimTable,
IReadOnlyList BusinessKeyColumns,
IReadOnlyList SourceBizKeyColumns,
IReadOnlyList Columns,
IReadOnlyList? MirrorUniqueColumns = null,
string? ParentDimTable = null,
IReadOnlyList? ParentKeyColumns = null)
{
/// 标识符白名单:表名 / 列名 / JsonPath 一律只允许字母数字下划线。
// 用 \A…\z 而非 ^…$:.NET 的 $(无 Multiline)也匹配「字符串末尾的 \n 之前」,
// 于是 "Domain\n" 能通过 ^…$。虽不可利用(产物是非法 JSON path / 不存在的表名,只会报错),
// 但标识符白名单不该留任何可通过的畸形输入。
internal static readonly Regex IdentifierRe = new(@"\A[A-Za-z0-9_]+\z", RegexOptions.Compiled);
/// 租户列名(dim 与 staging 两侧同名)。
public const string TenantColumn = "tenant_id";
///
/// 声明自洽性校验。
///
/// 实际调用点(无启动期钩子,勿误读):
/// S0DimRefreshService.RefreshAsync / ReconcileAsync 与控制器的 catalog 端点,
/// 以及每个 SQL 生成入口(BuildInsertSql 等)自身。
/// 本项目**没有**在 Startup 注册对 S0DimCatalog.ValidateAll() 的调用,
/// 因此声明错误不会在应用启动时暴露,而是在**首次刷新时**暴露。
///
/// 安全性不受影响:校验在任何数据被触碰之前完成(purge / pull / 物化都在其后),
/// 失败即整批 FAILED 且 DB 零写入。但「启动即 fail-fast」这个更强的保证目前并不存在。
///
public void Validate()
{
void Ident(string? v, string what)
{
if (string.IsNullOrWhiteSpace(v) || !IdentifierRe.IsMatch(v))
throw new InvalidOperationException($"[{Key}] 非法标识符({what}):{v}");
}
Ident(Key, nameof(Key));
Ident(EntityCode, nameof(EntityCode));
Ident(SourceTable, nameof(SourceTable));
Ident(SourceSystem, nameof(SourceSystem));
Ident(StagingTable, nameof(StagingTable));
Ident(DimTable, nameof(DimTable));
if (Columns is null || Columns.Count == 0)
throw new InvalidOperationException($"[{Key}] Columns 为空");
var targets = new HashSet(StringComparer.Ordinal);
var tenantCols = 0;
foreach (var c in Columns)
{
Ident(c.TargetColumn, "TargetColumn");
if (!targets.Add(c.TargetColumn))
throw new InvalidOperationException($"[{Key}] TargetColumn 重复:{c.TargetColumn}");
if (c.Kind == S0DimValueKind.TenantIdColumn)
{
tenantCols++;
if (c.JsonPath is not null)
throw new InvalidOperationException($"[{Key}] TenantIdColumn 不得声明 JsonPath:{c.TargetColumn}");
if (!string.Equals(c.TargetColumn, TenantColumn, StringComparison.Ordinal))
throw new InvalidOperationException($"[{Key}] TenantIdColumn 的目标列必须是 {TenantColumn}");
}
else
{
// 🔴 这条是 F-16 的编译期护栏:JsonPath 必须显式给出,不允许由 TargetColumn 推导
if (string.IsNullOrWhiteSpace(c.JsonPath))
throw new InvalidOperationException(
$"[{Key}] 列 {c.TargetColumn} 缺少 JsonPath —— raw_data 的键是源表物理列名,禁止由列名推导");
Ident(c.JsonPath, "JsonPath");
}
if (c.Kind == S0DimValueKind.Dec && (c.Precision <= 0 || c.Scale < 0 || c.Scale > c.Precision))
throw new InvalidOperationException($"[{Key}] 列 {c.TargetColumn} 的 DECIMAL 精度非法");
}
if (tenantCols != 1)
throw new InvalidOperationException($"[{Key}] 必须且只能声明一个 TenantIdColumn,当前 {tenantCols} 个");
if (BusinessKeyColumns is null || BusinessKeyColumns.Count < 2)
throw new InvalidOperationException($"[{Key}] BusinessKeyColumns 至少需要 tenant_id + 1 列");
if (!string.Equals(BusinessKeyColumns[0], TenantColumn, StringComparison.Ordinal))
throw new InvalidOperationException($"[{Key}] BusinessKeyColumns 首列必须是 {TenantColumn}");
foreach (var bk in BusinessKeyColumns)
{
if (!targets.Contains(bk))
throw new InvalidOperationException($"[{Key}] 业务键列未在 Columns 中声明:{bk}");
var col = Columns.First(x => x.TargetColumn == bk);
if (col.Kind != S0DimValueKind.TenantIdColumn && !col.Required)
throw new InvalidOperationException($"[{Key}] 业务键列必须 Required=true:{bk}");
if (col.Kind is S0DimValueKind.BoolTrue or S0DimValueKind.Dec)
throw new InvalidOperationException($"[{Key}] 业务键列不得为布尔/小数:{bk}");
}
if (SourceBizKeyColumns is null || SourceBizKeyColumns.Count != BusinessKeyColumns.Count - 1)
throw new InvalidOperationException(
$"[{Key}] SourceBizKeyColumns 必须与 BusinessKeyColumns(去掉 tenant_id)一一对应");
foreach (var s in SourceBizKeyColumns) Ident(s, "SourceBizKeyColumns");
// 源侧业务键物理列必须正是 dim 业务键列所映射的 JsonPath,且顺序一致 ——
// 否则 stg.source_biz_key(由 mdp_entity.biz_key_expr 生成)与 dim 业务键无法对账
for (var i = 1; i < BusinessKeyColumns.Count; i++)
{
var expected = Columns.First(x => x.TargetColumn == BusinessKeyColumns[i]).JsonPath;
if (!string.Equals(expected, SourceBizKeyColumns[i - 1], StringComparison.Ordinal))
throw new InvalidOperationException(
$"[{Key}] 业务键顺序不一致:dim 列 {BusinessKeyColumns[i]} 映射 {expected}," +
$"但 SourceBizKeyColumns[{i - 1}] = {SourceBizKeyColumns[i - 1]}");
}
if (MirrorUniqueColumns is { Count: > 0 })
{
foreach (var m in MirrorUniqueColumns)
if (!targets.Contains(m))
throw new InvalidOperationException($"[{Key}] MirrorUniqueColumns 未在 Columns 中声明:{m}");
}
if ((ParentDimTable is null) != (ParentKeyColumns is null or { Count: 0 }))
throw new InvalidOperationException($"[{Key}] ParentDimTable 与 ParentKeyColumns 必须同时提供或同时省略");
if (ParentDimTable is not null)
{
Ident(ParentDimTable, nameof(ParentDimTable));
foreach (var p in ParentKeyColumns!)
if (!targets.Contains(p))
throw new InvalidOperationException($"[{Key}] ParentKeyColumns 未在 Columns 中声明:{p}");
}
}
}