// ============================================================================ // 【文件说明】HwPortalMapperRegistry.cs - MyBatis 风格 XML SQL 解析注册表 // ============================================================================ // 这个类是"类 MyBatis"架构的核心组件,负责解析 XML 中定义的 SQL 语句, // 并将 MyBatis 风格的动态 SQL(如 )转换为可执行的 SQL。 // // 【架构定位】 // 在 Java MyBatis 中: // XML 文件由 SqlSessionFactoryBuilder 解析,生成 Configuration 对象, // 包含所有 MappedStatement(映射语句)。 // // 在这个 C# 实现中: // HwPortalMapperRegistry 扮演类似角色: // - 从嵌入资源加载 XML // - 解析并缓存 SQL 定义 // - 提供 Prepare() 方法渲染最终 SQL // // 【核心功能】 // 1. XML 加载:从程序集嵌入资源读取 Mapper XML 文件 // 2. 延迟初始化:Lazy<T> 模式,第一次使用时才加载 // 3. 动态 SQL:支持 <if>、<where>、<foreach>、<trim>、<choose> 等标签 // 4. 参数处理:#{paramName} 占位符替换为 @p0、@p1 等数据库参数 // // 【与 Java MyBatis 的对比】 // Java MyBatis 特性: // - OGNL 表达式引擎(复杂但功能强大) // - 完整的动态 SQL 标签支持 // - 类型处理器(TypeHandler) // - 结果映射(ResultMap) // // 这个 C# 实现: // - 自定义轻量级表达式解析(EvaluateTest) // - 支持常用标签的子集(覆盖 90% 业务场景) // - 直接使用 SqlSugar 的参数和结果映射 // - 代码量少,容易理解和维护 // // 【性能优化点】 // 1. 正则表达式预编译:RegexOptions.Compiled 提高匹配速度 // 2. 延迟加载:应用启动时不解析 XML,第一次请求时才加载 // 3. 缓存策略:Dictionary 缓存解析后的 Mapper 定义 // 4. 嵌入资源:XML 文件打包进 DLL,避免文件 IO // // 【适合场景】 // - 从 Java 迁移过来的项目,有大量现成的 XML SQL // - 需要动态 SQL 但不想引入完整 ORM // - 复杂 SQL 用原生写法比 Lambda 更清晰 // ============================================================================ namespace Admin.NET.Plugin.HwPortal; public sealed class HwPortalMapperRegistry { // 用正则把 MyBatis 风格的 #{name} 占位符替换成数据库参数。 private static readonly Regex ParameterRegex = new(@"#\{\s*([A-Za-z0-9_]+)\s*\}", RegexOptions.Compiled); // MyBatis 标签的一个常见能力是自动去掉开头多余的 and/or。 // 这里手动模拟这个行为。 private static readonly Regex LeadingWhereRegex = new(@"^\s*(and|or)\s+", RegexOptions.IgnoreCase | RegexOptions.Compiled); // Lazy 表示“延迟初始化”: // 第一次真正用到 _mapperCache.Value 时,才会执行 LoadMappers。 // 这样可以减少应用启动时的无效开销。 private readonly Lazy> _mapperCache; public HwPortalMapperRegistry() { _mapperCache = new Lazy>(LoadMappers, true); } public HwPortalPreparedSql Prepare(string mapperName, string statementId, object parameter) { // TryGetValue 是 Dictionary 的安全取值方式: // 找到返回 true,没找到返回 false,不会直接抛异常。 if (!_mapperCache.Value.TryGetValue(mapperName, out HwPortalMapperDefinition mapper)) { throw Oops.Oh($"未找到 Mapper:{mapperName}"); } if (!mapper.Statements.TryGetValue(statementId, out XElement statementElement)) { throw Oops.Oh($"未找到 SQL 语句:{mapperName}.{statementId}"); } // context 是本次 SQL 渲染过程中的运行时上下文, // 里面会保存参数对象、foreach 临时变量、最终生成的数据库参数集合。 HwPortalRenderContext context = new(parameter); string sql = RenderNodes(statementElement.Nodes(), mapper, context); return new HwPortalPreparedSql(NormalizeSql(sql), context.Parameters); } private static Dictionary LoadMappers() { // 这里不是从磁盘直接读文件,而是从程序集嵌入资源里读取 XML。 // 对应 csproj 里配置的 。 Assembly assembly = typeof(HwPortalMapperRegistry).Assembly; string[] resources = assembly.GetManifestResourceNames() .Where(name => name.Contains(".Sql.", StringComparison.OrdinalIgnoreCase) && name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) .ToArray(); Dictionary cache = new(StringComparer.OrdinalIgnoreCase); foreach (string resource in resources) { // using 声明会在当前作用域结束时自动释放资源。 using Stream stream = assembly.GetManifestResourceStream(resource); using XmlReader reader = XmlReader.Create(stream, new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore }); XDocument document = XDocument.Load(reader); XElement mapperElement = document.Root ?? throw Oops.Oh($"SQL 资源解析失败:{resource}"); string namespaceValue = mapperElement.Attribute("namespace")?.Value ?? string.Empty; string mapperName = namespaceValue.Split('.').Last(); Dictionary sqlFragments = mapperElement.Elements("sql") .Where(u => u.Attribute("id") != null) .ToDictionary(u => u.Attribute("id")!.Value, u => u, StringComparer.OrdinalIgnoreCase); Dictionary statements = mapperElement.Elements() .Where(u => u.Name.LocalName is "select" or "insert" or "update" or "delete") .Where(u => u.Attribute("id") != null) .ToDictionary(u => u.Attribute("id")!.Value, u => u, StringComparer.OrdinalIgnoreCase); cache[mapperName] = new HwPortalMapperDefinition(mapperName, sqlFragments, statements); } return cache; } private static string RenderNodes(IEnumerable nodes, HwPortalMapperDefinition mapper, HwPortalRenderContext context) { // StringBuilder 适合做频繁字符串拼接,比不断用 + 更节省内存。 StringBuilder builder = new(); foreach (XNode node in nodes) { if (node is XText textNode) { builder.Append(ReplaceParameters(textNode.Value, context)); continue; } if (node is not XElement element) { continue; } switch (element.Name.LocalName) { case "include": // :复用 SQL 片段。 string refId = element.Attribute("refid")?.Value ?? string.Empty; if (!mapper.SqlFragments.TryGetValue(refId, out XElement fragment)) { throw Oops.Oh($"未找到 SQL 片段:{mapper.MapperName}.{refId}"); } builder.Append(RenderNodes(fragment.Nodes(), mapper, context)); break; case "if": // :只有条件成立时才拼接内部 SQL。 if (EvaluateTest(element.Attribute("test")?.Value, context)) { builder.Append(RenderNodes(element.Nodes(), mapper, context)); } break; case "where": // :内部有内容才输出 WHERE,并自动清理开头多余的 and/or。 string whereSql = NormalizeSql(RenderNodes(element.Nodes(), mapper, context)); whereSql = LeadingWhereRegex.Replace(whereSql, string.Empty); if (!string.IsNullOrWhiteSpace(whereSql)) { builder.Append(" WHERE ").Append(whereSql).Append(' '); } break; case "trim": // :常用于 insert / update 动态拼接字段时,去掉多余逗号。 string trimBody = NormalizeSql(RenderNodes(element.Nodes(), mapper, context)); string suffixOverrides = element.Attribute("suffixOverrides")?.Value ?? string.Empty; string prefixOverrides = element.Attribute("prefixOverrides")?.Value ?? string.Empty; trimBody = ApplyOverrides(trimBody, prefixOverrides, suffixOverrides); if (!string.IsNullOrWhiteSpace(trimBody)) { string prefix = element.Attribute("prefix")?.Value ?? string.Empty; string suffix = element.Attribute("suffix")?.Value ?? string.Empty; builder.Append(prefix).Append(' ').Append(trimBody).Append(' ').Append(suffix).Append(' '); } break; case "choose": // 相当于 if / else if / else。 XElement chosen = element.Elements("when").FirstOrDefault(u => EvaluateTest(u.Attribute("test")?.Value, context)) ?? element.Element("otherwise"); if (chosen != null) { builder.Append(RenderNodes(chosen.Nodes(), mapper, context)); } break; case "foreach": // :常用于 in (...) 批量参数拼接。 builder.Append(RenderForeach(element, mapper, context)); break; case "when": case "otherwise": builder.Append(RenderNodes(element.Nodes(), mapper, context)); break; default: builder.Append(RenderNodes(element.Nodes(), mapper, context)); break; } } return builder.ToString(); } private static string RenderForeach(XElement element, HwPortalMapperDefinition mapper, HwPortalRenderContext context) { string collectionName = element.Attribute("collection")?.Value ?? string.Empty; string itemName = element.Attribute("item")?.Value ?? "item"; string open = element.Attribute("open")?.Value ?? string.Empty; string close = element.Attribute("close")?.Value ?? string.Empty; string separator = element.Attribute("separator")?.Value ?? ","; IEnumerable values = context.ResolveCollection(collectionName); List fragments = new(); foreach (object value in values) { // 这里模拟 MyBatis foreach 的 item 变量作用域: // 每一轮循环都把当前值压入上下文,渲染后再移除。 context.PushScoped(itemName, value); string sql = NormalizeSql(RenderNodes(element.Nodes(), mapper, context)); context.PopScoped(itemName); if (!string.IsNullOrWhiteSpace(sql)) { fragments.Add(sql); } } if (fragments.Count == 0) { return string.Empty; } return string.Concat(open, string.Join(separator, fragments), close); } private static string ReplaceParameters(string text, HwPortalRenderContext context) { return ParameterRegex.Replace(text, match => { string propertyName = match.Groups[1].Value; // AddParameter 会把真正的值存进参数集合,并返回类似 @p0、@p1 的参数名。 // 这样做的目的是防 SQL 注入,而不是字符串直接拼接。 string parameterName = context.AddParameter(propertyName, context.ResolveValue(propertyName)); return parameterName; }); } private static bool EvaluateTest(string expression, HwPortalRenderContext context) { // 当前实现只覆盖 ruoyi-portal 里实际用到的 test 表达式子集, // 例如:a != null、a != ''、a == ''、多个 and 条件。 if (string.IsNullOrWhiteSpace(expression)) { return true; } string[] conditions = expression.Split("and", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); foreach (string condition in conditions) { if (!EvaluateCondition(condition.Trim(), context)) { return false; } } return true; } private static bool EvaluateCondition(string condition, HwPortalRenderContext context) { if (condition.Contains("!=", StringComparison.Ordinal)) { string[] parts = condition.Split("!=", 2, StringSplitOptions.TrimEntries); object leftValue = context.ResolveValue(parts[0]); object rightValue = ParseLiteral(parts[1], context); return !EqualsNormalized(leftValue, rightValue); } if (condition.Contains("==", StringComparison.Ordinal)) { string[] parts = condition.Split("==", 2, StringSplitOptions.TrimEntries); object leftValue = context.ResolveValue(parts[0]); object rightValue = ParseLiteral(parts[1], context); return EqualsNormalized(leftValue, rightValue); } return context.ResolveValue(condition) != null; } private static object ParseLiteral(string token, HwPortalRenderContext context) { // 这里把 test 表达式右侧的字面量解析成真正的 C# 值: // null -> null // 'abc' -> 字符串 // 123 -> long if (string.Equals(token, "null", StringComparison.OrdinalIgnoreCase)) { return null; } if (token.StartsWith('\'') && token.EndsWith('\'') && token.Length >= 2) { return token[1..^1]; } if (long.TryParse(token, out long longValue)) { return longValue; } return context.ResolveValue(token); } private static bool EqualsNormalized(object leftValue, object rightValue) { string leftText = NormalizeCompareValue(leftValue); string rightText = NormalizeCompareValue(rightValue); return string.Equals(leftText, rightText, StringComparison.Ordinal); } private static string NormalizeCompareValue(object value) { return value switch { null => "", string stringValue => stringValue, DateTime dateTime => dateTime.ToString("O", CultureInfo.InvariantCulture), _ => Convert.ToString(value, CultureInfo.InvariantCulture) }; } private static string ApplyOverrides(string sql, string prefixOverrides, string suffixOverrides) { string result = sql.Trim(); if (!string.IsNullOrWhiteSpace(prefixOverrides)) { foreach (string candidate in prefixOverrides.Split('|', StringSplitOptions.RemoveEmptyEntries)) { if (result.StartsWith(candidate, StringComparison.OrdinalIgnoreCase)) { result = result[candidate.Length..].TrimStart(); } } } if (!string.IsNullOrWhiteSpace(suffixOverrides)) { foreach (string candidate in suffixOverrides.Split('|', StringSplitOptions.RemoveEmptyEntries)) { if (result.EndsWith(candidate, StringComparison.OrdinalIgnoreCase)) { result = result[..^candidate.Length].TrimEnd(); } } } return result; } private static string NormalizeSql(string sql) { // 把连续空白折叠成单个空格,减少最终 SQL 的噪音。 return Regex.Replace(sql, @"\s+", " ").Trim(); } private sealed record HwPortalMapperDefinition(string MapperName, Dictionary SqlFragments, Dictionary Statements); public sealed record HwPortalPreparedSql(string Sql, SugarParameter[] Parameters); private sealed class HwPortalRenderContext { // _parameter 对应业务层传进来的查询对象/DTO/匿名对象。 private readonly object _parameter; // _scopedValues 用来保存 foreach 里临时注入的变量,例如 item。 private readonly Dictionary _scopedValues = new(StringComparer.OrdinalIgnoreCase); // _parameters 是最终交给数据库执行的参数集合。 private readonly List _parameters = new(); private int _parameterIndex; public HwPortalRenderContext(object parameter) { _parameter = parameter; } public SugarParameter[] Parameters => _parameters.ToArray(); public object ResolveValue(string name) { // 先查临时作用域变量,再查主参数对象。 if (_scopedValues.TryGetValue(name, out object scopedValue)) { return scopedValue; } if (_parameter == null) { return null; } if (_parameter is IDictionary dictionary) { // 支持 Dictionary / 匿名对象混用,是为了兼容不同调用方式。 foreach (DictionaryEntry entry in dictionary) { if (string.Equals(Convert.ToString(entry.Key, CultureInfo.InvariantCulture), name, StringComparison.OrdinalIgnoreCase)) { return entry.Value; } } } // 反射取属性: // 这是 C# 运行时动态读取对象属性的常见方式。 PropertyInfo property = _parameter.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public) .FirstOrDefault(u => string.Equals(u.Name, name, StringComparison.OrdinalIgnoreCase)); return property?.GetValue(_parameter); } public IEnumerable ResolveCollection(string name) { object value = ResolveValue(name); if (value is string or null) { return Array.Empty(); } return value as IEnumerable ?? Array.Empty(); } public string AddParameter(string propertyName, object value) { // 数据库参数名并不需要和原属性名完全一致,唯一即可。 string name = $"@p{_parameterIndex++}"; _parameters.Add(new SugarParameter(name, value ?? DBNull.Value)); return name; } public void PushScoped(string name, object value) { _scopedValues[name] = value; } public void PopScoped(string name) { _scopedValues.Remove(name); } } }