WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

null 許容参照型と EF Core Code First

by WebSurfer 2022年5月12日 15:15

Visual Studio 2022 のテンプレートを使って .NET 6.0 アプリのプロジェクトを作ると、デフォルトで「Null 許容」オプションが有効にされています。その状態での EF Code First による SQL Server データベース生成に新発見 (自分が知らなかっただけですが) があったので備忘録として書いておきます。

「Null 許容」オプション

EF Code First でデータベースを生成すると、元となるコードのクラス定義の中のプロパティの型と付与する属性によって生成されるデータベースの列の型と NULL 可/不可が決まってきます。新発見というのは「Null 許容」オプションの有効化によって、生成される列の NULL 可/不可が以前と違ってくるということです。

値型の場合は「Null 許容」オプションの有効/無効は関係なく結果は以前と同じになります。すなわちデフォルトでデータベースの当該列は NULL 不可になります。NULL 可にしたい場合は Nullable<T> 型(例えば int? とか DateTime? など)をプロパティの型に使います。

違うのは参照型の場合です。「Null 許容」オプションが有効にされていると、例えばプロパティの型を string とすると当該データベースの列は NULL 不可に、string? とすると NULL 可になります。

以前 (null 許容参照型が使えない時代または「Null 許容」オプションが無効) は string 型は NULL 可になりました。NULL 不可にしたい場合は当該プロパティに RequiredAttribute 属性を付与していました。

実際にアプリを作って試してみましたので具体例を以下に書きます。

Visual Studio 2022 のテンプレートでフレームワークを .NET 6.0 としてコンソールアプリを作成します。その状態で上の画像のように「Null 許容」オプションが有効化されています。

NuGet パッケージ Microsoft.EntityFrameworkCore.SqlServer と Microsoft.EntityFrameworkCore.Tool をインストールします。前者は SQL Server 用の EF Core 本体、後者は Migration 操作を行うためのツールです。

NuGet パッケージ

Microsoft のドキュメント「新しいデータベースの Code First」と同様なコンテキストクラスとエンティティクラスを実装します。コードは以下の通りです。(null 許容参照型対応のため = null! を追加するなどしていますが基本は同じ)

(注: EF Core 7.0 以降では、DbContext と DbSet に "EF がリフレクションを使用してこれらのプロパティを自動的に初期化するため、この警告は抑制されます" と書いてある通り、下の Blogs, Posts プロパティに = null!; は不要です)

public class Blog
{
    public int BlogId { get; set; }
    public string Name { get; set; } = null!;

    public virtual List<Post>? Posts { get; set; }
}

public class Post
{
    public int PostId { get; set; }
    public string Title { get; set; } = null!;

    public string? Content { get; set; }

    public int BlogId { get; set; }
    public virtual Blog? Blog { get; set; }
}

public class BloggingContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; } = null!;
    public DbSet<Post> Posts { get; set; } = null!;

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        if (!optionsBuilder.IsConfigured)
        {
            optionsBuilder.UseSqlServer("接続文字列");
        }
    }
}

上のコードの Blog クラスの Name プロパティ、Post クラスの Title, Content プロパティの型を string / string? と使い分けている点に注目してください。これをベースに Migration 操作によって SQL Server にデータベースを生成した結果が以下の画像です。

SQL Server にデータベース生成

プロパティが string 型になっている Name, Title プロパティに対応する SQL Server データベースの Name 列と Title 列は NULL 不可に、string? 型になっている Content プロパティに対応するデータベースの Content 列は NULL 可になっています。

以前 (null 許容参照型が使えない時代または「Null 許容」オプションが無効) は、上にも書きましたが、プロパティの型が参照型の場合はデータベースの当該列は NULL 可になります。NULL 不可にする場合は RequiredAttribute 属性を付与します。試しに、以下のように #nullable disable を付与したクラス定義を追加し、Migration 操作で SQL Server に Products テーブルを生成してみました。

#nullable disable
public class Product
{
    public int ProductId { get; set; }

    [Required]
    public string ProductName { get; set; }

    public string Decription { get; set; }

    [Column(TypeName = "decimal(18,4)")]
    public decimal UnitPrice { get; set; }
}

結果は以下の通りです。プロパティの型が string の ProductName, Description に該当する列の NULL 可/不可を見てください。プロパティに RequiredAttribute 属性を付与しないと NULL 可になります。

#nullable disable で生成

既存のデータベースからリバースエンジニアリングで生成したエンティティクラスの各プロパティの型が Null 許容か否かも、データベースの当該フィールドの NULL 可/不可と同じになります。

ナビゲーションプロパティの型については注意が必要です。例えば、上の画像の dbo.Blogs, dbo.Posts テーブルからリバースエンジニアリングでエンティティクラスを作成した場合、dbo.Blog テーブルの外部キーフィールド BlogId が NULL 不可になっているため、Blog クラスの Posts ナビゲーションプロパティと、Post クラスの Blog ナビゲーションプロパティの型は Null 許容にはなりません。

そうなるとどういう問題が起きるかと言うと、エンティティクラスをビューモデルに使ってブラウザからのデータを MVC アプリのアクションメソッドで受け取る場合、モデルバインディングでナビゲーションプロパティには null が代入されるので、ModelState.IsValid が false になり、Create, Edit に失敗することです。解決策は、生成されたコードに手を加えて Null 許容にすることです。

試しに Microsoft のサンプルデータベース Northwind の Categories テーブルからリバースエンジニアリングでコンテキストクラス、エンティティクラスを生成してみました。Categories テーブルは以下の内容になっています。CategoryName 列が NULL 不可、Description 列が NULL 可になっているところに注目してください。

Northwind の Categories テーブル

上の Categories テーブルからリバースエンジニアリングを使ってデータアノテーション属性を含めてエンティティクラスを生成すると以下の通りとなります。データベースのテーブルの各列の NULL 可/不可と、生成されたクラス定義の各プロパティの型を見てください。データベースの列が NULL 可の場合はプロパティの型は null 許容(? を付与されている)となっています。

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

namespace MvcCore6App2.Models
{
    [Index("CategoryName", Name = "CategoryName")]
    public partial class Category
    {
        public Category()
        {
            Products = new HashSet<Product>();
        }

        [Key]
        [Column("CategoryID")]
        public int CategoryId { get; set; }
        [StringLength(15)]
        public string CategoryName { get; set; } = null!;
        [Column(TypeName = "ntext")]
        public string? Description { get; set; }
        [Column(TypeName = "image")]
        public byte[]? Picture { get; set; }

        [InverseProperty("Category")]
        public virtual ICollection<Product> Products { get; set; }
    }
}

この記事の本題の話は以上ですが、このクラス/プロパティ定義をそのまま ASP.NET MVC の Model として使った場合、ユーザー入力の検証がどうなるのかが気になります。それも調べましたので以下に書いておきます。

以前は string 型のプロパティに該当するテキストボックスへのユーザー入力を必須とする場合、その項目の当該プロパティに RequiredAttribute 属性を付与していました。それにより未入力の場合は検証 NG となってエラーメッセージが表示されます。

「Null 許容」オプションが有効化されている場合、string 型の項目は必須入力になるはずですが、上のコードではプロパティには RequiredAttribute 属性は付与されていません。そこはどうなるのかが疑問でした。

実際にアプリを動かして試してみると、RequiredAttribute 属性は付与されてなくても、未入力の場合は検証 NG となってエラーメッセージが表示されました。

View から生成される html ソースを調べてみると、当該 input 要素には data-val-required="The xxx field is required." という検証属性が付与され、未入力の場合は検証機能が働いてエラーメッセージが表示されるようになっていました。

エラーメッセージを任意のものに変えたい場合は、プロパティに RequiredAttribute を付与して ErrorMessage にメッセージを設定します。そうすると data-val-required 属性に設定される文字列が ErrorMessage に設定したものに置き換わります。

このあたりは先の記事「int 型プロパティの検証、エラーメッセージ」に書いた int 型の場合と同じになっているようです。

最後にもう一つ、こんなことをする人はいないかもしれませんが、string? 型のプロパティ(null 可)に RequiredAttribute 属性を付与(null 不可)するとどうなるかを書いておきます。

そのような設定をすると、EF Core を使って SQL Server からデータを取得する際当該列のデータに NULL が含まれていると、

System.Data.SqlTypes.SqlNullValueException: Data is Null. This method or property cannot be called on Null values.

・・・という例外がスローされます。下の画像を見てください。

SqlNullValueException

Microsoft のドキュメントによると、SqlNullValueException は「System.Data.SqlTypes 構造体の Value プロパティが null に設定されている場合にスローされる例外」ということだそうです。

メカニズムは不明ですが、string? 型のプロパティに RequiredAttribute 属性を付与し EF Core で SQL Server からデータを取得してくるときに、データに NULL があると「SqlTypes 構造体の Value プロパティが null に設定」ということになるようです。

SqlNullValueException の説明と RequiredAttribute 属性を設定したことが結びつかなくて、解決に悩んで無駄な時間を費やすことになるかもしれませんので注意してください。(実は自分がそうでした)

Tags: , , ,

ADO.NET

Code First で外部キープロパティの定義

by WebSurfer 2016年9月11日 14:08

先に、Entity Framework Code First の機能を利用して SQL Server データベースに親子関係のあるテーブルを生成し、ASP.NET MVC4 で登録・編集・削除を行うという以下の 3 つの記事を書きました。

  1. MVC4 EF Code First(Entity Framework Code First の機能を利用して SQL Server 2008 Express に MVC4 アプリケーション用のテーブルを生成)
  2. 親子関係のあるデータ登録(Create アクションメソッド / ビューを追加して、上記 1 で作成したテーブルにデータを登録)
  3. 親子関係のあるデータの編集・削除(Edit および Delete アクションメソッド / ビューを追加して、上記 2 で登録したデータを編集・削除)

上の記事では、テーブル生成のベースとなる Child クラスに Parent クラスを参照するナビゲーションプロパティと外部キープロパティは定義していませんでした。(具体的には上記 1 の記事の Model のコードを見てください)

それに、Microsoft の文書「Code First 規約」の「リレーションシップ規約」セクションに書いてある "型には、ナビゲーションプロパティに加え、依存オブジェクトを表す外部キーのプロパティを追加することをお勧めします" に従って、ナビゲーションプロパティと外部キープロパティを定義するとどのような影響があるかを書きます。

追加後のコードは以下の通りです。上で言う「依存オブジェクトを表す」型は Parent クラスですので、Child クラスに Parent クラスへのナビゲーションプロパティと外部キープロパティを追加します。外部キープロパティは int 型とし、Children テーブルに生成される外部キーフィールドを NULL 不可に(連鎖削除を設定)します。

public class Child
{
    public int Id { get; set; }

    [Required(ErrorMessage = "{0} は必須")]
    [StringLength(5, ErrorMessage = "{0} は {1} 文字以内")]
    [Display(Name = "Child Name")]
    public string Name { get; set; }

    // 外部キープロパティを追加
    // int 型なので外部キーフィールドは NULL 不可になる
    public int ParentId { get; set; }

    // ナビゲーションプロパティを追加
    public virtual Parent Parent { get; set; } 
}

その後、上記 1 の記事「MVC4 EF Code First」に書いた手順に従って、Controller と View を作って Controller のアクションメソッドを呼び出すと、以下とおり Parents テーブルと Children テーブルが自動的に生成されます。

生成されたテーブル

Child クラスへの int 型外部キープロパティ ParentId の追加により、Children テーブルの外部キーフィールドがプロパティ名と同名の ParentId となり NULL 不可に設定されたことが上記 1 の記事で作成したデータベースと異なる点です。

ASP.NET MVC4 アプリからこれらのテーブルへのデータの登録は、上記 2 の記事「親子関係のあるデータ登録」の手順どおりで可能です。

編集・削除は上記 3 の記事「親子関係のあるデータの編集・削除」とは若干異なってきます。注意すべき点を以下に箇条書きにします。

編集 (Edit)

  • Child クラスに ParentId プロパティが定義されているので ParentId の値もポストする必要がある。Id と同様に View に隠しフィールドを追加して対応する。そうしないと、モデルバインディングの際 ParentId プロパティにはゼロがバインドされ、db.Entry(postedParent).State を Modified に設定する時に参照整合性制約違反でエラーになる。
  • UpdateModel(db.Parents.Find(id)) の後 db.SaveChanges するのはエラーになる。Children テーブルの関連するレコードの外部キーフィールド ParentId を NULL に書き換え、ポストされたデータでレコードを新たに作り INSERT するという動きになるが、外部キーフィールド ParentId は NULL 不可なのでエラーになる。(先の記事の例のように外部キーフィールドが NULL 可であればエラーは出ない。外部キーフィールドが NULL の余計なレコードは残るが DB の整合性は保たれる)
  • db.Entry(親).State を Modified にしただけでは、先の記事の例と同様、親しか更新されない。コードを書いて子の State も Modified に設定する必要がある。(実は、連鎖削除だけでなく連鎖更新もされるのではと期待したがダメでした)
  • 親も子も無条件で State を Modified に設定すると、変更する必要のないレコードまで UPDATE されてしまう。先の記事の例ではそうコーディングしたが、考え直した方がよさそう。具体的な案としては、db.Parents.Find(id) で Parent オブジェクトを取得し、そのプロパティをポストされた値(アクションメソッドの引数 postedParent から取得)で一つ一つ書き換えるのがよさそう。そうすると、前の値と変更になった場合のみ自動的に Modified マークが付けられ、db.SaveChanges で更新される。(前の値と変わらなければ Unchanged マークのままなので更新されない) この方法を取れば、View の隠しフィールドで Id や ParentId をポストする必要もなくなる。

削除 (Delete)

  • Microsoft の文書「Code First の規約」によると、"依存エンティティの外部キーで null 値が許容されない場合、Code First はリレーションシップに連鎖削除を設定します" とのこと。外部キーフィールド ParentId が NULL 不可なので、フレームワークはリレーションシップに連鎖削除を設定しているはず。実際に削除を試してみると、親だけ Remove すれば連鎖的に子も削除されることを確認できた。(上記 3 の記事のように、子を Remove するのは不要)
  • 同じ Microsoft の文書には "依存エンティティの外部キーで null 値が許容される場合は、Code First はリレーションシップに連鎖削除を設定しないため、プリンシパルが削除されると外部キーが null に設定されます" と書いてあるが、上記 3 の記事(外部キーは NULL 可)で試した限りではそのようにはならない。子を持つ親を削除しようとすると FK 制約に引っかかって SqlException がスローされる。

Tags: ,

MVC

EF でレコードの削除

by WebSurfer 2015年12月21日 16:34
2016/9/13 書換
間違いや新事実を見つけて部分的な書き換えや追記を行っているうちに意味がよく分からな��記事になってしまったので、内容を整理して全面的に書き換えました。

Entity Framework Code First の機能を利用して SQL Server データーベースに作った親子関係のあるテーブルで、レコードの削除を行った際にハマって悩んだ話を書きます。

以下のコードは、Microsoft のチュートリアル「新しいデータベースの Code First」に記載されていたクラス定義ですが、これをそのままサンプルとして使用して説明します。

public class Blog
{
    public int BlogId { get; set; }
    public string Name { get; set; }

    public virtual List<Post> Posts { get; set; }
}

public class Post
{
    public int PostId { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }

    public int BlogId { get; set; }
    public virtual Blog Blog { get; set; }
}

public class BloggingContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
    public DbSet<Post> Posts { get; set; }
} 

上記のコードをベースに、チュートリアルの手順通りに SQL Server 2008 Express にデータベースを作ると以下の画像のテーブルとフィールドが生成されます。上に親子関係のあるテーブルと書きましたが、Blogs が親、Posts が子です。「Code First の規約 (Code First Conventions)」に従い、Posts テーブルに BlogId という名前の外部キーフィールドが生成され、NULL 不可になっているところに注目してください。

生成された DB

これに上の画像に示すデータを追加した後、例えば以下のようなコードで、コンテキストから一つの親(Blog オブジェクト)を取得し、その中の子のコレクション(List<Post>)から最初の要素を削除した後、SaveChanges メソッドでデータベースに結果を反映しようとしたとします。(Posts テーブルの中の PostId が 4 のレコードを削除しようと試みたということです)

class Program
{
  static void Main(string[] args)
  {
    using (var db = new BloggingContext())
    {
      var b = db.Blogs.Single(i => i.BlogId == 3);
      b.Posts.Remove(b.Posts[0]);
      db.SaveChanges();
    }
  }
}

そうすると、db.SaveChanges(); で InvalidOperationException がスローされます。エラーメッセージは以下のようになります。

"The operation failed: The relationship could not be changed because one or more of the foreign-key properties is non-nullable. When a change is made to a relationship, the related foreign-key property is set to a null value. If the foreign-key does not support null values, a new relationship must be defined, the foreign-key property must be assigned another non-null value, or the unrelated object must be deleted."

上のエラーメッセージが何を言っているかを簡単に言うと「Posts テーブルの BlogId フィールドは NULL 不可だが、それを NULL にしようとして失敗した」ということです。

つまり、上記のコードは Posts テーブルの当該レコードを DELETE するのではなく、当該レコードの外部キーフィールド BlogId を NULL にしようとします。結果、BlogId フィールドは NULL 不可なので失敗します。

コードを少し変更(b.Posts.Remove... の b を db 変更)して以下のようにすると削除に成功します。上の画像の Posts テーブルのレコード一覧で赤枠で囲った部分が下のコードの実行結果ですが、元あった PostId が 7 のレコードが削除されています。

class Program
{
  static void Main(string[] args)
  {
    using (var db = new BloggingContext())
    {
      var b = db.Blogs.Single(i => i.BlogId == 4);
      db.Posts.Remove(b.Posts[0]);
      db.SaveChanges();
    }
  }
}

ハマったのはこの違いです。b と db で何が違うのでしょうか? 以下にその説明を書きます。

前者のコード b.Posts.Remove(b.Posts[0]);

b は Blog クラスのオブジェクトです。詳しく言うと、BloggingContext オブジェクトから Blogs プロパティを使って DbSet<Blog> オブジェクト(親のコレクション)を取得し、その中から BlogId == 3 の条件で取得した Blog オブジェクトです。

なので、前者のコードの意味は、Blog オブジェクトから Posts プロパティを使って List<Post>(外部キーで関連付けられた Post オブジェクトのコレクション)を取得し、それから b.Posts[0] に該当する子を削除するということになります。

つまり、親子の関係を絶つという指示を出しただけで、データベースの Posts テーブルから該当するレコードを削除していいとは誰も言ってないです。

親子の関係を絶つというのは、データベース上では Posts テーブルの当該レコードの外部キーフィールド BlodId を NULL に設定することに相当し、db.SaveChanges() でその操作が行われたということのようです。

なお、外部キーフィールド BlodId を NULL に設定する動きになると言っても、b.Posts[0] の BlogId プロパティが null に書き換えられるわけではないので注意してください(元の値のまま変わりません)。

コード上では、(1) 親が保持する子のコレクション List<Post> から b.Posts[0] に該当する子が外され、(2) b.Posts[0] に該当するエンティティの EntityState が Unchanged から Modified に変わるのみです。

結果からの想像ですが、フレームワークは上の (1), (2) を見て、db.SaveChanges() でデータベースの Posts テーブルの当該レコードの外部キーフィールド BlodId を NULL に設定しようとするようです。

後者のコード db.Posts.Remove(b.Posts[0]);

前者のコードからは、b(Blog オブジェクト)が db(BloggingContext オブジェクト)に変わっている点に注意してください。

なので、後者のコードの意味は、BloggingContext オブジェクト の Posts プロパティを使って DbSet<Post> オブジェクト(子エンティティのコレクション)を取得し、それから b.Posts[0] に該当する子エンティティを削除するということになります。

データベース上では Posts テーブルの当該レコードを削除することに相当するので、当該子エンティティの EntityState は Deleted に設定され、db.SaveChanges() で当該レコードは削除されます。


Posts テーブルの BlogId フィールドが NULL 不可になるのは Post クラスの外部キープロパティ BlogId を int 型にしたからです(クラスに定義されるプロパティが null にできない型の場合は、Code First の規約に従って、データベースのフィールドも NULL 不可になります)。

Microsoft の文書「Code First の規約」によると、外部キーフィールドの NULL 可 / 不可によって DELETE 操作の結果に以下の違いがあるそうです。

"依存エンティティの外部キーで null 値が許容されない場合、Code First はリレーションシップに連鎖削除を設定します。依存エンティティの外部キーで null 値が許容される場合は、Code First はリレーションシップに連鎖削除を設定しないため、プリンシパルが削除されると外部キーが null に設定されます"

上の例では Posts テーブルの BlogId フィールドは NULL 不可なので cascade delete が設定され、例えば以下のようなコードで親を DELETE すると関連する子も DELETE されます。

Blog b = db.Blogs.Single(i => i.BlogId == 6);
db.Blogs.Remove(b);
db.SaveChanges();

Post クラスの外部キープロパティ BlogId を int? 型(null 可)に変更して、Code First の機能を使って Posts テーブルを作ると、Code First の規約に従って、外部キーフィールド BlogId は NULL 可になります。

そのようにして作成したデータベースに対しては、外部キーフィールドBlogId は NULL 可なので当然ですが、上の「前者のコード」でエラーにならず、Posts テーブルの当該レコードの BlogId は NULL に設定されます。

「後者のコード」では Posts テーブルの当該レコードは削除されます。

一つ分からないのが、Microsoft の文書に "依存エンティティの外部キーで null 値が許容される場合は、Code First はリレーションシップに連鎖削除を設定しないため、プリンシパルが削除されると外部キーが null に設定されます" と書いてあったのに、実際に試したらそうなならなかった点です。

子を持つ親を削除しようとしたら FK 制約に引っかかって SqlException がスローされました。子があるのに先に親を削除しようとしたようです。なぜ Microsoft の文書と違うのか理由は不明です。

Tags:

ADO.NET

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2024年4月  >>
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar