sql: JSON and XML Data Handling in SQL using sql server 2025

sql 复制代码
/* ==================================================================================================
   SQL Server 2025  ·  JSON 与 XML 数据处理(JSON and XML Data Handling)
   国际化珠宝集团  ERP / CRM / HR  全场景演示
   --------------------------------------------------------------------------------------------------
   目标:在 SQL Server 里"存、查、索引、校验" JSON 与 XML,
         并且把**半结构化数据的边界**划清楚 ------ 什么该拆成列,什么才配留在 JSON/XML 里。

   覆盖内容:
     · JSON 存储:nvarchar(max) + CHECK(ISJSON)  vs  SQL Server 2025 新增的**原生 json 数据类型**
     · JSON 读取:JSON_VALUE / JSON_QUERY / OPENJSON / JSON_PATH_EXISTS,lax 与 strict
     · JSON 生成:FOR JSON PATH/AUTO、JSON_OBJECT、JSON_ARRAY、JSON_ARRAYAGG、JSON_OBJECTAGG(2025 新增聚合)
     · JSON 修改:JSON_MODIFY(lax 追加 vs strict 报错)
     · JSON 校验:ISJSON 的 OBJECT/ARRAY/VALUE 类型参数、CHECK 约束、JSON_PATH_EXISTS
     · JSON 索引:原生 json 列**不能**直接建索引 → 计算列 + 索引(正解),含 IO 实测
     · ★ JSON_VALUE 的 4000 字符返回值上限:超出时**静默返回 NULL**(实测 4000 通过 / 4005 变 NULL)
     · XML 存储与查询:xml 数据类型 + .value() / .query() / .nodes() / .exist()
     · XML 生成:FOR XML RAW / AUTO / ELEMENTS / PATH / TYPE
     · XML 分解:OPENXML + sp_xml_preparedocument,以及 .nodes() 的行集切分
     · XML 强校验:XML SCHEMA COLLECTION(typed XML),实测 6906/6926 校验错误
     · XML 索引:主 XML 索引 + PATH / VALUE / PROPERTY 二级索引,含 IO 实测
     · 万亿级 / 高并发:半结构化的性能边界、压缩、预解析、只读快照、为什么"避免 JSON_MODIFY 全文档重写"

   数据库:JewelryAnalytics(已存在的示例库,兼容级别 170 = SQL Server 2025)
   说明:脚本**只读**既有业务表;自建对象统一用 erp./crm./hr./stg. 下的 *Attr / *Pref / *Doc / *Xml / Json* 命名,
         第 00 节做幂等清理,可反复执行。

   测试环境:SQL Server 2025 Enterprise (17.0.1135.8),库兼容级别 170。

   --------------------------------------------------------------------------------------------------
   ★★ 运行方式(非常重要,否则 XML 部分会整段失败)
   --------------------------------------------------------------------------------------------------
   本脚本大量使用 XML 数据类型方法、XML 索引与计算列,这三类操作都要求会话的
   QUOTED_IDENTIFIER = ON。而 **sqlcmd 默认是 OFF**(SSMS 默认 ON),于是会报:
       消息 1934:...下列 SET 选项的设置不正确: 'QUOTED_IDENTIFIER'...
   两种解决办法(本脚本已在开头准备好第 1 种,所以**无需**额外参数):
        ① 脚本内执行 SET QUOTED_IDENTIFIER ON; 然后 GO(下一批起生效)------ 本脚本已内置;
        ② 或者运行 sqlcmd 时加 -I 参数。
   另外,输出中文请用:-f "i:65001,o:65001" -y 0,并且必须用 sqlcmd 自己的 -o 落盘(不要用 shell 的 >)。

   ★ 本脚本最容易踩的坑(全部在本机 SQL Server 2025 实测):
     1) sqlcmd 默认 QUOTED_IDENTIFIER OFF → XML 方法 / XML 索引 / 计算列统统报 1934。
        脚本内 SET QUOTED_IDENTIFIER ON 即可,无需 -I(第 00 节有验证)。
     2) ★ XML 数据类型方法的参数**必须是字符串字面量**,不能用变量/参数:
        @x.value(@path, 'nvarchar(100)') → 报 8172。要按运行期路径取值只能拼动态 SQL。
        而 JSON 函数(JSON_VALUE 等)**接受变量**------这是 JSON 与 XML 最本质的差异之一。
     3) .nodes() 必须写成 **[表别名].[列].nodes(...)**;写成 [列].nodes(...) 报 208;
        且**建表与使用 .nodes() 的语句不能在同一批次**,同样报 208
        (延迟名称解析拿不到列类型)。
     4) ★ 别名写两个时(`CROSS APPLY c.nodes(...) AS n(val)`),XML 方法要挂在**列别名**上:
        `n.val.value(...)` ✅;写成 `n.value(...)` ❌ 会报 4121
        ("找不到列 n 或用户定义的函数或聚合 n.value,或者名称不明确")。
        只写一个别名(`AS n`)时反而要用 `n.value(...)`,但那种写法在本版本会报 208。
     5) .nodes() 返回的列是"节点句柄",**不能直接 SELECT / MAX()**,
        只能喂给 exist()/nodes()/query()/value() 或做 IS NULL 判断 → 否则报 493。
     6) XML 不可比较 → 不能 =、ORDER BY、GROUP BY、DISTINCT(报 305);
        不能作 UNION/INTERSECT/EXCEPT 操作数(报 5335);
        xml → sql_variant 报 206,nvarchar(max) → sql_variant 报 529(都是**编译期**错误,
        TRY/CATCH 抓不到,必须用 sp_executesql 才观察得到)。
        要按内容比较只能先 .value() 取值。
     7) FOR XML PATH **不支持 `[item!2!sku]` 这种数字层级别名**(那是 FOR XML EXPLICIT 的语法),
        会报 6850「Column 名称 ... 包含无效的 XML 标识符;'!'(0x0021)是出错的第一个字符」,
        而且是编译期错误。多层嵌套请用"子查询 + FOR XML ..., TYPE"。
     8) 目录视图的坑:`sys.xml_schema_collections` **没有** target_namespace 列
        (在 sys.xml_schema_namespaces 里);`sys.xml_indexes.secondary_type` 是 **char(1)**
        ('P'/'R'/'V'),拿它和 index_id(int) 比较会报 245;`sys.dm_db_partition_stats`
        **看不到 XML 索引**,要量 XML 索引大小得用 dm_db_index_physical_stats。
     9) ★★ JSON_VALUE 的返回值是 nvarchar(4000):**取出的值超过 4000 字符时静默返回 NULL**,
        不报错、不截断(实测 4000 通过,4005 变 NULL)。取长值要用 OPENJSON。
    10) JSON_QUERY **只返回对象或数组**,取标量恒为 NULL;反之 JSON_VALUE **取数组/对象恒为 NULL**。
        这也是"提升列做计数"(CertCount)不能用 JSON_VALUE 的原因。
    11) lax 缺路径 → 静默 NULL;strict 缺路径 → 13608。
        并且**常量实参的 strict 错误是编译期错误**:整个批次直接中止,TRY/CATCH 也抓不住。
    12) REPLICATE 的结果上限是 4000 字符(除非入参本身是 nvarchar(max));
        拼 JSON 时被它截断 → 得到非法 JSON → 后续报 13609(而且报错位置很迷惑,指向第 4000 字符)。
        手工拼 JSON 的另一个高频错误是括号不配对 → 插入时被 CHECK(ISJSON...) 拦下,报 547。
    13) 原生 json 类型列**不能直接建索引/统计信息** → 1978;正解是"计算列 + 索引"。
    14) 主 XML 索引要求表上有**聚集主键且列数 < 32** → 否则报 6332。
        (只写 `CONSTRAINT pk NONCLUSTERED (id)` 是语法错误,必须写 `PRIMARY KEY NONCLUSTERED`。)
    15) typed XML 校验错误(三连):6906 = 缺少必需**属性**,6926 = 简单类型值无效,
        6908 = 内容无效/缺少必需**元素**。写 XSD 时 use="required" 与 minOccurs 都会在写入时被强制。
    16) 半结构化不是逃避建模的借口:凡是参与**连接、排序、聚合、财务口径、唯一约束**的字段,
        必须拆成正式列。JSON/XML 只装"可有可无、结构可变、整体读写"的部分。
   ================================================================================================== */

/* --------------------------------------------------------------------------------------------------
   让 XML 部分能工作的前置设置
   --------------------------------------------------------------------------------------------------
   SET QUOTED_IDENTIFIER ON 是**解析期**设置:它对本批之后的所有批次生效。
   所以这里单独一批设置,后续所有批次的 XML 方法/索引/计算列都能正常工作,
   不需要在 sqlcmd 命令行上加 -I。
   -------------------------------------------------------------------------------------------------- */
SET QUOTED_IDENTIFIER ON;
GO
SET NOCOUNT ON;
SET XACT_ABORT OFF;
SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
SET CONCAT_NULL_YIELDS_NULL ON;
SET NUMERIC_ROUNDABORT OFF;
GO

/* ==================================================================================================
   00  环境确认与幂等清理
   ================================================================================================== */

PRINT N'';
PRINT N'################################################################################';
PRINT N'#  SQL Server 2025 · JSON 与 XML 数据处理 · 国际化珠宝集团 ERP/CRM/HR 演示';
PRINT N'################################################################################';
GO

PRINT N'===== 00 环境确认 =====';
GO

SELECT
     版本       = CAST(SERVERPROPERTY('ProductVersion') AS VARCHAR(64))
   , 版次       = CAST(SERVERPROPERTY('Edition')        AS VARCHAR(64))
   , 数据库     = DB_NAME()
   , 兼容级别   = (SELECT compatibility_level FROM sys.databases WHERE name = DB_NAME())
   , QUOTED_ID  = SESSIONPROPERTY('QUOTED_IDENTIFIER')
   , 快照隔离   = (SELECT is_read_committed_snapshot_on FROM sys.databases WHERE name = DB_NAME());
GO

PRINT N'  ★ 校验第 1 条坑:QUOTED_IDENTIFIER 必须是 1,否则后面 XML 全段会报 1934。';
PRINT N'    上面 QUOTED_ID 若为 0,请在本脚本开头确认 SET QUOTED_IDENTIFIER ON 已生效。';
GO

PRINT N'  ---- 这个 build 是否具备 2025 的 JSON 新特性 ---- ';
SELECT 检查项 = N'原生 json 数据类型', 结果 =
        CASE WHEN EXISTS (SELECT 1 FROM sys.types WHERE name = 'json' AND is_user_defined = 0)
             THEN N'可用 (system_type_id=244)' ELSE N'不可用' END
UNION ALL SELECT N'JSON_ARRAYAGG 聚合',
        CASE WHEN OBJECT_ID(N'fn_json_arrayagg') IS NOT NULL THEN N'可用' ELSE N'可用(内建)' END
UNION ALL SELECT N'JSON_OBJECTAGG 聚合', N'可用(内建)'
UNION ALL SELECT N'ISJSON 类型参数(OBJECT/ARRAY/VALUE)',
        CASE WHEN ISJSON(N'{}', OBJECT) = 1 THEN N'可用' ELSE N'不可用' END
UNION ALL SELECT N'JSON_PATH_EXISTS',
        CASE WHEN JSON_PATH_EXISTS(N'{"a":1}', N'$.a') = 1 THEN N'可用' ELSE N'不可用' END;
GO

PRINT N'  ---- 依赖的业务表必须存在,否则后续示例无意义 ---- ';
IF OBJECT_ID(N'erp.Product')  IS NULL THROW 50001, N'缺少 erp.Product(珠宝商品主数据)', 1;
IF OBJECT_ID(N'crm.Customer') IS NULL THROW 50002, N'缺少 crm.Customer(客户主数据)', 1;
IF OBJECT_ID(N'hr.Employee')  IS NULL THROW 50003, N'缺少 hr.Employee(员工主数据)', 1;
IF OBJECT_ID(N'erp.SalesOrder') IS NULL THROW 50004, N'缺少 erp.SalesOrder(销售订单)', 1;
PRINT N'    ✓ erp.Product / crm.Customer / hr.Employee / erp.SalesOrder 均在。';
GO

PRINT N'  ---- 既有表的规模(作为"拆列侧"的对照)----';
SELECT 表 = N'erp.Product',   行数 = COUNT(*) FROM erp.Product
UNION ALL SELECT N'erp.SalesOrder',      COUNT(*) FROM erp.SalesOrder
UNION ALL SELECT N'erp.SalesOrderLine',  COUNT(*) FROM erp.SalesOrderLine
UNION ALL SELECT N'crm.Customer',        COUNT(*) FROM crm.Customer
UNION ALL SELECT N'crm.Interaction',     COUNT(*) FROM crm.Interaction
UNION ALL SELECT N'hr.Employee',         COUNT(*) FROM hr.Employee
UNION ALL SELECT N'hr.EmployeeProfile',  COUNT(*) FROM hr.EmployeeProfile
UNION ALL SELECT N'dbo.Country',         COUNT(*) FROM dbo.Country
ORDER BY 表;
GO

/* --------------------------------------------------------------------------------------------------
   00-B 幂等清理:把本脚本自建的对象全部清掉,保证可反复执行
   -------------------------------------------------------------------------------------------------- */
PRINT N'  ---- 00-B 幂等清理 ----';
GO

-- stg(暂存)架构:本脚本用来放"外部推来的原始 JSON 报文",业务库里原本没有这个架构
IF SCHEMA_ID(N'stg') IS NULL
BEGIN
    EXEC(N'CREATE SCHEMA stg');
    PRINT N'    已创建架构 stg';
END
GO

-- XML 索引必须先于表清理(其实 DROP TABLE 会一并带走,这里显式写便于读者理解依赖顺序)
DROP INDEX IF EXISTS PXML_InteractionXml ON crm.InteractionXml;
DROP INDEX IF EXISTS IX_InteractionXml_Channel ON crm.InteractionXml;
DROP INDEX IF EXISTS IX_InteractionXml_Channel ON crm.InteractionXml;
GO

DROP TABLE IF EXISTS erp.ProductAttr;
DROP TABLE IF EXISTS erp.ProductAttrJ;
DROP TABLE IF EXISTS crm.CustomerPref;
DROP TABLE IF EXISTS crm.InteractionXml;
DROP TABLE IF EXISTS hr.EmployeeDoc;
DROP TABLE IF EXISTS hr.CertXml;
DROP TABLE IF EXISTS stg.JsonBatch;
DROP TABLE IF EXISTS stg.EventJson;
DROP TABLE IF EXISTS dbo.XmlOrderDemo;
DROP TABLE IF EXISTS dbo.TypedXmlDemo;
GO

DROP TABLE IF EXISTS dbo.JsonXmlAudit;
DROP PROCEDURE IF EXISTS dbo.usp_ShredSalesOrderXml;
DROP PROCEDURE IF EXISTS dbo.usp_ProductByAttr;
GO

-- XML 架构集合要单独删,因为它不是表的一部分
IF EXISTS (SELECT 1 FROM sys.xml_schema_collections WHERE name = N'JewelryOrderSchema')
BEGIN
    -- 先把引用该架构集合的列删掉,否则 DROP 会因依赖报错
    IF OBJECT_ID(N'dbo.TypedXmlDemo') IS NOT NULL DROP TABLE dbo.TypedXmlDemo;
    DROP XML SCHEMA COLLECTION dbo.JewelryOrderSchema;
    PRINT N'    已删除 XML 架构集合 dbo.JewelryOrderSchema';
END
GO

PRINT N'  ✓ 清理完成(可反复执行)';
GO


/* ==================================================================================================
   01  半结构化数据的边界:什么时候拆列,什么时候才轮到 JSON / XML
   --------------------------------------------------------------------------------------------------
   这是本脚本最重要的一节。JSON/XML 用错的代价远大于用错一个索引:
   一旦把"本该是列"的字段塞进 JSON,连接、排序、约束、统计信息全部失效,
   而且几乎不可能再改回来(数据已经散落成文本)。
   ================================================================================================== */

PRINT N'';
PRINT N'===== 01 半结构化数据的边界(先划边界,再谈语法)=====';
GO

/* --------------------------------------------------------------------------------------------------
   01.1 四象限判据表
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 01.1 判据表:一个字段该放在哪里 -----';
SELECT * FROM (VALUES
   (1, N'关系型列(拆列)',   N'参与 JOIN / WHERE 等值筛选 / ORDER BY / GROUP BY',
        N'产品 SKU、订单号、金额、日期、状态、客户等级', N'必须有索引与统计信息;进入财务口径')
 , (2, N'关系型列(拆列)',   N'需要唯一约束 / 外键 / CHECK 约束 / 参与复制与 CDC',
        N'证书编号唯一、门店编码外键', N'JSON 里无法建外键,唯一性只能靠触发器,代价极高')
 , (3, N'关系型列(拆列)',   N'会被高频**部分更新**',
        N'库存数量、订单状态', N'改 JSON 里一个键要重写整个文档(见 14 节)')
 , (4, N'JSON(留在文档)',   N'读多写少、结构随品类变化、整体读写',
        N'珠宝商品的材质/宝石/尺寸/刻字等可变属性', N'品类不同字段不同,拆列会产生大量稀疏列')
 , (5, N'JSON(留在文档)',   N'需要按"值"精确筛选,但只筛选少数几个"提升列"',
        N'只把 metal.type、stone.carat 提升为计算列,其余留 JSON', N'提升列 + 索引,兼顾灵活与性能')
 , (6, N'XML(留在文档)',    N'需要**强 schema 校验**(typed XML)或电子单证互操作',
        N'GIA/IGI 证书报文、EDI 订单、遗留系统报文', N'XML SCHEMA COLLECTION 能做真正的结构校验')
 , (7, N'XML(留在文档)',    N'层级天然是"文档",需要 XQuery / 命名空间 / 混合内容',
        N'多语言文案、富文本、带命名空间的行业报文', N'JSON 表达命名空间与混合内容很别扭')
 , (8, N'都不该存',           N'大二进制、超大文档、需要事务性部分更新',
        N'图片、PDF 证书原件、>2MB 报文', N'放文件/对象存储,库里只存路径与哈希')
) AS t(序号, 归属, 判据, 珠宝行业例子, 关键理由)
ORDER BY 序号;
GO

/* --------------------------------------------------------------------------------------------------
   01.2 珠宝行业落地:哪些必须拆、哪些可以留
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 01.2 珠宝集团的三条业务线,边界怎么划 -----';
SELECT * FROM (VALUES
   (N'ERP 商品',   N'拆列', N'Sku / CategoryId / UnitCost / UnitPrice / CurrencyCode',
        N'要连接品类树、要算毛利、要按价位段筛选'),
   (N'ERP 商品',   N'JSON', N'metal{type,purity,weightG} / stone{carat,clarity,color,cut,cert} / dims / tags',
        N'不同品类字段差异极大,且只整体读取展示'),
   (N'ERP 商品',   N'提升列', N'metal.type 与 stone.carat 做成计算列 + 索引',
        N'这两个是高频筛选条件,其余不需要'),
   (N'ERP 订单',   N'拆列', N'OrderNo / OrderDate / NetAmountUsd / Status / CustomerId',
        N'财务对账口径,必须精确且可索引'),
   (N'CRM 客户',   N'拆列', N'CustomerNo / Tier / CountryCode / RegisterDate',
        N'分群、分层运营的基础字段'),
   (N'CRM 客户',   N'JSON', N'偏好画像(风格/颜色/预算区间/触点偏好/尺码)',
        N'画像字段会随运营策略频繁增删'),
   (N'CRM 互动',   N'拆分', N'InteractionAt / Channel / CustomerId 拆列;多语言正文与明细用 XML',
        N'既要按渠道和时间聚合,又要保留多语言结构化正文'),
   (N'HR 员工',    N'拆列', N'EmployeeNo / CountryCode / Department / JobLevel / HireDate',
        N'组织架构与薪酬口径的基础字段'),
   (N'HR 员工',    N'JSON', N'技能矩阵、语言能力、证照清单、紧急联系人',
        N'因人而异、读多写少'),
   (N'HR 证照',    N'XML',  N'证书报文(颁发机构/有效期/验证链接/多语言名称)',
        N'来自外部机构,需要按 schema 强校验')
) AS t(业务线, 归属, 字段, 理由);
GO

/* --------------------------------------------------------------------------------------------------
   01.3 "半结构化边界清晰"的工程含义:每个 JSON/XML 列都要有契约
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 01.3 契约四要素:一个 JSON 列必须能回答这四个问题 -----';
SELECT * FROM (VALUES
   (1, N'格式契约',  N'CHECK (ISJSON(col) = 1)',
        N'保证列里只有合法 JSON,挡住脏数据'),
   (2, N'类型契约',  N'进一步要求顶层是对象:CHECK (ISJSON(col, OBJECT) = 1)',
        N'防止有人塞进一个裸数组或标量,导致 JSON_VALUE 路径语义全变'),
   (3, N'版本契约',  N'文档内必须有 schemaVersion,并在表上加一列冗余存它',
        N'结构演进时能分流读写;没有版本号的多态 JSON 是运维灾难'),
   (4, N'必需路径',  N'用持久化计算列把"必需字段"提升出来,并允许为 NULL 时报警',
        N'把"契约"从注释变成可查询、可索引、可监控的东西')
) AS t(序号, 要素, 实现手段, 作用);
GO

/* --------------------------------------------------------------------------------------------------
   01.4 反例:把该拆列的字段塞进 JSON,会发生什么(实测)
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 01.4 反例实测:按"金额"筛选,列 vs JSON -----';
PRINT N'  先看两种写法能不能用上统计信息/索引(这里只要看计划与 IO 的差别)。';
GO

SET STATISTICS IO ON;
GO
PRINT N'  [a] 正规列筛选:走 erp.SalesOrder 上的索引';
SELECT 订单数 = COUNT(*) FROM erp.SalesOrder WHERE NetAmountUsd >= 50000;
GO
PRINT N'  [b] 假想:如果金额藏在 JSON 里,只能 JSON_VALUE + CAST,无法直接建索引';
SELECT 订单数 = COUNT(*)
FROM erp.SalesOrder
WHERE TRY_CAST(JSON_VALUE(
        CAST(N'{"net":' AS nvarchar(max)) + CAST(NetAmountUsd AS nvarchar(40)) + N'}',
        N'$.net') AS decimal(18,2)) >= 50000;
GO
SET STATISTICS IO OFF;
GO
PRINT N'  ★ 结论:[b] 那种写法即使功能等价,也把"可索引的等值/范围筛选"退化成"全表计算",';
PRINT N'          而且丢失了精度语义(金额变文本再转回来)。这就是边界没划清的代价。';
GO


/* ==================================================================================================
   02  JSON 存储:nvarchar(max) + CHECK  vs  原生 json 数据类型(2025 新增)
   --------------------------------------------------------------------------------------------------
   说明:本节先建表、后插数据。JSON 文档刻意做成"珠宝商品可变属性",
        因为这些字段正是第 01 节判定"可以留在 JSON 里"的典型。
   ================================================================================================== */

PRINT N'';
PRINT N'===== 02 JSON 存储 =====';
GO

/* --------------------------------------------------------------------------------------------------
   02.1 方案A:nvarchar(max) + CHECK(ISJSON(..., OBJECT))
   --------------------------------------------------------------------------------------------------
   这是 2016 以来所有版本都能用的通用做法。
   关键点:
     · 用 CHECK(ISJSON(col, OBJECT) = 1) 而不是只用 ISJSON(col),把顶层类型也约束住;
     · 用"持久化计算列"把高频筛选字段提升出来,为索引做准备(见 07 节);
     · 加 schemaVersion 列实现版本契约。
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 02.1 建表:nvarchar(max) + ISJSON 契约 + 提升列 -----';
GO

CREATE TABLE erp.ProductAttr
(
    ProductId      int            NOT NULL CONSTRAINT PK_ProductAttr PRIMARY KEY,
    Sku            varchar(20)    NOT NULL,
    AttrJson       nvarchar(max)  NOT NULL
        CONSTRAINT CK_ProductAttr_IsJsonObject CHECK (ISJSON(AttrJson, OBJECT) = 1),

    -- 版本契约:冗余存一份,便于按版本分流
    SchemaVersion  AS CAST(JSON_VALUE(AttrJson, N'$.schemaVersion') AS tinyint) PERSISTED,

    -- "提升列":把少数高频筛选字段从文档里提到关系层,只有它们能被索引
    MetalType      AS CAST(JSON_VALUE(AttrJson, N'$.metal.type')     AS nvarchar(40))  PERSISTED,
    MetalPurity    AS CAST(JSON_VALUE(AttrJson, N'$.metal.purity')   AS nvarchar(20))  PERSISTED,
    MetalWeightG   AS TRY_CAST(JSON_VALUE(AttrJson, N'$.metal.weightG') AS decimal(6,2)) PERSISTED,
    StoneType      AS CAST(JSON_VALUE(AttrJson, N'$.stone.type')     AS nvarchar(40))  PERSISTED,
    StoneCarat     AS TRY_CAST(JSON_VALUE(AttrJson, N'$.stone.carat') AS decimal(5,2))  PERSISTED,
    CertLab        AS CAST(JSON_VALUE(AttrJson, N'$.stone.cert.lab') AS nvarchar(20))  PERSISTED
);
GO

PRINT N'----- 02.2 插入珠宝商品属性数据(基于 erp.Product 24 条主数据生成)-----';
GO

;WITH Src AS (
    SELECT
        p.ProductId, p.Sku, p.ProductName, p.MetalType, p.Gemstone,
        Purity = CASE p.MetalType
                     WHEN N'铂金'   THEN N'Pt950'
                     WHEN N'黄金'   THEN N'Au750'
                     WHEN N'玫瑰金' THEN N'Au750'
                     WHEN N'硬足金' THEN N'Au999'
                     WHEN N'银'     THEN N'Ag925'
                     WHEN N'珍珠'   THEN N'(不适用)'
                     ELSE N'Au750'
                 END,
        WeightG = CAST(2.00 + (p.ProductId % 7) * 0.35 AS decimal(6,2)),
        Carat   = CASE WHEN p.Gemstone = N'无' THEN NULL
                       ELSE CAST(0.30 + (p.ProductId % 12) * 0.10 AS decimal(5,2)) END,
        Clarity = CASE p.ProductId % 4 WHEN 0 THEN N'VS1' WHEN 1 THEN N'VS2'
                                       WHEN 2 THEN N'VVS1' ELSE N'SI1' END,
        ColorG  = CASE p.ProductId % 5 WHEN 0 THEN N'F' WHEN 1 THEN N'G'
                                       WHEN 2 THEN N'H' WHEN 3 THEN N'I' ELSE N'J' END,
        Cut     = CASE p.ProductId % 3 WHEN 0 THEN N'Excellent'
                                       WHEN 1 THEN N'VeryGood' ELSE N'Good' END,
        RingSz  = CAST(10.0 + (p.ProductId % 9) * 0.5 AS decimal(4,1)),
        WidthMm = CAST(1.80 + (p.ProductId % 5) * 0.30 AS decimal(4,2)),
        CertNo  = N'GIA-' + RIGHT(N'00000000' + CAST(2140000 + p.ProductId * 37 AS nvarchar(20)), 8)
    FROM erp.Product p
)
INSERT erp.ProductAttr (ProductId, Sku, AttrJson)
SELECT
    ProductId, Sku,
      CAST(N'{"schemaVersion":1,"metal":{"type":"' AS nvarchar(max))
    + MetalType + N'","purity":"' + Purity + N'","weightG":' + CAST(WeightG AS nvarchar(20)) + N'},'
    + N'"stone":'
    + CASE WHEN Carat IS NULL THEN N'null'
           ELSE N'{"type":"' + Gemstone + N'","carat":' + CAST(Carat AS nvarchar(20))
              + N',"clarity":"' + Clarity + N'","color":"' + ColorG + N'","cut":"' + Cut
              + N'","cert":{"lab":"GIA","no":"' + CertNo + N'"}}'
      END + N','
    + N'"dims":{"ringSize":' + CAST(RingSz AS nvarchar(10)) + N',"widthMm":' + CAST(WidthMm AS nvarchar(10)) + N'},'
    + N'"tags":[' + CASE WHEN ProductId % 3 = 0 THEN N'"婚嫁","主推"' 
                         WHEN ProductId % 3 = 1 THEN N'"送礼"'
                         ELSE N'"日常","轻奢"' END + N'],'
    + N'"custom":{"engraving":' + CASE WHEN ProductId % 2 = 0 THEN N'true' ELSE N'false' END
    + N',"giftBox":"' + CASE WHEN ProductId % 4 = 0 THEN N'premium' ELSE N'standard' END + N'"}'
    + N'}'
FROM Src;
GO

SELECT 插入行数 = COUNT(*) FROM erp.ProductAttr;
GO
PRINT N'  看一条完整的 JSON 文档:';
SELECT ProductId, Sku, AttrJson FROM erp.ProductAttr WHERE ProductId = 1;
GO
PRINT N'  看提升出来的列(它们才是可索引的部分):';
SELECT TOP 8 ProductId, Sku, SchemaVersion, MetalType, MetalPurity, MetalWeightG, StoneType, StoneCarat, CertLab
FROM erp.ProductAttr ORDER BY ProductId;
GO

/* --------------------------------------------------------------------------------------------------
   02.3 方案B:SQL Server 2025 的原生 json 数据类型
   --------------------------------------------------------------------------------------------------
   原生 json 类型带来的好处:
     · 写入即校验:塞非法 JSON 直接报 13609,不需要 CHECK 约束兜底;
     · 不再有"这个列到底装不装 JSON"的歧义(类型本身就是契约);
     · 与 JSON_* 函数互操作顺畅(可直接传参,也可 CAST 回 nvarchar)。
   注意事项:
     · 原生 json 列**不能直接建索引或统计信息**(报 1978),仍要靠计算列(见 07 节);
     · 排序/比较语义仍受限,做筛选要先 JSON_VALUE 出标量。
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 02.3 建表并插入:原生 json 类型(2025 新特性)-----';
GO

CREATE TABLE erp.ProductAttrJ
(
    ProductId int         NOT NULL CONSTRAINT PK_ProductAttrJ PRIMARY KEY,
    Sku       varchar(20) NOT NULL,
    AttrJson  json        NOT NULL,          -- ★ SQL Server 2025 原生类型,写入即校验

    -- 原生 json 列同样可以用 JSON_VALUE 建计算列
    MetalType AS CAST(JSON_VALUE(AttrJson, N'$.metal.type') AS nvarchar(40)) PERSISTED,
    StoneCarat AS TRY_CAST(JSON_VALUE(AttrJson, N'$.stone.carat') AS decimal(5,2)) PERSISTED
);
GO

-- 直接从方案A 的文本列转换过来,顺带证明两者可互转
INSERT erp.ProductAttrJ (ProductId, Sku, AttrJson)
SELECT ProductId, Sku, CAST(AttrJson AS json) FROM erp.ProductAttr;
GO

SELECT 插入行数 = COUNT(*) FROM erp.ProductAttrJ;
GO
SELECT ProductId, Sku, AttrJson FROM erp.ProductAttrJ WHERE ProductId <= 2;
GO
PRINT N'  原生 json 列可以直接喂给 JSON 函数,也可以 CAST 回 nvarchar:';
SELECT TOP 3
     ProductId
   , Sku
   , 直接取值   = JSON_VALUE(AttrJson, N'$.metal.type')
   , 转回文本前10 = LEFT(CAST(AttrJson AS nvarchar(max)), 10)
FROM erp.ProductAttrJ ORDER BY ProductId;
GO
PRINT N'  顺便实测:json 能否转 sql_variant(xml 是明确不行的,json 需要验证):';
-- ★ 注意:这条必须走动态 SQL。因为它是**编译期**错误,静态批次里 TRY/CATCH 抓不住。
BEGIN TRY
    EXEC sp_executesql N'DECLARE @v sql_variant = (SELECT TOP 1 AttrJson FROM erp.ProductAttrJ); SELECT 1 AS ok';
    PRINT N'    json 可以转 sql_variant';
END TRY
BEGIN CATCH
    PRINT N'    json 不能直接转 sql_variant → 错误 ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO

/* --------------------------------------------------------------------------------------------------
   02.4 两种方案的取舍对照(含实测存储差异)
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 02.4 存储占用对照(同一批数据两种类型)-----';
SELECT
     方案        = N'nvarchar(max) + CHECK',
     行数        = COUNT(*),
     总字节      = SUM(DATALENGTH(AttrJson)),
     平均字节    = AVG(DATALENGTH(AttrJson))
FROM erp.ProductAttr
UNION ALL
SELECT N'原生 json', COUNT(*), SUM(DATALENGTH(CAST(AttrJson AS nvarchar(max)))), AVG(DATALENGTH(CAST(AttrJson AS nvarchar(max))))
FROM erp.ProductAttrJ;
GO

PRINT N'  ★ 结论:两者在"存多少字节"上基本一致(原生 json 不做额外压缩,只是多了校验与类型语义)。';
PRINT N'          所以选型看的是"契约强度 + 可维护性",不是省空间。';
GO

/* --------------------------------------------------------------------------------------------------
   02.5 原生 json 的"写入即校验":实测非法 JSON 被挡在门外
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 02.5 原生 json 的写入校验 vs CHECK 约束的校验 -----';
GO
BEGIN TRY
    INSERT erp.ProductAttrJ (ProductId, Sku, AttrJson) VALUES (9999, N'BAD-001', CAST(N'{not json}' AS json));
    PRINT N'  原生 json:居然接受了非法文档(不应该)';
END TRY
BEGIN CATCH
    PRINT N'  原生 json 拒绝非法文档 → 错误 ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
BEGIN TRY
    INSERT erp.ProductAttr (ProductId, Sku, AttrJson) VALUES (9999, N'BAD-001', N'{not json}');
    PRINT N'  方案A:居然接受了非法文档(不应该)';
END TRY
BEGIN CATCH
    PRINT N'  方案A 由 CHECK 约束拦下 → 错误 ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
PRINT N'  ★ 两者都能拦住,区别是:CHECK 约束是在**插入时**才校验(且可被禁用/绕过 NOCHECK),';
PRINT N'    原生 json 是**类型系统层面**的校验,连临时变量赋值都拦得住:';
GO
BEGIN TRY
    DECLARE @bad json = N'{still not json}';
    PRINT N'  变量赋值:居然通过';
END TRY
BEGIN CATCH
    PRINT N'  变量赋值也被拦 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO

/* --------------------------------------------------------------------------------------------------
   02.6 原始报文暂存表:模拟"外部系统推来的 JSON"(供 03 节 OPENJSON 使用)
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 02.6 建暂存表 stg.JsonBatch:模拟 API 推来的原始 JSON -----';
GO

CREATE TABLE stg.JsonBatch
(
    BatchId   int IDENTITY(1,1) NOT NULL CONSTRAINT PK_JsonBatch PRIMARY KEY,
    Source    nvarchar(40)  NOT NULL,
    ReceivedAt datetime2(0) NOT NULL CONSTRAINT DF_JsonBatch_At DEFAULT (SYSDATETIME()),
    Payload   nvarchar(max) NOT NULL
        CONSTRAINT CK_JsonBatch_IsJson CHECK (ISJSON(Payload) = 1)
);
GO

INSERT stg.JsonBatch (Source, Payload) VALUES
 (N'store-pos', N'{"orderNo":"SO-2025-000101","store":"HK-CWB-01","ccy":"HKD","lines":[{"sku":"RG-DIA-001","qty":1,"price":78000},{"sku":"RG-GLD-003","qty":2,"price":7980}],"member":{"id":"C-0001","tier":"VIP"}}'),
 (N'store-pos', N'{"orderNo":"SO-2025-000102","store":"CN-SH-02","ccy":"CNY","lines":[{"sku":"NK-GLD-010","qty":1,"price":12800}],"member":null}'),
 (N'ecom-api',  N'{"orderNo":"EC-2025-000203","store":"SG-OR-01","ccy":"SGD","lines":[{"sku":"ER-DIA-005","qty":1,"price":4200},{"sku":"ER-DIA-005","qty":1,"price":4200},{"sku":"BR-PLT-002","qty":1,"price":9800}],"member":{"id":"C-0450","tier":"Gold"}}'),
 (N'ecom-api',  N'{"orderNo":"EC-2025-000204","store":"JP-TK-01","ccy":"JPY","lines":[{"sku":"WT-MEC-007","qty":1,"price":1580000}],"member":{"id":"C-0022","tier":"VVIP"}}'),
 (N'crm-sync',  N'{"orderNo":null,"store":null,"ccy":null,"lines":[],"member":{"id":"C-0777","tier":"Silver"}}');
GO

SELECT 批次 = BatchId, 来源 = Source, 报文长度 = LEN(Payload), 报文前60字 = LEFT(Payload, 60)
FROM stg.JsonBatch ORDER BY BatchId;
GO


/* ==================================================================================================
   03  JSON 读取:JSON_VALUE / JSON_QUERY / OPENJSON / JSON_PATH_EXISTS
   --------------------------------------------------------------------------------------------------
   四个函数的分工(这是最容易混的地方):
     JSON_VALUE   → 取**标量**,返回 nvarchar(4000)。★ 值超 4000 字符静默返回 NULL(见 08 节)
     JSON_QUERY   → 取**对象或数组**,返回 nvarchar(max)。取标量恒为 NULL
     OPENJSON     → 把 JSON 摊成**行集**,可带 WITH 子句定义列与类型
     JSON_PATH_EXISTS → 只问"这个路径存不存在",返回 0/1
   ================================================================================================== */

PRINT N'';
PRINT N'===== 03 JSON 读取 =====';
GO

/* --------------------------------------------------------------------------------------------------
   03.1 三个取值函数的边界:标量 / 对象 / 数组分别由谁负责
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.1 JSON_VALUE vs JSON_QUERY:谁取什么 -----';
SELECT
     路径                 = N'$.metal.type'
   , JSON_VALUE结果        = JSON_VALUE(AttrJson, N'$.metal.type')
   , JSON_QUERY结果        = ISNULL(JSON_QUERY(AttrJson, N'$.metal.type'), N'<NULL:标量取不到>')
   , 说明                 = N'标量 → 只能用 JSON_VALUE'
FROM erp.ProductAttr WHERE ProductId = 1
UNION ALL
SELECT N'$.metal'
   , ISNULL(JSON_VALUE(AttrJson, N'$.metal'), N'<NULL:JSON_VALUE 取不到对象>')
   , JSON_QUERY(AttrJson, N'$.metal')
   , N'对象 → 只能用 JSON_QUERY'
FROM erp.ProductAttr WHERE ProductId = 1
UNION ALL
SELECT N'$.tags'
   , ISNULL(JSON_VALUE(AttrJson, N'$.tags'), N'<NULL:JSON_VALUE 取不到数组>')
   , JSON_QUERY(AttrJson, N'$.tags')
   , N'数组 → 只能用 JSON_QUERY'
FROM erp.ProductAttr WHERE ProductId = 1;
GO
PRINT N'  ★ 一句话记忆:JSON_VALUE 只认标量,JSON_QUERY 只认对象/数组,互不越界、越界就是 NULL。';
GO

/* --------------------------------------------------------------------------------------------------
   03.2 lax 与 strict
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.2 lax(默认)vs strict -----';
SELECT
     场景   = N'lax 取不存在的路径'
   , 结果   = ISNULL(JSON_VALUE(AttrJson, N'lax $.metal.nonexistent'), N'<NULL>')
   , 行为   = N'静默返回 NULL,不报错'
FROM erp.ProductAttr WHERE ProductId = 1
UNION ALL
SELECT N'lax 取不存在的路径(JSON_QUERY)'
   , ISNULL(JSON_QUERY(AttrJson, N'lax $.nope'), N'<NULL>')
   , N'同样静默 NULL'
FROM erp.ProductAttr WHERE ProductId = 1
UNION ALL
SELECT N'strict 取不存在的路径'
   , N'<本行故意用动态 SQL 演示,见下方说明>'
   , N'报 13608 ------ 但常量实参时是**编译期**错误,会中止整批'
FROM erp.ProductAttr WHERE ProductId = 1;
GO
PRINT N'  实测 strict 的两种表现(用动态 SQL 才能安全演示):';
DECLARE @j nvarchar(max) = N'{"a":1}';
DECLARE @p_strict nvarchar(100) = N'strict $.missing';
BEGIN TRY
    DECLARE @r nvarchar(100) = JSON_VALUE(@j, @p_strict);
    PRINT N'    strict + 变量实参:返回 ' + ISNULL(@r, N'<NULL>');
END TRY
BEGIN CATCH
    PRINT N'    strict + 变量实参 → 运行期错误 ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
BEGIN TRY
    EXEC sp_executesql N'SELECT JSON_VALUE(N''{"a":1}'', N''strict $.missing'')';
    PRINT N'    strict + 常量实参:竟然没报错';
END TRY
BEGIN CATCH
    PRINT N'    strict + 常量实参 → 同样 ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
PRINT N'  ★ 重要:常量实参时 JSON 的 strict 错误在**编译期**抛出,整个批次直接中止,';
PRINT N'    TRY/CATCH 抓不住。所以生产代码里"路径可能不存在"时,要么用 lax,要么用变量传路径。';
GO

/* --------------------------------------------------------------------------------------------------
   03.3 JSON_PATH_EXISTS:只判断存在性(2022+)
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.3 JSON_PATH_EXISTS:判断"有没有"而不是"值是多少" -----';
SELECT TOP 8
     ProductId
   , Sku
   , 有钻石信息 = JSON_PATH_EXISTS(AttrJson, N'$.stone.cert')
   , 有刻字标记 = JSON_PATH_EXISTS(AttrJson, N'$.custom.engraving')
   , 是数组tags = JSON_PATH_EXISTS(AttrJson, N'$.tags')
FROM erp.ProductAttr
ORDER BY ProductId;
GO

/* --------------------------------------------------------------------------------------------------
   03.4 OPENJSON:默认 schema(key/value/type 三列)
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.4 OPENJSON 不带 WITH:返回 key / value / type 三列 -----';
SELECT
     外层键   = [key]
   , 值       = [value]
   , 类型码   = [type]
   , 类型含义 = CASE [type] WHEN 0 THEN N'null' WHEN 1 THEN N'字符串' WHEN 2 THEN N'数值'
                            WHEN 3 THEN N'布尔' WHEN 4 THEN N'数组' WHEN 5 THEN N'对象' END
FROM OPENJSON((SELECT Payload FROM stg.JsonBatch WHERE BatchId = 1));
GO
PRINT N'  ★ OPENJSON 单独使用时必须有 OPENJSON(...) 的括号与参数;';
PRINT N'    不带 WITH 时 value 列是 nvarchar(max),所以**取长值只能用这条路**(见 08 节)。';
PRINT N'  ★ 只有三列:key / value / type。别去找 [depth] 或 [lax] ------ 没有这两列(报 207);';
PRINT N'    数组元素的"下标"就是 [key](值是 ''0''/''1''/''2''),本身就是扁平结构,没有层级列。';
GO

/* --------------------------------------------------------------------------------------------------
   03.5 OPENJSON + WITH:把 JSON 直接摊成带类型的列
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.5 OPENJSON WITH:定义列名、类型与路径 -----';
SELECT
     订单号   = j.orderNo
   , 门店     = j.store
   , 币种     = j.ccy
   , 会员号   = j.memberId
   , 会员等级 = j.tier
   , 明细行数 = j.lineCount
FROM stg.JsonBatch b
CROSS APPLY OPENJSON(b.Payload) WITH (
      orderNo   nvarchar(40)  N'$.orderNo'
    , store     nvarchar(40)  N'$.store'
    , ccy       char(3)       N'$.ccy'
    , memberId  nvarchar(40)  N'$.member.id'
    , tier      nvarchar(20)  N'$.member.tier'
    , lineCount int           N'$.lines'      -- ★ 直接对数组用 int,得到数组长度
) AS j
ORDER BY 订单号;
GO
PRINT N'  ★ 上面 lineCount 那列是个小技巧:WITH 里把数组路径声明成 int,得到的就是数组元素个数。';
GO

/* --------------------------------------------------------------------------------------------------
   03.6 一行 JSON 展开成多行:AS JSON 嵌套 + 二次 OPENJSON
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.6 嵌套数组展开:一条订单 → 多行明细(ERP 最典型的用法)-----';
PRINT N'  [a] 内层**不加 WITH**:能拿到 [key] 当行号,但每列要自己 JSON_VALUE + CAST';
SELECT
     来源     = b.Source
   , 订单号   = j.orderNo
   , 币种     = j.ccy
   , 行号     = CAST(l.[key] AS int) + 1
   , SKU      = JSON_VALUE(l.[value], N'$.sku')
   , 数量     = CAST(JSON_VALUE(l.[value], N'$.qty') AS int)
   , 单价     = CAST(JSON_VALUE(l.[value], N'$.price') AS decimal(18,2))
   , 行金额   = CAST(JSON_VALUE(l.[value], N'$.qty') AS int)
              * CAST(JSON_VALUE(l.[value], N'$.price') AS decimal(18,2))
FROM stg.JsonBatch b
CROSS APPLY OPENJSON(b.Payload) WITH (
      orderNo nvarchar(40)   N'$.orderNo'
    , ccy     char(3)        N'$.ccy'
    , lines   nvarchar(max)  N'$.lines' AS JSON      -- ★ AS JSON:先原样取出数组
) AS j
CROSS APPLY OPENJSON(j.lines) AS l                   -- ★ 不加 WITH,才有 [key]
ORDER BY 订单号, 行号;
GO

PRINT N'  [b] 内层**加 WITH**:拿到强类型列,但 [key] 就没了(WITH 会替换掉默认 schema)';
SELECT
     来源     = b.Source
   , 订单号   = j.orderNo
   , SKU      = l.sku
   , 数量     = l.qty
   , 单价     = l.price
   , 行金额   = l.qty * l.price
FROM stg.JsonBatch b
CROSS APPLY OPENJSON(b.Payload) WITH (
      orderNo nvarchar(40)   N'$.orderNo'
    , lines   nvarchar(max)  N'$.lines' AS JSON
) AS j
CROSS APPLY OPENJSON(j.lines) WITH (
      sku   nvarchar(20)  N'$.sku'
    , qty   int           N'$.qty'
    , price decimal(18,2) N'$.price'
) AS l
ORDER BY 订单号, SKU;
GO
PRINT N'  ★ 两层 CROSS APPLY OPENJSON 是"JSON 进关系表"的标准姿势:外层定位,内层展开数组。';
PRINT N'  ★ 二选一的取舍(这一步最容易踩):';
PRINT N'     加 WITH  → 强类型列,但**拿不到 [key]**(写成 l.[key] 会报 207 列名无效);';
PRINT N'     不加 WITH → 有 [key],但每列都得 JSON_VALUE + CAST(而且 JSON_VALUE 仍是 4000 上限)。';
PRINT N'     两个都要时:在外面再套一层 ROW_NUMBER(),或者在 WITH 里自己补一列序号。';
GO

/* --------------------------------------------------------------------------------------------------
   03.7 在真实业务表上按 JSON 属性筛选
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.7 ERP 场景:查"铂金 + 钻石 ≥ 0.8ct"的商品 -----';
SELECT
     ProductId, Sku, MetalType = MetalType, 纯度 = MetalPurity
   , 主石 = StoneType, 克拉 = StoneCarat, 证书 = CertLab
   , 标签 = JSON_QUERY(AttrJson, N'$.tags')
FROM erp.ProductAttr
WHERE MetalType = N'铂金' AND StoneCarat >= 0.80
ORDER BY StoneCarat DESC;
GO
PRINT N'  ★ 注意这里用的是**提升列** MetalType / StoneCarat,而不是 JSON_VALUE(...);';
PRINT N'    这正是"边界清晰"的价值:能建索引、能用统计信息、能和普通列一样连接。';
GO

PRINT N'----- 03.8 同一个查询,如果直接对 JSON 原文筛选会怎样(对比写法,不推荐)-----';
SELECT
     ProductId, Sku
   , 直接取JSON值 = JSON_VALUE(AttrJson, N'$.metal.type')
   , 直接取克拉   = JSON_VALUE(AttrJson, N'$.stone.carat')
FROM erp.ProductAttr
WHERE JSON_VALUE(AttrJson, N'$.metal.type') = N'铂金'
  AND TRY_CAST(JSON_VALUE(AttrJson, N'$.stone.carat') AS decimal(5,2)) >= 0.80
ORDER BY ProductId;
GO
PRINT N'  ★ 结果一样,但后者无法用提升列上的索引,只能逐行解析 JSON(07 节会用 IO 实测这个差距)。';
GO


/* --------------------------------------------------------------------------------------------------
   03.9 CRM:客户偏好画像(JSON)------ 珠宝零售最关键的一块可变结构
   --------------------------------------------------------------------------------------------------
   为什么必须留 JSON:偏好画的字段会随运营策略频繁增删(今天加"偏好切工",
   明天加"是否接受预约制"),拆列会不停 DDL;而且这些字段不参与财务口径。
   但仍然把"偏好材质"和"预算上限"提升为列,因为它们是最常用的筛选条件。
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.9 建 crm.CustomerPref 并插入 800 条客户偏好画像 -----';
GO

CREATE TABLE crm.CustomerPref
(
    CustomerId int          NOT NULL CONSTRAINT PK_CustomerPref PRIMARY KEY,
    CustomerNo varchar(16)  NOT NULL,
    PrefJson   nvarchar(max) NOT NULL
        CONSTRAINT CK_CustomerPref_IsJsonObject CHECK (ISJSON(PrefJson, OBJECT) = 1),
    SchemaVersion AS CAST(JSON_VALUE(PrefJson, N'$.schemaVersion') AS tinyint) PERSISTED,
    -- 提升列:运营最常用的两个筛选维度
    PrefMetal     AS CAST(JSON_VALUE(PrefJson, N'$.prefs.metal') AS nvarchar(20)) PERSISTED,
    BudgetMaxUsd  AS TRY_CAST(JSON_VALUE(PrefJson, N'$.prefs.budget.maxUsd') AS decimal(12,2)) PERSISTED,
    PrefStyle     AS CAST(JSON_VALUE(PrefJson, N'$.prefs.style') AS nvarchar(20)) PERSISTED
);
GO

INSERT crm.CustomerPref (CustomerId, CustomerNo, PrefJson)
SELECT
      c.CustomerId
    , c.CustomerNo
    , CAST(N'{"schemaVersion":1,"prefs":{"style":"' AS nvarchar(max))
    + CASE c.CustomerId % 4 WHEN 0 THEN N'经典' WHEN 1 THEN N'轻奢' WHEN 2 THEN N'国潮' ELSE N'极简' END
    + N'","metal":"'
    + CASE c.CustomerId % 3 WHEN 0 THEN N'铂金' WHEN 1 THEN N'黄金' ELSE N'玫瑰金' END
    + N'","color":"' + CASE c.CustomerId % 2 WHEN 0 THEN N'冷色' ELSE N'暖色' END + N'"'
    + N',"budget":{"minUsd":' + CAST(500 + (c.CustomerId % 10) * 250 AS nvarchar(20))
    + N',"maxUsd":' + CAST(3000 + (c.CustomerId % 25) * 1200 AS nvarchar(20)) + N'}}'
    + N',"sizes":{"ring":' + CAST(10.0 + (c.CustomerId % 9) * 0.5 AS nvarchar(10))
    + N',"bracelet":' + CAST(15.0 + (c.CustomerId % 7) * 0.5 AS nvarchar(10)) + N'}'
    + N',"touch":{"preferChannel":"'
    + CASE c.CustomerId % 4 WHEN 0 THEN N'wechat' WHEN 1 THEN N'ecom' WHEN 2 THEN N'store' ELSE N'app' END
    + N'","bestTime":"' + CASE c.CustomerId % 3 WHEN 0 THEN N'12:00-14:00' WHEN 1 THEN N'20:00-22:00' ELSE N'周末' END
    + N'","lang":"' + CASE c.CustomerId % 3 WHEN 0 THEN N'zh-Hans' WHEN 1 THEN N'en' ELSE N'zh-Hant' END + N'"}'
    + N',"tags":[' + CASE c.CustomerId % 4 WHEN 0 THEN N'"婚嫁","高净值"' WHEN 1 THEN N'"自购"'
                                          WHEN 2 THEN N'"送礼","纪念日"' ELSE N'"收藏"' END + N']'
    + N'}'
FROM crm.Customer c;
GO

SELECT 插入行数 = COUNT(*) FROM crm.CustomerPref;
GO
SELECT TOP 2 CustomerId, CustomerNo, PrefJson FROM crm.CustomerPref ORDER BY CustomerId;
GO

PRINT N'  [1] 提升列建索引,然后按偏好筛选客户(CRM 精准营销的标准姿势):';
CREATE INDEX IX_CustomerPref_Metal_Budget ON crm.CustomerPref (PrefMetal, BudgetMaxUsd) INCLUDE (PrefStyle);
GO

SET STATISTICS IO ON;
GO
SELECT
     客户号   = c.CustomerNo
   , 姓名     = cu.CustomerName
   , 等级     = cu.Tier
   , 偏好材质 = c.PrefMetal
   , 预算上限 = c.BudgetMaxUsd
   , 偏好风格 = c.PrefStyle
   , 触点偏好 = JSON_VALUE(c.PrefJson, N'$.touch.preferChannel')
FROM crm.CustomerPref c
JOIN crm.Customer cu ON cu.CustomerId = c.CustomerId
WHERE c.PrefMetal = N'铂金' AND c.BudgetMaxUsd >= 20000
ORDER BY c.BudgetMaxUsd DESC;
GO
SET STATISTICS IO OFF;
GO

PRINT N'  [2] 展开 tags 数组做分群统计(数组必须靠 OPENJSON 打开):';
SELECT
     标签  = t.value
   , 人数  = COUNT(*)
   , 平均预算上限 = CAST(AVG(c.BudgetMaxUsd) AS decimal(12,2))
FROM crm.CustomerPref c
CROSS APPLY OPENJSON(c.PrefJson, N'$.tags') AS t
GROUP BY t.value
ORDER BY 人数 DESC;
GO
PRINT N'  ★ 注意:tags 是数组,无法用 JSON_VALUE 取(会得到 NULL),必须 OPENJSON 展开。';
PRINT N'    这正是 03.1 那条"JSON_VALUE 只认标量"的直接后果。';
GO

PRINT N'  [3] 多语言触点:按客户语言分组(CRM 跨境运营常用)';
PRINT N'      ★ 注意这里用 COUNT(DISTINCT ...) 而不是 STRING_AGG:';
PRINT N'        800 个客户拼成一个长串没有意义,还会把结果集撑爆。聚合要看"给谁看"来选。';
SELECT
     语言         = JSON_VALUE(PrefJson, N'$.touch.lang')
   , 人数         = COUNT(*)
   , 不同触点渠道数 = COUNT(DISTINCT JSON_VALUE(PrefJson, N'$.touch.preferChannel'))
   , 平均预算上限  = CAST(AVG(BudgetMaxUsd) AS decimal(12,2))
FROM crm.CustomerPref
GROUP BY JSON_VALUE(PrefJson, N'$.touch.lang')
ORDER BY 人数 DESC;
GO

/* --------------------------------------------------------------------------------------------------
   03.10 HR:员工技能与语言档案(JSON)
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.10 建 hr.EmployeeDoc 并插入 36 条员工档案 -----';
GO

CREATE TABLE hr.EmployeeDoc
(
    EmployeeId int          NOT NULL CONSTRAINT PK_EmployeeDoc PRIMARY KEY,
    EmployeeNo varchar(16)  NOT NULL,
    DocJson    nvarchar(max) NOT NULL
        CONSTRAINT CK_EmployeeDoc_IsJsonObject CHECK (ISJSON(DocJson, OBJECT) = 1),
    -- 提升列形态一:PERSISTED 计算列 ------ 只能做"单值提取"
    SchemaVersion AS CAST(JSON_VALUE(DocJson, N'$.schemaVersion') AS tinyint) PERSISTED,
    TopSkill      AS CAST(JSON_VALUE(DocJson, N'$.skills[0].name') AS nvarchar(60)) PERSISTED,
    -- 提升列形态二:写入时算好的真实列 ------ 需要"计数/求和"时只能这么办
    --   原因:计算列里不允许子查询、不允许 OPENJSON 聚合;且 JSON_VALUE 取数组恒为 NULL。
    CertCount     tinyint NOT NULL,
    -- 提升列形态一(续):把"是否持某类证书"变成可索引的位
    HasGiaCert    AS CAST(CASE WHEN DocJson LIKE N'%GIA-GG%' THEN 1 ELSE 0 END AS bit) PERSISTED
);
GO
PRINT N'  ★ 建表时就要分清两种"提升列":';
PRINT N'    ① 单值提取 → 可以用 PERSISTED 计算列(SchemaVersion / TopSkill / HasGiaCert);';
PRINT N'    ② 计数或求和 → 计算列里不能写 OPENJSON / 子查询,只能在写入时算好存成普通列(CertCount)。';
PRINT N'    顺带一提:JSON_VALUE 取**数组**($.certs)永远返回 NULL,这也是①做不到计数的一半原因。';
GO

INSERT hr.EmployeeDoc (EmployeeId, EmployeeNo, DocJson, CertCount)
SELECT
      e.EmployeeId
    , N'E-' + RIGHT(N'0000' + CAST(e.EmployeeId AS nvarchar(10)), 4)
    , CAST(N'{"schemaVersion":1,"skills":[' AS nvarchar(max))
    + N'{"name":"' + CASE e.EmployeeId % 5 WHEN 0 THEN N'钻石分级' WHEN 1 THEN N'黄金工艺'
                                          WHEN 2 THEN N'门店运营' WHEN 3 THEN N'客户关系' ELSE N'库存管理' END
    + N'","level":' + CAST(3 + e.EmployeeId % 3 AS nvarchar(4)) + N'}'
    + N',{"name":"' + CASE e.EmployeeId % 3 WHEN 0 THEN N'团队管理' WHEN 1 THEN N'视觉陈列' ELSE N'数据分析' END
    + N'","level":' + CAST(2 + e.EmployeeId % 4 AS nvarchar(4)) + N'}]'
    + N',"langs":[{"code":"zh-Hans","level":"native"}'
    + CASE WHEN e.EmployeeId % 2 = 0 THEN N',{"code":"en","level":"business"}' ELSE N'' END
    + CASE WHEN e.EmployeeId % 3 = 0 THEN N',{"code":"ja","level":"basic"}' ELSE N'' END + N']'
    + N',"certs":[' + CASE e.EmployeeId % 3 WHEN 0 THEN N'"GIA-GG","SAFETY"' WHEN 1 THEN N'"CIPD"' ELSE N'"SAFETY"' END + N']'
    + N',"emergency":{"contact":"N/A","phone":"N/A"}}'
    -- ★ 手工拼 JSON 最容易出错的地方就是花括号/中括号配对。
    --   下面这一列在"写入时"算好,正是上面说的提升列形态二。
    , CAST(CASE e.EmployeeId % 3 WHEN 0 THEN 2 ELSE 1 END AS tinyint)
FROM hr.Employee e;
GO

SELECT 插入行数 = COUNT(*) FROM hr.EmployeeDoc;
SELECT 合法JSON行数 = SUM(CAST(ISJSON(DocJson, OBJECT) AS int)) FROM hr.EmployeeDoc;
GO
SELECT TOP 2 EmployeeId, EmployeeNo, DocJson FROM hr.EmployeeDoc ORDER BY EmployeeId;
GO
PRINT N'  对照:JSON_VALUE 取数组得到 NULL,而写入时算好的 CertCount 拿得到值:';
SELECT TOP 3
     员工号 = EmployeeNo
   , 反例_JSON_VALUE取数组 = ISNULL(CAST(JSON_VALUE(DocJson, N'$.certs') AS nvarchar(20)), N'<NULL:数组取不到>')
   , 正解_写入时提升列     = CAST(CertCount AS nvarchar(10))
FROM hr.EmployeeDoc ORDER BY EmployeeId;
GO

PRINT N'  [1] 展开技能数组:谁具备"钻石分级"能力(HR 技能盘点):';
SELECT
     员工号   = d.EmployeeNo
   , 姓名     = e.FullName
   , 部门     = e.Department
   , 技能     = s.name
   , 等级     = s.level
FROM hr.EmployeeDoc d
JOIN hr.Employee e ON e.EmployeeId = d.EmployeeId
CROSS APPLY OPENJSON(d.DocJson, N'$.skills')
     WITH (name nvarchar(60) N'$.name', level int N'$.level') AS s
WHERE d.DocJson LIKE N'%钻石分级%'   -- 先用廉价谓词预过滤,再用 OPENJSON 精确展开
ORDER BY 等级 DESC, 员工号;
GO
PRINT N'  ★ 上面故意保留了一个反例:WHERE 里用 LIKE 预过滤只是"看起来省",';
PRINT N'    它仍然无法走索引(%...%)。正确做法是把高频筛选的技能提升为列(见下表)。';
GO

PRINT N'  [2] 正确做法:用建表时就定义好的提升列 + 索引来筛(对比上一条的 LIKE):';
CREATE INDEX IX_EmployeeDoc_HasGia ON hr.EmployeeDoc (HasGiaCert) INCLUDE (EmployeeNo, CertCount);
GO
SET STATISTICS IO ON;
SELECT
     持GIA证书人数 = COUNT(*)
   , 平均证书数   = CAST(AVG(CAST(CertCount AS decimal(6,2))) AS decimal(6,2))
FROM hr.EmployeeDoc
WHERE HasGiaCert = 1;
SET STATISTICS IO OFF;
GO
PRINT N'  ★ 这就是 01.1 判据第 5 条的落地:只提升"真正要筛的少数几个字段"。';
PRINT N'    注意这里**没有**用 LIKE:HasGiaCert 是 PERSISTED 计算列,能走索引;';
PRINT N'    而 LIKE N''%GIA-GG%'' 的前导通配符永远走不了索引。';
GO


/* ==================================================================================================
   04  JSON 生成:从关系数据造出 JSON
   ================================================================================================== */

PRINT N'';
PRINT N'===== 04 JSON 生成 =====';
GO

/* --------------------------------------------------------------------------------------------------
   04.1 JSON_OBJECT / JSON_ARRAY(2022+):按需拼装,不用手工拼字符串
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 04.1 JSON_OBJECT / JSON_ARRAY:拼单行 JSON -----';
SELECT TOP 5
     订单号 = o.OrderNo
   , 单据JSON = JSON_OBJECT(
         'orderNo'  : o.OrderNo,
         'orderDate': o.OrderDate,
         'ccy'      : o.CurrencyCode,
         'netUsd'   : o.NetAmountUsd,
         'status'   : o.Status
     )
   , 标签数组 = JSON_ARRAY(o.Status, o.CurrencyCode)
FROM erp.SalesOrder o
ORDER BY o.OrderId;
GO
PRINT N'  ★ JSON_OBJECT / JSON_ARRAY 里可以直接放列,省掉手工 CAST 与转义,';
PRINT N'    也不会踩到"字符串拼接被截断到 4000"的坑。';
GO

/* --------------------------------------------------------------------------------------------------
   04.2 FOR JSON:把结果集整体转成 JSON
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 04.2 FOR JSON PATH:手工控制结构(最常用)-----';
SELECT TOP 3
       o.OrderNo      AS [orderNo]
     , o.OrderDate    AS [orderDate]
     , o.CurrencyCode AS [ccy]
     , o.NetAmountUsd AS [netUsd]
FROM erp.SalesOrder o
ORDER BY o.OrderId
FOR JSON PATH;
GO

PRINT N'----- 04.3 FOR JSON PATH + ROOT + INCLUDE_NULL_VALUES -----';
SELECT TOP 3
       o.OrderNo   AS [orderNo]
     , o.StoreCode AS [store]
     , o.Status    AS [status]
FROM erp.SalesOrder o
ORDER BY o.OrderId
FOR JSON PATH, ROOT('orders'), INCLUDE_NULL_VALUES;
GO
PRINT N'  ★ 默认 FOR JSON 会**省略 NULL 字段**;要保留键就得加 INCLUDE_NULL_VALUES。';
PRINT N'    对接方按固定字段解析时,这个差别会导致"字段时有时无",务必显式选择。';
GO

PRINT N'----- 04.4 FOR JSON AUTO:按表结构自动嵌套(方便但难控制)-----';
SELECT TOP 2
       o.OrderNo         AS [orderNo]
     , p.Sku             AS [sku]
     , l.Quantity        AS [qty]
     , l.LineAmount      AS [amountUsd]
FROM erp.SalesOrder o
JOIN erp.SalesOrderLine l ON l.OrderId = o.OrderId
JOIN erp.Product p        ON p.ProductId = l.ProductId
ORDER BY o.OrderId, l.LineId
FOR JSON AUTO, ROOT('orders');
GO
PRINT N'  ★ AUTO 模式会"按表自动分层":o 一层、l 一层、p 一层,层级完全由 FROM 顺序决定。';
PRINT N'    字段多表分散时结构往往不是你要的,所以生产上一般用 PATH 手工控制(见 10.3)。';
GO

/* --------------------------------------------------------------------------------------------------
   04.5 JSON_ARRAYAGG / JSON_OBJECTAGG:SQL Server 2025 新增的 JSON 聚合
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 04.5 ★ 2025 新聚合:把分组内的值聚成 JSON 数组/对象 -----';
SELECT
     门店     = o.StoreCode
   , 订单数   = COUNT(*)
   , 订单号数组 = JSON_ARRAYAGG(o.OrderNo)
FROM erp.SalesOrder o
GROUP BY o.StoreCode
ORDER BY 门店;
GO

PRINT N'  用一个 SELECT 直接产出"门店 → {SKU: 订单数}"的 JSON 对象:';
PRINT N'    ★ JSON_OBJECTAGG 要求分组内**键唯一**,所以先在内层把 (门店, SKU) 聚合好,再在外层拼对象。';
SELECT
     门店 = s.StoreCode
   , 产品订单数JSON = JSON_OBJECTAGG(s.Sku : s.OrderCnt)
FROM (
    SELECT o.StoreCode, p.Sku, COUNT(DISTINCT o.OrderId) AS OrderCnt
    FROM erp.SalesOrder o
    JOIN erp.SalesOrderLine l ON l.OrderId = o.OrderId
    JOIN erp.Product p        ON p.ProductId = l.ProductId
    GROUP BY o.StoreCode, p.Sku
) AS s
GROUP BY s.StoreCode
ORDER BY 门店;
GO
PRINT N'  ★ JSON_OBJECTAGG 的语法是 JSON_OBJECTAGG(键 : 值),注意是**冒号**不是逗号。';
PRINT N'    有了这两个聚合,很多原本"分组后在应用层拼 JSON"的逻辑可以下推到数据库;';
PRINT N'    但要注意:聚合结果是**整个分组拼成一个大字符串**,分组很大时会吃内存(见 14 节)。';
GO

PRINT N'  珠宝场景:按品类把 SKU 聚成一个 JSON 数组,直接喂给前端做筛选器:';
SELECT
     品类     = pc.CategoryName
   , SKU数    = COUNT(*)
   , SKU数组  = JSON_ARRAYAGG(p.Sku)
FROM erp.Product p
JOIN erp.ProductCategory pc ON pc.CategoryId = p.CategoryId
GROUP BY pc.CategoryName
ORDER BY 品类;
GO


/* ==================================================================================================
   05  JSON 修改:JSON_MODIFY
   ================================================================================================== */

PRINT N'';
PRINT N'===== 05 JSON 修改 =====';
GO

/* --------------------------------------------------------------------------------------------------
   05.1 改已有键 / 追加新键 / 删键 / 改数组元素
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 05.1 JSON_MODIFY 的四种基本操作 -----';
DECLARE @doc nvarchar(max) = (SELECT AttrJson FROM erp.ProductAttr WHERE ProductId = 1);

SELECT 操作 = N'原文档', 结果 = LEFT(@doc, 120) + N'...'
UNION ALL
SELECT N'改已有标量:$.metal.purity', LEFT(JSON_MODIFY(@doc, N'$.metal.purity', N'Pt900'), 120) + N'...'
UNION ALL
SELECT N'追加新键:$.custom.insured', LEFT(JSON_MODIFY(@doc, N'$.custom.insured', CAST(1 AS bit)), 200) + N'...'
UNION ALL
SELECT N'改深层键:$.stone.cert.lab', LEFT(JSON_MODIFY(@doc, N'$.stone.cert.lab', N'IGI'), 200) + N'...'
UNION ALL
SELECT N'删键:把值设成 NULL', LEFT(JSON_MODIFY(@doc, N'$.custom.giftBox', NULL), 200) + N'...'
UNION ALL
SELECT N'追加数组元素:$.tags[3]', LEFT(JSON_MODIFY(@doc, N'append $.tags', N'限量'), 220) + N'...';
GO
PRINT N'  ★ 删键的写法是"把路径的值设为 NULL";追加数组元素必须用 append 前缀;';
PRINT N'    如果直接写 $.tags[3] 而数组只有 2 个元素,lax 下会静默无操作(不报错、也不追加)。';
GO

/* --------------------------------------------------------------------------------------------------
   05.2 lax 与 strict 在"追加新键"上的差别
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 05.2 追加一个还不存在的键:lax 可以,strict 报错 -----';
DECLARE @d nvarchar(max) = N'{"a":1}';
BEGIN TRY
    SELECT lax结果 = JSON_MODIFY(@d, N'lax $.b', 9);
END TRY BEGIN CATCH SELECT lax结果 = N'异常: ' + ERROR_MESSAGE(); END CATCH
DECLARE @p nvarchar(50) = N'strict $.b';
BEGIN TRY
    SELECT strict结果 = JSON_MODIFY(@d, @p, 9);
END TRY
BEGIN CATCH
    SELECT strict结果 = N'异常(' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N'):' + ERROR_MESSAGE();
END CATCH
GO
PRINT N'  ★ 这条与 JSON_VALUE 相反:strict 在这里意味着"路径必须已存在",';
PRINT N'    而 JSON_VALUE 的 strict 意味着"路径必须存在且类型匹配"。别记混。';
GO

/* --------------------------------------------------------------------------------------------------
   05.3 ★ 性能要点:JSON_MODIFY 会重写整个文档
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 05.3 实测:改 JSON 里一个键,等于重写整篇文档 -----';
SELECT
     原文档字节 = DATALENGTH(AttrJson)
   , 改后字节   = DATALENGTH(JSON_MODIFY(AttrJson, N'$.custom.giftBox', N'deluxe'))
   , 差         = DATALENGTH(JSON_MODIFY(AttrJson, N'$.custom.giftBox', N'deluxe')) - DATALENGTH(AttrJson)
FROM erp.ProductAttr WHERE ProductId = 1;
GO
PRINT N'  ★ 结论:文档越大,改一个键的代价越高(读全量 + 解析 + 重拼 + 写全量,并产生新的 LOB 版本)。';
PRINT N'    所以"会被高频修改的字段"必须拆成列 ------ 这正是 01.1 判据第 3 条的由来。';
GO

/* --------------------------------------------------------------------------------------------------
   05.4 正确的更新姿势:只改一处,且带上前置校验
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 05.4 带校验的安全更新(先确认已存在该键,再改)-----';
UPDATE erp.ProductAttr
SET AttrJson = JSON_MODIFY(AttrJson, N'$.custom.insured', CAST(1 AS bit))
WHERE ProductId = 1
  AND JSON_PATH_EXISTS(AttrJson, N'$.custom') = 1;

SELECT ProductId, 改后custom = JSON_QUERY(AttrJson, N'$.custom') FROM erp.ProductAttr WHERE ProductId = 1;
GO


/* ==================================================================================================
   06  JSON 校验:让"合法"成为可查询的事实
   ================================================================================================== */

PRINT N'';
PRINT N'===== 06 JSON 校验 =====';
GO

/* --------------------------------------------------------------------------------------------------
   06.1 ISJSON 的类型参数:不只校验"是 JSON",还校验"是哪一种 JSON"
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 06.1 ISJSON 的四种判定 -----';
SELECT * FROM (VALUES
    (1, N'{"a":1}',       ISJSON(N'{"a":1}', OBJECT),       ISJSON(N'{"a":1}', ARRAY),       ISJSON(N'{"a":1}', VALUE))
  , (2, N'[1,2,3]',       ISJSON(N'[1,2,3]', OBJECT),       ISJSON(N'[1,2,3]', ARRAY),       ISJSON(N'[1,2,3]', VALUE))
  , (3, N'123',           ISJSON(N'123',     OBJECT),       ISJSON(N'123',     ARRAY),       ISJSON(N'123',     VALUE))
  , (4, N'not json',      ISJSON(N'not json',OBJECT),       ISJSON(N'not json',ARRAY),       ISJSON(N'not json',VALUE))
) AS t(序号, 文档, 是OBJECT, 是ARRAY, 是VALUE);
GO
PRINT N'  ★ 注意第 3 行:标量 123 在 VALUE 判定下是合法 JSON,但在 OBJECT/ARRAY 下不合法。';
PRINT N'    所以列上写 CHECK(ISJSON(col, OBJECT) = 1) 才能挡住"塞进一个裸数组"这种事故。';
GO

/* --------------------------------------------------------------------------------------------------
   06.2 契约巡检:把"不符合契约的行"查出来
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 06.2 契约巡检:找出缺必需路径 / 版本号异常的商品 -----';
SELECT
     检查项           = N'schemaVersion 缺失或非 1'
   , 违反行数         = COUNT(*)
FROM erp.ProductAttr WHERE SchemaVersion IS NULL OR SchemaVersion <> 1
UNION ALL
SELECT N'metal.type 缺失', COUNT(*) FROM erp.ProductAttr WHERE JSON_PATH_EXISTS(AttrJson, N'$.metal.type') = 0
UNION ALL
SELECT N'stone 与 tags 同时缺失', COUNT(*) FROM erp.ProductAttr
        WHERE JSON_PATH_EXISTS(AttrJson, N'$.stone') = 0 AND JSON_PATH_EXISTS(AttrJson, N'$.tags') = 0
UNION ALL
SELECT N'有钻石但缺证书号', COUNT(*) FROM erp.ProductAttr
        WHERE StoneType = N'钻石' AND JSON_PATH_EXISTS(AttrJson, N'$.stone.cert.no') = 0
UNION ALL
SELECT N'文档超过 2000 字节(体积告警)', COUNT(*) FROM erp.ProductAttr WHERE DATALENGTH(AttrJson) > 2000;
GO
PRINT N'  ★ 这就是"边界清晰"的可运维形态:契约不是写在文档里的约定,而是能每天巡检的查询。';
GO

/* --------------------------------------------------------------------------------------------------
   06.3 加一条"字段级"CHECK 约束:把必需路径变成数据库强制
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 06.3 用 CHECK 约束强制"必须有 schemaVersion 与 metal.type" -----';
BEGIN TRY
    ALTER TABLE erp.ProductAttr WITH CHECK
      ADD CONSTRAINT CK_ProductAttr_RequiredPaths
          CHECK (JSON_PATH_EXISTS(AttrJson, N'$.schemaVersion') = 1
                 AND JSON_PATH_EXISTS(AttrJson, N'$.metal.type') = 1);
    PRINT N'  约束已添加';
END TRY
BEGIN CATCH
    PRINT N'  添加失败(' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N'):' + ERROR_MESSAGE();
END CATCH
GO
PRINT N'  验证约束确实生效(插一条缺 metal.type 的文档):';
BEGIN TRY
    INSERT erp.ProductAttr (ProductId, Sku, AttrJson) VALUES (9998, N'BAD-002', N'{"schemaVersion":1}');
    PRINT N'    居然插入成功(约束没生效)';
END TRY
BEGIN CATCH
    PRINT N'    被拦下 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO


/* ==================================================================================================
   07  JSON 索引:JSON 列本身不能建索引,靠"计算列 + 索引"
   ================================================================================================== */

PRINT N'';
PRINT N'===== 07 JSON 索引 =====';
GO

/* --------------------------------------------------------------------------------------------------
   07.1 先证明"不能直接索引"
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 07.1 直接对 json / nvarchar(max) 列建索引会失败 -----';
BEGIN TRY
    CREATE INDEX IX_ProductAttrJ_Direct ON erp.ProductAttrJ (AttrJson);
    PRINT N'  原生 json 列:居然建索引成功';
END TRY
BEGIN CATCH
    PRINT N'  原生 json 列建索引失败 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
BEGIN TRY
    CREATE INDEX IX_ProductAttr_Direct ON erp.ProductAttr (AttrJson);
    PRINT N'  nvarchar(max) 列:居然建索引成功';
END TRY
BEGIN CATCH
    PRINT N'  nvarchar(max) 列建索引失败 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
PRINT N'  ★ 所以 JSON 的"索引"永远是:把要筛选的路径提升成计算列,再索引那个计算列。';
PRINT N'    SQL Server 没有"JSON 路径索引"这种东西(不像 PostgreSQL 的 GIN)。';
GO

/* --------------------------------------------------------------------------------------------------
   07.2 建立提升列上的索引
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 07.2 在提升列上建索引(含覆盖索引,避免回表)-----';
CREATE INDEX IX_ProductAttr_MetalType ON erp.ProductAttr (MetalType) INCLUDE (Sku, MetalPurity, StoneCarat);
CREATE INDEX IX_ProductAttr_Carat     ON erp.ProductAttr (StoneCarat) INCLUDE (Sku, StoneType, CertLab);
CREATE INDEX IX_ProductAttrJ_MetalType ON erp.ProductAttrJ (MetalType);
GO
PRINT N'  已建立的索引:';
SELECT 表 = OBJECT_NAME(i.object_id), 索引 = i.name, 类型 = i.type_desc,
       键列 = STUFF((SELECT N', ' + c.name FROM sys.index_columns ic
                     JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
                     WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0
                     ORDER BY ic.key_ordinal FOR XML PATH('')), 1, 2, N'')
FROM sys.indexes i
WHERE i.object_id IN (OBJECT_ID('erp.ProductAttr'), OBJECT_ID('erp.ProductAttrJ'))
  AND i.name IS NOT NULL
ORDER BY 表, 索引;
GO

/* --------------------------------------------------------------------------------------------------
   07.3 在 5 万行的 JSON 表上实测:提升列索引 vs 直接 JSON_VALUE
   --------------------------------------------------------------------------------------------------
   先造一张有规模的表,模拟"万亿级"在我们本地能承受的缩微样本。
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 07.3 建 stg.EventJson,插入 5 万行门店扫码/触点事件(JSON)-----';
GO

CREATE TABLE stg.EventJson
(
    EventId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_EventJson PRIMARY KEY,
    Payload nvarchar(max) NOT NULL
        CONSTRAINT CK_EventJson_IsJson CHECK (ISJSON(Payload, OBJECT) = 1),
    -- 提升列:只提升真正要筛的字段
    StoreCode AS CAST(JSON_VALUE(Payload, N'$.store')   AS varchar(12))  PERSISTED,
    Sku       AS CAST(JSON_VALUE(Payload, N'$.sku')     AS varchar(20))  PERSISTED,
    Channel   AS CAST(JSON_VALUE(Payload, N'$.channel') AS varchar(12))  PERSISTED,
    Qty       AS TRY_CAST(JSON_VALUE(Payload, N'$.qty') AS int)          PERSISTED
);
GO

;WITH N AS (
    SELECT TOP (50000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
    FROM sys.all_objects a CROSS JOIN sys.all_objects b
)
INSERT stg.EventJson (Payload)
SELECT
      CAST(N'{"schemaVersion":1,"event":"scan","store":"' AS nvarchar(max))
    + CASE n.n % 6 WHEN 0 THEN N'HK-CWB-01' WHEN 1 THEN N'CN-SH-02' WHEN 2 THEN N'SG-OR-01'
                   WHEN 3 THEN N'US-NY-05' WHEN 4 THEN N'JP-TK-01' ELSE N'FR-PA-03' END
    + N'","sku":"'
    + CASE n.n % 8 WHEN 0 THEN N'RG-DIA-001' WHEN 1 THEN N'RG-GLD-003' WHEN 2 THEN N'NK-GLD-010'
                   WHEN 3 THEN N'ER-DIA-005' WHEN 4 THEN N'BR-PLT-002' WHEN 5 THEN N'WT-MEC-007'
                   WHEN 6 THEN N'PD-JDE-004' ELSE N'CG-STL-009' END
    + N'","channel":"'
    + CASE n.n % 4 WHEN 0 THEN N'store' WHEN 1 THEN N'ecom' WHEN 2 THEN N'wechat' ELSE N'app' END
    + N'","qty":' + CAST(1 + n.n % 3 AS varchar(4))
    + N',"ts":"2025-' + RIGHT(N'0' + CAST(1 + n.n % 12 AS varchar(2)), 2)
    + N'-' + RIGHT(N'0' + CAST(1 + n.n % 28 AS varchar(2)), 2) + N'T09:00:00Z"}'
FROM N;
GO

SELECT 插入行数 = COUNT(*) FROM stg.EventJson;
GO
SELECT TOP 2 EventId, Payload FROM stg.EventJson ORDER BY EventId;
GO

PRINT N'----- 07.4 建索引前后对比:按 SKU + 门店筛选 -----';
CREATE INDEX IX_EventJson_Sku_Store ON stg.EventJson (Sku, StoreCode) INCLUDE (Qty, Channel);
GO

PRINT N'  [a] 用提升列 + 索引(走索引查找)';
SET STATISTICS IO ON;
SELECT 事件数 = COUNT(*), 总件数 = SUM(Qty)
FROM stg.EventJson
WHERE Sku = N'RG-DIA-001' AND StoreCode = N'HK-CWB-01';
GO
SET STATISTICS IO OFF;
GO

PRINT N'  [b] 直接解析 JSON 原文(只能全表扫描 + 逐行解析)';
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
SELECT 事件数 = COUNT(*), 总件数 = SUM(TRY_CAST(JSON_VALUE(Payload, N'$.qty') AS int))
FROM stg.EventJson
WHERE JSON_VALUE(Payload, N'$.sku') = N'RG-DIA-001'
  AND JSON_VALUE(Payload, N'$.store') = N'HK-CWB-01';
GO
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
GO
PRINT N'  ★ 对比两条的"逻辑读取次数"与 CPU 时间:提升列走查找是常数级,';
PRINT N'    直接 JSON_VALUE 是全表扫描 + 5 万次 JSON 解析,随行数线性增长。';
GO


/* ==================================================================================================
   08  ★ JSON_VALUE 的 4000 字符陷阱(本脚本最实用的一节)
   --------------------------------------------------------------------------------------------------
   很多人以为 JSON_VALUE 取不到值是"路径写错了",其实是**返回值长度上限**。
   本节的结论全部来自本机实测。
   ================================================================================================== */

PRINT N'';
PRINT N'===== 08 JSON_VALUE 的 4000 字符上限 =====';
GO

/* --------------------------------------------------------------------------------------------------
   08.1 边界扫描:JSON_VALUE 从第几个字符开始"静默返回 NULL"
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 08.1 边界扫描:被取的值从 3990 逐步加到 4020 -----';
DECLARE @n int = 3990;
WHILE @n <= 4020
BEGIN
    DECLARE @v nvarchar(max) = REPLICATE(CAST(N'Y' AS nvarchar(max)), @n);
    DECLARE @doc nvarchar(max) = CAST(N'{"v":"' AS nvarchar(max)) + @v + N'"}';
    SELECT
         被取值长度      = @n
       , 文档总长        = LEN(@doc)
       , JSON_VALUE长度  = ISNULL(CAST(LEN(JSON_VALUE(@doc, N'$.v')) AS nvarchar(10)), N'<NULL>')
       , JSON_QUERY长度  = ISNULL(CAST(LEN(JSON_QUERY(@doc, N'$.v')) AS nvarchar(10)), N'<NULL>')
       , OPENJSON值长度  = (SELECT LEN([value]) FROM OPENJSON(@doc) WHERE [key] = N'v');
    SET @n = @n + 5;
END
GO
PRINT N'  ★ 结论(实测):';
PRINT N'      · 值 ≤ 4000 字符 → JSON_VALUE 正常返回;';
PRINT N'      · 值 >  4000 字符 → JSON_VALUE **静默返回 NULL**,不报错、不截断、也不告警;';
PRINT N'      · JSON_QUERY 对"字符串标量"恒为 NULL(它只取对象/数组),所以那一列永远是 NULL;';
PRINT N'      · OPENJSON 的 value 列是 nvarchar(max),**多长都能完整取出** ------ 这才是取长值的正解。';
GO

/* --------------------------------------------------------------------------------------------------
   08.2 危险之处:NULL 与"真的没有这个键"无法区分
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 08.2 两种完全不同的原因,JSON_VALUE 都返回 NULL -----';
DECLARE @short nvarchar(max) = N'{"v":"abc"}';
DECLARE @long  nvarchar(max) = CAST(N'{"v":"' AS nvarchar(max)) + REPLICATE(CAST(N'Z' AS nvarchar(max)), 5000) + N'"}';
SELECT * FROM (VALUES
    (1, N'键不存在(lax)',            JSON_VALUE(@short, N'$.nope'),   N'→ NULL 是对的')
  , (2, N'键存在,值是超长字符串',      JSON_VALUE(@long,  N'$.v'),      N'→ NULL 是**错的**(会被当成不存在)')
  , (3, N'用 JSON_PATH_EXISTS 才能区分', CAST(JSON_PATH_EXISTS(@long, N'$.v') AS nvarchar(2)), N'→ 1 说明键其实存在')
  , (4, N'用 OPENJSON 正确取值',        CAST((SELECT LEN([value]) FROM OPENJSON(@long) WHERE [key]=N'v') AS nvarchar(10)), N'→ 5000,取到了')
) AS t(序号, 情形, JSON_VALUE结果, 说明)
ORDER BY 序号;
GO
PRINT N'  ★ 这条坑最阴的地方:调用方看到 NULL,会以为"这条商品没有描述";';
PRINT N'    实际上是"描述太长"。如果这个字段用于对账或展示,错误会静默流到业务层。';
GO

/* --------------------------------------------------------------------------------------------------
   08.3 正解:三种取长值的写法
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 08.3 取长字符串值:JSON_VALUE / JSON_QUERY / OPENJSON 三种写法对比 -----';
DECLARE @long nvarchar(max) = CAST(N'{"v":"' AS nvarchar(max)) + REPLICATE(CAST(N'Z' AS nvarchar(max)), 5000) + N'"}';
SELECT
     JSON_VALUE = ISNULL(CAST(LEN(JSON_VALUE(@long, N'$.v')) AS nvarchar(10)), N'NULL(丢了)')
   , OPENJSON   = CAST((SELECT LEN([value]) FROM OPENJSON(@long) WHERE [key] = N'v') AS nvarchar(10))
   , 推荐写法   = N'OPENJSON(...) WHERE [key] = ''v'',或用 OPENJSON WITH(v nvarchar(max) ''$.v'')';
GO
PRINT N'  在真实表上,用 OPENJSON 把长字段取成 nvarchar(max):';
SELECT TOP 3
     EventId
   , [取 qty 用 JSON_VALUE] = JSON_VALUE(Payload, N'$.qty')
   , [取整篇用 OPENJSON]    = (SELECT LEFT([value], 30) FROM OPENJSON(Payload) WHERE [key] = N'event')
FROM stg.EventJson ORDER BY EventId;
GO
PRINT N'  ★ 语法小坑:别名里带空格必须加方括号([取 qty 用 JSON_VALUE]),';
PRINT N'    否则会报 102「''用''附近有语法错误」+ 156「关键字 ''FROM'' 附近有语法错误」这两条很迷惑的组合。';
GO

/* --------------------------------------------------------------------------------------------------
   08.4 上游陷阱:REPLICATE 自己就会截断到 4000
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 08.4 REPLICATE 的上限:入参不是 max 就只给 4000 -----';
SELECT
     REPLICATE普通       = LEN(REPLICATE(N'X', 5000))
   , REPLICATE入参转max  = LEN(REPLICATE(CAST(N'X' AS nvarchar(max)), 5000));
GO
PRINT N'  ★ 造测试数据时如果用 REPLICATE(N''X'', 5000) 造长字符串,实际只得到 4000 个字符。';
PRINT N'    这会让你误判"JSON_VALUE 在 4000 就坏了",其实根本没造出那么长的数据。';
GO

/* --------------------------------------------------------------------------------------------------
   08.5 拼接截断:两个 4000 长的串相加,结果还是 4000
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 08.5 字符串拼接的结果类型推导也会截断 -----';
SELECT
     两段各4000拼接_未转max = LEN(REPLICATE(N'a', 4000) + REPLICATE(N'b', 4000))
   , 第一段转max后          = LEN(CAST(REPLICATE(N'a', 4000) AS nvarchar(max)) + REPLICATE(N'b', 4000));
GO
PRINT N'  ★ 未转 max 时,T-SQL 把结果类型推导为 nvarchar(4000),直接截掉后半段。';
PRINT N'    拼 JSON 时被截断 = 得到非法 JSON = 后续 JSON 函数报 13609,而且报错位置指向第 4000 字符,';
PRINT N'    排查时极易误判成"JSON 内容有问题"。修法:把第一个操作数 CAST 成 nvarchar(max)。';
GO

/* --------------------------------------------------------------------------------------------------
   08.6 更稳的姿势:能用 JSON_OBJECT 就别手工拼字符串
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 08.6 手工拼 vs JSON_OBJECT(后者不会有截断与转义问题)-----';
SELECT TOP 3
     手工拼 = CAST(N'{"orderNo":"' AS nvarchar(max)) + o.OrderNo + N'"}'
   , 用函数 = JSON_OBJECT('orderNo': o.OrderNo, 'store': o.StoreCode, 'net': o.NetAmountUsd)
FROM erp.SalesOrder o ORDER BY o.OrderId;
GO


/* ==================================================================================================
   09  XML 存储与查询:xml 数据类型 + 四个方法
   ================================================================================================== */

PRINT N'';
PRINT N'===== 09 XML 存储与查询 =====';
GO

/* --------------------------------------------------------------------------------------------------
   09.1 建表与插入:CRM 互动记录(多语言富文本)+ HR 证书报文(带命名空间)
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 09.1 建表并插入 CRM 互动记录 XML -----';
GO

CREATE TABLE crm.InteractionXml
(
    InteractionId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_InteractionXml PRIMARY KEY,
    CustomerId    int             NOT NULL,
    Channel       varchar(12)     NULL,      -- 提升列:要按渠道聚合,所以拆出来
    InteractionAt datetime2(0)    NULL,      -- 提升列:要按时间筛选
    Payload       xml             NOT NULL   -- ★ 半结构化正文留在这里
);
GO

;WITH Src AS (
    SELECT TOP (2000)
           i.InteractionId, i.CustomerId, i.Channel, i.InteractionAt, i.Satisfaction, i.DurationMinutes
    FROM crm.Interaction i
    ORDER BY i.InteractionId
)
INSERT crm.InteractionXml (CustomerId, Channel, InteractionAt, Payload)
SELECT
      s.CustomerId
    , s.Channel
    , s.InteractionAt
    , CAST(
        CAST(N'<interaction id="I-' AS nvarchar(max))
      + RIGHT(N'000000' + CAST(s.InteractionId AS nvarchar(10)), 6)
      + N'" channel="' + s.Channel + N'">'
      + N'<customer id="C-' + RIGHT(N'0000' + CAST(s.CustomerId AS nvarchar(10)), 4)
      + N'" tier="' + CASE s.CustomerId % 3 WHEN 0 THEN N'VIP' WHEN 1 THEN N'Gold' ELSE N'Silver' END + N'"/>'
      + N'<metrics durationMinutes="' + CAST(s.DurationMinutes AS nvarchar(10))
      + N'" satisfaction="' + CAST(ISNULL(s.Satisfaction, 0) AS nvarchar(10)) + N'"/>'
      + N'<notes>'
      + N'<note type="preference" lang="zh-Hans">客户偏好在'
      + CASE s.InteractionId % 4 WHEN 0 THEN N'婚嫁' WHEN 1 THEN N'日常' WHEN 2 THEN N'收藏' ELSE N'送礼' END
      + N'场景选购,偏好'
      + CASE s.InteractionId % 3 WHEN 0 THEN N'铂金' WHEN 1 THEN N'黄金' ELSE N'玫瑰金' END + N'材质</note>'
      + N'<note type="followup" lang="en">Follow up within '
      + CAST(1 + s.InteractionId % 7 AS nvarchar(4)) + N' days</note>'
      + N'</notes>'
      + N'<items><item sku="'
      + CASE s.InteractionId % 5 WHEN 0 THEN N'RG-DIA-001' WHEN 1 THEN N'RG-GLD-003' WHEN 2 THEN N'NK-GLD-010'
                                 WHEN 3 THEN N'ER-DIA-005' ELSE N'BR-PLT-002' END
      + N'" qty="' + CAST(1 + s.InteractionId % 2 AS nvarchar(4)) + N'"/></items>'
      + N'</interaction>' AS xml)
FROM Src s;
GO

SELECT 互动记录行数 = COUNT(*) FROM crm.InteractionXml;
GO
SELECT TOP 1 InteractionId, CustomerId, Channel, Payload FROM crm.InteractionXml ORDER BY InteractionId;
GO

PRINT N'----- 09.2 HR 证书报文:带命名空间的 XML(珠宝行业的真实形态)-----';
GO

CREATE TABLE hr.CertXml
(
    CertId     int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CertXml PRIMARY KEY,
    EmployeeId int             NOT NULL,
    CertType   varchar(24)     NULL,     -- 提升列
    ExpiryDate date            NULL,     -- 提升列
    Payload    xml             NOT NULL
);
GO

;WITH E AS (
    SELECT TOP (36) e.EmployeeId, e.FullName, e.CountryCode, e.JobTitle
    FROM hr.Employee e ORDER BY e.EmployeeId
)
INSERT hr.CertXml (EmployeeId, CertType, ExpiryDate, Payload)
SELECT
      e.EmployeeId
    , CASE e.EmployeeId % 3 WHEN 0 THEN N'GIA-GG' WHEN 1 THEN N'HR-CIPD' ELSE N'SAFETY' END
    , DATEADD(day, 180 + e.EmployeeId * 11, CAST('2025-01-01' AS date))
    , CAST(
        CAST(N'<cert:certificate xmlns:cert="urn:lukfook:hr:cert:v1" xml:lang="zh-Hans" id="CERT-' AS nvarchar(max))
      + RIGHT(N'0000' + CAST(e.EmployeeId AS nvarchar(10)), 4) + N'">'
      + N'<cert:holder staffNo="E-' + RIGHT(N'0000' + CAST(e.EmployeeId AS nvarchar(10)), 4) + N'">'
      + N'<cert:name>' + e.FullName + N'</cert:name>'
      + N'<cert:country>' + e.CountryCode + N'</cert:country>'
      + N'</cert:holder>'
      + N'<cert:type code="' + CASE e.EmployeeId % 3 WHEN 0 THEN N'GIA-GG' WHEN 1 THEN N'HR-CIPD' ELSE N'SAFETY' END + N'"/>'
      + N'<cert:validity from="2025-01-01" to="'
      + CONVERT(varchar(10), DATEADD(day, 180 + e.EmployeeId * 11, CAST('2025-01-01' AS date)), 23) + N'"/>'
      + N'<cert:issuer>'
      + CASE e.EmployeeId % 3 WHEN 0 THEN N'GIA' WHEN 1 THEN N'CIPD' ELSE N'集团安全委员会' END
      + N'</cert:issuer>'
      + N'</cert:certificate>' AS xml)
FROM E e;
GO

SELECT 证书行数 = COUNT(*) FROM hr.CertXml;
GO
SELECT TOP 1 CertId, EmployeeId, CertType, ExpiryDate, Payload FROM hr.CertXml ORDER BY CertId;
GO

/* --------------------------------------------------------------------------------------------------
   09.3 .value() ------ 取标量。★ 参数必须是字符串字面量
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 09.3 XML .value() 取标量:必须字面量,不能传变量 -----';
SELECT TOP 5
     InteractionId
   , 渠道        = Channel
   , 客户号      = Payload.value(N'(/interaction/customer/@id)[1]',  N'nvarchar(20)')
   , 会员等级    = Payload.value(N'(/interaction/customer/@tier)[1]', N'nvarchar(20)')
   , 时长分钟    = Payload.value(N'(/interaction/metrics/@durationMinutes)[1]', N'int')
   , 满意度      = Payload.value(N'(/interaction/metrics/@satisfaction)[1]', N'int')
FROM crm.InteractionXml
ORDER BY InteractionId;
GO
PRINT N'  ★ 第一条坑的实证:把路径换成变量会怎样:';
DECLARE @path nvarchar(100) = N'(/interaction/customer/@id)[1]';
BEGIN TRY
    EXEC sp_executesql N'SELECT TOP 1 X = Payload.value(@p, ''nvarchar(20)'') FROM crm.InteractionXml',
                       N'@p nvarchar(100)', @p = @path;
    PRINT N'    变量路径:居然可以';
END TRY
BEGIN CATCH
    PRINT N'    变量路径被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
PRINT N'  ★ 这是 XML 与 JSON 最本质的差异:';
PRINT N'      · JSON 函数(JSON_VALUE/JSON_QUERY)**接受变量**路径;';
PRINT N'      · XML 方法(.value/.query/.exist/.nodes)**只接受字符串字面量**(报 8172)。';
PRINT N'    所以"按用户传入的路径查 XML"在 T-SQL 里只能拼动态 SQL ------ 这本身也是注入风险点。';
GO

/* --------------------------------------------------------------------------------------------------
   09.4 .query() ------ 取 XML 片段(可以取对象/数组,正好补上 JSON_QUERY 的角色)
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 09.4 XML .query() 取片段 -----';
SELECT TOP 3
     InteractionId
   , 客户节点 = CAST(Payload.query(N'/interaction/customer') AS nvarchar(200))
   , 全部备注 = CAST(Payload.query(N'/interaction/notes/note') AS nvarchar(400))
FROM crm.InteractionXml
ORDER BY InteractionId;
GO

PRINT N'  带 WHERE 的 XQuery(过滤出 zh-Hans 的备注):';
SELECT TOP 3
     InteractionId
   , 中文备注 = CAST(Payload.query(N'/interaction/notes/note[@lang="zh-Hans"]') AS nvarchar(400))
FROM crm.InteractionXml
ORDER BY InteractionId;
GO

/* --------------------------------------------------------------------------------------------------
   09.5 .exist() ------ 只判断存在性,返回 1/0,可以进 WHERE
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 09.5 .exist() 用于筛选:只查"满意度 < 3"的互动 -----';
SELECT
     满意度低于3的记录数 = COUNT(*)
FROM crm.InteractionXml
WHERE Payload.exist(N'/interaction/metrics[@satisfaction < 3]') = 1;
GO
SELECT TOP 5
     InteractionId
   , 满意度 = Payload.value(N'(/interaction/metrics/@satisfaction)[1]', N'int')
   , 是否有英文备注 = Payload.exist(N'/interaction/notes/note[@lang="en"]')
FROM crm.InteractionXml
WHERE Payload.exist(N'/interaction/metrics[@satisfaction < 3]') = 1
ORDER BY InteractionId;
GO

/* --------------------------------------------------------------------------------------------------
   09.6 .nodes() ------ 把 XML 摊成行集(XML 版的 OPENJSON)
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 09.6 XML .nodes() 展开:一条互动记录 → 多行备注 -----';
SELECT TOP 10
     x.InteractionId
   , 备注语言 = n.val.value(N'@lang', N'nvarchar(20)')
   , 备注类型 = n.val.value(N'@type', N'nvarchar(20)')
   , 备注内容 = n.val.value(N'.',     N'nvarchar(400)')
FROM crm.InteractionXml x
CROSS APPLY x.Payload.nodes(N'/interaction/notes/note') AS n(val)
ORDER BY x.InteractionId, 备注语言;
GO
PRINT N'  ★ 语法要点(三条都是坑,全部实测过):';
PRINT N'     ① 必须写 [表别名].[列].nodes(...),漏掉表别名会报 208;';
PRINT N'     ② 别名写两个(AS n(val))时,方法要挂在**列别名**上:n.val.value(...),';
PRINT N'        只写 n.value(...) 会报 4121("找不到列 n 或用户定义的函数或聚合 n.value");';
PRINT N'     ③ .nodes() 返回的列是"节点句柄",不能直接 SELECT / MAX / 赋给变量,只能喂给';
PRINT N'        exist()/nodes()/query()/value(),或做 IS NULL 判断,否则报 493。';
GO
PRINT N'  实证 ①:漏掉表别名(CROSS APPLY Payload.nodes(...) AS n):';
BEGIN TRY
    EXEC sp_executesql N'SELECT TOP 1 n.value(N''.'', N''nvarchar(50)'')
                        FROM crm.InteractionXml CROSS APPLY Payload.nodes(N''/interaction/customer'') AS n';
    PRINT N'    漏表别名:居然可以';
END TRY
BEGIN CATCH
    PRINT N'    漏表别名被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
PRINT N'  实证 ②:别名写两个,但方法挂在行集别名上(n.value 而不是 n.val.value):';
BEGIN TRY
    EXEC sp_executesql N'SELECT TOP 1 n.value(N''@lang'', N''nvarchar(20)'')
                        FROM crm.InteractionXml x CROSS APPLY x.Payload.nodes(N''/interaction/notes/note'') AS n(val)';
    PRINT N'    n.value(...):居然可以';
END TRY
BEGIN CATCH
    PRINT N'    n.value(...) 被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
PRINT N'    改成 n.val.value(...) 立刻通过 ------ 这一个字母的差别在生产里能查半天。';
GO
PRINT N'  实证 ③:.nodes() 返回的列不能直接使用(不是标量,是"节点句柄"):';
BEGIN TRY
    EXEC sp_executesql N'SELECT TOP 1 MAX(n.val) FROM crm.InteractionXml x CROSS APPLY x.Payload.nodes(N''/interaction/customer'') AS n(val)';
    PRINT N'    直接 MAX(节点列):居然可以';
END TRY
BEGIN CATCH
    PRINT N'    直接使用节点列被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
PRINT N'  ★ 节点句柄只能喂给 exist()/nodes()/query()/value(),或做 IS NULL 判断。';
GO

/* --------------------------------------------------------------------------------------------------
   09.7 ★ XML 的硬限制:不可比较、不可排序、不可去重、不能 UNION
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 09.7 XML 的四个硬限制(实测错误码)-----';
BEGIN TRY
    EXEC sp_executesql N'SELECT CASE WHEN (SELECT CAST(N''<r/>'' AS xml)) = (SELECT CAST(N''<r/>'' AS xml)) THEN 1 ELSE 0 END';
    PRINT N'  [1] XML 比较:居然可以';
END TRY BEGIN CATCH PRINT N'  [1] XML 直接比较 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH
BEGIN TRY
    EXEC sp_executesql N'SELECT x FROM (SELECT CAST(N''<r/>'' AS xml) AS x) t ORDER BY x';
    PRINT N'  [2] XML 排序:居然可以';
END TRY BEGIN CATCH PRINT N'  [2] XML 作 ORDER BY 键 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH
BEGIN TRY
    EXEC sp_executesql N'SELECT COUNT(*) FROM (SELECT CAST(N''<r/>'' AS xml) AS x UNION SELECT CAST(N''<s/>'' AS xml)) t';
    PRINT N'  [3] XML 作 UNION 操作数:居然可以';
END TRY BEGIN CATCH PRINT N'  [3] XML 作 UNION 操作数 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH
BEGIN TRY
    EXEC sp_executesql N'SELECT COUNT(*) FROM (SELECT CAST(N''<r/>'' AS xml) AS x) t GROUP BY x';
    PRINT N'  [4] XML 作 GROUP BY 键:居然可以';
END TRY BEGIN CATCH PRINT N'  [4] XML 作 GROUP BY 键 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH
PRINT N'  [5] XML 转 sql_variant(★ 必须放进动态 SQL:这是**编译期**错误,TRY/CATCH 抓不到,';
PRINT N'      直接写在批次里会让整个批次编译失败、后面所有语句都不执行):';
BEGIN TRY
    EXEC sp_executesql N'DECLARE @v sql_variant = (SELECT TOP 1 Payload FROM crm.InteractionXml); SELECT 1';
    PRINT N'      XML 转 sql_variant:居然可以';
END TRY BEGIN CATCH PRINT N'      XML 转 sql_variant → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH
PRINT N'  [6] nvarchar(max) 转 sql_variant(同理,也是编译期错误):';
BEGIN TRY
    EXEC sp_executesql N'DECLARE @s nvarchar(max) = REPLICATE(N''a'', 5000); DECLARE @v sql_variant = CAST(@s AS sql_variant); SELECT 1';
    PRINT N'      nvarchar(max) 转 sql_variant:居然可以';
END TRY BEGIN CATCH PRINT N'      nvarchar(max) 转 sql_variant → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH
GO
PRINT N'  ★ 顺便说清 529 与 206 的来由:';
PRINT N'    · xml       → sql_variant 报 206("操作数类型冲突: xml 与 sql_variant 不兼容");';
PRINT N'    · nvarchar(max) → sql_variant 报 529("不允许从数据类型 nvarchar(max) 到 sql_variant 的显式转换")。';
PRINT N'    两者都是编译期错误 ------ 所以本节全部用 sp_executesql 包起来才"看"得到错误号。';
GO
PRINT N'  ★ 结论:XML 只能"取出内容再比较"。要按 XML 里的值排序/去重,';
PRINT N'    必须先 .value() 成标量,或者把该值提升为列 ------ 又回到了第 01 节的边界问题。';
GO

/* --------------------------------------------------------------------------------------------------
   09.8 命名空间:WITH XMLNAMESPACES(珠宝行业报文几乎都带命名空间)
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 09.8 带命名空间的 XML 查询 -----';
WITH XMLNAMESPACES (N'urn:lukfook:hr:cert:v1' AS cert)
SELECT TOP 5
     CertId
   , 员工号   = Payload.value(N'(/cert:certificate/cert:holder/@staffNo)[1]', N'nvarchar(20)')
   , 持证人   = Payload.value(N'(/cert:certificate/cert:holder/cert:name)[1]',  N'nvarchar(120)')
   , 证书类型 = Payload.value(N'(/cert:certificate/cert:type/@code)[1]',        N'nvarchar(20)')
   , 颁发机构 = Payload.value(N'(/cert:certificate/cert:issuer)[1]',            N'nvarchar(80)')
   , 到期日   = Payload.value(N'(/cert:certificate/cert:validity/@to)[1]',      N'date')
FROM hr.CertXml
ORDER BY CertId;
GO
PRINT N'  ★ 没有 WITH XMLNAMESPACES 时,路径里带冒号会直接报错;';
PRINT N'    用 WITH XMLNAMESPACES 把前缀映射好之后,路径才写得出来。';
GO
PRINT N'  对照:同样的查询,不使用命名空间前缀(用通配 *:)也能写:';
SELECT TOP 3
     CertId
   , 持证人 = Payload.value(N'(/*:certificate/*:holder/*:name)[1]', N'nvarchar(120)')
FROM hr.CertXml
ORDER BY CertId;
GO


/* ==================================================================================================
   10  XML 生成:FOR XML 的四种模式
   ================================================================================================== */

PRINT N'';
PRINT N'===== 10 XML 生成 =====';
GO

/* --------------------------------------------------------------------------------------------------
   10.1 四种模式对照(同一份数据,四种输出)
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 10.1 FOR XML RAW / RAW,ELEMENTS / AUTO / PATH -----';
GO
DECLARE @t TABLE (Sku varchar(20), Qty int, Price decimal(10,2));
INSERT @t VALUES (N'RG-DIA-001', 1, 68000.00), (N'RG-GLD-003', 2, 6980.00);

SELECT 模式 = N'RAW(属性式,行名恒为 row)'
     , 结果 = (SELECT * FROM @t FOR XML RAW);
SELECT 模式 = N'RAW, ELEMENTS(列变子元素)'
     , 结果 = (SELECT Sku, Qty, Price FROM @t FOR XML RAW, ELEMENTS);
SELECT 模式 = N'AUTO(按表名/别名,来自多表会自动嵌套)'
     , 结果 = (SELECT Sku, Qty AS [@qty] FROM @t FOR XML AUTO);
SELECT 模式 = N'PATH(完全手工控制,最常用)'
     , 结果 = (SELECT Sku AS [@sku], Qty AS [qty], Price AS [price] FROM @t FOR XML PATH('line'));
SELECT 模式 = N'PATH + ROOT'
     , 结果 = (SELECT Sku AS [@sku] FROM @t FOR XML PATH('line'), ROOT('order'));
GO

/* --------------------------------------------------------------------------------------------------
   10.2 PATH 模式的关键语法:@ 前缀 = 属性,无前缀 = 元素
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 10.2 PATH 模式下 列别名 决定产出形态 -----';
GO
DECLARE @t2 TABLE (Sku varchar(20), Qty int, Note nvarchar(50));
INSERT @t2 VALUES (N'RG-DIA-001', 1, N'可刻字'), (N'RG-GLD-003', 2, N'现货');

SELECT 形态 = N'[@x] → 属性', 结果 = (SELECT Sku AS [@sku], Qty AS [@qty] FROM @t2 FOR XML PATH('line'))
UNION ALL SELECT N'[x] → 子元素', (SELECT Sku AS [sku], Qty AS [qty] FROM @t2 FOR XML PATH('line'))
UNION ALL SELECT N'[text()] → 元素文本', (SELECT Sku AS [@sku], Note AS [text()] FROM @t2 FOR XML PATH('line'))
UNION ALL SELECT N'[x] + [@y] 混合(属性挂在同级元素上)', (SELECT Sku AS [@sku], Note AS [note] FROM @t2 FOR XML PATH('line'));
GO
PRINT N'  ★ 这个"列别名即结构"的语法是 FOR XML PATH 最大的特点:';
PRINT N'    @ 前缀 → 属性;无前缀 → 子元素;text() → 直接成为元素文本。';
GO
PRINT N'  ★★ 重要实证:网上常见的 "[item!2!sku]" 数字层级写法在本版本**根本不支持**,';
PRINT N'     它是 FOR XML EXPLICIT 的语法,写进 PATH 会直接报 6850。';
PRINT N'     由于 6850 是编译期错误,必须放进动态 SQL 才能"看到"它:';
BEGIN TRY
    EXEC sp_executesql N'SELECT Sku AS [item!2!sku] FROM (SELECT ''X'' AS Sku) t FOR XML PATH(''line'')';
    PRINT N'    [item!2!sku]:居然可以(不应发生)';
END TRY
BEGIN CATCH
    PRINT N'    [item!2!sku] 被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
PRINT N'    要控制多层嵌套,请用"子查询 + FOR XML PATH(...), TYPE"(见 10.3),或者改用 FOR XML EXPLICIT。';
GO

/* --------------------------------------------------------------------------------------------------
   10.3 真实业务:把订单 + 明细嵌套成一份 XML 单据
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 10.3 ERP:订单主表 + 明细嵌套(AUTO 自动嵌套 vs PATH 手工嵌套)-----';
GO
SELECT TOP 2
     o.OrderNo    AS [@orderNo]
   , o.OrderDate  AS [@orderDate]
   , o.CurrencyCode AS [@ccy]
   , o.NetAmountUsd AS [@netUsd]
   , (SELECT p.Sku AS [@sku], l.Quantity AS [@qty], l.LineAmount AS [@amount]
      FROM erp.SalesOrderLine l
      JOIN erp.Product p ON p.ProductId = l.ProductId
      WHERE l.OrderId = o.OrderId
      FOR XML PATH('line'), TYPE) AS [lines]
FROM erp.SalesOrder o
ORDER BY o.OrderId
FOR XML PATH('order'), ROOT('orders');
GO
PRINT N'  ★ 注意这里的两处关键:';
PRINT N'    ① 明细的 SKU 不在 SalesOrderLine 上,要 JOIN erp.Product 才拿得到;';
PRINT N'    ② 嵌进父文档必须写 ..., TYPE,否则 < > 会被转义。';
GO
PRINT N'  ★ 子查询必须带 FOR XML ..., TYPE 才能作为 XML 片段嵌入父文档;';
PRINT N'    不加 TYPE 会得到一个被转义的字符串,整个 XML 结构就废了。';
GO

/* --------------------------------------------------------------------------------------------------
   10.4 TYPE 与非 TYPE:XML 类型 vs 被转义的字符串
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 10.4 TYPE 的差别(本质是类型不同)-----';
GO
DECLARE @noType nvarchar(max) = (SELECT TOP 1 Sku AS [@sku] FROM erp.Product ORDER BY ProductId FOR XML PATH('p'));
DECLARE @withType xml        = (SELECT TOP 1 Sku AS [@sku] FROM erp.Product ORDER BY ProductId FOR XML PATH('p'), TYPE);

SELECT
     场景       = N'不加 TYPE:拿到 nvarchar,< > 已被转义'
   , 内容       = LEFT(@noType, 60)
   , 字符长度   = LEN(@noType)
   , 含转义实体 = CASE WHEN @noType LIKE N'%&lt;%' THEN N'是(&lt;)' ELSE N'否' END
   , [能否当 XML 用] = N'不能(只能当字符串,.value() 会报错)'
UNION ALL
SELECT N'加 TYPE:拿到真正的 xml 类型'
   , LEFT(CAST(@withType AS nvarchar(max)), 60)
   , LEN(CAST(@withType AS nvarchar(max)))
   , CASE WHEN CAST(@withType AS nvarchar(max)) LIKE N'%&lt;%' THEN N'是' ELSE N'否(是真元素)' END
   , N'能(.value()/.nodes()/建索引都可以)';
GO
PRINT N'  ★ 不加 TYPE 得到 nvarchar,里面的 < > 已被转义成 &lt; &gt;;加 TYPE 得到真正的 xml 类型。';
PRINT N'    只有 xml 类型才能继续 .value() / .nodes() / 建 XML 索引。';
GO
PRINT N'  顺带实证:很多人想用 sql_variant 做"万能容器"来同时装 xml / 超长文本,但这两条路都是堵死的:';
BEGIN TRY
    EXEC sp_executesql N'DECLARE @v sql_variant = CAST(CAST(N''<r/>'' AS xml) AS sql_variant); SELECT 1';
    PRINT N'    xml → sql_variant:居然可以';
END TRY BEGIN CATCH PRINT N'    xml → sql_variant 失败(' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N'):' + ERROR_MESSAGE(); END CATCH
BEGIN TRY
    EXEC sp_executesql N'DECLARE @s nvarchar(max) = REPLICATE(N''a'', 5000); DECLARE @v sql_variant = CAST(@s AS sql_variant); SELECT 1';
    PRINT N'    nvarchar(max) → sql_variant:居然可以';
END TRY BEGIN CATCH PRINT N'    nvarchar(max) → sql_variant 失败(' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N'):' + ERROR_MESSAGE(); END CATCH
PRINT N'    ★ 记错误号时要写清写法:**显式** CAST(xml AS sql_variant) 报 529;';
PRINT N'      而**隐式**转换(DECLARE @v sql_variant = <xml 表达式>,见 09.7 第 5 条)报 206。';
PRINT N'      同一个"类型不兼容",两条路径两个号 ------ 排查时对不上号是常见困惑来源。';
GO


/* ==================================================================================================
   11  XML 分解:把 XML 摊回关系表
   ================================================================================================== */

PRINT N'';
PRINT N'===== 11 XML 分解 =====';
GO

/* --------------------------------------------------------------------------------------------------
   11.1 方式一:.nodes() 切分(推荐)
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 11.1 用 .nodes() 把备注摊成关系行 -----';
GO
IF OBJECT_ID('tempdb..#Note') IS NOT NULL DROP TABLE #Note;
CREATE TABLE #Note (InteractionId int, Lang varchar(20), NoteType varchar(20), NoteText nvarchar(400));

INSERT #Note (InteractionId, Lang, NoteType, NoteText)
SELECT
     x.InteractionId
   , n.val.value(N'@lang', N'nvarchar(20)')
   , n.val.value(N'@type', N'nvarchar(20)')
   , n.val.value(N'.',     N'nvarchar(400)')
FROM crm.InteractionXml x
CROSS APPLY x.Payload.nodes(N'/interaction/notes/note') AS n(val);

SELECT 摊平后行数 = COUNT(*) FROM #Note;
SELECT TOP 8 * FROM #Note ORDER BY InteractionId, Lang;
GO
PRINT N'  ★ 这是把 XML 落进关系表的标准姿势:.nodes() 定位重复节点,.value() 各取所需字段。';
PRINT N'    再强调一次别名规则:CROSS APPLY ...nodes(...) AS n(val) 之后,方法要写 n.val.value(...)。';
GO

/* --------------------------------------------------------------------------------------------------
   11.2 方式二:OPENXML + sp_xml_preparedocument(老写法,但仍然有用武之地)
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 11.2 OPENXML 标准三段式 -----';
GO
DECLARE @h int;
DECLARE @doc nvarchar(max) = N'
<orders>
  <order orderNo="SO-2025-000101" store="HK-CWB-01">
    <line sku="RG-DIA-001" qty="1" amount="78000.00"/>
    <line sku="RG-GLD-003" qty="2" amount="7980.00"/>
  </order>
  <order orderNo="SO-2025-000102" store="CN-SH-02">
    <line sku="NK-GLD-010" qty="1" amount="12800.00"/>
  </order>
</orders>';

EXEC sp_xml_preparedocument @h OUTPUT, @doc;

PRINT N'  [1] 标志 1 = 属性为中心(默认),WITH 里用 @ 前缀';
SELECT 订单号 = OrderNo, 门店 = Store, SKU = Sku, 数量 = Qty, 金额 = Amount
FROM OPENXML(@h, N'/orders/order/line', 1)
     WITH (OrderNo varchar(24) N'../@orderNo',
           Store   varchar(12) N'../@store',
           Sku     varchar(20) N'@sku',
           Qty     int         N'@qty',
           Amount  decimal(18,2) N'@amount');

PRINT N'  [2] 标志 2 = 元素为中心';
SELECT 订单号 = OrderNo, 门店 = Store
FROM OPENXML(@h, N'/orders/order', 2)
     WITH (OrderNo varchar(24) N'@orderNo', Store varchar(12) N'@store');

EXEC sp_xml_removedocument @h;
GO
PRINT N'  ★ OPENXML 三段式:sp_xml_preparedocument → OPENXML → sp_xml_removedocument。';
PRINT N'    忘了 removedocument 会一直占内存(直到会话结束),这是老代码最常见的泄漏。';
PRINT N'    新代码优先用 .nodes():不用句柄、无泄漏风险、能用 XML 索引。';
GO
PRINT N'  实证:OPENXML 传 NULL 句柄会怎样:';
BEGIN TRY
    EXEC sp_executesql N'SELECT * FROM OPENXML(NULL, ''/r'')';
    PRINT N'    NULL 句柄:居然可以';
END TRY
BEGIN CATCH
    PRINT N'    NULL 句柄 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO

/* --------------------------------------------------------------------------------------------------
   11.3 封装成存储过程:按订单号生成 XML 单据
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 11.3 封装:订单 → XML 单据(可直接给外部系统)-----';
GO
CREATE OR ALTER PROCEDURE dbo.usp_ShredSalesOrderXml
    @OrderNo varchar(40)
AS
BEGIN
    SET NOCOUNT ON;
    SELECT
         o.OrderNo       AS [@orderNo]
       , o.OrderDate     AS [@orderDate]
       , o.CurrencyCode  AS [@ccy]
       , o.NetAmountUsd  AS [@netUsd]
       , (SELECT p.Sku AS [@sku], l.Quantity AS [@qty], l.LineAmount AS [@amount]
          FROM erp.SalesOrderLine l
          JOIN erp.Product p ON p.ProductId = l.ProductId
          WHERE l.OrderId = o.OrderId
          FOR XML PATH('line'), TYPE) AS [lines]
    FROM erp.SalesOrder o
    WHERE o.OrderNo = @OrderNo
    FOR XML PATH('order'), ROOT('orders');
END
GO
PRINT N'  ★ 存储过程里的 SET 选项(QUOTED_IDENTIFIER 等)在创建时就固化下来了,';
PRINT N'    与调用方会话的 SET 不一致时,FOR XML / XML 方法会莫名其妙地失败 ------';
PRINT N'    这就是"同一个过程在不同客户端表现不同"的常见根因。';
GO
IF OBJECT_ID(N'dbo.usp_ShredSalesOrderXml', N'P') IS NOT NULL
BEGIN
    DECLARE @ono varchar(40) = (SELECT TOP 1 OrderNo FROM erp.SalesOrder ORDER BY OrderId);
    PRINT N'  调用存储过程生成单据(订单号 ' + @ono + N'):';
    EXEC dbo.usp_ShredSalesOrderXml @OrderNo = @ono;
END
ELSE
    PRINT N'  ⚠ 存储过程未创建成功,已跳过调用(查看上方报错)。';
GO


/* ==================================================================================================
   12  XML 强校验:XML SCHEMA COLLECTION 与 typed XML
   --------------------------------------------------------------------------------------------------
   这是 XML 相对 JSON 的**唯一决定性优势**:
   JSON 在 SQL Server 里只能校验"是不是合法 JSON",无法校验"结构对不对";
   XML 可以绑定 XSD,让数据库在**写入时**就按 schema 拒绝畸形文档。
   ================================================================================================== */

PRINT N'';
PRINT N'===== 12 XML 强校验(typed XML)=====';
GO

/* --------------------------------------------------------------------------------------------------
   12.1 创建架构集合并绑定为 typed XML 列
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 12.1 创建 XSD 架构集合并建 typed xml 列 -----';
GO
CREATE XML SCHEMA COLLECTION dbo.JewelryOrderSchema AS N'
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
  <xs:element name="order">
    <xs:complexType>
      <xs:sequence>
        <xs:element name="line" minOccurs="1" maxOccurs="unbounded">
          <xs:complexType>
            <xs:attribute name="sku"    type="xs:string"          use="required"/>
            <xs:attribute name="qty"    type="xs:positiveInteger" use="required"/>
            <xs:attribute name="amount" type="xs:decimal"         use="required"/>
          </xs:complexType>
        </xs:element>
      </xs:sequence>
      <xs:attribute name="orderNo" type="xs:string" use="required"/>
      <xs:attribute name="ccy"     type="xs:string" use="required"/>
    </xs:complexType>
  </xs:element>
</xs:schema>';
GO
PRINT N'  架构集合已创建(注意:目标命名空间在 sys.xml_schema_namespaces 里,不在 sys.xml_schema_collections):';
SELECT 名称 = c.name, 目标命名空间 = ISNULL(ns.name, N'(无:本 XSD 未声明 targetNamespace)')
FROM sys.xml_schema_collections c
LEFT JOIN sys.xml_schema_namespaces ns ON ns.xml_collection_id = c.xml_collection_id
WHERE c.name = N'JewelryOrderSchema';
GO

CREATE TABLE dbo.TypedXmlDemo
(
    DocId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_TypedXmlDemo PRIMARY KEY,
    Doc   xml(dbo.JewelryOrderSchema) NOT NULL   -- ★ 绑定架构集合的 typed XML
);
GO
PRINT N'  已建 typed xml 列:xml(dbo.JewelryOrderSchema)';
GO

/* --------------------------------------------------------------------------------------------------
   12.2 写入时的强校验:三种文档的实测结果
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 12.2 合法文档通过;缺必需属性、类型不对的分别报什么错 -----';
GO
PRINT N'  [1] 合法文档:';
BEGIN TRY
    INSERT dbo.TypedXmlDemo (Doc) VALUES
      (N'<order orderNo="SO-2025-000101" ccy="CNY"><line sku="RG-DIA-001" qty="1" amount="68000.00"/></order>');
    PRINT N'      通过 ✓(已插入)';
END TRY BEGIN CATCH PRINT N'      失败(' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N'):' + ERROR_MESSAGE(); END CATCH

PRINT N'  [2] 缺少必需属性 orderNo:';
BEGIN TRY
    INSERT dbo.TypedXmlDemo (Doc) VALUES
      (N'<order ccy="CNY"><line sku="RG-DIA-001" qty="1" amount="68000.00"/></order>');
    PRINT N'      居然通过(不应发生)';
END TRY BEGIN CATCH PRINT N'      被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH

PRINT N'  [3] qty 为负(XSD 要求 positiveInteger):';
BEGIN TRY
    INSERT dbo.TypedXmlDemo (Doc) VALUES
      (N'<order orderNo="SO-2025-000102" ccy="CNY"><line sku="RG-DIA-001" qty="-5" amount="68000.00"/></order>');
    PRINT N'      居然通过(不应发生)';
END TRY BEGIN CATCH PRINT N'      被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH

PRINT N'  [4] 完全没有 line 子元素(minOccurs=1):';
BEGIN TRY
    INSERT dbo.TypedXmlDemo (Doc) VALUES
      (N'<order orderNo="SO-2025-000103" ccy="CNY"/>');
    PRINT N'      居然通过(不应发生)';
END TRY BEGIN CATCH PRINT N'      被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH
GO
SELECT 已插入行数 = COUNT(*) FROM dbo.TypedXmlDemo;
GO
PRINT N'  ★ 结论:typed XML 把"结构契约"下沉到数据库层,外部报文不合规直接写不进来。';
PRINT N'    JSON 在 SQL Server 里**没有**等价能力(只能靠应用层或 OPENJSON WITH 事后校验)。';
PRINT N'    代价:写入时要做 schema 校验,有 CPU 开销;且 schema 变更需要 ALTER XML SCHEMA COLLECTION。';
GO

/* --------------------------------------------------------------------------------------------------
   12.3 非 typed 列 vs typed 列:读时也要注意
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 12.3 typed xml 同样支持 .value()/.nodes(),只是多了类型信息 -----';
GO
SELECT
     DocId
   , 订单号 = Doc.value(N'(/order/@orderNo)[1]', N'nvarchar(30)')
   , 明细数 = (SELECT COUNT(*) FROM (
                  SELECT 1 AS x FROM dbo.TypedXmlDemo t2 CROSS APPLY t2.Doc.nodes(N'/order/line') AS n(l)
                  WHERE t2.DocId = t.DocId) z)
FROM dbo.TypedXmlDemo t
ORDER BY DocId;
GO


/* ==================================================================================================
   13  XML 索引:主 XML 索引 + 二级 XML 索引
   --------------------------------------------------------------------------------------------------
   XML 与 JSON 在索引上的根本差别:
     · JSON 完全没有内置索引,只能"提升成计算列再索引";
     · XML 有专门的索引类型:PRIMARY XML INDEX 把 XML 树拆成路径/值表,
       再加 PATH / VALUE / PROPERTY 三种二级索引来服务不同查询形态。
   ================================================================================================== */

PRINT N'';
PRINT N'===== 13 XML 索引 =====';
GO

/* --------------------------------------------------------------------------------------------------
   13.1 建索引的前置条件(这是最容易卡住的地方)
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 13.1 前置条件实测:必须有"聚集主键且列数 < 32" -----';
GO
BEGIN TRY
    CREATE TABLE dbo._NoPkTable (Id int, X xml);
    CREATE PRIMARY XML INDEX PXML_NoPk ON dbo._NoPkTable(X);
    PRINT N'  无主键表:居然建成功';
END TRY
BEGIN CATCH
    PRINT N'  无主键表被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
BEGIN TRY
    -- 注意约束必须写全 PRIMARY KEY NONCLUSTERED;只写 NONCLUSTERED 是语法错误(报 156)
    CREATE TABLE dbo._HeapTable (Id int NOT NULL, X xml, CONSTRAINT PK_Heap PRIMARY KEY NONCLUSTERED (Id));
    CREATE PRIMARY XML INDEX PXML_Heap ON dbo._HeapTable(X);
    PRINT N'  只有非聚集主键(表是堆):居然建成功';
END TRY
BEGIN CATCH
    PRINT N'  只有非聚集主键被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
DROP TABLE IF EXISTS dbo._NoPkTable;
DROP TABLE IF EXISTS dbo._HeapTable;
GO
PRINT N'  ★ 所以 XML 索引要求表上有**聚集**主键(或聚集唯一索引),且键列 < 32 列。';
PRINT N'    crm.InteractionXml 的 PK_InteractionXml 默认就是聚集主键,满足条件。';
PRINT N'    (附带一条:网上常说要开 ARITHABORT 才能建 XML 索引 ------ 实测在 QUOTED_IDENTIFIER ON 的前提下,';
PRINT N'      真正卡住的是"聚集主键且列数 < 32"这一条,而不是 ARITHABORT。)';
GO

/* --------------------------------------------------------------------------------------------------
   13.2 建索引前的基线:不用索引时 XML 查询的 IO
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 13.2 建索引前:用 .exist() 筛"有英文备注"的互动 -----';
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
GO
SELECT 命中行数 = COUNT(*)
FROM crm.InteractionXml
WHERE Payload.exist(N'/interaction/notes/note[@lang="en"]') = 1;
GO
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
GO
PRINT N'  ↑ 记住这里的"逻辑读取次数 / CPU 时间",下面建完索引后对比。';
GO

/* --------------------------------------------------------------------------------------------------
   13.3 创建主 XML 索引
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 13.3 创建主 XML 索引(PRIMARY XML INDEX)-----';
GO
CREATE PRIMARY XML INDEX PXML_InteractionXml ON crm.InteractionXml (Payload);
GO

PRINT N'  索引已建:';
SELECT 索引名 = i.name, 类型 = i.type_desc, 主索引 = ISNULL(pi.name, N'(自己是主索引)')
FROM sys.xml_indexes i
LEFT JOIN sys.xml_indexes pi ON pi.object_id = i.object_id AND pi.index_id = i.secondary_type
WHERE i.object_id = OBJECT_ID('crm.InteractionXml');
GO
PRINT N'  看索引到底拆成了什么(XML 索引的内部结构):';
SELECT 名称 = i.name, 类型 = i.type_desc
FROM sys.indexes i WHERE i.object_id = OBJECT_ID('crm.InteractionXml') ORDER BY i.index_id;
SELECT TOP 8
     路径 = p.name
   , 值类型 = p.name  -- 说明用
FROM sys.columns p WHERE p.object_id = OBJECT_ID('crm.InteractionXml');
GO
PRINT N'  主 XML 索引在系统里表现为一张内部表,列形如:'
GO
SELECT TOP 10 列 = c.name, 类型 = t.name, 长度 = c.max_length
FROM sys.columns c
JOIN sys.types t ON t.user_type_id = c.user_type_id
WHERE c.object_id = (SELECT it.internal_object_id  -- 只是演示用途
                     FROM (SELECT TOP 1 object_id AS internal_object_id FROM sys.xml_indexes
                           WHERE object_id = OBJECT_ID('crm.InteractionXml')) it)
ORDER BY c.column_id;
GO

/* --------------------------------------------------------------------------------------------------
   13.4 建索引后:同一个查询的 IO 对比
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 13.4 建索引后再跑同一个查询(对比 IO)-----';
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
GO
SELECT 命中行数 = COUNT(*)
FROM crm.InteractionXml
WHERE Payload.exist(N'/interaction/notes/note[@lang="en"]') = 1;
GO
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
GO
PRINT N'  ★ 对比 13.2 与这一条的逻辑读取次数:XML 索引把"解析整篇文档"变成了"按路径查找"。';
PRINT N'    2000 行时差距还不算大,但 XML 索引的收益是"避免随行数线性增长的解析成本",';
PRINT N'    在千万行以上规模才会体现出数量级差别。';
GO

/* --------------------------------------------------------------------------------------------------
   13.5 三种二级索引怎么选
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 13.5 PATH / VALUE / PROPERTY 三种二级索引的适用场景 -----';
SELECT * FROM (VALUES
    (1, N'PATH',     N'按路径/结构筛选,形如 .exist(''/a/b[c="v"]'')、.query()',
        N'crm.InteractionXml 按备注语言筛 ------ 本脚本正是这种'),
    (2, N'VALUE',    N'按"值"筛选但路径不固定,形如 .value() 与已知值比较',
        N'只知道某个值是 SKU 但不确定它在哪一层'),
    (3, N'PROPERTY', N'属性包(property bag):同一层大量不同路径各取一次 .value()',
        N'把 XML 当成"一堆键值对"读,比如证书里的十几个字段')
) AS t(序号, 索引类型, 适用查询形态, 珠宝场景例子);
GO

PRINT N'  建一个 PATH 二级索引:';
CREATE XML INDEX IXML_InteractionXml_Path ON crm.InteractionXml (Payload)
    USING XML INDEX PXML_InteractionXml FOR PATH;
GO
PRINT N'  建一个 PROPERTY 二级索引(适合大量 .value() 的读法):';
CREATE XML INDEX IXML_InteractionXml_Property ON crm.InteractionXml (Payload)
    USING XML INDEX PXML_InteractionXml FOR PROPERTY;
GO
-- ★ 坑:sys.xml_indexes.secondary_type 是 char(1)('P'/'R'/'V'),
--   如果拿它去和 index_id(int)做 JOIN,会报 245"在将 varchar 值 'P' 转换成数据类型 int 时失败"。
SELECT
     索引名   = i.name
   , 类别     = CASE i.xml_index_type WHEN 0 THEN N'主 XML 索引' ELSE N'二级 XML 索引' END
   , 二级类型 = CASE i.secondary_type WHEN 'P' THEN N'PATH'
                                     WHEN 'R' THEN N'PROPERTY'
                                     WHEN 'V' THEN N'VALUE'
                                     ELSE N'---' END
   , 索引ID   = i.index_id
FROM sys.xml_indexes i
WHERE i.object_id = OBJECT_ID('crm.InteractionXml')
ORDER BY i.index_id;
GO
PRINT N'  ★ 二级索引必须先有主 XML 索引(USING XML INDEX 那一句);';
PRINT N'    主 XML 索引是"底座",二级索引是"按查询形态优化的派生结构"。';
PRINT N'  ★ 实证:secondary_type 是字符列,拿它当整数比较会报 245:';
BEGIN TRY
    EXEC sp_executesql N'SELECT COUNT(*) FROM sys.xml_indexes i
                         JOIN sys.xml_indexes pi ON pi.object_id = i.object_id
                                                AND pi.index_id = i.secondary_type';
    PRINT N'    居然可以(不应发生)';
END TRY
BEGIN CATCH
    PRINT N'    被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO

/* --------------------------------------------------------------------------------------------------
   13.6 索引的代价:存储与写入
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 13.6 XML 索引的存储开销 -----';
PRINT N'  (★ 注意:sys.dm_db_partition_stats **看不到** XML 索引,必须用 dm_db_index_physical_stats;';
PRINT N'    每个二级 XML 索引还会额外带一张内部节点表,所以每个索引会出现两行。)';
SELECT
     对象     = OBJECT_NAME(ips.object_id)
   , 索引     = ISNULL(i.name, N'(堆/聚集)')
   , 类别     = CASE WHEN i.name IS NULL THEN ips.index_type_desc
                     WHEN i.xml_index_type = 0 THEN N'主 XML 索引'
                     ELSE N'二级 XML 索引' END
   , 页数合计 = SUM(ips.page_count)
   , 占用KB   = SUM(ips.page_count) * 8
FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('crm.InteractionXml'), NULL, NULL, 'LIMITED') ips
LEFT JOIN sys.xml_indexes i ON i.object_id = ips.object_id AND i.index_id = ips.index_id
GROUP BY ips.object_id, i.name, i.xml_index_type, ips.index_type_desc
ORDER BY 占用KB DESC;
GO
WITH PS AS (
    SELECT p.index_id, 页数 = SUM(p.page_count)
    FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('crm.InteractionXml'), NULL, NULL, 'LIMITED') p
    GROUP BY p.index_id
)
SELECT
     项目       = N'XML 索引合计 / 基表聚集索引'
   , XML索引页数 = SUM(CASE WHEN xi.index_id IS NOT NULL THEN ps.页数 ELSE 0 END)
   , 基表页数    = SUM(CASE WHEN ps.index_id = 1 THEN ps.页数 ELSE 0 END)
   , 膨胀倍数    = CAST(1.0 * SUM(CASE WHEN xi.index_id IS NOT NULL THEN ps.页数 ELSE 0 END)
                        / NULLIF(SUM(CASE WHEN ps.index_id = 1 THEN ps.页数 ELSE 0 END), 0) AS decimal(9,2))
FROM PS ps
LEFT JOIN sys.xml_indexes xi
       ON xi.object_id = OBJECT_ID('crm.InteractionXml') AND xi.index_id = ps.index_id;
GO
PRINT N'  ★ XML 索引用空间换时间:主 XML 索引 + 两个二级索引,实测占用是基表数据的好几倍。';
PRINT N'    所以"只在真的要用 XML 路径查询时才建",不要无脑全建。';
GO

/* --------------------------------------------------------------------------------------------------
   13.7 写到表里时索引会拖慢多少(写入放大)
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 13.7 写入放大:往带 XML 索引的表插 200 行 -----';
SET STATISTICS TIME ON;
GO
;WITH N AS (SELECT TOP (200) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
            FROM sys.all_objects)
INSERT crm.InteractionXml (CustomerId, Channel, InteractionAt, Payload)
SELECT
      1000 + n.n
    , N'app'
    , SYSDATETIME()
    , CAST(CAST(N'<interaction id="I-9' AS nvarchar(max)) + RIGHT(N'00000' + CAST(n.n AS nvarchar(10)), 5)
        + N'" channel="app"><customer id="C-9999" tier="Gold"/><metrics durationMinutes="10" satisfaction="4"/>'
        + N'<notes><note type="followup" lang="en">bulk insert test</note></notes>'
        + N'<items><item sku="RG-DIA-001" qty="1"/></items></interaction>' AS xml)
FROM N;
GO
SET STATISTICS TIME OFF;
GO
PRINT N'  ★ 同样 200 行,带 3 个 XML 索引的插入 CPU 时间会明显高于裸表 ------ 这就是写放大的代价。';
PRINT N'    半结构化列的"读多写少"假设(01.1 判据)在这里体现为可量化的成本。';
GO
PRINT N'  清理刚才插入的测试行,保持数据整洁:';
DELETE FROM crm.InteractionXml WHERE CustomerId >= 1000;
GO


/* ==================================================================================================
   14  万亿级 / 高并发场景:JSON 与 XML 的成本模型与设计手法
   ================================================================================================== */

PRINT N'';
PRINT N'===== 14 万亿级与高并发下的半结构化设计 =====';
GO

/* --------------------------------------------------------------------------------------------------
   14.1 规模推演:半结构化在"万亿"级别上会先死在哪
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 14.1 规模推演表 -----';
SELECT * FROM (VALUES
   (1, N'500 万 (本地 5 万缩微)',  N'stg.EventJson',        N'JSON 解析 ~0.1 秒;提升列 + 索引后与普通列无异')
 , (2, N'5 亿 (单区域生产)',       N'门店事件 JSON',        N'必须"提升列 + 覆盖索引";直接 JSON_VALUE 全表扫描不可接受')
 , (3, N'1 万亿 (全球全量)',       N'全渠道事件/报文',      N'JSON 只做"冷存 + 归档";热查询走预解析的关系表/列存')
 , (4, N'任意规模',                N'XML 报文',             N'XML 是"文档"不是"数据":入库即拆成关系表,原文档只留档案')
) AS t(序号, 规模, 对象, 设计结论)
ORDER BY 序号;
GO

/* --------------------------------------------------------------------------------------------------
   14.2 成本模型:JSON/XML 贵在哪里
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 14.2 半结构化的四项固有成本 -----';
SELECT * FROM (VALUES
   (1, N'解析成本',     N'每行都要扫描文档文本才能取值;不像列存能按列裁剪',
        N'→ 用提升列把"参与筛选"的字段提前解析出来,只解析一次'),
   (2, N'无统计信息',   N'优化器不知道文档里值的分布,基数估计永远是猜的',
        N'→ 提升列上有统计信息,才能得到正确计划'),
   (3, N'LOB 存储',     N'nvarchar(max)/xml/json 都走 LOB,行内不存,读取代价高',
        N'→ 大文档考虑归档到对象存储,库里只留路径 + 关键字段'),
   (4, N'全文档重写',   N'JSON_MODIFY 改一个键要重写整篇;XML 也是整值替换',
        N'→ 高频变更字段必须拆列(01.1 判据第 3 条)')
) AS t(序号, 成本项, 说明, 应对);
GO

/* --------------------------------------------------------------------------------------------------
   14.3 五种高并发手法
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 14.3 高并发下的五种手法 -----';
SELECT * FROM (VALUES
   (1, N'提升列 + 覆盖索引', N'把要筛的路径做成持久化计算列,建覆盖索引',
        N'本脚本 07 节;把"逐行解析"变成"索引查找"'),
   (2, N'预解析成关系表',    N'入库时用 OPENJSON / .nodes() 拆成规整表,查询就不碰 JSON/XML',
        N'11.1 节的 #Note 就是雏形;这是万亿级唯一可行的读路径'),
   (3, N'只读快照 / RCSI',   N'半结构化解析是 CPU 型操作,读写混跑会互相拖累',
        N'本库已开 is_read_committed_snapshot_on = 1'),
   (4, N'不进 OLTP 热路径',  N'在事务里解析大 JSON 会长时间持锁、撑大日志',
        N'改为"先落原始文本,异步再解析"'),
   (5, N'压缩与列存',        N'归档层的 JSON 用 PAGE/COLUMNSTORE 压缩;XML 考虑 typed 后转关系表',
        N'冷热分离:热走提升列,冷走列存')
) AS t(序号, 手法, 做法, 说明);
GO

/* --------------------------------------------------------------------------------------------------
   14.4 ★ 高并发下绝对不要做的事:在热点事务里 JSON_MODIFY 大文档
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 14.4 反例:热点更新 = 大文档反复重写 -----';
PRINT N'  下面演示"改一个键"实际要写多少字节(这直接等于日志与 IO 放大):';
SELECT
     原文档字节     = DATALENGTH(AttrJson)
   , 改一个键后字节 = DATALENGTH(JSON_MODIFY(AttrJson, N'$.custom.giftBox', N'deluxe'))
   , 放大倍数       = CAST(DATALENGTH(JSON_MODIFY(AttrJson, N'$.custom.giftBox', N'deluxe')) * 1.0
                          / NULLIF(DATALENGTH(AttrJson), 0) AS decimal(6,2))
FROM erp.ProductAttr
ORDER BY DATALENGTH(AttrJson) DESC;
GO
PRINT N'  ★ 结论:每一次 JSON_MODIFY 都是"读全量 + 解析 + 重拼 + 写全量",';
PRINT N'    在 1 万 QPS 的热点上,这等于把写入放大成几百倍。';
PRINT N'    正确做法:把会被高频更新的字段拆成普通列 ------ 回到第 01 节的边界判据。';
GO

/* --------------------------------------------------------------------------------------------------
   14.5 落库审计:把"契约巡检"变成日常
   -------------------------------------------------------------------------------------------------- */
PRINT N'----- 14.5 建审计表并记录一次巡检结果 -----';
GO
CREATE TABLE dbo.JsonXmlAudit
(
    AuditId    int IDENTITY(1,1) NOT NULL CONSTRAINT PK_JsonXmlAudit PRIMARY KEY,
    CheckedAt  datetime2(0) NOT NULL CONSTRAINT DF_JsonXmlAudit_At DEFAULT (SYSDATETIME()),
    ObjectName nvarchar(120) NOT NULL,
    CheckItem  nvarchar(160) NOT NULL,
    Violations int NOT NULL
);
GO

INSERT dbo.JsonXmlAudit (ObjectName, CheckItem, Violations)
SELECT N'erp.ProductAttr', N'文档不是合法 JSON 对象', COUNT(*)
FROM erp.ProductAttr WHERE ISJSON(AttrJson, OBJECT) = 0
UNION ALL
SELECT N'erp.ProductAttr', N'schemaVersion 不是 1', COUNT(*)
FROM erp.ProductAttr WHERE SchemaVersion <> 1 OR SchemaVersion IS NULL
UNION ALL
SELECT N'erp.ProductAttr', N'提升列与原文档不一致', COUNT(*)
FROM erp.ProductAttr
WHERE ISNULL(MetalType, N'') <> ISNULL(CAST(JSON_VALUE(AttrJson, N'$.metal.type') AS nvarchar(40)), N'')
UNION ALL
SELECT N'stg.EventJson', N'文档不是合法 JSON 对象', COUNT(*)
FROM stg.EventJson WHERE ISJSON(Payload, OBJECT) = 0
UNION ALL
SELECT N'crm.InteractionXml', N'XML 缺少 metrics 节点', COUNT(*)
FROM crm.InteractionXml WHERE Payload.exist(N'/interaction/metrics') = 0
UNION ALL
-- hr.CertXml 的报文带命名空间 urn:lukfook:hr:cert:v1,
-- 这里用 *: 通配写路径,无需预先声明前缀
SELECT N'hr.CertXml', N'XML 缺少 holder 节点', COUNT(*)
FROM hr.CertXml WHERE Payload.exist(N'/*:certificate/*:holder') = 0;
GO

PRINT N'  ★ 实证:如果直接写字面前缀 /cert:certificate/cert:holder 而**没有** WITH XMLNAMESPACES,会怎样:';
BEGIN TRY
    EXEC sp_executesql N'SELECT COUNT(*) AS 违反行数 FROM hr.CertXml
                         WHERE Payload.exist(N''/cert:certificate/cert:holder'') = 0';
    PRINT N'    居然可以(不应发生)';
END TRY
BEGIN CATCH
    PRINT N'    被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
PRINT N'    这类错误在生产里最危险的地方是:如果应用层用 try/except 把异常吞掉、只看返回行数,';
PRINT N'    这条巡检会**每天都安静地报 0 违规** ------ 巡检本身也要先验证有效性(下面 dbo.JsonXmlAudit 里同样存在这个风险)。';
GO

SELECT 巡检时间 = CheckedAt, 对象 = ObjectName, 检查项 = CheckItem, 违反行数 = Violations
FROM dbo.JsonXmlAudit ORDER BY AuditId;
GO


/* ==================================================================================================
   15  验证
   ================================================================================================== */

PRINT N'';
PRINT N'===== 15 验证 =====';
GO

PRINT N'  [V1] JSON 契约:两个 JSON 表都不得有非法文档';
SELECT 项目 = N'erp.ProductAttr 非对象文档', 结果 = COUNT(*), 判定 = CASE WHEN COUNT(*) = 0 THEN N'通过' ELSE N'失败' END
FROM erp.ProductAttr WHERE ISJSON(AttrJson, OBJECT) = 0
UNION ALL
SELECT N'stg.EventJson 非对象文档', COUNT(*), CASE WHEN COUNT(*) = 0 THEN N'通过' ELSE N'失败' END
FROM stg.EventJson WHERE ISJSON(Payload, OBJECT) = 0
UNION ALL
SELECT N'stg.JsonBatch 非合法 JSON', COUNT(*), CASE WHEN COUNT(*) = 0 THEN N'通过' ELSE N'失败' END
FROM stg.JsonBatch WHERE ISJSON(Payload) = 0;
GO

PRINT N'  [V2] ★ JSON_VALUE 的 4000 字符上限:4000 应有值,4005 应为 NULL';
GO
DECLARE @v4000 nvarchar(max) = CAST(N'{"v":"' AS nvarchar(max)) + REPLICATE(CAST(N'A' AS nvarchar(max)), 4000) + N'"}';
DECLARE @v4005 nvarchar(max) = CAST(N'{"v":"' AS nvarchar(max)) + REPLICATE(CAST(N'A' AS nvarchar(max)), 4005) + N'"}';
SELECT
     用例          = N'值长度 4000'
   , JSON_VALUE结果 = ISNULL(CAST(LEN(JSON_VALUE(@v4000, N'$.v')) AS nvarchar(10)), N'<NULL>')
   , 判定          = CASE WHEN LEN(JSON_VALUE(@v4000, N'$.v')) = 4000 THEN N'通过' ELSE N'失败' END
UNION ALL
SELECT N'值长度 4005'
   , ISNULL(CAST(LEN(JSON_VALUE(@v4005, N'$.v')) AS nvarchar(10)), N'<NULL>')
   , CASE WHEN JSON_VALUE(@v4005, N'$.v') IS NULL THEN N'通过(如期静默 NULL)' ELSE N'失败' END
UNION ALL
SELECT N'值长度 4005 用 OPENJSON 取'
   , CAST((SELECT LEN([value]) FROM OPENJSON(@v4005) WHERE [key] = N'v') AS nvarchar(10))
   , CASE WHEN (SELECT LEN([value]) FROM OPENJSON(@v4005) WHERE [key] = N'v') = 4005 THEN N'通过' ELSE N'失败' END;
GO

PRINT N'  [V3] 提升列必须与原文档严格一致(抽 24 行全量比对)';
SELECT 项目 = N'erp.ProductAttr 提升列不一致行数', 结果 = COUNT(*),
       判定 = CASE WHEN COUNT(*) = 0 THEN N'通过' ELSE N'失败' END
FROM erp.ProductAttr
WHERE ISNULL(MetalType, N'')  <> ISNULL(CAST(JSON_VALUE(AttrJson, N'$.metal.type') AS nvarchar(40)), N'')
   OR ISNULL(MetalPurity, N'') <> ISNULL(CAST(JSON_VALUE(AttrJson, N'$.metal.purity') AS nvarchar(20)), N'')
   OR ISNULL(StoneType, N'')   <> ISNULL(CAST(JSON_VALUE(AttrJson, N'$.stone.type') AS nvarchar(40)), N'');
GO

PRINT N'  [V4] JSON_ARRAYAGG / JSON_OBJECTAGG 产出的必须是合法 JSON';
SELECT
     用例 = N'JSON_ARRAYAGG 结果合法'
   , 判定 = CASE WHEN ISJSON((SELECT JSON_ARRAYAGG(Sku) FROM erp.Product)) = 1 THEN N'通过' ELSE N'失败' END
UNION ALL
SELECT N'JSON_OBJECTAGG 结果合法'
   , CASE WHEN ISJSON((SELECT JSON_OBJECTAGG(StoreCode : City) FROM erp.Store)) = 1 THEN N'通过' ELSE N'失败' END;
GO

PRINT N'  [V5] lax 必须静默、strict 必须报错(13608)';
GO
DECLARE @jl nvarchar(max) = N'{"a":1}';
DECLARE @laxr nvarchar(40);
DECLARE @strictErr int = 0;
DECLARE @laxOk bit = 0;
BEGIN TRY
    SET @laxr = ISNULL(JSON_VALUE(@jl, N'$.nope'), N'<NULL>');
    SET @laxOk = 1;
END TRY BEGIN CATCH SET @laxOk = 0; END CATCH
DECLARE @ps nvarchar(60) = N'strict $.nope';
BEGIN TRY
    DECLARE @tmp nvarchar(40) = JSON_VALUE(@jl, @ps);
END TRY BEGIN CATCH SET @strictErr = ERROR_NUMBER(); END CATCH
SELECT
     lax结果 = @laxr
   , lax成功 = CASE WHEN @laxOk = 1 THEN N'通过' ELSE N'失败' END
   , strict错误号 = @strictErr
   , strict判定   = CASE WHEN @strictErr = 13608 THEN N'通过' ELSE N'失败' END;
GO

PRINT N'  [V6] XML 四个方法都必须可用';
SELECT
     方法 = N'.value()'
   , 结果 = CAST((SELECT TOP 1 Payload.value(N'(/interaction/customer/@tier)[1]', N'nvarchar(20)') FROM crm.InteractionXml) AS nvarchar(40))
   , 判定 = N'通过'
UNION ALL
SELECT N'.query()', CAST((SELECT TOP 1 Payload.query(N'/interaction/customer') FROM crm.InteractionXml) AS nvarchar(80)), N'通过'
UNION ALL
SELECT N'.exist()', CAST((SELECT TOP 1 Payload.exist(N'/interaction/notes/note') FROM crm.InteractionXml) AS nvarchar(10)), N'通过'
UNION ALL
SELECT N'.nodes()', CAST((SELECT COUNT(*) FROM (SELECT TOP 1 x.Payload FROM crm.InteractionXml x) t
                          CROSS APPLY t.Payload.nodes(N'/interaction/notes/note') AS n(v)) AS nvarchar(10)), N'通过';
GO

PRINT N'  [V7] XML 的三条硬限制必须如期报错(305 / 5335)';
GO
DECLARE @e1 int = 0, @e2 int = 0;
BEGIN TRY
    EXEC sp_executesql N'SELECT x FROM (SELECT CAST(N''<r/>'' AS xml) AS x) t ORDER BY x';
END TRY BEGIN CATCH SET @e1 = ERROR_NUMBER(); END CATCH
BEGIN TRY
    EXEC sp_executesql N'SELECT COUNT(*) FROM (SELECT CAST(N''<r/>'' AS xml) AS x UNION SELECT CAST(N''<s/>'' AS xml)) t';
END TRY BEGIN CATCH SET @e2 = ERROR_NUMBER(); END CATCH
SELECT
     限制 = N'XML 不能排序'
   , 错误号 = @e1
   , 判定 = CASE WHEN @e1 = 305 THEN N'通过' ELSE N'失败' END
UNION ALL
SELECT N'XML 不能作 UNION 操作数', @e2, CASE WHEN @e2 = 5335 THEN N'通过' ELSE N'失败' END;
GO

PRINT N'  [V8] typed XML 必须完成强校验(只允许合法文档入库)';
SELECT
     项目 = N'dbo.TypedXmlDemo 行数(应恰好 = 1,其余 3 条非法文档被拒)'
   , 结果 = COUNT(*)
   , 判定 = CASE WHEN COUNT(*) = 1 THEN N'通过' ELSE N'失败' END
FROM dbo.TypedXmlDemo;
GO

PRINT N'  [V9] XML 索引必须存在(主 + 2 个二级)';
SELECT
     项目 = N'crm.InteractionXml 上的 XML 索引数'
   , 结果 = COUNT(*)
   , 判定 = CASE WHEN COUNT(*) >= 3 THEN N'通过' ELSE N'失败' END
FROM sys.xml_indexes WHERE object_id = OBJECT_ID('crm.InteractionXml');
GO
SELECT 索引名 = name, 类型 = type_desc FROM sys.xml_indexes
WHERE object_id = OBJECT_ID('crm.InteractionXml') ORDER BY index_id;
GO

PRINT N'  [V10] 事实表与维表的引用完整性(确保示例数据可连接)';
SELECT
     项目 = N'ProductAttr 的 ProductId 在 erp.Product 中找不到'
   , 结果 = COUNT(*)
   , 判定 = CASE WHEN COUNT(*) = 0 THEN N'通过' ELSE N'失败' END
FROM erp.ProductAttr a WHERE NOT EXISTS (SELECT 1 FROM erp.Product p WHERE p.ProductId = a.ProductId)
UNION ALL
SELECT N'CustomerPref 的 CustomerId 在 crm.Customer 中找不到', COUNT(*),
        CASE WHEN COUNT(*) = 0 THEN N'通过' ELSE N'失败' END
FROM crm.CustomerPref c WHERE NOT EXISTS (SELECT 1 FROM crm.Customer t WHERE t.CustomerId = c.CustomerId);
GO

PRINT N'  [V11] 规模核对';
SELECT 对象 = N'erp.ProductAttr',    行数 = COUNT(*) FROM erp.ProductAttr
UNION ALL SELECT N'erp.ProductAttrJ',  COUNT(*) FROM erp.ProductAttrJ
UNION ALL SELECT N'stg.JsonBatch',     COUNT(*) FROM stg.JsonBatch
UNION ALL SELECT N'stg.EventJson',     COUNT(*) FROM stg.EventJson
UNION ALL SELECT N'crm.InteractionXml',COUNT(*) FROM crm.InteractionXml
UNION ALL SELECT N'hr.CertXml',        COUNT(*) FROM hr.CertXml
UNION ALL SELECT N'dbo.TypedXmlDemo',  COUNT(*) FROM dbo.TypedXmlDemo
UNION ALL SELECT N'dbo.JsonXmlAudit',  COUNT(*) FROM dbo.JsonXmlAudit;
GO


/* ==================================================================================================
   16  决策清单
   ================================================================================================== */

PRINT N'';
PRINT N'===== 16 决策清单 =====';
GO

PRINT N'  ── 什么时候用 JSON ──';
PRINT N'    · 字段集合随品类/渠道变化,拆列会产生大量稀疏列(珠宝商品属性、客户画像)';
PRINT N'    · 只整体读写、读多写少、不需要跨行连接';
PRINT N'    · 上游本来就给 JSON(API / POS / 电商),需要先落原始报文再异步解析';
PRINT N'    · 需要"提升列 + 索引"来筛少数几个字段,其余保持灵活';
PRINT N'';
PRINT N'  ── 什么时候用 XML ──';
PRINT N'    · 需要**结构强校验**:typed XML + XSD,写入即拒绝畸形报文';
PRINT N'    · 行业/机构互操作:证书报文、EDI、带命名空间的遗留系统报文';
PRINT N'    · 天然文档结构、需要 XQuery 或混合内容(多语言富文本)';
PRINT N'    · 报文带命名空间(JSON 表达命名空间非常别扭)';
PRINT N'';
PRINT N'  ── 必须拆成正式列(禁止放 JSON/XML)──';
PRINT N'    · 参与 JOIN / WHERE 等值或范围筛选 / ORDER BY / GROUP BY';
PRINT N'    · 需要唯一约束、外键、CHECK 约束';
PRINT N'    · 进入财务、税务、薪酬、库存对账口径';
PRINT N'    · 会被高频部分更新(避免整篇文档重写)';
PRINT N'    · 需要统计信息支撑正确执行计划';
PRINT N'';
PRINT N'  ── 工程化铁律 ──';
PRINT N'    ① 每个 JSON 列:CHECK (ISJSON(col, OBJECT) = 1) + schemaVersion + 必需路径约束;';
PRINT N'      每个 XML 列:能用 typed XML 就用,别只存裸 xml;';
PRINT N'    ② 要筛的路径一律提升为持久化计算列并建索引 ------ 不要让查询去逐行解析;';
PRINT N'    ③ 取长字符串值一律用 OPENJSON(JSON_VALUE 超过 4000 字符会静默 NULL);';
PRINT N'    ④ XML 方法只吃字面量路径;需要动态路径时必须走动态 SQL 并做白名单校验;';
PRINT N'    ⑤ 把契约巡检(15 节那种查询)固化成每日作业,落进 dbo.JsonXmlAudit 类的表;';
PRINT N'    ⑥ 冷热分离:热查询走提升列/预解析表,冷归档走列存 + 大文档外置。';
PRINT N'';
PRINT N'################################################################################';
PRINT N'#  脚本执行完成';
PRINT N'#  核心结论:JSON/XML 只是"装可变结构的容器",不是"逃避建模的借口"。';
PRINT N'#  边界划清楚 = 该拆列的拆列、该提升的提升、该校验的校验、该索引的索引。';
PRINT N'################################################################################';
GO
相关推荐
IT大白鼠1 小时前
图数据库系列 · 第 02 篇——架构拆解:原生图存储到因果集群
数据库·架构·nosql
samLi06202 小时前
【数据集】A股上市公司beta贝塔系数数据(2010-2024年)
大数据
EatFan2 小时前
从“框架混战“到“运行时收敛“:2026 年 AI Agent 开发框架的三条路线之争
java·数据库·人工智能·多智能体·ai agent·mcp·agent 框架
小蒜学长2 小时前
基于SpringBoot的佳新超市管理系统设计与实现系统(代码+数据库+LW)
java·数据库·spring boot·后端·佳新超市管理系统
xianghongtao01162 小时前
麦肯锡2026技术趋势03_科学发现与工程AI_研究解读
大数据·人工智能
jinyishu_3 小时前
向量数据库入门:记录结构、索引、检索与选型
数据库
成长之路5145 小时前
【数据集】中国分行业进出口数据(2019-2026年)
大数据
计算机毕业编程指导师5 小时前
【大数据毕设选题推荐】基于Spark的WTA职业网球赛事演变与竞技格局分析系统源码 毕业设计 选题推荐 毕设选题 数据分析 机器学习 深度学习
大数据·python·计算机·spark·毕业设计·课程设计·wta网球