ドメインモデルをLINQで構築する(その7)

今回は更新系のSQL文のカスタマイズにチャレンジします。

オブジェクトのトラッキング

LINQ to SQLには検索したオブジェクトの変更管理を行う仕組みあり、dbmlファイルで作成したコードにも組み込まれます。

[Table(Name="dbo.Shops")]
public partial class Shop : INotifyPropertyChanging, INotifyPropertyChanged
{
	...
	partial void OnLoaded();
	partial void OnValidate();
	partial void OnCreated();
	partial void OnShopIDChanging(int value);
	partial void OnShopIDChanged();

	public Shop()
	{
		OnCreated();
	}
	
	[Column(Storage="_ShopID", AutoSync=AutoSync.OnInsert, 
	DbType="Int NOT NULL IDENTITY", IsPrimaryKey=true, IsDbGenerated=true)]
	public int ShopID
	{
		get
		{
			return this._ShopID;
		}
		set
		{
			if ((this._ShopID != value))
			{
				this.OnShopIDChanging(value);
				this.SendPropertyChanging();
				this._ShopID = value;
				this.SendPropertyChanged("ShopID");
				this.OnShopIDChanged();
			}
		}
	}
	...


少しごちゃごちゃしていますが、この仕組みのおかげでDataSetのような変更をマークする仕組みを用意できるようです。追加や削除については、TableのAdd,Removeを使って明示的にDataContextに通知する仕組みが用意されています。DataContextは、これらの機能を駆使することで変更を管理し、SubmitChangesメソッドを行うことでデータベースに永続化できるようになっています。

標準の更新SQL

お買い上げ(Order)の登録シナリオを利用して実際に実行されるSQL文を確認します。

[TestMethod]
public void TestTransaction()
{
    using (var db = new BurgerShopDataContext())
    {
        db.Log = Console.Out;
        var shops = db.GetTable<Shop>();
        var orders = db.GetTable<Order>();
        var products = new ProductMapper(db).Load(p => true);

        var shop = shops.Single(s => s.Name == "OSAKA");
        var myOrder = shop.CreateOrder();
        
        var setItem = myOrder.AddItem((SaleProduct)products.Single(p => p.Title == "CheeseBurgerSet"));
        var setOption = setItem.Product.OptionList;
        setItem.Choice(setOption.FindByTitle("Main", "CheeseBurger"));
        setItem.Choice(setOption.FindByTitle("Sub", "PotatoS"));
        setItem.Choice(setOption.FindByTitle("Drink", "CoffeeS"));
        myOrder.AddItem((SaleProduct)products.Single(p => p.Title == "Burger"),2);
        var preId = myOrder.OrderID;
        orders.Add(myOrder);
        db.SubmitChanges();

        Assert.AreEqual(0, db.GetChangeSet().AddedEntities.Count);
        Assert.AreNotEqual(preId, myOrder.OrderID); 
        Assert.AreEqual(500m,orders.Single(o => o.OrderID == myOrder.OrderID).TotalPrice);
    }
}

発行されるSQL文はほぼ予想通りでINSERT+IDENTITY機能で自動採番した番号のSELECTです。オブジェクトグラフを辿って内部のオブジェクトも自動的に処理してくれるようで、ルートオブジェクトだけの追加でAggregate全体を登録することができました。発行されるSQL文はオブジェクトの基本的には登録順のように動作しているようですが詳細は不明です。

INSERT INTO [dbo].[Orders]([OrderDateTime], [TotalPrice], [ShopID]) VALUES (@p0, @p1, @p2)

SELECT [t0].[OrderID]
FROM [dbo].[Orders] AS [t0]
WHERE [t0].[OrderID] = (SCOPE_IDENTITY())

-- @p0: Input DateTime (Size = 0; Prec = 0; Scale = 0) [08/13/2007 23:04:35]
-- @p1: Input Currency (Size = 0; Prec = 19; Scale = 4) [500]
-- @p2: Input Int32 (Size = 0; Prec = 0; Scale = 0) [1]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

INSERT INTO [dbo].[OrderItems]([OrderID], [ProductID], [Amount], [UnitPrice]) VALUES (@p0, @p1, @p2, @p3)

SELECT [t0].[OrderItemID]
FROM [dbo].[OrderItems] AS [t0]
WHERE [t0].[OrderItemID] = (SCOPE_IDENTITY())

-- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [151]
-- @p1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [2]
-- @p2: Input Decimal (Size = 0; Prec = 18; Scale = 0) [1]
-- @p3: Input Currency (Size = 0; Prec = 19; Scale = 4) [300]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

INSERT INTO [dbo].[OrderItemOptions]([ProductID], [OrderItemID], [ItemName]) VALUES (@p0, @p1, @p2)

SELECT [t0].[OrderItemOptionID]
FROM [dbo].[OrderItemOptions] AS [t0]
WHERE [t0].[OrderItemOptionID] = (SCOPE_IDENTITY())

-- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [14]
-- @p1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [113]
-- @p2: Input String (Size = 4; Prec = 0; Scale = 0) [Main]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

INSERT INTO [dbo].[OrderItemOptions]([ProductID], [OrderItemID], [ItemName]) VALUES (@p0, @p1, @p2)

SELECT [t0].[OrderItemOptionID]
FROM [dbo].[OrderItemOptions] AS [t0]
WHERE [t0].[OrderItemOptionID] = (SCOPE_IDENTITY())

-- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [3]
-- @p1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [113]
-- @p2: Input String (Size = 3; Prec = 0; Scale = 0) [Sub]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

INSERT INTO [dbo].[OrderItemOptions]([ProductID], [OrderItemID], [ItemName]) VALUES (@p0, @p1, @p2)

SELECT [t0].[OrderItemOptionID]
FROM [dbo].[OrderItemOptions] AS [t0]
WHERE [t0].[OrderItemOptionID] = (SCOPE_IDENTITY())

-- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [9]
-- @p1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [113]
-- @p2: Input String (Size = 5; Prec = 0; Scale = 0) [Drink]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

INSERT INTO [dbo].[OrderItems]([OrderID], [ProductID], [Amount], [UnitPrice]) VALUES (@p0, @p1, @p2, @p3)

SELECT [t0].[OrderItemID]
FROM [dbo].[OrderItems] AS [t0]
WHERE [t0].[OrderItemID] = (SCOPE_IDENTITY())

-- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [151]
-- @p1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [22]
-- @p2: Input Decimal (Size = 0; Prec = 18; Scale = 0) [2]
-- @p3: Input Currency (Size = 0; Prec = 19; Scale = 4) [100]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1


SQL文の発行順序のコントロール

標準で発行されるSQL文・順序で特に今のところ問題なのですが、高負荷トランザクションが予想されるアプリケーションの場合デッドロック対策などでSQL文の発行順序を調整する必要があります。LINQ to SQLでどこまで調整可能か考えてみます。もちろん標準ではそのような調整するための仕組みは提供されていないため特別な仕組みを用意する必要があります。まずは、更新処理の組み換えを可能なようにするために、インタフェースを定義して更新処理用のコンポーネントを作成します。

public interface ISaveMapper<T>
    where T : class
{
    void Save();
}
public class OrderMapper : ISaveMapper<Order>
{
    private DataContext Context { set; get; }

    public OrderMapper(DataContext context)
    {
        this.Context = context;
    }

    public void Save()
    {
        Context.SubmitChanges();
    }
}

最初の実装は単にSubmitChangesを呼び出すだけで標準の更新機能を利用するだけにしておきます。テストコードもこれに合わせて変更します。

[TestMethod]
public void TestTransaction()
{
    using (var db = new BurgerShopDataContext())
    {
        ...
        orders.Add(myOrder);
        OrderMapper mapper = new OrderMapper(db);
        mapper.Save();

        Assert.AreEqual(0, db.GetChangeSet().AddedEntities.Count);
        Assert.AreNotEqual(preId, myOrder.OrderID); 
        Assert.AreEqual(500m,orders.Single(o => o.OrderID == myOrder.OrderID).TotalPrice);
    }
}


更新DataMapperの実装

カスタムの更新処理としてはテーブル毎にグループ化してSQL文を発行するようにします。今回は追加処理のみ実装してみます。

public void Save()
{
    using (var work = new BurgerShopDataContext(Context))
    {
        work.Transaction = Context.Transaction;
        work.Log = Context.Log;

        var addedSet = Context.GetChangeSet().AddedEntities;
        var orderAdded = addedSet.Where(e => e is Order).Cast<Order>();
        work.GetTable<Order>().AddAll(orderAdded);
        var itemAdded = addedSet.Where(e => e is OrderItem).Cast<OrderItem>();
        work.GetTable<OrderItem>().AddAll(itemAdded);
        var optionAdded = addedSet.Where(e => e is OrderItemOption).Cast<OrderItemOption>();
        work.GetTable<OrderItemOption>().AddAll(optionAdded);

        work.SubmitChanges();

        Context.GetTable<Order>().RemoveAll(orderAdded);
        Context.GetTable<OrderItem>().RemoveAll(itemAdded);
        Context.GetTable<OrderItemOption>().RemoveAll(optionAdded);
    }
}

考え方としては、DataContextのGetChangeSetメソッドを利用して更新されたオブジェクト一覧を取得し、処理したい順序に整理します。今回はテーブル毎にSQL文を実行したいのでテーブル毎にグループ化します。次に、新しいワーク用のDataContextを用意してSQL文を実行します。DataContextに変更が通知された順に動作するようなので、今回はこの特性を利用して処理したいOrder・OrderItem・OrderItemOptionの順に追加していきます。独自にSQL文を発行する方が確実なのでフレームワーク化して対応する方法も考えられます。最後に元のDataContextの変更リストから取り除き、該当のオブジェクトを反映済みの状態にします。Added状態であればRemoveすればOKです。

INSERT INTO [dbo].[Orders]([OrderDateTime], [TotalPrice], [ShopID]) VALUES (@p0, @p1, @p2)

SELECT [t0].[OrderID]
FROM [dbo].[Orders] AS [t0]
WHERE [t0].[OrderID] = (SCOPE_IDENTITY())

-- @p0: Input DateTime (Size = 0; Prec = 0; Scale = 0) [08/13/2007 23:45:30]
-- @p1: Input Currency (Size = 0; Prec = 19; Scale = 4) [500]
-- @p2: Input Int32 (Size = 0; Prec = 0; Scale = 0) [1]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

INSERT INTO [dbo].[OrderItems]([OrderID], [ProductID], [Amount], [UnitPrice]) VALUES (@p0, @p1, @p2, @p3)

SELECT [t0].[OrderItemID]
FROM [dbo].[OrderItems] AS [t0]
WHERE [t0].[OrderItemID] = (SCOPE_IDENTITY())

-- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [154]
-- @p1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [2]
-- @p2: Input Decimal (Size = 0; Prec = 18; Scale = 0) [1]
-- @p3: Input Currency (Size = 0; Prec = 19; Scale = 4) [300]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

INSERT INTO [dbo].[OrderItems]([OrderID], [ProductID], [Amount], [UnitPrice]) VALUES (@p0, @p1, @p2, @p3)

SELECT [t0].[OrderItemID]
FROM [dbo].[OrderItems] AS [t0]
WHERE [t0].[OrderItemID] = (SCOPE_IDENTITY())

-- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [154]
-- @p1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [22]
-- @p2: Input Decimal (Size = 0; Prec = 18; Scale = 0) [2]
-- @p3: Input Currency (Size = 0; Prec = 19; Scale = 4) [100]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

INSERT INTO [dbo].[OrderItemOptions]([ProductID], [OrderItemID], [ItemName]) VALUES (@p0, @p1, @p2)

SELECT [t0].[OrderItemOptionID]
FROM [dbo].[OrderItemOptions] AS [t0]
WHERE [t0].[OrderItemOptionID] = (SCOPE_IDENTITY())

-- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [14]
-- @p1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [119]
-- @p2: Input String (Size = 4; Prec = 0; Scale = 0) [Main]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

INSERT INTO [dbo].[OrderItemOptions]([ProductID], [OrderItemID], [ItemName]) VALUES (@p0, @p1, @p2)

SELECT [t0].[OrderItemOptionID]
FROM [dbo].[OrderItemOptions] AS [t0]
WHERE [t0].[OrderItemOptionID] = (SCOPE_IDENTITY())

-- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [3]
-- @p1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [119]
-- @p2: Input String (Size = 3; Prec = 0; Scale = 0) [Sub]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

INSERT INTO [dbo].[OrderItemOptions]([ProductID], [OrderItemID], [ItemName]) VALUES (@p0, @p1, @p2)

SELECT [t0].[OrderItemOptionID]
FROM [dbo].[OrderItemOptions] AS [t0]
WHERE [t0].[OrderItemOptionID] = (SCOPE_IDENTITY())

-- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [9]
-- @p1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [119]
-- @p2: Input String (Size = 5; Prec = 0; Scale = 0) [Drink]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1


更新ロック

変換デッドロック対策やロック順序を制御するためにSELECT文に対して更新ロックヒントを付与することが良くあります。LINQ to SQLはこの更新ロックヒントを付与したようなSELECT文を生成することは残念ながらできません。
したがって、更新ロックヒントを付与したSELECT文を実行したい場合には独自にSELECT文を組み立てて実行する必要があります。LINQ to SQLには、このための汎用的にSQLを実行するための機能が用意されているので利用します。

using (var db = new ShopDBDataContext())
{
    db.Log = Console.Out;
    var queryShop = db.Shops
        .Where(shop => shop.Name == "OSAKA" || shop.Name == "KYOTO");

    var cmd = db.GetCommand(queryShop.AsQueryable());
    var cmdText = cmd.CommandText.Replace("FROM [dbo].[Shops] AS [t0]"
        , "FROM [dbo].[Shops] AS [t0] WITH (UPDLOCK)");
    var paras = cmd.Parameters.Cast<IDbDataParameter>().Select(p => p.Value);

    var resultShop = db.ExecuteQuery<Shop>(cmdText, paras.ToArray());

    Assert.AreEqual(resultShop.First().Tel, "06-666-9999");
    ...

DataContextのGetCommandを利用するとLINQが発行するSQL文を取得できます。このSQL文を変更して、ExecuteQueryコマンドで変更したSQL文を実行しています。

SELECT [t0].[ShopID], [t0].[Name], [t0].[Address], [t0].[Tel]
FROM [dbo].[Shops] AS [t0] WITH (UPDLOCK)
WHERE ([t0].[Name] = @p0) OR ([t0].[Name] = @p1)
-- @p0: Input String (Size = 5; Prec = 0; Scale = 0) [OSAKA]
-- @p1: Input String (Size = 5; Prec = 0; Scale = 0) [KYOTO]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1