65.9K
CodeProject 正在变化。 阅读更多。
Home

SQL 中的嵌套 XML 转换为 JSON

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.81/5 (11投票s)

2014年10月21日

CPOL

4分钟阅读

viewsIcon

55779

将 SQL Server 的嵌套 XML 数据获取为 JSON 字符串

引言

本文介绍如何将 SQL Server 中的数据输出为 JSON 字符串。它遵循 Phil Factor 在 https://www.simple-talk.com/sql/t-sql-programming/producing-json-documents-from-sql-server-queries-via-tsql 提供的路径,但采用了一种不同的(我认为更简单)方法。

背景

在 SQL Server 中,从 SQL 转换为 XML 非常直接,SQL Server 本身就直接支持。然而,转换为 JSON 是一个不同的问题,因为没有直接的 JSON 支持,唯一的选择是生成一个包含所需数据的 NVARCHAR。

从 XML 节点生成简单的(无嵌套对象)JSON 对象非常容易,只需使用 Xml 数据类型方法:local-name()text(),进行一些类型转换和转义,将所有内容连接成一个 NVARCHAR,就完成了。然而,对于嵌套对象,问题会变得棘手一些,因为何时停止连接就变得至关重要。

何时停止连接取决于你从哪里开始连接。如果你从根节点开始,那么你应该在进入叶节点时停止。或者,如果你从叶节点开始,那么你必须在到达根节点时停止。

Phil 的提议遵循后者方向,它解析 XML,构建一个层次结构表,识别叶节点,将它们转换为 JSON 字符串,然后是它们的父节点,然后是它们的父节点的父节点……一直持续到根节点。这是一个使用自下而上方法的通用解决方案。在实际情况中,XML 解析可能会被省略,因为使用 SQL 或 XPath 查询可以更有效地填充层次结构表。

那么自顶向下呢?起始节点(即根节点)是已知的,叶节点是未知的,但识别它们很简单:它们是没有子节点的节点。

生成我们的 XML

我们知道我们需要一些具有一定嵌套级别的 XML 数据。既然我们在 SQL 世界中,不如从一些表中生成。让我们为气象服务创建一个示例数据库。我们的服务有数据收集站。每个站点收集月度温度和降雨统计数据,每个统计数据由一个或多个操作员审查。为了使其更现实,让我们添加一些 null 值,但有一些统计数据未被审查。

SET NOCOUNT ON

IF OBJECT_ID('STATS') IS NOT NULL DROP TABLE STATS
IF OBJECT_ID('STATIONS') IS NOT NULL DROP TABLE STATIONS
IF OBJECT_ID('OPERATORS') IS NOT NULL DROP TABLE OPERATORS
IF OBJECT_ID('REVIEWS') IS NOT NULL DROP TABLE REVIEWS

-- Create and populate table with Station
CREATE TABLE STATIONS(ID INTEGER PRIMARY KEY, CITY NVARCHAR(20), STATE CHAR(2), LAT_N REAL, LONG_W REAL);
INSERT INTO STATIONS VALUES (13, 'Phoenix', 'AZ', 33, 112);
INSERT INTO STATIONS VALUES (44, 'Denver', 'CO', 40, 105);
INSERT INTO STATIONS VALUES (66, 'Caribou', 'ME', 47, 68);

-- Create and populate table with Operators
CREATE TABLE OPERATORS(ID INTEGER PRIMARY KEY, NAME NVARCHAR(20), SURNAME NVARCHAR(20));
INSERT INTO  OPERATORS VALUES (50, 'John "The Fox"', 'Brown');
INSERT INTO  OPERATORS VALUES (51, 'Paul', 'Smith');
INSERT INTO  OPERATORS VALUES (52, 'Michael', 'Williams'); 

-- Create and populate table with normalized temperature and precipitation data
CREATE TABLE STATS (
        STATION_ID INTEGER REFERENCES STATIONS(ID),
        MONTH INTEGER CHECK (MONTH BETWEEN 1 AND 12),
        TEMP_F REAL CHECK (TEMP_F BETWEEN -80 AND 150),
        RAIN_I REAL CHECK (RAIN_I BETWEEN 0 AND 100), PRIMARY KEY (STATION_ID, MONTH));
INSERT INTO STATS VALUES (13,  1, 57.4, 0.31);
INSERT INTO STATS VALUES (13,  7, 91.7, 5.15);
INSERT INTO STATS VALUES (44,  1, 27.3, 0.18);
INSERT INTO STATS VALUES (44,  7, 74.8, 2.11);
INSERT INTO STATS VALUES (66,  1, 6.7, 2.10);
INSERT INTO STATS VALUES (66,  7, 65.8, 4.52);

-- Create and populate table with Review
CREATE TABLE REVIEWS(STATION_ID     INTEGER,STAT_MONTH  INTEGER,OPERATOR_ID INTEGER)  
insert into REVIEWS VALUES (13,1,50)
insert into REVIEWS VALUES (13,7,50)
insert into REVIEWS VALUES (44,7,51)
insert into REVIEWS VALUES (44,7,52)
insert into REVIEWS VALUES (44,7,50)
insert into REVIEWS VALUES (66,1,51)
insert into REVIEWS VALUES (66,7,51)

让我们熟悉一下我们的数据

select     STATIONS.ID       as ID,
           STATIONS.CITY     as City,
           STATIONS.STATE    as State,
           STATIONS.LAT_N    as LatN,
           STATIONS.LONG_W   as LongW,
           STATS.MONTH       as Month,
           STATS.RAIN_I      as Rain,
           STATS.TEMP_F      as Temp,
       OPERATORS.NAME    as Name,
       OPERATORS.SURNAME as Surname
from       stations 
inner join stats     on stats.STATION_ID=STATIONS.ID 
left join  reviews   on reviews.STATION_ID=stations.id 
                     and reviews.STAT_MONTH=STATS.[MONTH]
left join  OPERATORS on OPERATORS.ID=reviews.OPERATOR_ID

ID  City     State LatN  LongW  Month Rain  Temp  Name            Surname
--------------------------------------------------------------------------
13  Phoenix  AZ    33    112    1     0.31  57.4  John "The Fox"  Brown
13  Phoenix  AZ    33    112    7     5.15  91.7  John "The Fox"  Brown
44  Denver   CO    40    105    1     0.18  27.3  {null}          {null}
44  Denver   CO    40    105    7     2.11  74.8  Paul            Smith
44  Denver   CO    40    105    7     2.11  74.8  Michael         Williams
44  Denver   CO    40    105    7     2.11  74.8  John "The Fox"  Brown
66  Caribou  ME    47    68     1     2.1   6.7   Paul            Smith
66  Caribou  ME    47    68     7     4.52  65.8  Paul            Smith


select stations.*,
       (select stats.*, 
               (select OPERATORS.*  
                from   OPERATORS 
                inner  join reviews on OPERATORS.ID=reviews.OPERATOR_ID 
                where  reviews.STATION_ID=STATS.STATION_ID 
                and    reviews.STAT_MONTH=STATS.MONTH 
                for xml path('operator'),type
               ) operators
        from  STATS 
        where STATS.STATION_ID=stations.ID 
        for xml path('stat'),type
       ) stats 
from   stations 
for    xml path('station'),type


<station>
  <ID>13</ID>
  <CITY>Phoenix</CITY>
  <STATE>AZ</STATE>
  <LAT_N>3.3000000e+001</LAT_N>
  <LONG_W>1.1200000e+002</LONG_W>
  <stats>
    <stat>
      <STATION_ID>13</STATION_ID>
      <MONTH>1</MONTH>
      <TEMP_F>5.7400002e+001</TEMP_F>
      <RAIN_I>3.1000000e-001</RAIN_I>
      <operators>
        <operator>
          <ID>50</ID>
          <NAME>John "The Fox"</NAME>
          <SURNAME>Brown</SURNAME>
        </operator>
      </operators>
    </stat>
    <stat>
      <STATION_ID>13</STATION_ID>
      <MONTH>7</MONTH>
      <TEMP_F>9.1699997e+001</TEMP_F>
         ...
         ...
</station>

转换为 JSON

为了将 XML 节点转换为 JSON,我们将使用 Xml 数据类型方法

  • 为了获取 XML 的节点,我们将选择 .nodes(Xpath_Expression) 的结果,该函数将匹配给定 Xpath_Expression 的每个节点作为一行,其中只有一列是 XML 类型。这些 XML 行将被“选择”,在提取它们的 JSON 名称和 JSON 值后,将构成每个 JSON 实体的属性。
  • 为了从 XML 行获取 JSON 属性名,我们将使用 .value('local-name(.)', 'NVARCHAR(255)') 获取节点的本地名称作为 NVARCHAR
  • 同样,为了从 XML 行获取 JSON 值,我们将使用 .value('text()[1]','NVARCHAR(MAX)'),该函数返回 XML 文本作为 NVARCHAR,之后会进行格式化。

这里是新的部分

  • 我们可以通过测试一个节点是否有零个子节点 .value('count(*)','int')=0 来判断它是否是叶节点。如果一个节点没有子节点,那么它的内容就是一个简单的 JSON 值,它可以是字符串或数字(因为 XML 中没有 null 值)。在 JSON 表示方面,唯一的区别是引号和字符转义。这种表示由 dbo.qfn_JsonEscape 处理。
  • 如果一个 XML 节点有一个或多个子节点,那么我们将通过执行 .query('*') 来获取它的子节点作为 XML 子树,然后我们可以再次使用 XML 节点到 JSON 函数。

也就是说,我们引入了递归来遵循 XML 层次结构。每次调用该函数,都会:

  • 处理一个无子节点的 XML 子树,通过简单地使用 Xml 数据类型方法和简单的字符串连接,可以将其转换为 JSON 字符串。此过程非常直接,只需连接:
  1. {,} 符号
  2. {"} 符号
  3. 节点 local-name() 或空字符串(如果为 null)(注意 coalesce 函数)
  4. 再次是 {"} 符号
  5. {;} 符号
  6. 将节点值应用 dbo.qfn_JsonEscape 的结果
  • 或者处理一个 XML 子树,它将连接数组字符([ ])并使用子树作为新参数调用函数。

整合所有内容

CREATE FUNCTION [dbo].[qfn_XmlToJson](@XmlData xml)
RETURNS nvarchar(max)
AS
BEGIN
  declare @m nvarchar(max)
  SELECT @m='['+Stuff
  (
     (SELECT theline from
    (SELECT ','+' {'+Stuff
       (
              (SELECT ',"'+coalesce(b.c.value('local-name(.)', 'NVARCHAR(255)'),'')+'":'+
                      case when b.c.value('count(*)','int')=0 
                      then dbo.[qfn_JsonEscape](b.c.value('text()[1]','NVARCHAR(MAX)'))
                      else dbo.qfn_XmlToJson(b.c.query('*'))
                      end
                 from x.a.nodes('*') b(c)                                                                
                 for xml path(''),TYPE).value('(./text())[1]','NVARCHAR(MAX)')
               ,1,1,'')+'}'
          from @XmlData.nodes('/*') x(a)
       ) JSON(theLine)
       for xml path(''),TYPE).value('.','NVARCHAR(MAX)')
      ,1,1,'')+']'
   return @m
END


最后一步是,一旦整个集合被连接和处理,SQL 的 stuff 函数会删除数组中的第一个逗号(,)符号。

帮助函数 dbo.[qfn_JsonEscape] 只是

  1. 将任何非数字值视为字符串,用双引号括起来,并对控制字符进行 JSON 转义。

  2. 返回接收到的任何数字值(不加引号)

CREATE FUNCTION [dbo].[qfn_JsonEscape](@value nvarchar(max) )
returns nvarchar(max)
as begin
 
 if (@value is null) return 'null'
 if (TRY_PARSE( @value as float) is not null) return @value

 set @value=replace(@value,'\','\\')
 set @value=replace(@value,'"','\"')

 return '"'+@value+'"'
end

测试

我们可以通过执行以下命令来测试结果

select dbo.qfn_XmlToJson
(
  (
    select stations.ID,stations.CITY,stations.STATE,stations.LAT_N,stations.LONG_W ,
          (select stats.*, 
                   (select OPERATORS.*  
                    from   OPERATORS inner join reviews 
                    on     OPERATORS.ID=reviews.OPERATOR_ID
                    where  reviews.STATION_ID=STATS.STATION_ID 
                    and    reviews.STAT_MONTH=STATS.MONTH 
                    for xml path('operator'),type
                   ) operators
           from  STATS 
           where STATS.STATION_ID=stations.ID for xml path('stat'),type
          ) stats 
     from stations for xml path('stations'),type
   )
)

这会返回

[ {"ID":13,"CITY":"Phoenix","STATE":"AZ","LAT_N":3.3000000e+001,"LONG_W":1.1200000e+002,"stats":[ {"STATION_ID":13,"MONTH":1,"TEMP_F":5.7400002e+001,"RAIN_I":3.1000000e-001,"operators":[ {"ID":50,"NAME":"John \"The Fox\"","SURNAME":"Brown"}]}, {"STATION_ID":13,"MONTH":7,"TEMP_F":9.1699997e+001,"RAIN_I":5.1500001e+000,"operators":[ {"ID":50,"NAME":"John \"The Fox\"","SURNAME":"Brown"}]}]}, {"ID":44,"CITY":"Denver","STATE":"CO","LAT_N":4.0000000e+001,"LONG_W":1.0500000e+002,"stats":[ {"STATION_ID":44,"MONTH":1,"TEMP_F":2.7299999e+001,"RAIN_I":1.8000001e-001}, {"STATION_ID":44,"MONTH":7,"TEMP_F":7.4800003e+001,"RAIN_I":2.1099999e+000,"operators":[ {"ID":51,"NAME":"Paul","SURNAME":"Smith"}, {"ID":52,"NAME":"Michael","SURNAME":"Williams"}, {"ID":50,"NAME":"John \"The Fox\"","SURNAME":"Brown"}]}]}, {"ID":66,"CITY":"Caribou","STATE":"ME","LAT_N":4.7000000e+001,"LONG_W":6.8000000e+001,"stats":[ {"STATION_ID":66,"MONTH":1,"TEMP_F":6.6999998e+000,"RAIN_I":2.0999999e+000,"operators":[ {"ID":51,"NAME":"Paul","SURNAME":"Smith"}]}, {"STATION_ID":66,"MONTH":7,"TEMP_F":6.5800003e+001,"RAIN_I":4.5200000e+000,"operators":[ {"ID":51,"NAME":"Paul","SURNAME":"Smith"}]}]}]

这是预期的结果(注意 John “The Fox” 引号的转义)。你可以在 http://sqlfiddle.com/#!6/77909/1 进行测试。

结论

在 XML 和 JSON 中使用递归是“自然而然”的,尽管这在 TSQL 中通常不是这样,但它允许直接而优雅的解决方案。像任何基于递归的解决方案一样,它在性能方面是有代价的,所以我建议不要将此技术用于返回超过一千条记录的查询。

© . All rights reserved.