SqlParser.Druid 0.1.0-preview.1

This is a prerelease version of SqlParser.Druid.
dotnet add package SqlParser.Druid --version 0.1.0-preview.1
                    
NuGet\Install-Package SqlParser.Druid -Version 0.1.0-preview.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SqlParser.Druid" Version="0.1.0-preview.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SqlParser.Druid" Version="0.1.0-preview.1" />
                    
Directory.Packages.props
<PackageReference Include="SqlParser.Druid" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SqlParser.Druid --version 0.1.0-preview.1
                    
#r "nuget: SqlParser.Druid, 0.1.0-preview.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SqlParser.Druid@0.1.0-preview.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SqlParser.Druid&version=0.1.0-preview.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=SqlParser.Druid&version=0.1.0-preview.1&prerelease
                    
Install as a Cake Tool

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
  • 完整 ASTSQLUtils.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 Csql_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.propsnet10.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
  1. 安装 Zig,并把 scripts/zig-cc 放到 PATH(或 sudo cp scripts/zig-cc /usr/local/bin/zig-cc && sudo chmod +x /usr/local/bin/zig-cc)。
  2. 不要在 CLI 传 -p:LinkerArg=-target(MSBuild 会忽略或重复传 triplet)。target 只写在 wrapper 的 ZIG_TARGET
  3. 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

其它方言会抛 NotSupportedExceptionSELECT * FROM t WHERE id IN (SELECT id FROM t2) 这类带子查询的单条语句仍返回一整段,不会切出内层 SELECT。入口:DbesColumnRelation.GetRelationSqlListsrc/sql-parser/Antlr/DbesColumnRelation.cs)。

高级用法:查询脱敏与字段加解密改写

本库提供 AST 与字段血缘,不内置策略引擎。查询脱敏、库内加解密由调用方按策略匹配后改 AST:SELECT 血缘后替换投影列;WHERE / JOIN / SET 等表达式用加解密函数包裹。用 SQLObject.replaceByParent(child, replacement) 在父节点替换子表达式。

1. 用 RelationVisitor 做查询脱敏

RelationVisitor 遍历 SELECT 列表,把每个投影列追溯到物理 schema / table / column,并记下外层函数栈(如 COUNTDISTINCT)。子查询、UNION、CTE 会把内层血缘提升到外层。

推荐流程:解析 → 注册表结构(可选,见第 3 节)→ SchemaRepository.resolvestmt.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),不是只改函数参数。NeedMaskSelectItemRelationColumnVo 的 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.putExprSQLBinaryOpExpr.putLeft),并维护 parent 指针。默认实现为空操作:若某节点未 override,替换不会生效,需要改该类型或改 list 下标。

典型网关路径:策略命中加密列后生成 AES_DECRYPT(...) / AES_ENCRYPT(...),再对父节点 replaceByParent

3. 遇到 * 且已知表结构时先展开再脱敏

SELECT * 在 AST 里是 SQLAllColumnExprRelationVisitor 不把 * 拆成列,因此无法按列匹配脱敏规则。有表结构时,应在 accept(RelationVisitor) 之前* 展开成显式列。

  1. SchemaRepository.console 注册表列(不必解析 DDL)。
  2. 扫描 SQLSelectQueryBlock.fetchSelectList(),找到 SQLAllColumnExpr(含 t.*)。
  3. 按表列生成 SQLSelectItem,删掉原来的 *
  4. 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

相关类型:RelationVisitorRelationColumnUtilSelectItemRelationColumnVoSQLObject.replaceByParentSchemaRepository.consoleTableColumnEntity

许可证与致谢

本仓库以 Apache License 2.0 开源,详见 NOTICE

本仓库的 SQL 解析内核移植并修改自 Alibaba Druid。ANTLR 4 C# runtime 来自 antlr/antlr4(BSD)。感谢上述项目作者。

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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