news 2026/11/4 9:37:11

SQL Server动态SQL生成JSON分页接口与存储过程封装

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SQL Server动态SQL生成JSON分页接口与存储过程封装

简介:一份聚焦 SQL 自动生成 JSON 数据的 docx 技术文档,面向数据库开发、后端接口开发及需要将关系表数据快速转为 JSON 的初中级技术人员,重点解决手工拼接 JSON 字符串繁琐、分页查询与字段动态适配困难等问题。压缩包共 1 个 docx 文件,约 30KB,篇幅集中,便于随查随用。文档围绕 SQL Server 环境展开,涉及变量声明、动态 SQL 拼接、SYS.SYSCOLUMNS 读取表结构、WITH 分页与 ROW_NUMBER() 排序编号,并给出将结果组装为 JSON、通过 EXEC 执行及写入数据表的示例;同时说明前端借助 AJAX 调用接口获取 JSON 数据的思路。已有 1148 人学习,适合需要理解动态字段转 JSON、分页输出与前后端数据对接的读者参考,也可作为项目中的拼接模板与排错线索。

1. 一条 SQL 直接吐 JSON 的老派做法,救了多少没写完的接口

前阵子帮某开发者看他手上一个仓储系统的列表页,后端请了假,前端对着空表格干等。他把一段存到文档里的脚本转给我,说这东西能把整张表直接变成带分页信息的 JSON 数组——我跑了一遍,确实能。这个「SQL 自动生成 JSON 数据」的思路,核心就三件事:从sys.columns读出字段名动态拼出键,用ROW_NUMBER()切页,最后EXEC把拼好的语句扔回去执行。它适合谁?手上跑着 SQL Server 2005 到 2014 这类没有FOR JSON的老库,又不想为了一个列表接口专门写一层后端序列化代码的场景。往下读,你能把这段脚本完整跑通,也能知道它在哪些地方会翻车、怎么改才敢上线。

2. 动态拼接骨架:sys.columns怎么把一行数据拼成 JSON 键值对

这套脚本真正的难点不在分页,而在“怎么在不写死字段名的前提下,把一张表的所有列拼成"key":"value"这种形状”。作者选了字典表SYS.SYSCOLUMNS(新版对应sys.columns),在编译期拿到列名,把列名当作字面量拼进一段待执行的 SQL 文本里。理解这一步,后面的分页只是加两条尾巴。

2.1 五个变量定死整个脚本的输入面

脚本开头只声明了五个变量,看起来简陋,但这五个恰好覆盖了“查哪张表、怎么排、取哪一段、装到哪”四个问题。类型选得比较保守,varchar(3000)和varchar(20)这种长度在真实业务表上很容易撑爆,后面第 4 章会专门讲这个坑。

变量类型作用常见取值
@TableNamevarchar(50)目标表名,直接拼进动态 SQL'Merchandise'
@sqlvarchar(3000)承载最终拼出来的整条语句初值必须是NULL
@CurPageFirstRowint本页第一行在结果集中的序号(pageNo-1)*pageSize+1
@CurPageLastRowint本页最后一行序号pageNo*pageSize
@OrderByColumnvarchar(20)ROW_NUMBER()的排序依据列'MerbarCode'

这里有个很容易被忽略的细节:@sql的初值必须是NULL而不是空串。因为后面的拼接用的是ISNULL(@sql, 'with paging as ( ...'),只有NULL才会触发这个兜底前缀。有人图省事写了SET @sql = '',结果整条语句从with中间开始,EXEC直接抛语法错误,排查起来还挺费时间。

DECLARE @TableName varchar(50) = 'Merchandise'; -- 要查的表 DECLARE @OrderByColumn varchar(20) = 'MerbarCode'; -- 排序依据 DECLARE @CurPageFirstRow int = 1; -- (pageNo-1)*pageSize+1 DECLARE @CurPageLastRow int = 20; -- pageNo*pageSize DECLARE @sql varchar(3000); -- 注意:不赋值,保持 NULL

赋值语句本身没什么可说的,但把@CurPageFirstRow和@CurPageLastRow交给调用方算,是个有意的设计取舍:脚本不关心页码,只认行号区间。好处是存储过程或前端算错了也不会在这里二次出错;坏处是pageNo传 0 或负数时没有任何校验,BETWEEN -19 AND 0会静默返回空结果集,前端就只能看到一个空白列表。

2.2 一行 SELECT 把整张表的字段片段拼出来

核心拼接只有一条SELECT语句,但它用了@sql = @sql + ...这种“累加赋值”的写法,把sys.columns里的每一行变成一段 JSON 片段文本。这是 SQL Server 里做字符串聚合的老手法,在STRING_AGG出现之前基本是唯一选择。

SELECT @sql = ISNULL(@sql, N'with paging as ( select replace(''{''+ ') + N'''"' + c.name + N'":"'' + ISNULL(CAST(' + c.name + N' AS varchar), '''') + N''',''' FROM sys.columns c WHERE c.object_id = OBJECT_ID(@TableName) ORDER BY c.column_id; -- 原脚本没写排序,字段顺序不稳定

拆开看这段在拼什么:第一次进入循环时@sql是NULL,ISNULL取第二个参数,也就是with paging as ( select replace('{'+这段前缀。之后的每一次循环都在后面追加一段'"列名":"'+ISNULL(CAST(列名 AS varchar),'')+'",'——注意这里拼的是待执行 SQL 的文本,不是结果。列名是编译期就已知的,所以直接当字面量写进去;而“值”那一部分被拼成了运行时才会求值的表达式。

OBJECT_ID(@TableName)负责把表名翻译成对象 ID,sys.columns里一行对应一个字段。如果表名写错或者当前登录账号没权限,这里返回空集,@sql会停留在NULL,后面的EXEC直接报“参数未定义”,报错信息和真正原因隔了十万八千里——这类空集导致的连锁报错在动态 SQL 里特别常见。

我在原脚本基础上补了ORDER BY c.column_id。别小看这一句,不写的话字段顺序由执行计划决定,同一张表两次调用可能给出不同的键顺序,虽然 JSON 语义上无序,但前端如果是拿Object.keys()的位置做解析,就会变成偶发问题。

2.3 用@q变量把引号层数降下来

上面那段代码最劝退的地方是引号。原作者为了让拼出来的文本里出现",用了''''这样的多层转义,加上replace的查找串和替换串,一行里能出现七八个连续单引号。改一个字符就可能全盘失效,而且报错信息完全指不到出问题的那一行。

我的习惯是把双引号先存进变量,拼接时只拼变量名:

DECLARE @q char(1) = '"'; -- 双引号,避免每处都写 '''' DECLARE @cols nvarchar(max) = N''; SELECT @cols = @cols + CASE WHEN ROW_NUMBER() OVER (ORDER BY c.column_id) = 1 THEN N'' ELSE N',' END -- 首列不加分隔符,天然没有尾逗号 + @q + c.name + @q + N':' + N'''' + N'ISNULL(CONVERT(nvarchar(max), ' + QUOTENAME(c.name) + N'), N'''')' + N'''' + @q FROM sys.columns c WHERE c.object_id = OBJECT_ID(@TableName);

这段拼出来的@cols大概长这样(以一张三列的表为例):

"{'" + '"MerbarCode":"' + ISNULL(CONVERT(nvarchar(max), [MerbarCode]), N'') + '"' + ',"MerName":"' + ISNULL(CONVERT(nvarchar(max), [MerName]), N'') + '"' + ',"MerQty":"' + ISNULL(CONVERT(nvarchar(max), [MerQty]), N'') + '"' + '"}' -- 实际拼装时首尾由外层补齐

跟原脚本比,改动有三个:一是用CASE判断首列不加逗号,从根上避免了“拼完再 replace 掉尾逗号”这种事后补救;二是CONVERT(nvarchar(max), ...)取代CAST(... AS varchar),中文不会掉字;三是QUOTENAME(c.name)只用在值表达式里,JSON 的键仍然是原始列名,不会被加上方括号。

提示:QUOTENAME默认加的是[],如果你的列名本身带空格或特殊字符,键那边也得单独处理,不能直接复用QUOTENAME的结果。

到这里,@sql里已经有了一段完整的字段拼装表达式,剩下的工作就是把它套进WITH paging AS (...)里,并补上分页和总数。这套“先把结构拼成文本、再交给引擎执行”的模式,本质上就是手写一个极简的 JSON 序列化器,只不过它跑在数据库进程里。

3. 分页与总数:ROW_NUMBER()和count()一次拼出三样数据

字段片段搞定之后,分页反而是最规整的部分。整套逻辑围绕一个 CTE 展开:CTE 内部负责给每一行编号、算总数、把列值拼成 JSON 片段;CTE 外部负责按行号区间筛选。这个结构在 SQL Server 2005 之后就通用了,不依赖任何新版本特性,也是它在老系统里能活下来的原因。

3.1 CTE +ROW_NUMBER()构出分页骨架

先看把第 2 章产物套进去之后,@sql拼出来的完整语句长什么样:

with paging as ( select '{"MerbarCode":"' + ISNULL(CONVERT(nvarchar(max), [MerbarCode]), N'') + '"' + ',"MerName":"' + ISNULL(CONVERT(nvarchar(max), [MerName]), N'') + '"' + ',"MerQty":"' + ISNULL(CONVERT(nvarchar(max), [MerQty]), N'') + '"' + '}' as sourceTable, ROW_NUMBER() OVER (ORDER BY MerbarCode) as rn, (select count(*) from Merchandise) as total from Merchandise ) select * from paging where rn between 1 and 20;

ROW_NUMBER() OVER (ORDER BY MerbarCode)是整段分页的心脏。它给结果集按指定列排序后从 1 开始编号,编号在本次查询内唯一且连续。外层WHERE rn BETWEEN 1 AND 20直接切出第一页。这种写法和OFFSET ... FETCH(SQL Server 2012+)功能等价,但兼容性更好,在 2005/2008 上也能跑。

ORDER BY后面的列名同样是拼进去的字面量,不是变量。这就意味着@OrderByColumn的值必须是真实存在的列名,写错了会在EXEC阶段才报“列名无效”,静态检查完全发现不了。

排序稳定性是这类分页最常见的隐患。如果MerbarCode上有大量重复值,ROW_NUMBER()给出的序号在两次执行之间可能不同(取决于执行计划),翻页时就会出现某条记录在第 1 页和第 2 页都出现、或者干脆被跳过。稳妥做法是在排序里补一个唯一列兜底,比如ORDER BY MerbarCode, MerId。

3.2total子查询:把总数塞进每一行

分页列表通常还要返回总数,否则前端画不出页码。原脚本的做法是在 CTE 里放一个标量子查询(select count(...) from 表),SQL Server 会把它当常量对待,整张表只算一次,不会每行都跑。

-- 原脚本写法:count(排序列) (select count(MerbarCode) from Merchandise) as total -- 更稳的写法:count(*),不受 NULL 影响 (select count_big(*) from Merchandise) as total

这两个版本的差别在 NULL 上。COUNT(列名)只统计非 NULL 的行,如果排序列恰好允许为空,总数就会比实际行数少,前端算出来的总页数跟着偏小,最后一页永远翻不到。改成COUNT(*)或COUNT_BIG(*)就没这个问题;表特别大的话用COUNT_BIG,避免 int 溢出。

标量子查询在这里是能用的,但它有一个前提:CTE 内部没有WHERE过滤条件。一旦你在 CTE 里加了业务过滤(比如只查未删除的记录),这个count也必须同步加上同样的条件,否则总数和列表对不上。这是二次开发时最容易漏改的地方。

3.3 外层收尾:把sourceTable、rn、total拼成一个完整对象

第一版脚本到select * from paging where rn between ...就结束了,返回的每一行里sourceTable是一个 JSON 对象字符串,rn和total是独立的两列。前端拿到手还得自己再拼一次,体验不好。所以原文档里还给了第二版:

select sourceTable + '"rn":"' + CAST(rn as varchar) + '",' + '"total":"' + CAST(total as varchar) + '"},' from paging where rn between 1 and 20;

这样每一行输出的就是一个完整的 JSON 对象,末尾带一个逗号,方便调用方按行直接concat成数组。rn和total被塞进对象内部,前端解析一次就能同时拿到数据和分页信息。

但这一版有两个明显的半成品痕迹。一是没有在最外层包[和],输出的是若干个用逗号分隔的对象,不是合法 JSON 数组;二是每个对象(包括最后一个)后面都跟着逗号,直接拼起来会多一个尾逗号,JSON.parse会抛错。作者大概是打算让前端自己处理,但这属于把复杂度推给了调用方。

要收干净,两种方式。老版本用FOR XML PATH('')把行拼成一个字符串,再去掉尾逗号:

DECLARE @json nvarchar(max); SELECT @json = N'[' + STUFF(( SELECT N',' + sourceTable + N'"rn":"' + CAST(rn as varchar) + N'","total":"' + CAST(total as varchar) + N'"}' FROM paging WHERE rn BETWEEN @CurPageFirstRow AND @CurPageLastRow FOR XML PATH(''), TYPE ).value('.', 'nvarchar(max)'), 1, 1, N'') + N']';

STUFF(..., 1, 1, '')的作用是删掉拼接结果开头的第一个逗号。FOR XML PATH('')负责把多行拼成一个字符串,.value('.', 'nvarchar(max)')把 XML 类型转回文本,顺带处理了实体转义。这套组合在 2005 上就能用,是STRING_AGG之前的通用解法。SQL Server 2017 之后可以直接换成STRING_AGG(sourceTable, ','),代码短一半。

注意:FOR XML PATH会转义&、<、>这些字符,转成&amp;之类。如果字段值里本来就有这些符号,前端拿到的是被二次转义的文本,还得再解一次。用STRING_AGG就没这个副作用。

3.4EXEC(@sql)之后,调用方到底拿到什么

最后一句是EXEC(@sql)。它把拼好的整条语句当字符串执行,结果集直接返回给客户端。客户端看到的是一张表,每行一列,列名是sourceTable(第二版里没有别名,列名会是空或者表达式文本),值是一个 JSON 对象字符串。

这里有个行为差异值得记住:EXEC(@sql)只能把结果集吐给调用方,没法直接SET @变量 = EXEC(...)。想在存储过程内部把结果留下来继续加工,必须借道临时表:

CREATE TABLE #j (line nvarchar(max)); INSERT INTO #j (line) EXEC(@sql); -- 先落表,再加工

INSERT ... EXEC有几个限制:不能嵌套(被执行的语句里不能再有INSERT ... EXEC),不能和OUTPUT子句混用,而且目标表必须先建好、列数和类型要匹配。这些限制在写一次性脚本时无所谓,一旦要封装成通用存储过程就会撞上,第 5 章会换一条路走。

4. 避坑与排查:这条脚本最容易翻车的五个地方

这段脚本短,但每一行都埋着雷。下面五条是我和周边人实际踩过的,按“现象 → 原因 → 解决”记下来,照着自己那份脚本对一遍,能省不少排查时间。

第一坑:字段一多就报语法错误

现象:表只有七八列时跑得好好的,业务加了几个字段之后突然开始报Incorrect syntax near ...或者Unclosed quotation mark after the character string,回滚几个字段又正常。

原因:@sql声明成了varchar(3000)。动态 SQL 的文本长度随列数线性增长,每列大约贡献 60~80 个字符,四十列左右就顶到上限。varchar在赋值时会静默截断,不报错,截断后的语句多半停在某个字符串中间,EXEC阶段才炸。

解决:@sql直接声明成nvarchar(max),@TableName和@OrderByColumn用sysname(等价nvarchar(128))。顺带把拼接过程中的所有字面量都加上N前缀,不然中文字符串会被降级成varchar,前面的nvarchar(max)白改了。

第二坑:字段值里的逗号被吃掉了

现象:某个商品名原本是螺丝, M6,接口返回变成螺丝 M6,逗号凭空消失。数据表里查出来是好的,只有接口返回的不对。

原因:原作者用replace(拼接结果, ',', '')去清理末尾多余的逗号。这个replace作用在整行拼接结果上,不区分“结构分隔符”和“内容里的逗号”,值里出现的逗号一样被删。

解决:别用replace事后清理,改成拼接时首列不加分隔符的写法(见 2.3),从根上没有尾逗号。如果已经在线上跑着不好大改,至少把查找串改得更精确,比如先拼成{...,"x":"1",}这种固定形态,再用LEFT(s, LEN(s)-1)砍掉最后一个字符,而不是全局替换。

第三坑:中文字段变成问号

现象:nvarchar列里的中文,输出到 JSON 里变成?或者空白。

原因:CAST(列名 AS varchar)会按当前数据库的排序规则做代码页转换,非中文代码页下汉字直接丢失,且不报错。

解决:CONVERT(nvarchar(max), 列名),同时拼接用的字面量全部加N前缀,@sql用nvarchar(max)。三处缺一不可,只改一处还是会丢。

第四坑:值里有双引号或换行,前端JSON.parse直接抛错

现象:大部分数据正常,偶尔某个接口 500,日志里看不出所以然。把返回体打印出来,发现某个值里带了个双引号。

原因:脚本只做了 NULL 处理,没做 JSON 转义。"、\、换行、制表符这些字符在 JSON 里都有特殊含义,原样输出就是非法 JSON。

解决:在拼值的那一层套REPLACE:

-- 顺序很重要:反斜杠必须先处理,否则会把后面加的转义符再转一遍 REPLACE(REPLACE(REPLACE(REPLACE(v, N'\', N'\\'), N'"', N'\"'), CHAR(13), N'\r'), CHAR(10), N'\n')

SQL Server 2016 之后有STRING_ESCAPE(v, 'json'),一行搞定,但注意它不处理控制字符,换行还得单独REPLACE。

第五坑:翻页出现重复行,总数对不上

现象:第 1 页的最后一条在第 2 页第一条又出现一次;或者前端算出来 5 页,翻到第 5 页是空的。

原因有两个,经常同时出现。一是ORDER BY的列有重复值,ROW_NUMBER()在并列行之间的编号不稳定,不同次执行给出不同顺序;二是总数用了COUNT(排序列),排序列上有 NULL 时少算行。

解决:排序补一个唯一列做次级排序(ORDER BY MerbarCode, MerId);总数改COUNT(*)或COUNT_BIG(*)。如果业务上确实需要按可能重复的列排序,就把主键永远挂在排序列表最后。

5. 工程化封装:从一段粘贴脚本到可调用的存储过程

脚本放在文档里,每次用的时候改改变量再跑,在临时查数时没问题。但要做成接口,就得把它封装成一个有明确入参、出参、错误处理的存储过程。这一章把散在前面的碎片拼起来,给出一个能直接建到库里的版本。

5.1 存储过程签名与参数校验

签名设计上,我把“页码 + 每页条数”作为对外参数,行号区间在内部算,这样调用方不用关心BETWEEN的边界。出参用OUTPUT直接把 JSON 字符串带出去,调用方拿到就是一段可以直接塞进 HTTP 响应的文本。

CREATE OR ALTER PROCEDURE dbo.usp_TableToJsonPage @TableName sysname, @OrderByColumn sysname, @PageNo int = 1, @PageSize int = 20, @Json nvarchar(max) OUTPUT AS BEGIN SET NOCOUNT ON; -- 入参兜底,避免前端传 0 或负数导致静默返回空集 IF @PageNo < 1 SET @PageNo = 1; IF @PageSize < 1 SET @PageSize = 20; IF @PageSize > 500 SET @PageSize = 500; -- 上限保护,防止一次拉全表 -- 表名必须真实存在,否则 sys.columns 返回空集,后续拼接全是 NULL IF OBJECT_ID(@TableName, N'U') IS NULL BEGIN RAISERROR(N'表不存在或不是用户表: %s', 16, 1, @TableName); RETURN; END; DECLARE @FirstRow int = (@PageNo - 1) * @PageSize + 1; DECLARE @LastRow int = @PageNo * @PageSize; ... END

OBJECT_ID(@TableName, N'U')里的'U'限定只匹配用户表,避免误传视图名或系统表。RAISERROR的严重级别用 16,属于用户可修复的错误,客户端能正常捕获。@PageSize加 500 的上限,是因为PageSize传大了等于全表扫描加全量字符串拼装,一次就能把内存打满。

5.2 拼字段时的转义与类型处理

在 2.3 的版本基础上,把转义补进值表达式。注意转义要作用在值上,不能作用在整行结果上,否则又回到第二坑。

DECLARE @cols nvarchar(max) = N''; SELECT @cols = @cols + CASE WHEN ROW_NUMBER() OVER (ORDER BY c.column_id) = 1 THEN N'' ELSE N',' END + N'"' + c.name + N'":"' + N''' + ISNULL(REPLACE(REPLACE(REPLACE(REPLACE(CONVERT(nvarchar(max), ' + QUOTENAME(c.name) + N'), N''\'', N''\\''), N''"'', N''\"''), CHAR(13), N''\r''), CHAR(10), N''\n''), N'''') + N'"' FROM sys.columns c WHERE c.object_id = OBJECT_ID(@TableName);

这段生成的是待执行 SQL 里的表达式片段,四层REPLACE的转义在拼接时需要写成双写引号,可读性很差但逻辑清楚:内层先把列值转成nvarchar(max),再依次替换反斜杠、双引号、回车、换行,最后ISNULL兜底成空串。反斜杠必须排在最前面,这个顺序写反了会把后面生成的\"再转义一次,变成\\",JSON 依旧非法。

日期和数值类型这里偷了个懒,直接CONVERT(nvarchar(max), ...),日期会变成Jan 1 2024 12:00AM这种默认格式。如果前端要标准 ISO 格式,把日期列单独挑出来做CONVERT(varchar(19), 列, 120),做法是按sys.types.name判断:

CASE WHEN t.name IN ('datetime', 'datetime2', 'smalldatetime') THEN N'CONVERT(varchar(19), ' + QUOTENAME(c.name) + N', 120)' ELSE N'CONVERT(nvarchar(max), ' + QUOTENAME(c.name) + N')' END

120是 ODBC 标准格式yyyy-mm-dd hh:mi:ss,前端new Date()能直接解析。这段要加JOIN sys.types t ON c.user_type_id = t.user_type_id才能拿到类型名。

5.3 用sp_executesql执行并回收结果

前面提过EXEC(@sql)没法直接给变量赋值。这里换成sp_executesql,它能声明 OUTPUT 参数,让被执行的语句把结果写回外层变量,比INSERT ... EXEC干净,也不受“不能嵌套”的限制。

DECLARE @stmt nvarchar(max) = N' WITH paging AS ( SELECT ' + @cols + N' AS sourceTable, ROW_NUMBER() OVER (ORDER BY ' + QUOTENAME(@OrderByColumn) + N') AS rn, COUNT_BIG(*) OVER () AS total FROM ' + QUOTENAME(@TableName) + N' ) SELECT @out = N''['' + STUFF(( SELECT N'','' + sourceTable + N''"rn":"'' + CAST(rn AS varchar) + N''","total":"'' + CAST(total AS varchar) + N''"}'' FROM paging WHERE rn BETWEEN @a AND @b FOR XML PATH(''''), TYPE ).value(''.'', ''nvarchar(max)''), 1, 1, N'''') + N'']'';'; EXEC sp_executesql @stmt, N'@a int, @b int, @out nvarchar(max) OUTPUT', @a = @FirstRow, @b = @LastRow, @out = @Json OUTPUT;

COUNT_BIG(*) OVER ()是窗口聚合,一次扫描就能把总数带到每一行,比 3.2 里的标量子查询少扫一遍表。QUOTENAME(@TableName)给表名加方括号,顺带挡住表名里带空格的情况——但它挡不住注入,QUOTENAME遇到超长输入会返回NULL,这点在第 6 章会补白名单。

外层@stmt里用@a、@b、@out三个参数占位,sp_executesql负责把值传进去。表名和排序列没法参数化,只能拼,这是动态 SQL 的固有局限,只能靠白名单约束。执行计划方面,sp_executesql比EXEC(@sql)好一点,参数化部分能复用计划,尽管表名一变计划还是会重新生成。

接口层拿到@Json之后基本就是透传:

// 某后端服务的列表接口,拿到存储过程返回值直接透出 const pool = await sql.connect(config); const result = await pool.request() .input('TableName', sql.NVarChar(128), 'Merchandise') .input('OrderByColumn', sql.NVarChar(128), 'MerbarCode') .input('PageNo', sql.Int, req.query.page || 1) .input('PageSize', sql.Int, req.query.size || 20) .output('Json', sql.NVarChar(sql.MAX)) .execute('dbo.usp_TableToJsonPage'); res.type('application/json').send(result.output.Json);

sql.NVarChar(sql.MAX)这个类型声明必须和存储过程的OUTPUT参数严格对齐,写成sql.NVarChar不带长度会默认 4000,超过就截断。这类“类型对不上导致数据截断”的问题不报错,只表现为前端偶尔少几条数据,很难往驱动层想。

6. 二次开发:白名单、换分页位置、以及怎么验证改对了

这套脚本真正要投入二次开发,我一般从三个地方下手,按改动风险从低到高排。

第一件事是给表名和排序列加白名单。QUOTENAME(@TableName)挡不住Merchandise; DROP TABLE ...这种输入,因为分号在QUOTENAME包裹之后仍然是语句分隔符。最省事的做法是建一张配置表,把允许暴露的表和排序列登记进去,进存储过程第一件事就是EXISTS校验:

IF NOT EXISTS (SELECT 1 FROM dbo.JsonApiWhitelist WHERE TableName = @TableName AND OrderColumn = @OrderByColumn) BEGIN RAISERROR(N'表或排序列未授权: %s / %s', 16, 1, @TableName, @OrderByColumn); RETURN; END;

维护成本不高,新增一张表加一行记录就行,比在代码里硬编码IF @TableName = 'A' OR @TableName = 'B'好扩展。我一般还会在这张表上加一列MaxPageSize,不同表给不同的上限,大表限制严一点。

第二件事是把 JSON 拼装挪到分页之后。原脚本的结构是“先对全表拼 JSON,再按rn切页”。SQL Server 通常会做谓词下推,但sourceTable是 CTE 内的计算列,优化器不一定愿意把它推迟到外层,表大的时候会白算几万行字符串。改成先在 CTE 里只算rn和total,外层SELECT再拼:

WITH paged AS ( SELECT *, ROW_NUMBER() OVER (ORDER BY MerbarCode, MerId) AS rn, COUNT_BIG(*) OVER () AS total FROM dbo.Merchandise ) SELECT '{"MerbarCode":"' + ... + '"}' FROM paged WHERE rn BETWEEN @a AND @b;

这样字符串拼装只对本页的 20 行做,代价是 CTE 里SELECT *会把宽列全带出来排序。如果表特别宽,可以只SELECT需要的列,效果更明显。我在一张 30 万行、40 列的表上对比过,改前单次约 900ms,改后 120ms 左右,差距主要来自字符串拼装的次数。

第三件事,如果数据库版本够,直接换FOR JSON。SQL Server 2016 起原生支持:

SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (ORDER BY MerbarCode, MerId) AS rn FROM dbo.Merchandise ) t WHERE rn BETWEEN 1 AND 20 FOR JSON PATH, INCLUDE_NULL_VALUES;

转义、类型、空值全由引擎处理,不用自己拼字符串,也不用担心注入。缺点一是版本门槛,二是输出的键名跟列名完全一致,没法做别名映射(除非在子查询里AS)。老系统升不上去,就只能继续用前面那套。

验证方法。我一般准备一张回归表,塞四类脏数据:值里带逗号、值里带双引号和反斜杠、整行全 NULL、中文加 emoji。改完脚本跑一遍,SQL Server 2016+ 直接用ISJSON(@Json)判断是否合法,返回 1 才算过;老版本就把@Json贴到浏览器控制台跑JSON.parse。再

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 9:37:10

SHAP模型可解释性实战:从Shapley值到可视化避坑

1. 为什么模型越准&#xff0c;团队越不敢用 我见过太多这样的情况&#xff1a;一个模型在验证集上把指标刷到了 0.92&#xff0c;业务方兴冲冲拿去上线&#xff0c;结果风控同事一句"这模型为什么把这位老客户拒了"就把整件事卡住了。指标再漂亮&#xff0c;答不上&…

作者头像 李华
网站建设 2026/11/4 9:37:05

Synopsys License服务启动与Checkout失败排查指南

简介&#xff1a;Synopsys工具license配置学习文档面向需要搭建本地学习测试环境的IC设计初学者与工程师&#xff0c;围绕license文件生成、路径指向与feature缺失排错等常见问题展开&#xff0c;帮助读者理解license管理的基本流程&#xff0c;并明确仅限学习使用。资源包仅含…

作者头像 李华
网站建设 2026/11/4 9:37:05

Django与大数据构建智能营养分析系统

1. 项目概述与核心价值这个基于Django和大数据技术的食物营养分析系统&#xff0c;本质上解决的是现代人面临的"营养信息过载"问题。我们每天接触的食物种类繁多&#xff0c;但很少有人能准确掌握各类食物的营养成分构成及其对健康的影响。传统营养分析工具往往存在三…

作者头像 李华
网站建设 2026/11/4 9:36:36

三数之和问题:排序与双指针算法详解

1. 问题背景与核心挑战 三数之和问题&#xff08;LeetCode 15题&#xff09;是算法面试中的经典题目&#xff0c;要求在一个整数数组中找到所有不重复的三元组&#xff0c;使得这三个数之和等于零。这个问题看似简单&#xff0c;但实际蕴含着多个需要巧妙处理的算法难点。 我第…

作者头像 李华
网站建设 2026/11/4 9:36:25

从帕累托最优到帕累托前沿:多目标优化选型与Python实战

做技术方案评审最怕听到一句话&#xff1a;“这个方案更好。”好在哪里&#xff1f;成本降了三成&#xff0c;延迟涨了一成半&#xff0c;这到底算更好还是更差&#xff1f;会议室里两拨人谁也说服不了谁&#xff0c;最后往往靠拍板定案。踩过几次这种坑之后我才想明白&#xf…

作者头像 李华
网站建设 2026/11/4 9:36:02

MFC自绘Edit:子类化与纯自绘路线及避坑

简介&#xff1a;面向 MFC 界面定制开发者的自绘 Edit 控件示例工程&#xff0c;适合已掌握 CEdit 基本用法、希望突破系统默认外观限制的 Windows 桌面开发人员参考。它围绕重写 CEdit 绘制流程展开&#xff0c;涉及 WM_PAINT 与 OnPaint、CDC/CRect 绘图、文本颜色与背景模式…

作者头像 李华