Skip to content

WITH 查询为什么返回空数据:重新定义 SQL 批处理结果 ​

一个让人困惑的问题:同一段 SQL,在数据库管理工具里执行有结果;放进报表系统,却返回了空数组,页面显示“暂无数据”。

问题不一定出在 SQL 本身。也可能是数据库封装先看了一眼 SQL 的第一个关键字,觉得它“不是查询”,于是走了不读取结果的路径。

本文依据一个遗留 PHP 报表服务和配套 PC 页面的近期改动整理,核对日期为 2026-10-23。只做了本地源码分析,没有连接数据库或执行任何 SQL;下面的示例使用合成内容,验证项均指隔离的测试环境。

读完这篇你会

  • 解释为什么 WITH 开头的查询会被误判为“写操作”
  • 理解一次批处理可能返回多个结果,以及怎样找到真正的表格
  • 区分“空表”“没有表格”和“执行失败”三种结果
  • 知道支持 CTE 和“允许任意 SQL”之间的安全边界

1. 原理:先端上来的不一定是主菜 ​

去餐厅点了一份套餐,服务员先端上来一杯水,然后才是主菜。如果你根据“第一个端上来的东西”判断“有没有上菜”,就会以为主菜没有来。

SQL 批处理也是这样:一次执行可能返回好几个结果。前面的结果可能只是“影响了多少行”这样的信息(没有列的结果),真正的数据表格排在后面。

2. 坑一:按第一个关键字判断“是读还是写” ​

现象:以 WITH 开头的查询、带前置注释的查询、先声明变量再查询的语句,在报表系统里都返回空数据。

原因:旧的封装根据 SQL 的第一个关键字来区分读和写:SELECT 开头的才读取结果。遇到 WITH、声明语句、前置注释或多语句批处理时,SQL 确实执行了,却没有按结果集去读取。

这个最小示例就足以复现:

sql
WITH sample AS (
  SELECT 7 AS value
)
SELECT value FROM sample;

修复:给首词判断再加一个 WITH 分支,只能解决一种写法,注释、声明语句还会出问题。近期的修复增加了专门的批处理执行路径,不再根据首个关键字决定是否取数,而是根据驱动返回的结果描述(这个结果有没有列)来判断。

3. 坑二:批处理的第一个结果没有列 ​

现象:一段先 UPDATE 临时数据、再 SELECT 的报表 SQL,返回的是空。

原因:一次执行可能先返回“受影响行数”(没有列),之后才返回带列的表格。

修复:近期实现约定:跳过没有列的结果,返回遇到的第一个有列的结果集。

text
执行批处理
   ↓
当前结果有列? ── 是 → 读取行并返回
   │ 否
   ↓
激活下一个结果
   ├─ 还有结果 → 继续检查
   ├─ 已结束   → 无表格结果
   └─ 出错     → 失败

遍历时要注意驱动的返回值含义。SQL Server PHP 驱动的 sqlsrv_next_result() 有三种不同的返回值:true 表示成功切换到下一个结果,null 表示没有更多结果,false 表示切换时出错。后两者不能不加区分地混为一谈。Microsoft 驱动说明

PDO 也能跨结果集读取,但要按照对应驱动和错误模式处理失败,不能直接套用另一个驱动的返回值语义。PDOStatement::nextRowset

4. “第一个有列的结果”是一个约定,要明确取舍 ​

执行结果按当前约定应该怎样理解
先出现受影响行数,再出现表格跳过前者,读取表格
第一个表格有列但没有行这是有效的空结果,不应该继续寻找非空的表格
连续返回两张表格只返回第一张,不能默认是最后一张
执行成功,但没有任何表格“没有表格”,和“执行失败”不同

所以,报表 SQL 里不要随手加一句调试用的前置查询,否则它可能变成被展示的“第一张表”。需要返回多张表时,应该扩展明确的返回协议,而不是依赖开发者去猜驱动的行为。

5. 坑三:执行失败被显示成了“暂无数据” ​

现象:SQL 有语法错误、或者没有权限,页面却显示“暂无数据”,用户以为确实没有数据,开发者也很难发现问题。

原因:封装把各种失败都转成了空数组。

修复进展:近期控制器开始把初始执行返回的 false 转换成失败响应,避免把执行错误显示为“暂无数据”。这是有价值的进步,但还不是完整的错误约定:当前的驱动适配中,仍有把“结果集切换失败”或“捕获到的异常”转成空数组的分支。所以不能说“所有数据库错误都已经被正确上报”。

建议继续把下面三种结果区分开:

ts
type ReportOutcome =
  | { kind: 'table'; columns: string[]; rows: unknown[][] }
  | { kind: 'no-table' }
  | { kind: 'failure'; incidentId: string };

这是建议的教学模型,并不是当前的接口。空表属于 table,它和“没有结果集”“执行失败”分别表达不同的事实。

服务端的内部错误信息可以用于排查,但直接把原始的数据库错误返回给用户,可能暴露表名、结构或执行细节。建议在受控的日志里保留原因,对外提供一个可以关联的错误编号;日志本身也不要无差别地记录完整的参数和结果数据。

6. 坑四:以为支持了 CTE,就可以执行任意 SQL ​

批处理读取能力只解决了“返回值”的问题,不会自动提供只读权限、租户隔离、超时限制或参数安全。前置语句仍然可能有副作用,不能因为最后返回了一张表格,就把整段 SQL 当成只读查询。

允许维护报表 SQL 的系统,需要单独核对:谁可以修改语句、使用什么数据库权限、输入怎样绑定参数、执行资源怎样限制、审计怎样脱敏。这些需要真实的配置作为证据,不能靠这次兼容修复推出“已经安全”。

7. 坑五:页面每页显示更多,不等于服务端更快 ​

配套的前端近期调大了报表默认的每页条数。这改变的是用户一次看到多少行,并没有改变上面的封装“一次读取整个首个结果集”的事实。

所以要分别度量:数据库执行耗时、服务端取数耗时、网络响应大小和浏览器渲染耗时。数据量增大时,可以评估服务端分页或游标,但不能把界面上的分页设置当成已经实现了这些能力。

排查思路:报表显示“暂无数据”时 ​

  1. 先在数据库工具里执行同一段 SQL,确认它确实有数据;
  2. 看 SQL 的开头:是不是 WITH、注释、声明语句,或者前面还有其他语句;
  3. 看封装走的是哪条路径:是否按首个关键字分类,是否遍历了多个结果集;
  4. 看服务端日志:是否有被吞掉的错误(权限、超时、语法);
  5. 区分空表和失败:接口返回的是“有列但没有行”,还是空数组,还是错误。

建议验证 ​

  • [ ] 普通查询、带前置注释的查询和 CTE 都能返回预期的列和行
  • [ ] 声明语句、受影响行数之后的表格能被正确读取
  • [ ] 第一张空表不会被第二张非空表替代
  • [ ] 语法错误、权限错误、超时和读取中断都不会被误报为成功的空表
  • [ ] 多个驱动的部署分别验证,不假定 PDO 和 SQLSRV 的行为完全一样
  • [ ] 大结果集检查内存、耗时和前端渲染,而不只看每页条数

延伸阅读 ​

为复用而记录,为理解而整理。