SqlParser.Druid
0.1.0-preview.1
dotnet add package SqlParser.Druid --version 0.1.0-preview.1
NuGet\Install-Package SqlParser.Druid -Version 0.1.0-preview.1
<PackageReference Include="SqlParser.Druid" Version="0.1.0-preview.1" />
<PackageVersion Include="SqlParser.Druid" Version="0.1.0-preview.1" />
<PackageReference Include="SqlParser.Druid" />
paket add SqlParser.Druid --version 0.1.0-preview.1
#r "nuget: SqlParser.Druid, 0.1.0-preview.1"
#:package SqlParser.Druid@0.1.0-preview.1
#addin nuget:?package=SqlParser.Druid&version=0.1.0-preview.1&prerelease
#tool nuget:?package=SqlParser.Druid&version=0.1.0-preview.1&prerelease
sql-parser
C# / .NET 10 Native AOT SQL 解析器。多方言、可改 AST、能打成无运行时动态库。
核心 AST / Parser / Visitor 移植并修改自 Alibaba Druid(Apache-2.0);方言 visitor 已合并为统一的 SQLASTVisitor / SQLASTVisitorAdapter。这是独立移植,不是 Druid 官方 C# 发行版。仓库、程序集、命令行都叫 sql-parser。
ANTLR 4 用来补 Druid 拆不了的批处理:先按 grammar 切成单条 SQL,再逐条交给 Druid 做 AST。例如 SQL Server 的 SELECT 1 FROM xxx SELECT 2 FROM bbb(两条语句之间没有分号)Druid 会报错,ANTLR 可以拆开后再解析。
| 产物 | 项目 | 说明 |
|---|---|---|
| 托管库 | sql-parser |
AST / 解析 / Visitor,供其余项目引用 |
| 动态库 | sql-parser.NativeLib |
Native AOT:libsql-parser.so / libsql-parser.dylib / sql-parser.dll |
| 可执行文件 | sql-parser.App |
sql-parser "SELECT 1";sql-parser --bench 打吞吐 |
项目优势
- 多方言:MySQL、PostgreSQL、Oracle、SQL Server、Hive 等,见
DbType。 - 完整 AST:
SQLUtils.parseStatements解析、SchemaRepository.resolve解析表列归属、Visitor 遍历、SQLUtils.toSQLString回写。 - Native AOT 真的快:.NET 10 单文件 ~14MB,无 JVM / 无运行时;嵌套 Oracle SELECT 解析 11 万+ 次/秒(约 8 µs),同机比 JIT 还快一倍。
- 字段血缘:
RelationVisitor把 SELECT 投影追溯到物理表列(含子查询、UNION、CTE),作为查询脱敏、库内加解密改写的基础;不内置策略引擎。 - 原地改写:
SQLObject.replaceByParent在父节点替换子表达式,适合网关改 SQL。 - ANTLR 拆批:Druid 无法整包解析时(无分号的多语句、部分 SQL Server 批),用 ANTLR 切成单条再交给 Druid。
适用场景
- SQL 网关 / 代理:解析后按策略改写再下发。
- 查询脱敏:SELECT 投影列追溯到表字段后替换为掩码表达式。
- 库内加密:WHERE / JOIN 对密文列包解密函数;INSERT / UPDATE 对明文值包加密函数。
- 血缘 / 审计:列级追溯,覆盖子查询、UNION、CTE。
- 嵌入式:通过 C ABI 动态库调用,AOT 发布后不依赖 .NET 运行时。
- 批处理拆分:客户端一次发来多条 SQL、且 Druid 无法整段解析时,先 ANTLR 拆条再逐条改写。
语言与依赖
| 角色 | 技术 |
|---|---|
| 实现语言 | C#(.NET 10,可空引用关闭) |
| 导出 ABI | C(sql_parser.h) |
| 解析内核来源 | Java Alibaba Druid 移植 |
| 批处理拆分 | ANTLR 4 MySQL / Oracle / SQL Server grammar(生成代码已入库,运行时不需要 Java) |
| 可选链接器 | Zig zig cc(Linux Native AOT 打到较老 glibc) |
环境
- .NET 10 SDK(若无 10,把
Directory.Build.props里net10.0改为net9.0) - 本机 Debug 可用系统默认 C 编译器;打兼容老 glibc 的 Linux
.so时需要 Zig(见下文 zig-cc) - Rider / Visual Studio 打开
sql-parser.sln
NuGet(预览)
托管库包名:SqlParser.Druid(含 ANTLR runtime / grammar,一个包引用即可)。
dotnet add package SqlParser.Druid --version 0.1.0-preview.1
using com.alibaba.druid;
using com.alibaba.druid.sql;
var stmts = SQLUtils.parseStatements("SELECT 1", DbType.mysql);
发版(维护者):
# 1. https://www.nuget.org → 登录 → API Keys → Create → 勾选 Push
# 2. 打包
dotnet pack src/sql-parser/sql-parser.csproj -c Release -o ./artifacts
# 3. 上传(key 不要写进仓库)
dotnet nuget push ./artifacts/SqlParser.Druid.*.nupkg \
--api-key YOUR_NUGET_API_KEY \
--source https://api.nuget.org/v3/index.json
预览版改 src/sql-parser/sql-parser.csproj 里的 Version(如 0.1.0-preview.2)。正式版去掉 -preview.N。Native AOT 动态库(.so / .dylib)仍走 GitHub Release,不进这个 NuGet 包。
测试
dotnet test sql-parser.sln -c Release
| 测试类 | 内容 |
|---|---|
SqlParseServiceTests |
核心 Parse / 版本 |
SqlParserNativeApiTests |
C API 同等逻辑、UTF-8 内存 |
CliAppTests |
命令行冒烟(需已 build App) |
RelationVisitorTests |
SELECT 字段血缘(直表 / 子查询 / UNION / CTE / 函数栈) |
DbesColumnRelationUtilTests |
ANTLR 按顶层语句切分 SQL |
ParsePerfTests |
解析 / 拆批 / 血缘吞吐下限(机器不同会变) |
性能
在 Intel Core i7-7700 @ 3.60GHz、macOS 14.7、.NET 10.0.7(osx-x64)上,对 238 字符的嵌套 Oracle SELECT(三层子查询 + ROWNUM)做 2 万次循环,先 80 次预热再计时:
| 路径 | JIT | Native AOT |
|---|---|---|
SQLUtils.parseStatements |
5.0 万 ops/s(20 µs) | 11.8 万 ops/s(8 µs) |
parse + resolve + RelationVisitor |
2.4 万 ops/s | 7.0 万 ops/s |
| ANTLR 拆批再 Druid 解析 | 3.6k ops/s | 2.9k ops/s |
读法:
- AOT 解析比 JIT 更快,不是「能跑就行」。单核每秒十万级
parseStatements,网关里解析通常不是瓶颈。 - 血缘(投影追到表列)同样是微秒级;脱敏 / 加解密改写可以跟在后面做。
- ANTLR 只在 Druid 拆不了批的时候上场,比纯 Druid 重一个数量级,这是预期。
- 产物是 14MB 原生可执行文件,拷到机器上就能跑,不装 .NET、不起 JVM。
自己复现:
# JIT
dotnet run -c Release --project src/sql-parser.App -- --bench 20000
# Native AOT(发布约 1–2 分钟)
dotnet publish src/sql-parser.App/sql-parser.App.csproj -c Release -r osx-x64 -o ./out/app
./out/app/sql-parser --bench 20000
数字随 CPU / RID 会变;ParsePerfTests 只卡一个很低的下限,防止回归到不可用。
构建
# 本机 Debug
dotnet build sql-parser.sln
单平台 Native AOT 发布:
dotnet publish src/sql-parser.NativeLib/sql-parser.NativeLib.csproj -c Release -r linux-x64 -o ./out/lib
dotnet publish src/sql-parser.App/sql-parser.App.csproj -c Release -r linux-x64 -o ./out/app
| 平台 | RID |
|---|---|
| Linux x64 (amd64) | linux-x64 |
| Linux ARM64 | linux-arm64 |
| Windows x64 | win-x64 |
| Windows ARM64 | win-arm64 |
| macOS x64 (Intel) | osx-x64 |
| macOS ARM64 (Apple Silicon) | osx-arm64 |
输出示例:
out/lib/ # 动态库 + sql_parser.h
out/app/ # 可执行文件
用 zig-cc 发布 Native AOT
默认 clang/gcc 往往链接到较新 glibc,在老发行版上加载 .so 会失败。用 Zig 指定 -target 可把动态库打到较老的 glibc。
| 产物 | glibc | 说明 |
|---|---|---|
NativeLib(.so) |
实测下限 x86_64-linux-gnu.2.12 |
推荐 ZIG_TARGET=x86_64-linux-gnu.2.12 |
| App(可执行文件) | 需要 ≥ 2.16 | getauxval / aligned_alloc;可用 2.17+ 或不用 zig-cc |
- 安装 Zig,并把
scripts/zig-cc放到PATH(或sudo cp scripts/zig-cc /usr/local/bin/zig-cc && sudo chmod +x /usr/local/bin/zig-cc)。 - 不要在 CLI 传
-p:LinkerArg=-target(MSBuild 会忽略或重复传 triplet)。target 只写在 wrapper 的ZIG_TARGET。 - wrapper 会消化
-pie/-no-pie以及重复的 linux-gnu triplet,避免 ILC 参数和 zig 冲突。
chmod +x scripts/zig-cc
export PATH="$PWD/scripts:$PATH"
export ZIG_TARGET=x86_64-linux-gnu.2.12
dotnet publish src/sql-parser.NativeLib/sql-parser.NativeLib.csproj \
-c Release -r linux-x64 -o ./out/lib \
-p:CppCompilerAndLinker=zig-cc \
-p:CppLinker=zig-cc \
-p:LinkerFlavor=lld
C 语言调用动态库
#include "sql_parser.h"
#include <stdio.h>
#include <string.h>
int main() {
printf("version: %s\n", sql_parser_version());
const char* sql = "SELECT 1";
char* out = sql_parser_parse(sql, (int)strlen(sql), "mysql", 5);
if (out) {
printf("result: %s\n", out);
sql_parser_free(out);
}
return 0;
}
链接:-L. -lsql-parser(Linux)/ -lsql-parser(macOS)/ sql-parser.lib(Windows)。
项目结构
sql-parser/
sql-parser.sln
src/
sql-parser/ # 核心(Druid 移植)
sql-parser.NativeLib/ # Native AOT 动态库 + C 导出
sql-parser.App/ # Native AOT 命令行
antlr4-runtime/ # vendored ANTLR 4 C# runtime
antlr4-grammars/ # ANTLR grammar
tests/sql-parser.Tests/
scripts/zig-cc # Linux Native AOT 链接包装
ANTLR 4:Druid 解析不了时先拆 SQL
Druid 按语句解析。多数库用分号分隔多语句,但 SQL Server 允许批里连续写多条语句且中间没有 ;:
SELECT 1 FROM xxx
SELECT 2 FROM bbb
整段交给 SQLUtils.parseStatements 会报错。ANTLR 用 T-SQL / MySQL / PL/SQL grammar 找出顶层语句的源码区间(子查询不算新语句),切成字符串列表,再对每一条走 Druid。
using com.alibaba.druid;
using com.alibaba.druid.sql;
using SqlParser.Antlr;
string batch = "SELECT 1 FROM xxx SELECT 2 FROM bbb";
IList<string> pieces = DbesColumnRelation.GetRelationSqlList(DbType.sqlserver, batch);
// pieces[0] == "SELECT 1 FROM xxx"
// pieces[1] == "SELECT 2 FROM bbb"
foreach (string piece in pieces)
{
SQLStatement stmt = SQLUtils.parseStatements(piece, DbType.sqlserver)[0];
// resolve、RelationVisitor、replaceByParent …
}
当前 DbesColumnRelation 支持:
DbType |
ANTLR grammar |
|---|---|
mysql / mariadb / tidb / sqlite |
MySQL |
oracle |
PL/SQL |
sqlserver |
T-SQL |
其它方言会抛 NotSupportedException。SELECT * FROM t WHERE id IN (SELECT id FROM t2) 这类带子查询的单条语句仍返回一整段,不会切出内层 SELECT。入口:DbesColumnRelation.GetRelationSqlList(src/sql-parser/Antlr/DbesColumnRelation.cs)。
高级用法:查询脱敏与字段加解密改写
本库提供 AST 与字段血缘,不内置策略引擎。查询脱敏、库内加解密由调用方按策略匹配后改 AST:SELECT 血缘后替换投影列;WHERE / JOIN / SET 等表达式用加解密函数包裹。用 SQLObject.replaceByParent(child, replacement) 在父节点替换子表达式。
1. 用 RelationVisitor 做查询脱敏
RelationVisitor 遍历 SELECT 列表,把每个投影列追溯到物理 schema / table / column,并记下外层函数栈(如 COUNT、DISTINCT)。子查询、UNION、CTE 会把内层血缘提升到外层。
推荐流程:解析 → 注册表结构(可选,见第 3 节)→ SchemaRepository.resolve → stmt.accept(RelationVisitor) → 再 walk AST,仅当 RelationColumnUtil.IsTopQueryBlock 为 true 时改该 block 的 SELECT item。
using com.alibaba.druid;
using com.alibaba.druid.sql;
using com.alibaba.druid.sql.ast.expr;
using com.alibaba.druid.sql.ast.statement;
using com.alibaba.druid.sql.repository;
using com.alibaba.druid.sql.visitor;
DbType dbType = DbType.mysql;
SQLStatement stmt = SQLUtils.parseStatements(sql, dbType)[0];
new SchemaRepository(dbType).resolve(stmt);
RelationVisitor relation = new RelationVisitor(dbType);
stmt.accept(relation);
stmt.accept(new MaskSelectVisitor(relation, dbType));
string rewritten = SQLUtils.toSQLString(stmt, dbType);
public class MaskSelectVisitor : SQLASTVisitorAdapter
{
private readonly RelationVisitor relation;
private readonly DbType dbType;
public MaskSelectVisitor(RelationVisitor relation, DbType dbType)
{
this.relation = relation;
this.dbType = dbType;
}
public override bool visit(SQLSelectQueryBlock x)
{
if (!RelationColumnUtil.IsTopQueryBlock(x))
{
return true; // 继续进子查询,但不改内层投影
}
int id = RelationColumnUtil.IdentityHash(x);
IList<List<SelectItemRelationColumnVo>> selectItems = relation.fetchQueryBlockMap()[id];
for (int i = 0; i < selectItems.Count; i++)
{
if (!NeedMask(selectItems[i]))
{
continue;
}
SQLSelectItem item = x.fetchSelectList()[i];
item.replaceByParent(item.fetchExpr(), SQLUtils.toSQLExpr("'****'", dbType));
}
return true;
}
}
判断外层 SELECT:IsTopQueryBlock
从当前 SQLSelectQueryBlock 沿 fetchParent() 一直走到根。只要祖先里再出现另一个 SQLSelectQueryBlock,说明它嵌在别的 SELECT 里,不是外层。
public static bool IsTopQueryBlock(SQLSelectQueryBlock x)
{
SQLObject parent = x.fetchParent();
while (parent != null)
{
if (parent is SQLSelectQueryBlock)
{
return false;
}
parent = parent.fetchParent();
}
return true;
}
| SQL | 该 query block | IsTopQueryBlock |
|---|---|---|
SELECT phone FROM emp |
唯一的 SELECT | true,改 phone |
SELECT phone FROM (SELECT phone FROM emp) t |
外层 | true,改外层 phone |
| 同上 | 内层 SELECT phone FROM emp |
false,不要改 |
SELECT phone FROM emp WHERE id IN (SELECT id FROM t2) |
WHERE 里的 SELECT | false,不要改 |
WITH c AS (SELECT phone FROM emp) SELECT phone FROM c |
CTE 内层 | false,不要改 |
SELECT phone FROM t1 UNION SELECT phone FROM t2 |
左右两个 SELECT | 都是 true(父节点是 SQLUnionQuery,不是另一个 query block) |
UNION 两个分支都是外层:客户端结果集来自每一支,脱敏时同一列下标要一起改,否则列类型/列数会对不齐(尤其 Oracle UNION)。
内层不改的原因:血缘是内层先算、再提升到外层。若把内层 phone 先换成 '****',外层就丢了表列信息,也无法保持子查询语义(过滤、JOIN 仍要用真实列)。
脱敏时改哪些语句
脱敏改的是返回给客户端看的结果列,不是谓词、也不是写入值。加解密才改 WHERE / SET / VALUES(见第 2 节)。
| 语句 | 是否做脱敏改写 | 改什么 |
|---|---|---|
SELECT ... |
是 | 外层 selectList 中命中规则的 item(replaceByParent 换成掩码/CAST/空串) |
SELECT ... UNION [ALL] SELECT ... |
是 | 每个外层分支的对应下标,保持列对齐 |
SELECT ... FROM (SELECT ...) / IN (SELECT ...) / CTE |
只改最外层投影 | 内层 SELECT 不改 |
INSERT ... SELECT ... |
一般不算脱敏 | 写入路径走加密(VALUES / SELECT 列包 AES_ENCRYPT),不是掩码 |
UPDATE ... SET / DELETE |
否 | 脱敏不改;加密才改 SET 值和 WHERE 里的密文列 |
RETURNING / 输出子句 |
视是否回给客户端 | 若作为结果集返回,按外层投影同样处理 |
SELECT ROW_NUMBER() OVER ... |
该项跳过 | 函数栈含 ROW_NUMBER 时不要替换,以免打乱窗口 |
命中规则后替换的是整个 SELECT item 表达式(保留原 alias),不是只改函数参数。NeedMask 用 SelectItemRelationColumnVo 的 schema/table/column 匹配策略即可。
要点:
- 必须先
resolve,否则resolvedOwnerObject为空,血缘对不上表。 - 用
IsTopQueryBlock过滤,不要用「语句里第一个 SELECT」当外层(UNION 有多个外层 block)。 fetchRelationMap()以规范化后的列名/别名为 key;fetchQueryBlockMap()按下标对齐selectList。
2. 用 replaceByParent 做库内加密 / 解密
库内加密通常改的不是投影列,而是谓词和写入值:读时对密文列包解密函数,写时对明文值包加密函数。不要只改 SQLIdentifierExpr 自身的 name,要把它从父节点上换成新表达式。
void ReplaceOnParent(SQLExpr oldExpr, SQLExpr newExpr)
{
SQLObject parent = oldExpr.fetchParent();
if (parent == null)
{
return;
}
parent.replaceByParent(oldExpr, newExpr);
}
// 解密:WHERE t.phone = '138...' → WHERE AES_DECRYPT(t.phone_enc, 'key') = '138...'
SQLExpr decrypt = SQLUtils.toSQLExpr("AES_DECRYPT(t.phone_enc, 'key')", dbType);
ReplaceOnParent(phoneColumnExpr, decrypt);
// 加密:SET phone = '138...' → SET phone_enc = AES_ENCRYPT('138...', 'key')
SQLExpr encrypt = SQLUtils.toSQLExpr("AES_ENCRYPT('138...', 'key')", dbType);
ReplaceOnParent(plainValueExpr, encrypt);
常见父节点:
| 场景 | 父节点 | 被替换的 child |
|---|---|---|
| SELECT 投影脱敏 | SQLSelectItem |
fetchExpr() |
| WHERE / JOIN ON | SQLBinaryOpExpr |
fetchLeft() / fetchRight() |
| 函数参数 | SQLMethodInvokeExpr |
fetchArguments()[i] |
| UPDATE SET | SQLUpdateSetItem |
fetchColumn() 或 fetchValue() |
replaceByParent 会走具体 AST 类型的 override(例如 SQLSelectItem.putExpr、SQLBinaryOpExpr.putLeft),并维护 parent 指针。默认实现为空操作:若某节点未 override,替换不会生效,需要改该类型或改 list 下标。
典型网关路径:策略命中加密列后生成 AES_DECRYPT(...) / AES_ENCRYPT(...),再对父节点 replaceByParent。
3. 遇到 * 且已知表结构时先展开再脱敏
SELECT * 在 AST 里是 SQLAllColumnExpr。RelationVisitor 不把 * 拆成列,因此无法按列匹配脱敏规则。有表结构时,应在 accept(RelationVisitor) 之前把 * 展开成显式列。
- 用
SchemaRepository.console注册表列(不必解析 DDL)。 - 扫描
SQLSelectQueryBlock.fetchSelectList(),找到SQLAllColumnExpr(含t.*)。 - 按表列生成
SQLSelectItem,删掉原来的*。 - 再
resolve+RelationVisitor,后续脱敏与第 1 节相同。
SchemaRepository repository = new SchemaRepository(dbType);
repository.console(new Dictionary<string, TableColumnEntity>
{
["hr.emp"] = new TableColumnEntity(new List<string> { "id", "name", "phone", "phone_enc" }),
});
SQLStatement stmt = SQLUtils.parseStatements("SELECT * FROM hr.emp", dbType)[0];
SQLSelectQueryBlock block = ((SQLSelectStatement) stmt).fetchSelect().fetchQueryBlock();
IList<SQLSelectItem> selectList = block.fetchSelectList();
for (int i = 0; i < selectList.Count; i++)
{
if (!(selectList[i].fetchExpr() is SQLAllColumnExpr star))
{
continue;
}
// 有 owner 时展开 t.*;无 owner 时展开 FROM 中的基表
IList<string> columns = new List<string> { "id", "name", "phone" }; // 来自表结构;可过滤密文/签名列
selectList.RemoveAt(i);
for (int j = 0; j < columns.Count; j++)
{
SQLExpr col = star.fetchOwner() != null
? new SQLPropertyExpr((SQLExpr) star.fetchOwner().clone(), columns[j])
: new SQLIdentifierExpr(columns[j]);
SQLSelectItem item = new SQLSelectItem(col);
selectList.Insert(i + j, item);
item.modifyParent(block);
}
i += columns.Count - 1;
}
repository.resolve(stmt);
RelationVisitor visitor = new RelationVisitor(dbType);
stmt.accept(visitor);
// 此时 queryBlockMap 按 id/name/phone 分列,可对 phone 做脱敏
实践建议:
- 展开时应过滤密文列、签名列,避免
SELECT *把phone_enc暴露给客户端。 t.*用SQLAllColumnExpr.fetchOwner()对齐别名/表名,再只展开该表的列。- JOIN 多表的无 owner
*需要按 FROM 中每张基表依次展开;子查询上的*只能展开子查询已投影的列,不能直接用物理表结构。 - 展开后必须重新
resolve,否则新插入的 identifier 没有resolvedOwnerObject。
相关类型:RelationVisitor、RelationColumnUtil、SelectItemRelationColumnVo、SQLObject.replaceByParent、SchemaRepository.console、TableColumnEntity。
许可证与致谢
本仓库以 Apache License 2.0 开源,详见 NOTICE。
本仓库的 SQL 解析内核移植并修改自 Alibaba Druid。ANTLR 4 C# runtime 来自 antlr/antlr4(BSD)。感谢上述项目作者。
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
This package has no dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.0-preview.1 | 64 | 8/21/2026 |