Hasura GraphQL Engine 中 SQL Server 的 DELETE 突变实现原理:从 RFC 到三条 SQL 事务
【免费下载链接】graphql-engineBlazing fast, instant realtime GraphQL APIs on all your data with fine grained access control, also trigger webhooks on database events.项目地址: https://gitcode.com/gh_mirrors/gr/graphql-engine
在 Hasura GraphQL Engine(graphql-engine 仓库)中,通过/v1/graphql端点向关系数据库发送 GraphQL 突变(mutation)是其核心能力之一。本文基于仓库中的 RFC 文档 rfcs/mssql-delete-mutations.md,系统讲解 SQL Server(MSSQL)数据源的DELETE突变是如何定义的、为什么要面对与 Postgres 不同的实现难题,以及最终落地的「三条 SQL 语句事务」执行方案。读完后,你将能理解 MSSQL 删除突变从 GraphQL 请求到 T-SQL 生成的完整链路,并掌握returning结果捕获这一核心设计问题在源码中的解法。
需求背景:让 MSSQL 表拥有与 Postgres 一致的删除语义
RFC 开篇的用户故事(User story)定义了目标:
As a user, I would like to be able to delete rows from a certain mssql table using a predicate, similarly to how I'm able to do so for a postgres table.
即:用户希望像操作 Postgres 表一样,通过谓词(predicate)从 MSSQL 表中删除行。删除操作统一通过向/v1/graphql端点发送 GraphQL 突变完成,并且删除突变必须遵守行级权限(row-level permissions)规则。
这与仓库整体的多数据源架构一致:MSSQL 后端与 Postgres 后端分别位于server/src-lib/Hasura/Backends/MSSQL/和server/src-lib/Hasura/Backends/Postgres/,两者共享Hasura.RQL.IR(查询中间表示)这一层,各自负责「IR → SQL」的翻译。DELETE 突变在 IR 中被表示为AnnDel 'MSSQL类型,携带表名、权限过滤器、where 子句、全部列、删除输出(returning 模型)等元数据。
接口设计:delete 与 delete_by_pk 两种语法
RFC 的 Interface 章节规定了用户可用的两种删除语法,与 Postgres 侧保持同构。
delete 语法:按谓词删除
支持三种谓词形态:
- 基于根表字段(root table fields)的谓词;
- 基于嵌套对象字段(nested object fields)的谓词;
- 使用
{}作为where参数删除表中所有对象。
通用语法骨架为:
mutation [<mutation-name>] { <mutation-field-name> ( [where-argument!] ) { [mutation-response!] } }delete_by_pk 语法:按主键删除
对具备主键(或自然键)的表,可额外提供<table>_delete_by_pk根字段,直接以主键值删除单行。这一字段由通用的突变字段构建器生成:buildTableDeleteMutationFields 会同时构建按 where 的delete和按主键的deleteByPk两个字段(见 Build.hs#L304-L305),二者均为Maybe,最终经catMaybes拼合——表若无可用主键则deleteByPk不存在。
响应结构
RFC 指出突变响应遵循 GraphQL 规范,成功时为:
{ data { affected_rows returning { # the `returning` statement can include nested objects response-field1 response-field2 .. } } }失败时返回:
{ errors { extensions message } }其中returning可以包含嵌套对象——这是与 Postgres 最大的兼容性约束之一,也是后面执行层设计复杂度的来源。
核心难题:MSSQL 没有 DELETE ... RETURNING
Postgres 原生支持DELETE ... RETURNING,删除的同时把被删行带回来。T-SQL 则没有这个特性,只有OUTPUT子句可以捕获删除行的数据,但OUTPUT捕获结果需要一个目标(表变量或另一张表),且不能直接作为响应 JSON 的查询来源。
RFC 的 Checkpoints 章节给出了两种候选方案:
方案一:表变量 + OUTPUT 子句
使用 T-SQL 表变量 结合OUTPUT子句捕获 DELETE 语句的输出:
DELETE FROM table_name OUTPUT deleted.* INTO @table_var WHERE <predicate>方案二:先 SELECT 后 DELETE
先跑一条包含 DELETE 的 WHERE 与权限过滤器的 SELECT 查询,取回returning与affected_rows所需数据,再执行 DELETE:
WITH with_alias AS ( SELECT * FROM table_name WHERE <where-from-delete-query> ) <select statement generated by mkMutationOutputSelect function>RFC 明确指出:开发者在实现时应考虑两种方案与事务隔离级别的交互影响;内部讨论后倾向方案一,但若不确定更好的做法,应与数据源团队重新讨论或做一个简短技术验证(spike)。同时约定:嵌套 return 语句可参考既有的mkMutationOutputSelect函数实现;删除权限与 WHERE 表达式在 IR 中经由mkDeleteObject打包,生成的 SQL 应把权限过滤器与用户 where 放在同一个WHERE子句里;对于INSTEAD OF DELETE触发器,不追求比OUTPUT语句本身更好的行为。
源码实现:三条 SQL 语句的事务
仓库中的最终实现见 server/src-lib/Hasura/Backends/MSSQL/Execute/Delete.hs,其模块注释把方案写得很直白——一次 GraphQL 删除突变被翻译为三条 SQL 语句(外加一次临时表清理),在同一读写事务(mssqlRunReadWrite)内执行:
SELECT INTO <temp_table> WHERE <false>—— 创建与源表 schema 相同(含约束处理)的临时表,用于暂存被删行;DELETE FROM ... OUTPUT—— 真正删除行,并通过OUTPUT子句把被删行写入第一步创建的临时表;SELECT—— 从临时表构建returning响应,包括与其他表的关系(nested objects)。
关键函数buildDeleteTx的执行链如下(Delete.hs#L78-L115):
buildDeleteTx deleteOperation stringifyNum queryTags = do let createInsertedTempTableQuery = toQueryFlat $ TQ.fromSelectIntoTempTable $ TSQL.toSelectIntoTempTable tempTableNameDeleted (_adTable deleteOperation) (_adAllCols deleteOperation) RemoveConstraints -- 1. Create a temp table Tx.unitQueryE defaultMSSQLTxErrorHandler (createInsertedTempTableQuery `withQueryTags` queryTags) let deleteQuery = TQ.fromDelete <$> TSQL.fromDelete deleteOperation deleteQueryValidated <- toQueryFlat . qwdQuery <$> runFromIrErrorOnCTEs deleteQuery -- 2. Execute DELETE statement Tx.unitQueryE mutationMSSQLTxErrorHandler (deleteQueryValidated `withQueryTags` queryTags) mutationOutputSelect <- qwdQuery <$> runFromIrUseCTEs (mkMutationOutputSelect stringifyNum withAlias $ _adOutput deleteOperation) -- 3. SELECT from the temp table, wrapped in a CTE alias "with_alias" let withSelect = emptySelect { selectProjections = [StarProjection], selectFrom = Just $ FromTempTable $ Aliased tempTableNameDeleted "deleted_alias" } finalMutationOutputSelect = mutationOutputSelect {selectWith = Just $ With $ pure $ Aliased (CTESelect withSelect) withAlias} -- ... -- 4. DROP the temporary table, then return results几个值得注意的实现细节:
- 临时表名常量:临时表统一命名为
deleted,定义于 server/src-lib/Hasura/Backends/MSSQL/FromIr/Constants.hs(tempTableNameDeleted = TempTableName "deleted")。同一文件还定义了插入/更新路径共用的inserted、values、updated等临时表名,说明 INSERT、UPDATE、DELETE 突变共享同一套「临时表 + OUTPUT」的基础设施。 - SELECT 阶段复用 CTE 别名机制:第三步的 SELECT 通过
WITH with_alias AS (SELECT * FROM deleted)包装临时表(见withAlias = "with_alias"与deleted_alias),这与 RFC 方案二中提到的with_alias命名一致,并让mkMutationOutputSelect生成的关系查询(嵌套 returning)无需感知数据来自临时表而非原表。 - 权限与 where 同在一个 WHERE 子句:IR 到 T-SQL 的翻译在 server/src-lib/Hasura/Backends/MSSQL/FromIr/Delete.hs 的
fromDelete中完成,它把权限过滤器(permFilter)与用户 where 子句(whereClause)分别经fromGBoolExp翻译后放入同一个Where列表:
fromDelete (IR.AnnDel table (permFilter, whereClause) _ allColumns _ _validateInput _isDeleteByPrimaryKey) = do tableAlias <- generateAlias (TableTemplate (tableName table)) ... pure Delete { deleteTable = Aliased { aliasedAlias = tableAlias, aliasedThing = table }, deleteOutput = Output Deleted (map OutputColumn columnNames), deleteTempTable = TempTable tempTableNameDeleted columnNames, deleteWhere = Where [permissionsFilter, whereExpression] }deleteOutput中的Output Deleted正是驱动OUTPUT deleted.*语义的标记,deleteTempTable则把输出定向到deleted临时表。这落实了 RFC 的约定「generated SQL should contain the filter expression and permissions in the same WHERE clause」。
- 查询标签(query tags):每条 SQL 都经
withQueryTags附加注释标签,便于在数据库侧的查询日志/监控中识别该语句属于哪个 GraphQL 操作,这是仓库跨数据源的统一可观测性机制。
端到端调用链
把上述模块串起来,一次 MSSQL DELETE 突变的完整链路为:
- Schema 生成:buildTableDeleteMutationFields 依据表元数据生成
<table>_delete与<table>_delete_by_pk根字段; - IR 构建:请求解析后,权限与 where 被打包进 IR(
AnnDel); - 事务构建:executeDelete 接收 IR,调用
buildDeleteTx生成「建临时表 → DELETE OUTPUT → SELECT」的事务计划,并由mssqlRunReadWrite在读写连接上执行; - 响应生成:
mkMutationOutputSelect生成的 SELECT 从deleted临时表取回affected_rows与嵌套的returning数据,最终以 JSON 编码返回。
该执行器经由 server/src-lib/Hasura/Backends/MSSQL/Instances/Execute.hs 挂接到 MSSQL 后端的Execute类型类实例,与 Postgres 侧的 Hasura.Backends.Postgres.Execute.Mutation 形成对照——后者利用原生RETURNING单条语句完成,而 MSSQL 侧用事务内多条语句达到等价语义。
验收标准与测试覆盖
RFC 的 Success 章节列出了明确的验收清单,仓库中均有对应落点:
- Python 集成测试:server/tests-py/test_graphql_mutations.py 需在 MSSQL 数据库上通过,覆盖 RFC 列出的每个查询形态,对应测试类包括:
- 基本删除:
TestGraphqlDeleteBasic - 表权限:
TestGraphqlDeletePermissions - 约束与错误路径:
TestGraphqlDeleteConstraints - 自定义 schema:自定义表名
TestGraphqlMutationCustomGraphQLTableName、自定义根字段TestGraphqlMutationCustomSchema
- 基本删除:
- 控制台与 CLI:删除突变可通过 Hasura Console 和 CLI 执行(Console 前端位于 frontend/ 目录,CLI 位于 cli/ 目录);
- 文档:SQL Server 的删除突变文档以 Postgres 的 delete 文档为参照模板编写(对应仓库文档目录 docs/docs/ 下的 mutations 相关 mdx 内容)。
适用前提与设计边界
结合 RFC 与源码,MSSQL DELETE 突变有几个明确的边界,使用与排查问题时需要注意:
- 事务性:三条 SQL 语句(含临时表清理)在同一
mssqlRunReadWrite事务内执行,returning数据与实际删除是原子的;临时表方案(RFC 方案一的变体:用真实临时表替代表变量)避免了「先 SELECT 后 DELETE」在并发下可能读到的数据与最终删除集合不一致的隔离级别风险。 - INSTEAD OF DELETE 触发器:RFC 明确不追求超越
OUTPUT语句本身的能力——若表上定义了INSTEAD OF DELETE触发器,其拦截行为决定OUTPUT能否捕获到行,MSSQL 后端不做额外补偿。 affected_rows与嵌套 returning:嵌套对象通过mkMutationOutputSelect生成跨表 SELECT 完成,与 Postgres 的returning语义对齐。
小结
rfcs/mssql-delete-mutations.md 描述了 Hasura 为 SQL Server 补齐 DELETE 突变的设计过程:接口上完全对齐 Postgres 的delete/delete_by_pk语法与affected_rows+returning响应;实现上,由于 T-SQL 缺乏RETURNING,最终采用「SELECT INTO建临时表 →DELETE ... OUTPUT写入deleted临时表 →WITH with_alias包装临时表执行 returning SELECT → 清理临时表」的四步事务方案,核心代码集中在 Hasura.Backends.MSSQL.Execute.Delete、Hasura.Backends.MSSQL.FromIr.Delete 与 FromIr.Constants,并由 test_graphql_mutations.py 中的删除测试类在 MSSQL 上持续验证。
【免费下载链接】graphql-engineBlazing fast, instant realtime GraphQL APIs on all your data with fine grained access control, also trigger webhooks on database events.项目地址: https://gitcode.com/gh_mirrors/gr/graphql-engine
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考