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