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

如何使用单个存储过程保存主从关系中的对象图

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.85/5 (42投票s)

2016 年 11 月 8 日

CPOL

4分钟阅读

viewsIcon

62730

downloadIcon

550

一种使用 ADO.NET 和表值参数通过单个存储过程将 C# 对象图持久化到数据库的方法

这是本系列的第一篇文章。接下来的文章

引言

我有一个 Web 客户端,它以 JSON 格式将数据发送到我的 ASP.NET 应用程序。数据是主-明细关系中的对象图,甚至是一个对象图的集合。数据被反序列化为 Web API 控制器中的 C# 对象图。我想使用 ADO.NET 和一个存储过程来保存该 C# 对象图。并且我不想使用 GUID、XML 和 EF!

背景

假设 Web 客户端发送的对象图包含三个对象

  • GrandRecord
  • Record
  • ChildRecord

设它是一个 GrandRecords 的集合,其中

  • 每个 GrandRecord 都有一个 Records 的集合
  • 每个 Record 都有一个 ChildRecords 的集合
  • Id 值是整数,由数据库自动生成
  • 并且当一个对象尚未保存在数据库中时,Id 的值为 0

以下是对象图集合(或对象图)的示例

                      Id, Name
GrandRecord           1,  (A)
    Record            |-- 2, (A)A
        ChildRecord       |-- 3, (A)Aa
        ChildRecord       |-- 0, (A)Ab
    Record            |-- 0, (A)B
        ChildRecord       |-- 0, (A)Ba
        ChildRecord       |-- 0, (A)Bb
GrandRecord           0,  (B)
    Record            |-- 0, (B)A

或者相同的 JSON 格式

grandRecords: [
    {
        id: 1,
        name: "(A)",
        records: [
            {
                id: 2,
                name: "(A)A",
                childRecords: [
                    {
                        id: 3,
                        name: "(A)Aa",
                    },
                    {
                        id: 0,
                        name: "(A)b",
                    },
                ]
            },
            {
                id: 0,
                name: "(A)B",
                childRecords: [
                    {
                        id: 0,
                        name: "(A)Ba",
                    },
                    {
                        id: 0,
                        name: "(A)Bb",
                    },
                ]
            }        
        ]        
    },
    {
        id: 0,
        name: "(B)",
        records: [
            {
                id: 0,
                name: "(B)A",
                childRecords: []
            }
        ]
    }
]

在 Web 服务器的 ASP.NET 控制器中,上面的 JSON 字符串被反序列化为三个类的对象图

public class GrandRecord
{
    public  Int32          Id       { get; set; }
    public  String         Name     { get; set; }
    public  IList<Record>  Records  { get; set; }
}

public class Record
{
    public  Int32               Id             { get; set; }
    public  Int32               GrandRecordId  { get; set; }        
    public  String              Name           { get; set; }
    public  IList<ChildRecord>  ChildRecords   { get; set; }
}

public class ChildRecord
{
    public  Int32   Id        { get; set; }
    public  Int32   RecordId  { get; set; }
    public  String  Name      { get; set; }
}

现在,必须使用一个存储过程将对象图保存到三个数据库表中

create table dbo.GrandRecords
(
    Id    int          not null  identity  primary key clustered,
    Name  varchar(30)  not null
);

create table dbo.Records
(
    Id             int          not null  identity  primary key clustered,
    GrandRecordId  int          not null  ,
    Name           varchar(30)  not null  ,
    
    foreign key (GrandRecordId) references dbo.GrandRecords (Id) on delete cascade
);

create table dbo.ChildRecords
(
    Id        int          not null  identity  primary key clustered,
    RecordId  int          not null  ,
    Name      varchar(30)  not null  ,
    
    foreign key (RecordId) references dbo.Records (Id) on delete cascade
);

问题是如何

解决方案

当然,存储过程的表值参数是答案的一部分!

拥有这些用户定义表类型

create type dbo.GrandRecordTableType as table
(
    Id    int          not null   primary key clustered,
    Name  varchar(30)  not null    
);

create type dbo.RecordTableType as table
(
    Id             int          not null   primary key clustered,
    GrandRecordId  int          not null   ,
    Name           varchar(30)  not null

);

create type dbo.ChildRecordTableType as table
(
    Id        int          not null   primary key clustered,
    RecordId  int          not null   ,
    Name      varchar(30)  not null    
);

用于保存上述对象图的存储过程以...开始

create procedure dbo.SaveGrandRecords
    @GrandRecords  dbo.GrandRecordTableType  readonly,
    @Records       dbo.RecordTableType       readonly,
    @ChildRecords  dbo.ChildRecordTableType  readonly
as

因此,我们必须按类型(GrandRecordRecordChildRecord)收集所有数据,创建 ADO.NET DataTables 并将它们传递给存储过程。

但是!因为我们的数据库表通过外键 GrandRecordIdRecordId 链接,我们必须在将对象图转换为单独的 DataTables 的过程中以某种方式持久化该链接。

更重要的是,新对象的标识必须是唯一的!否则,我们将无法区分 GrandRecord (A) 的 RecordsGrandRecord (B) 的 Records

然而,正如我们记得的那样,新对象的 Id0

为了解决这个问题,如果对象 Ids 等于 0,我们为它们分配一个不断递增的负数标识

var id = int.MinValue;

foreach (var grandRecord in grandRecords)
{
    if (grandRecord.Id == 0)
        grandRecord.Id = id++;

    foreach (var record in grandRecord.Records)
    {
        if (record.Id == 0)
            record.Id = id++;

        record.GrandRecordId = grandRecord.Id;

        foreach (var childRecord in record.ChildRecords)
        {
            if (childRecord.Id == 0)
                childRecord.Id = id++;

            childRecord.RecordId = record.Id;
        }
    }
}

现在是填充 DataTables 的时候了。

例如,这是准备一个包含 Records 数据的 DataTable 的方法

var recordTable = new DataTable("RecordTableType");

recordTable.Columns.Add( "Id"            , typeof( Int32  ));
recordTable.Columns.Add( "GrandRecordId" , typeof( Int32  ));
recordTable.Columns.Add( "Name"          , typeof( String ));


var records = grandRecords.SelectMany(gr => gr.Records);

foreach(var record in records) 
{
    table.Rows.Add(new object[] {record.Id, record.GrandRecordId, record.Name});
}

因此,在 DataTables 准备好之后,存储过程将在表值参数中接收以下数据

@GrandRecords

ID 名称
1 (A)
-2147483648 (B)

@Records

ID GrandRecordId 名称
2 1 (A)A
-2147483647 1 (A)B
-2147483646 -2147483648 (B)A

@ChildRecords

ID RecordId 名称
3 2 (A)Aa
-2147483645 2 (A)Ab
-2147483644 -2147483647 (A)Ba
-2147483643 -2147483647 (A)Bb

对象图保存技术

为了更新现有数据、插入新数据和删除旧数据,SQL Server 使用 MERGE 语句。

MERGE 语句具有 OUTPUT 子句。MERGE 语句中的 OUTPUT 可以收集刚插入的 Ids 以及源(参数)表中的 Ids

因此,“保存所有三个表并具有正确的外键”的技术是收集第一个表中的 InsertedId - ParamId 对,并将这些值翻译给第二个表。然后对第二个和第三个表执行相同的操作。

  • 如果一个记录存在于表中,MERGE 将执行 UPDATEinserted.Idsource.Id 等于现有 Id
  • 如果一个记录不存在于表中,MERGE 将执行 INSERTinserted.Id 等于新的 Id,而 source.Id 等于负数标识。
  • 如果一个记录不存在于源(参数)表中,MERGE 将执行 DELETEinserted.Idsource.Id 等于 NULL,但 deleted.Id 具有已删除记录的 Id

这是保存我们对象图的存储过程

create procedure dbo.SaveGrandRecords
    @GrandRecords  dbo.GrandRecordTableType  readonly,
    @Records       dbo.RecordTableType       readonly,
    @ChildRecords  dbo.ChildRecordTableType  readonly
as
begin
    set nocount on;

    declare @GrandRecordIds table (  -- translation table
        InsertedId  int  primary key, 
        ParamId     int  unique
    );

    declare @RecordIds table (       -- translation table
        InsertedId  int     primary key, 
        ParamId     int     unique, 
        [Action]    nvarchar(10)
    );

    -- save GrandRecords 

    merge into dbo.GrandRecords as target
        using 
        (
            select Id, Name from @GrandRecords
        ) 
        as source on source.Id = target.Id

    when matched then
        update set                
            Name = source.Name        

    when not matched by target then                                                         
        insert ( Name )
        values ( source.Name )

    output            -- collecting translation Ids
        inserted.Id,
        source.Id
    into @GrandRecordIds ( 
        InsertedId , 
        ParamId    );

    -- save Records 

    merge into dbo.Records as target
    using 
    (
        select
            Id             , 
            GrandRecordId  =  ids.InsertedId,   -- Id translation target
            Name    
        from
            @Records r
            inner join @GrandRecordIds ids 
                on ids.ParamId = r.GrandRecordId -- Id translation source
    ) 
    as source on source.Id = target.Id

    when matched then
        update set
            GrandRecordId  =  source.GrandRecordId, 
            Name           =  source.Name    

    when not matched by target then                                                         
        insert (    
            GrandRecordId , 
            Name          )
        values (
            source.GrandRecordId , 
            source.Name          )

    when not matched by source 
        and target.GrandRecordId in (select InsertedId from @GrandRecordIds) then
           delete

    output                 -- collecting translation Ids
        isnull(inserted.Id, deleted.Id),
        isnull(source.Id, deleted.Id), 
        $action
    into @RecordIds (
        InsertedId  , 
        ParamId     , 
        [Action]    );

    delete from @RecordIds where [Action] = 'DELETE';

    -- save ChildRecords

    merge into dbo.ChildRecords as target
        using 
        (
            select
                Id        ,
                RecordId  =  ids.InsertedId,    -- Id translation target
                Name        
            from
                @ChildRecords cr
                inner join @RecordIds ids 
                    on ids.ParamId = cr.RecordId -- Id translation source
        ) 
        as source on source.Id = target.Id

    when matched then
        update set
            RecordId = source.RecordId , 
            Name     = source.Name

    when not matched by target then
        insert (    
            RecordId , 
            Name     )
        values (
            source.RecordId , 
            source.Name     )

    when not matched by source 
        and target.RecordId in (select InsertedId from @RecordIds) then
            delete;
end;

重要提示

MERGE 语句中,源表和目标表必须在其 join 列上具有聚集索引!这可以防止死锁并保证插入顺序。

join 列位于 MERGE 语句的 as source on source.Id = target.Id 行。

这就是为什么用户定义表类型在其定义中具有主键聚集的原因。

这就是为什么负数标识是不断递增的并且从 MinValue 开始的原因。

同时请注意永久表的外键定义。它们包含 on delete cascade 子句,这有助于在 MERGE 语句中删除父记录时删除子记录。

关于源代码

附带的压缩文件包含一个在 Visual Studio 2015 中创建的解决方案,该解决方案包含三个项目

  • Database - 用于创建 SQL Server 2016 数据库的 SSDT 项目
  • ObjectGraphs - 包含存储库类的 DLL 项目
  • Tests - 用于调用存储库方法并查看结果的测试项目

该解决方案包含以下示例

  • 如何通过 Id 获取一个上级对象及其所有后代
  • 如何获取上级对象列表及其所有后代
  • 如何保存上级对象列表及其所有后代

要安装数据库并运行测试,请更改文件 ObjectGraphs.publish.xmlRepository.cs 中的连接字符串以使用您的。

下一步?

以上方法的后续和发展是以下文章

© . All rights reserved.