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#

// ============================================================================
// 【文件说明】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);
}
}
}