4. データの挿入、読み出し、更新、削除

4.1. DbContextとエンティティの状態

Entity Framework(EF)では,DBへのデータの挿入,読み出し,更新,削除といった基本的な操作はDbContextを通じて行います. Contextという名前から分かるように,DbContextは内部に挿入や更新を行うオブジェクトの状態を保持しています.

例えばデータをDBに挿入する場合,EFでは以下のように記述します.

public class Product
{
    public int ProductId { get; set; }
    public string Name { get; set; }
    public int Price { get; set; }
}

public class ShoppingContext : DbContext
{
    public DbSet<Product> Products { get; set; }
}

static void Main(string[] args)
{
    using (var context = new ShoppingContext())
    {
        // Addした段階ではSql文はDBに発行されない
        context.Products.Add(newProduct
        {
            Name = "Test",
            Price = 1000,
        });

        // SaveChangesが呼び出された段階で初めてInsert文が発行される
        context.SaveChanges();
    }
}

上記の例の場合,データをAddした段階で即座にDBに状態が反映されるわけではなく, SaveChangesを呼び出した段階で初めてデータが更新されます.

DbContext内部では,変更を加える前のオブジェクトの状態,及び変更を加えた後のオブジェクトの状態 を保持しています. データ挿入の場合は,Addを呼び出した段階で,新規オブジェクトをAdded状態としてDbContext内に挿入します. その後,SaveChangesを呼び出すことで,DBにSQLが発行され,オブジェクトがAdded状態からUnchanged状態に変化します. DbContext内のオブジェクトは,Contextを破棄するまで残りつづけます.

using(var context = new ShoppingContext())
{
   context.Products.Add(new Product(...)) // この時点で,Contextに新しいオブジェクトが追加されAdded状態になる
   context.SaveChanges(); // この時点でContextに保存されているデータがDBに反映される
}

Contextは内部状態を持っているため,不必要になったら必ずDisposeしなければならないことに注意してください.

更新の場合の例も見てみましょう,

using (var context = new ShoppingContext())
{
   // Singleは即座にDBにSelectコマンドを発行する
   // contextにはUnchangedな状態のオブジェクトが入る
   var product = context.Products.Single(x => x.Name == "Test");

   // 取得したオブジェクトに変更を加える
   // contextにはModifiedな状態のオブジェクトが入る
   product.Name = "別のものに書き換える"

   // ここで初めてUpdate文が発行される
   context.SaveChanges();
}

LINQのSingleはデータが1件かどうかを即座に判断しなければならないため, すぐにDBへコマンドが発行されます. データ取得後は,context内部にUnchanged状態でオブジェクトが挿入されます. その後,取得したオブジェクトを書き換えると,context内部ではModified状態として記憶されます. 最後にSaveChangesが呼び出されると,DBへUpdate文が発行され,オブジェクトはUnchanged状態に変化します.

エンティティの状態には,Unchanged, Added, Deleted, Detached, Modified という5つの状態が存在します. それぞれの状態の意味は以下の通りです.

  • Unchanged状態
    • DBにデータが存在していて,全く更新が加えられてないときの状態です. DBのデータがContextにアタッチされた(読み出された)直後や,SaveChanges直後はUnchanged状態になります.
  • Added状態
    • ContextにEntityがトラッキングされた状態で,かつDBにデータが存在しない場合はAdded状態になります. SaveChangesを呼び出すと,DBにはAdded状態のEntityのInsert文が発行されます. SaveChanegsを呼び出した後はUnchanged状態になります.
  • Deleted状態
    • DBにデータが存在していて,これから削除しようとしている場合はDeleted状態となります. SaveChangesを呼び出すと,DELETE文がDBに対して発行されます. その後,EntityはDetached状態になります.
  • Detached状態
    • Detached状態は,オブジェクトは存在しているけれど,DbContextによって状態がトラッキングされていない状態です.
  • Modified状態
    • Modifiedはオブジェクトのプロパティの一部が変更されていて,まだSaveChangesが呼び出されていない状態です. SaveChangesを呼び出すと,UPDATE文がDBに対して発行されます. SaveChangesを呼び出した後はUnchanged状態になります.

4.2. データの挿入

データの挿入は単純で,Add関数を使うことで実現できます.

using (var context = new ShoppingContext())
{
    context.Products.Add(newProduct
    {
        Name = "Test",
        Price = 1000,
    });

    context.SaveChanges();
}

dbcontext_and_entity_state で説明したように,Addを呼び出した段階では,DBに対してInsert文が 発行されないことに注意してください. SaveChangesを呼び出すことで,初めてDBへ情報が反映されます. SaveChangesを呼び出すことを忘れないようにしてください.

上記のサンプルコードの場合,以下のようなコマンドがDBに対して発行されます.

exec sp_executesql N'
insert [dbo].[Products]([Name], [Price])
values (@0, @1)

select [ProductId]
from [dbo].[Products]
where @@ROWCOUNT > 0 and [ProductId] = scope_identity()' ,
N'@0 nvarchar(max) ,@1 int' ,
@0=N'Test' , @1 =1000

上記のようにInsert文が発行され,挿入したデータのProductIdが返却されていることがわかります.

DbContextを使って 大量のデータを挿入する際には注意が必要 です. dbcontext_and_entity_state でも説明した通り,DbContextは内部に挿入したオブジェクトの状態を蓄えています. よって,大量のデータをDbContextを経由して挿入するとメモリ不足に陥る可能性があります. 大量のデータを挿入したい場合は,例えば1000件挿入した後にSaveChangesし,Contextを破棄するなどの工夫が必要 なので注意してください.

4.3. データの読み出し

データの呼び出しも単純で,DbContextのDbSetを経由してアクセスするだけです. 例えば, dbcontext_and_entity_state のサンプルコードで,Productsテーブル内のすべてのデータの Nameだけを表示したい場合は,以下のように記述します.

using (var context = new ShoppingContext())
{
    foreach (var product in context.Products)
    {
        Console.WriteLine(product.Name);
    }
}

呼び出しの場合は,結果が必要になった段階でDBにSelect文が発行されます. DBへは次のようなSql文が発行されます.

SELECT
[Extent1].[ProductId] AS [ProductId],
[Extent1].[Name] AS [Name],
[Extent1].[Price] AS [Price]
FROM [dbo].[Products] AS [Extent1]

ここで気をつけなければならないことがあります. 上記のサンプルコードの場合,Nameだけが欲しかったため,

SELECT [Extent1].[ProductName] AS [ProductId] FROM [dbo].[Products] AS [Extent1]

のようなSqlを発行してほしいものですが,

Select * FROM [dbo].[Products]

というSqlが発行されます. 不必要なデータも一緒に取得されてしまいますので,もし性能等を気にする場合は,独自にSQL文を記述 する必要があります.

4.3.1. キャッシュされたデータにアクセスする

DbContextを使ってアクセスする場合に気をつけなければならないことがあります. 例えば,下記のようなサンプルコードを書いたとしましょう.

using (var context = new ShoppingContext())
{
    foreach (var product in context.Products)
    {
        Console.WriteLine(product.Name);
    }
    foreach (var product in context.Products)
    {
        Console.WriteLine(product.Name);
    }
}

上記の例だと2回product.Nameにアクセスしていますが,データはContextにキャッシュされたものを使わず,2回DBに対してSelect文が 発行されてしまいます.

こういった事態をさける為には,以下の2通りの方法が考えられます.

  1. ToArrayを使ってはじめに結果を確定させ,そのデータに対してアクセスする
  2. Load関数を使ってDbContext内にデータをキャッシュさせる

一つ目の方法として,あらかじめToArray() などをして結果を確定させてしまう方法があります.

var products = context.Products.ToArray();

このproductsに対してアクセスすれば,DBへSqlが発行されることはありません. 結果を確定させてしまうためにToArrayを行うのは,LINQでよく使われるテクニックですね.

二つ目の方法として,Load関数を呼び出して,一度DbContext内部にキャッシュを作る方法もあります. 例えば,以下のように記述します.

using System;
using System.Data.Entity;

namespace EntityFrameworkSample
{
    public class Program
    {
        static void Main(string[] args)
        {
            using (var context = newShoppingContext())
            {
                // この時点で,DBに対して SELECT * FROM Productsを発行する
                context.Products.Load();

                // context.Products.Localを使う事で,ローカルにキャッシュされたデータを使う
                foreach (var product in context.Products.Local)
                {
                    Console.WriteLine(product.Name);
                }
            }
        }
    }
}

Load関数を呼び出すことで,一度DbContext内部にデータをキャッシュします. その後,context.Products.Local と言った感じで,Localというプロパティを使う事で キャッシュされたデータにアクセスする事が可能です.

4.3.2. LINQによるデータアクセス

EFでは,データへのアクセスにはLINQを使えます. 例えば,NameがTestというデータにアクセスしたい場合,

var products = context.Products
  .Where(x => x.Name == "Test")
  .ToArray();

と記述することで,DBに対して

SELECT * From [Products] Where Name = N'Test';

といったSqlが発行されます. LINQは遅延評価なため,結果が必要になるまではSql文が発行されないことに注意してください. 結果をあらかじめ確定させたい場合は,ToArray()やLoad()を使いましょう. 上記の場合は,ToArrayを呼び出して結果を確定させてしまっているため,即座にSql文が発行されます.

その他,名前順にデータをソートしたい場合は,

var products = context.Products
  .OrderBy(x => x.Name)
  .ToArray();

と記述することで,DBに対して

SELECT * From [Products] ORDER BY [Name];

のようなSql文が発行されます.

残念ながら,EF4.3の段階では次のような事は記述は行うことができません.

context.Products
  .Where(x => x.Name == GetXXValue() );

GetXXValue() は関数ですが,Where等で指定できる値は,あらかじめ定まった値でなければなりませんので注意してください.

4.3.3. LINQ To EntitiesとLINQ To Object

LINQは便利な機能です.やりたいことを命令的に記述でき,Objectに対してもDBに対しても同じように書くことができます. しかし,LINQは使い方を誤ると思わぬ落とし穴にはまることもあります.

LINQ To EntitiesとLINQ To Objectの違いをちゃんと意識して記述を行う必要があります. 例えば,

context.Products.Where(x => x.Name == "Test").ToArray();
var products = context.Products.ToArray();
products.Where(x => x.Name == "Test")

は,前者はLINQ To Entitiesですが,後者はLINQ To Objectです. LINQ To Entitiesの場合,DBに対してWhere句を発行し,その結果を取得しています. LINQ To Object の場合は,DBにはSelect * を発行して全ての値を貰ってきています. その後,データをWhereでフィルターしています. これら二つは,結果こそ同じですが,パフォーマンスの観点からみると全くの別物です.

LINQ To ObjectとLINQ To Entitiesの違いにはまることもあります. 例えば,C#側のソート条件がDBとのソート条件と違う場合があります.

context.Products.OrberBy(x => x.Name).ToArray()

context.Products.ToArray().OrderBy(x => x.Name)

とでは結果が違うことがあります. 上記の場合は,DB側でNameのソートを行い,その結果を返します. 下記の場合は,C#側でNameのソートを行っています. ソートの順番が変わってしまうので,注意しましょう.

また,DBでは大文字小文字の区別がありません, Where 句でアクセスした場合もLinq To EntitiesとLinq To Objectとで結果が違うことがあり, 思わぬ落とし穴にはまる可能性もあるので注意しましょう.

Load関数で呼び出し,Localプロパティに対してLINQを使った場合もLinq To Objectになるので注意しましょう.

context.Products.Load()
context.Products.Local.OrderBy(x => x.Name);

と記述した場合は,C#側でのソートとなります.

4.3.4. 大量データ読み出し時の注意

DbContextは内部に取得したオブジェクトの状態を蓄えていると, dbcontext_and_entity_state にて説明しました. データを読み出した際は,そのEntityをUnchanged状態でContext内に保存しておきます. よって,大量のデータをDbContextを経由して読み出すと,メモリ不足に陥る可能性があります.

こういった事態をさける為の方法として,AsNoTrackingという関数が存在します. 例えば,次のようにして利用します.

using (var context = new ShoppingContext())
{
    var products = context.Products
        .AsNoTracking()
        .ToArray();

    // productsにアクセス
}

AsNoTrackingをつける事によって,読み出されたデータをDbContext内でトラッキングしません. ただし,トラッキングされてないということは,読み出したproductsに対していかなる変更も 加えられないということです. データを読み取り専用で利用したい場合には,AsNoTrackingをつける事によってパフォーマンスが向上する可能性があります.

4.3.5. Findを使ったデータの検索

1件だけのデータを取得したい場合,LINQではSingleを使います. EFの場合も,Singleを使ってデータにアクセスします.

context.Products.Single(x => x.name == "Test");

DBには,Select Top 2 * From Products といったようなSQLが発行されます. 2件データを取ってきて,結果が2件,または0件の場合はエラーを返します. 結果が1件の場合は,そのデータを返します.

1件または0件のデータを取得したい場合,LINQではSingleOrDefaultを使いますが, EFではより効率の良い関数,Findが存在します. 例えば,次のようにして利用します.

context.Products.Find(x => x.name == "Test");

SingleOrDefaultと動作は同じですが,効率が違います. Findの場合は,あらかじめDbContext内部にキャッシュされているエンティティが有ればそれを返します. もしキャッシュがなければDBへSqlを発行し,結果を返します. つまり,DBへの不要なアクセスを押さえることができます.

4.4. データの更新

データの更新はとても簡単です.一度読み込んだデータを編集し,SaveChangesを行うだけです.

using (var context = new ShoppingContext())
{
     var product = context.Products.Single(x => x.Name == "Test");
     product.Name = "Aiueo";

     context.SaveChanges();
}

SaveChangesを行うと,Update文が発行されます.

4.5. データの削除

データを削除するには,Removeを使います.

using (var context = new ShoppingContext())
{
    var product = context.Products.Single(x => x.Name == "Test");
    context.Products.Remove(product);

    context.SaveChanges();
}

ProductIdが1の場合,次のようなSql文が発行されます.

SELECT TOP (2)
[Extent1].[ProductId] AS [ProductId],
[Extent1].[Name] AS [Name],
[Extent1].[Price] AS [Price]
FROM [dbo].[Products] AS [Extent1]
WHERE N'Test' = [Extent1].[Name]

exec sp_executesql N'
delete [dbo].[Products]
  where ([ProductId] = @0)',
N'@0 int',
@0=1

DELETEを発行するためには,一度DbContext内部にオブジェクトをトラッキングさせて(上記の場合はSingleでデータを取ってきて), Remove関数を使ってオブジェクトをDeleted状態に変化させます.その後,SaveChangesを呼び出す事によって DELETE文が発行されます. 削除したいデータのID(例の場合はProductId)があらかじめ分かっている場合, わざわざDBからデータをSelectしてきてからDeleteを発行するのは,やや無駄な処理のように思えます.

あらかじめIDが分かっているのなら,Attach関数とRemove関数を組み合わせることによって, いちいちデータをSelectしなくてもDELETE文を発行することができます. Attach関数を使う事によって,指定したエンティティが既にDBに存在しているかのように振る舞わせることができます. 例えば,ProductIdが1のデータを削除したい場合は,次のように記述できます.

using (var context = new ShoppingContext())
{
     var toRemoveProduct = new Product { ProductId = 1 };
     context.Products.Attach(toRemoveProduct);
     context.Products.Remove(toRemoveProduct);

     context.SaveChanges();
}

Attach関数を呼び出す事で,DbContextにProductId = 1のオブジェクトが既に存在するかのように 振る舞わせることができます. ここで,Remove関数を使い,オブジェクトをDeleted状態に変化させSaveChangesを呼び出すことで, Delete文が発行されます. Deleteに必要なのはProductIdだけなので,Productの他のプロパティ(例えばName等)は設定する必要はありません.

Attach関数は,既に呼び出されているエンティティに対して使っては行けません. すでに存在しているオブジェクトをAttachしようとすると,InvalidOperationExceptionが発生します.

4.6. SaveChangesの動作

SaveChangesは,DbContext内でトラッキングされているオブジェクトのうち, UnchangedまたはDetached状態以外のものを発見すると,それらの情報を反映させるSql文をDBに対して発行します.

SaveChangesはトランザクショナルな関数です. 例えばデータの更新と削除をDbContextに対して行い,SaveChangesを呼び出すとします. SaveChangesを呼び出した段階で,DBに対してUpdate文とDelete文が発行されます. ここで,Updateには成功して,Deleteには失敗した場合,Updateしたデータは自動的に ロールバックされます.