LINQ to SQL 備忘録1, 備忘録2 では、 Entity オブジェクトの検索に関する記事を記載しましたが、今回は、Entity オブジェクトの作成、更新、削除について記載します。

LINQ to SQL で使用する DataContext は既定で Entity オブジェクトの変更を追跡するサービスが有効になっています。 DataContext.ObjectTrackingEnabled を false に設定することで、トラッキングサービスを無効にすることができます。トラッキングサービスは Entity クラスのオブジェクトを次の StandardChangeTracker.StandardTrackedObject.State 列挙値の状態で管理しています。状態の値はUntracked, Unchanged, PossiblyModified, ToBeInserted, ToBeUpdated, ToBeDeleted, Deleted があります。各値については MSDN のドキュメントを参照願いします。

Entity オブジェクトのトラッキングに関する状態に関しては、次のリンクが参考になります。
オブジェクトの状態と変更の追跡 (LINQ to SQL)
http://msdn.microsoft.com/ja-jp/library/bb386982.aspx

 動作確認環境は次の通りです。

  • 実行OS: Windows Vista Enterprise
  • 開発環境 : Visual Studio 2008 Professional
  • .NET 3.5
  • 使用データベース: AdventureWorks

1. 使用する Entity クラスと AdventureWorksDataContext の定義

DataContext に対する処理を簡単にするために、DataContext を継承して、AdventureWorksDataContext というクラスを作成します。Entity クラスとして SalesReason, SalesOrder, Customer を定義しています。今回のサンプルで主に使用するのは SalesReason クラスです。

namespace LINQ2SQL.Entity
{
    /// <summary>
    /// AdventureWorks用のDataContextクラスの作成
    /// </summary>
    public class AdventureWorksDataContext : DataContext
    {
        public AdventureWorksDataContext(string fileOrServerOrConnection)
            : base(fileOrServerOrConnection) { }
        public AdventureWorksDataContext(IDbConnection connection)
            : base(connection) { }
        public AdventureWorksDataContext(string fileOrServerOrConnection, MappingSource mapping)
            : base(fileOrServerOrConnection, mapping) { }
        public AdventureWorksDataContext(IDbConnection connection, MappingSource mapping)
            : base(connection, mapping) { }

        /// <summary>
        /// エンティティの定義
        /// 初期時にベースクラス内でリフレクションを介して
        /// 初期化される。
        /// </summary>
        public Table<SalesOrder> SalesOrders;
        public Table<Customer> Customers;
        public Table<SalesReason> SalesReasons;

    }
    /// <summary>
    /// TableアトリビュートにはSystem.Data.Linqへの参照が必要
    /// </summary>
    [Table(Name = "Sales.SalesOrderHeader")]
    public class SalesOrder
    {
        [Column(IsPrimaryKey = true)]
        public int SalesOrderID;
        [Column]
        public DateTime OrderDate;
        [Column]
        public DateTime DueDate;
        [Column(CanBeNull = true)]
        public DateTime? ShipDate;
        [Column]
        public decimal SubTotal;
        [Column]
        public decimal TotalDue;

        [Column]
        public int CustomerID;
        private EntityRef<Customer> _customer;
        [Association(Storage = "_customer", ThisKey = "CustomerID", IsForeignKey = true)]
        public Customer Customer
        {
            get { return _customer.Entity; }
            set { _customer.Entity = value; }
        }

    }
    [Table(Name = "Person.Contact")]
    public class Customer
    {
        public Customer()
        {
            _salesOrders = new EntitySet<SalesOrder>();
        }
        [Column(Name = "ContactID", IsPrimaryKey = true, IsDbGenerated = true)]
        public int CustomerID;
        [Column]
        public string FirstName;
        [Column]
        public string LastName;
        private EntitySet<SalesOrder> _salesOrders;
        [Association(OtherKey = "CustomerID", Storage = "_salesOrders")]
        public EntitySet<SalesOrder> SalesOrders
        {
            get { return _salesOrders; }
            set { _salesOrders.Assign(value); }
        }
    }
    [Table(Name="Sales.SalesReason")]
    public class SalesReason
    {
        public SalesReason() { }
        private int _salesReasonID = 0;
        [Column(Name="SalesReasonID", Storage="_salesReasonID",IsPrimaryKey=true, IsDbGenerated=true)]
        public int SalesReasonID
        {
            get { return _salesReasonID; }
            set { _salesReasonID = value; }
        }
        private string _name;
        [Column(Name="Name", Storage="_name")]
        public string Name
        {
            get { return _name; }
            set { _name = value; }
        }
        private string _reasonType;
        [Column(Name = "ReasonType", Storage = "_reasonType")]
        public string ReasonType
        {
            get { return _reasonType; }
            set { _reasonType = value; }
        }
        private DateTime _modifiedDate;
        [Column(Name = "ModifiedDate", Storage = "_modifiedDate", IsVersion=true )]
        public DateTime ModifiedDate
        {
            get { return _modifiedDate; }
            set { _modifiedDate = value; }
        }
    }
}

前述したとおり、今回は SalesReason クラスを使用して、SalesReason テーブルにデータの登録、更新、削除を行ってみます。

2. Entity クラスの新規作成、更新、削除

LINQ to SQL では変更の反映は、 DataContext.SubmitChanges() メソッドを呼び出すことで行います。 SubmitChangtes() を呼び出すと、トラッキングサービスにより管理されていた Entity オブジェクトの更新、削除、新規作成処理がまとめて処理されデータベースのテーブルにクエリが発行されます。トラッキングサービスによって認識されている変更された Entity オブジェクトは DataContext.ChangeSet() メソッドを使用することにより、変更、削除、挿入対象の Entity オブジェクトを取得できます。 取得されたオブジェクトの変更内容が SubmitChanges を呼び出したときにデータベースにすべて反映されます ( Unit of Works )。

2.1 新規作成処理

新規作成する場合は、Table<TEntity>.InsertOnSubmit() を使用します。以下がサンプルメソッドです。

/// <summary>
/// Insert を行うテスト
/// </summary>
public static void InsertSalesReason()
{
    AdventureWorksDataContext dc = new AdventureWorksDataContext(ConnectionString);
    SalesReason sr = new SalesReason() { Name = "Reason1", ReasonType = "type1", ModifiedDate = DateTime.Now };
    dc.Log = Console.Out;
    dc.SalesReasons.InsertOnSubmit(sr);
    dc.SubmitChanges();
    Console.WriteLine(sr.SalesReasonID.ToString());
}

実行結果は次のようになります。下図で、input enter の上部に SalesReasonID が表示されています。この値は、SalesReason にレコードを新規作成するときに自動で採番される値です。登録時に設定される値は、ColumnAttribute.IsDbGenerated を true に設定しておくことで、登録後に値が設定されます。

2.2 更新処理

更新をする場合は、Entity クラスにデータを更新するだけで、トラッキングサービスにより、更新対象のオブジェクトとして認識されます。以下にサンプルメソッドを定義します。

/// <summary>
/// Update を行うテスト. Entity の列に ColumnAttribute.IsVersion=true の
/// プロパティ、フィールドがあるかによって、楽観的排他制御用の where 句の生成方法が変わる
/// </summary>
public static void UpdateSalesReason()
{
    AdventureWorksDataContext dc = new AdventureWorksDataContext(ConnectionString);

    SalesReason sr = dc.SalesReasons.Single(s => s.SalesReasonID == 11);
    sr.Name = "change with isversion";
   
    dc.Log = Console.Out;
    dc.SubmitChanges();
}

実行結果は下図のようになります。下図の実行結果は、 SalesReason.ModifiedData に設定されているColumnAttribute.IsVersion を false にした際の実行結果です。作成されたSQL を見ると、WHERE 句にプライマリキー列だけではなく、他のすべての列を条件として列挙していることが確認できます。これは、IsVersion が設定されたプロパティ、フィールドがないと、他のユーザなどによりすでに更新されたレコードに対して、最新ではないデータで更新をさせないようにするための、排他制御を行う際に、すべての列に対して比較を行い、変更が加わっていない場合のみ更新を行うようにSQL が作成されるためです。

1. 使用する Entity クラスと AdventureWorksDataContext の定義 で掲載しているサンプルのように、SalesReason.ModifiedDataにアノテートした、ColumnAttribute.IsVersion を true にした場合は次のようになります。WHERE 句の排他制御用の条件がModifiedDate のみになっていることが確認できます。

2.3 削除処理

削除処理を行う場合は、 DataContext.DeleteOnSubmit() メソッドを使用します。サンプルプログラムを以下のように定義します。

/// <summary>
/// Delete を行うテスト
/// Update と同様に Entity クラスのプロパティ、フィールドに
/// ColumnAttribute.IsVersion = true がアノテートされたもの
/// があるかにより、楽観的排他制御用の Where 句が変わる
/// </summary>
public static void DeleteSalesReason()
{
    AdventureWorksDataContext dc = new AdventureWorksDataContext(ConnectionString);

    SalesReason sr = dc.SalesReasons.Single(s => s.SalesReasonID == 12);
    dc.SalesReasons.DeleteOnSubmit(sr);
    dc.Log = Console.Out;

    dc.SubmitChanges();
}

実行結果は次のようになります。

 

 

Entity クラスのリレーションを更新する場合は、次のように直接参照を設定しなおすか、1対多のリレーションがある場合は、Add を呼び出します。

/// <summary>
/// リレーションのプロパティ、フィールドを変更する場合
/// </summary>
public static void UpdateRelation()
{
    DataContext dc = new DataContext(ConnectionString);
    Table<SalesOrder> orders = dc.GetTable<SalesOrder>();

    var query = from o in orders
                select o;

    SalesOrder s1 = null;
    SalesOrder s2 = null;
    foreach (var item in orders.Take(2))
    {
        if (s1 == null)
        {
            s1 = item;
        }
        else
        {
            s2 = item;
        }
    }
    // 関係を更新
    Customer c = s1.Customer;
    s1.Customer = s2.Customer;
    s2.Customer = c;

    // 関係を追加
    SalesOrder added = new SalesOrder(){ DueDate=DateTime.Now, OrderDate=DateTime.Now.Add(new TimeSpan(1000))};
    c.SalesOrders.Add(added);

    dc.SubmitChanges();
}

 

2.4 カスケーディングによる削除、更新

注文書と注文明細のように、注文書を削除した場合に注文明細を削除する(Cascading Delete) 場合や、複数の顧客をもつ担当営業にたいして、別の営業員に変更する(Cascading Update) などを行いたい場合があります。LINQ to SQL では、カスケーディングによる削除、更新はサポートしていませんので、開発者が自身で行う必要があります。

以下が、サンプルです。

/// <summary>
/// カスケードデリートを行う場合
/// </summary>
public static void CascadeDelete()
{
    AdventureWorksDataContext dc = new AdventureWorksDataContext(ConnectionString);

    var query = from c in dc.Customers
                where c.CustomerID == 2
                select c;

    var customer = query.Single();
    dc.Customers.DeleteOnSubmit(customer);
    dc.SalesOrders.DeleteAllOnSubmit(customer.SalesOrders);
    ChangeSet set =  dc.GetChangeSet();
    // SubmitChanges を呼び出すと削除処理が実行される
    //dc.SubmitChanges();
}
/// <summary>
/// カスケード更新を行う場合
/// CustomerのIDを更新する場合は、
/// LINQ to SQL はプライマリキーの更新を許可していないので、新しい Customer
/// クラスを作成して関連する SalesOrder の集合を設定しなおす必要がある。
/// </summary>
public static void CascadeUpdate()
{
    AdventureWorksDataContext dc = new AdventureWorksDataContext(ConnectionString);

    var query = from c in dc.Customers
                where c.CustomerID == 2
                select c;
    Customer oldCustomer = query.Single();
    Customer newCustomer = new Customer() { FirstName = "First", LastName = "Last" };
    newCustomer.CustomerID = oldCustomer.CustomerID();
    newCustomer.SalesOrders = oldCustomer.SalesOrders;
    // 省略..そのほかプロパティを設定 
     dc.Customers.DeleteOnSubmit(oldCustomer);
    dc.Customers.InsertOnSubmit(newCustomer);
    // DB に反映
    //dc.SubmitChanges();
}

2.5 SubmitChanges 後の、Entity オブジェクトのプロパティ、フィールド値のテーブルのカラム値との自動同期について

SubmitChanges メソッドにより更新や新規作成をデータベースに反映する場合、レコードの値がトリガー処理によって変更される場合や自動採番列が存在してデータが新規作成される場合、レコードからデータを読み直すひつようがあります。新規作成、更新処理時にレコードから特定の列を読み直すかは、ColumnAttribute.AutoSync の値のよって指定します。AutoSync は System.Data.Linq.Mapping.AutoSync 列挙型の値を指定します。既定では Default で、その場合、IsDbGenerated が true の場合は、Insert 処理時に列の値が Entity のプロパティ、フィールドに設定されます。ColumnAttribute.IsVersion が true の場合は 更新、新規作成処理時にプロパティ、フィールドに値が設定されます。他の値は Always, Never, OnInsert, OnUpdate があります。詳細な内容については MSDN ドキュメントを参照して下さい。

2.6 Insert, Update, Delete 処理をカスタマイズする

DataContext の SubmitChanges(ConflictMode failureMode) をオーバーライドし、GetChangeSet() メソッドで変更対象の Entity オブジェクトを取得し、カスタム処理を記述すると、データベースへの反映処理をカスタマイズできます。この場合、全体のロジックを変更することになります。特定の Entity クラスに対して、動的に作成されたクエリではなく、ストアドプロシージャを呼びだしたり、監査ログを出力するなどの処理をすることで、新規作成、更新、削除処理をカスタマイズする場合は、各 Entity の名前に対応する特別なシグネチャをもつメソッドを DataContext を継承したクラスで定義することで行えます。

Insert 処理のシグネチャは、 public void InsertType(Type inserted){}, 更新処理のシグネチャは、 public void UpdateType(Type current){}, 削除処理のシグネチャは public void DeleteType(Type deleted){} となります。ここで、Type は Entity クラスの名前となります。SalesReason エンティティクラスに対して、カスタム処理を記述した場合のサンプル処理を以下に掲載します。各メソッドは DataContext を継承した AdventureWorksDataContext  に定義されていとします。更新処理時に DataContext  からリフレクションを使用して各シグネチャにマッチするメソッドが呼び出されます。

/// <summary>
/// 新規作成のカスタマイズ。
/// DataContext からリフレクションを介して呼び出される。
/// </summary>
/// <param name="inserted"></param>
public void InsertSalesReason(SalesReason inserted)
{
    // 監査ログ、プロパティの変更等を行える
    // 既定の処理ではなく、ストアドを実行する
    // ことも可能

    // デフォルトの新規作成処理を実行する場合
    this.ExecuteDynamicInsert(inserted);
}
/// <summary>
/// 更新処理のカスタマイズ
/// DataContext からリフレクションを介して呼び出される。
/// </summary>
/// <param name="current"></param>
public void UpdateSalesReason(SalesReason current)
{
    // GetOriginalEntityState メソッドを使用しても変更前の状態の
    // オブジェクトにアクセス
    SalesReason original = SalesReasons.GetOriginalEntityState(current);

    int affectedRows = ExecuteCommand("exec dbo.usp_UpdateSalesReason @id={0}, @name={1}"
                                            , original.SalesReasonID, current.Name);
    if (affectedRows < 1)
        throw new ChangeConflictException();

    // デフォルトの更新処理を実行することもできる
    //this.ExecuteDynamicUpdate(current);
}
/// <summary>
/// 削除処理のカスタマイズ実行
/// DataContext からリフレクションを介して呼び出される。
/// </summary>
/// <param name="deleted"></param>
public void DeleteSalesReason(SalesReason deleted)
{
    // 既定の削除処理
    this.ExecuteDynamicDelete(deleted);
}

サンプルプログラムでも記載していますが、既定の新規作成、更新、削除を実行する場合は、それぞれ、 DataContext クラスの ExecuteDynamicInsert, ExecuteDynamicUpdate, ExecuteDynamicDelete メソッドを呼び出します。

カスタムInsert, Update, Delete 処理を実装した場合、排他制御の処理も開発者が責任を持つ必要があります。

2.7 排他制御発生と解決方法

DataContext.SubmitChanges() を呼び出したときに他ユーザによる更新などで、排他例外が発生すると、既定の処理では ChangeConflictException がスローされます。このとき発生したコンフリクトは、DataContext.ChangeConflicts に格納されています。 ChangeConflicts は ObjectChangeConflict インスタンスのコレクションです。ObjectChangeConflict.MemberConflicts には関係するメンバーにリストが格納されています。MemberConflicts プロパティはMemberChangeConflict クラスのインスタンスで、現在の Entity オブジェクトのプロパティ、フィールドの値CurrentValueと、 変更前の値 OriginalValue および、データベースの列値 DatabaseValue のプロパティを保持しています。排他例外が発生したテーブル名を調べる場合は、DataContext.Mapping.GetTable(objectChangeConflict.Object.GetType()) を呼び出すことで、MetaTable クラスにオブジェクトにアクセスして、テーブル名などのメタ情報にアクセスできます。ここで、objectChangeConflict はObjectChangeConflict クラスのインスタンスとします。

排他制御例外が発生した場合は、開発者はどのように例外を解決するかを決定する必要があります。排他制御を解決する場合は、DataContext を破棄してもう一度データを読み直すか、 DataContext.Refresh() メソッドを使用します。DataContext.Refresh メソッドには、 RefreshMode の列挙値を指定します。列挙値によってメモリ上にロードされている Entity オブジェクトを変更します。RefreshMode はKeepChanges, KeepCurrentValue, OverwriteCurrentValues があります。詳細は MSDN ドキュメントを参照して下さい。

排他制御のコンフリクト発生の(Where 句に含めるかの判定)判定に使用されるプロパティ、フィールドとするかどうかは ColumnAttribute の UpdateCheck の値によって決定されます。指定できる値は Always, Never, WhenChanged です。例えば Never を指定すると、排他制御のチェック条件として Where 句に現れなくなります。各値の詳細はMSDN ドキュメントを参照して下さい。ただし、ColumnAttribute.IsVersion に true が設定されているプロパティ、フィールドがある場合は、UpdateCheck の指定は無効になります。

3 トランザクション処理

トランザクション処理は従来の DbTransaction を使用する方法と TransactionScope を使用する方法があります。TransactionScope は DTS (Data Transaction Coordinator) を使用するので、DbTransaction と較べればパフォーマンスが劣る可能性がありますが、オンライン処理で使用する少量のデータの場合はプログラムが比較的単純になる TransactionScope を使用しても問題がないと思います。

/// <summary>
/// DbTransaction を使用したトランザクション
/// </summary>
public static void DbTransactionVersion()
{
    AdventureWorksDataContext dc = new AdventureWorksDataContext(ConnectionString);

    SalesReason sr = dc.SalesReasons.Single(s => s.SalesReasonID == 13);
    sr.Name = "change with Transaction";

    dc.Log = Console.Out;
    using (dc.Transaction = dc.Connection.BeginTransaction())
    {
        dc.SubmitChanges();
        dc.Transaction.Commit();
    }
}
/// <summary>
/// TransactionScope を使用したトランザクション
/// System.Transactions.dll の参照の追加が必要
/// </summary>
public static void TransactionScopeVersion()
{
    AdventureWorksDataContext dc = new AdventureWorksDataContext(ConnectionString);
    SalesReason sr = dc.SalesReasons.Single(s => s.SalesReasonID == 13);
    sr.Name = "change with TransactionScope";

    dc.Log = Console.Out;
    using (TransactionScope ts = new TransactionScope())
    {
        dc.SubmitChanges();
        ts.Complete();
    }
}

4. その他(非接続環境の対応)

SOA 環境でLINQ to SQL を使用するときに、Entity クラスをシリアライズする場合は、DataContractSerializer を使用するようにします。SQL Metal や Visual Studio 2008 のデザイナを使用すれば、DataContract がアノテートされた Entity クラスを自動生成してくれるようになります。シリアライズされた Entity オブジェクトをトラッキングサービスにアタッチするには、 Table<TEntity>.Attach メソッドを使用します。サンプルで掲載した例を使用するとAdventureWorksDataContext.SalesReasons.Attach(  salesReasonObject ) のように呼び出します。 Attach のオーバーライド版を使用すると、変更されたオブジェクトとしてシリアライズされた Entity オブジェクトを DataContext にアタッチできます。

Entity クラスからテーブルを作成することができます。AdventureWorksDataContext.CreateDatabase(), DeleteDatabase() を使用してテーブルの作成、削除を行えます。テーブルの作成、削除を行うためには、Entity クラスのプロパティ、フィールドに付与された ColumnAttribute の DbType を設定しておく必要があります。

説明は以上です。 誤り、指摘点などがありましたら、ご連絡ください。