You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

450 lines
19 KiB
C#

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// ============================================================================
// 【文件说明】HwPortalMapperRegistry.cs - MyBatis 风格 XML SQL 解析注册表
// ============================================================================
// 这个类是"类 MyBatis"架构的核心组件,负责解析 XML 中定义的 SQL 语句,
// 并将 MyBatis 风格的动态 SQL如 <if>、<where>、<foreach>)转换为可执行的 SQL。
//
// 【架构定位】
// 在 Java MyBatis 中:
// XML 文件由 SqlSessionFactoryBuilder 解析,生成 Configuration 对象,
// 包含所有 MappedStatement映射语句
//
// 在这个 C# 实现中:
// HwPortalMapperRegistry 扮演类似角色:
// - 从嵌入资源加载 XML
// - 解析并缓存 SQL 定义
// - 提供 Prepare() 方法渲染最终 SQL
//
// 【核心功能】
// 1. XML 加载:从程序集嵌入资源读取 Mapper XML 文件
// 2. 延迟初始化Lazy&lt;T&gt; 模式,第一次使用时才加载
// 3. 动态 SQL支持 &lt;if&gt;、&lt;where&gt;、&lt;foreach&gt;、&lt;trim&gt;、&lt;choose&gt; 等标签
// 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 <where> 标签的一个常见能力是自动去掉开头多余的 and/or。
// 这里手动模拟这个行为。
private static readonly Regex LeadingWhereRegex = new(@"^\s*(and|or)\s+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Lazy<T> 表示“延迟初始化”:
// 第一次真正用到 _mapperCache.Value 时,才会执行 LoadMappers。
// 这样可以减少应用启动时的无效开销。
private readonly Lazy<Dictionary<string, HwPortalMapperDefinition>> _mapperCache;
public HwPortalMapperRegistry()
{
_mapperCache = new Lazy<Dictionary<string, HwPortalMapperDefinition>>(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<string, HwPortalMapperDefinition> LoadMappers()
{
// 这里不是从磁盘直接读文件,而是从程序集嵌入资源里读取 XML。
// 对应 csproj 里配置的 <EmbeddedResource Include="Sql\\*.xml" />。
Assembly assembly = typeof(HwPortalMapperRegistry).Assembly;
string[] resources = assembly.GetManifestResourceNames()
.Where(name => name.Contains(".Sql.", StringComparison.OrdinalIgnoreCase) && name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
.ToArray();
Dictionary<string, HwPortalMapperDefinition> 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<string, XElement> sqlFragments = mapperElement.Elements("sql")
.Where(u => u.Attribute("id") != null)
.ToDictionary(u => u.Attribute("id")!.Value, u => u, StringComparer.OrdinalIgnoreCase);
Dictionary<string, XElement> 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<XNode> 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":
// <include refid="...">:复用 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":
// <if test="...">:只有条件成立时才拼接内部 SQL。
if (EvaluateTest(element.Attribute("test")?.Value, context))
{
builder.Append(RenderNodes(element.Nodes(), mapper, context));
}
break;
case "where":
// <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":
// <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":
// <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":
// <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<string> 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 => "<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<string, XElement> SqlFragments, Dictionary<string, XElement> 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<string, object> _scopedValues = new(StringComparer.OrdinalIgnoreCase);
// _parameters 是最终交给数据库执行的参数集合。
private readonly List<SugarParameter> _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<object>();
}
return value as IEnumerable ?? Array.Empty<object>();
}
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);
}
}
}