WebSurfer's Home

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

中間テーブルへのナビゲーションプロパティ

by WebSurfer 2023年5月3日 15:32

多対多のリレーションシップを関連付ける中間テーブルへのナビゲーションプロパティ定義の問題で ASP.NET Core MVC での Create, Edit に失敗するのにハマって悩みました。またハマることがないよう備忘録として書いておきます。

エンティティ クラス

上の画像は Microsoft のドキュメント「チュートリアル: ASP.NET MVC Web アプリでの EF Core の概要」のもので Enrollment が中間テーブルです。チュートリアルは Student テーブルの CRUD 操作を行う ASP.NET Core MVC アプリを作成するもので、これをこの記事の例に使います。

Visual Studio 2022 のテンプレートを使ってターゲットフレームワーク .NET 6.0 以降でプロジェクトを作ると、デフォルトでプロジェクト全体で「Null 許容」オプションが有効にされます。

「Null 許容」オプションが有効にされていると、チュートリアルの Student クラスの定義では LastName, FirstMiddleName, Enrollments プロパティ に対しては "null 非許容のプロパティ 'xxxxx' には、コンストラクタの終了時に null 以外の値が入っていなければなりません" という警告が出ます。

その警告を回避するのに、以下のように = null!; を追加したのですが、ナビゲーションプロパティ Enrollments に対してそれはダメでした。ASP.NET Core MVC アプリによる CRUD 操作のうち Create, Edit に失敗します。

public class Student
{
    public int ID { get; set; }
    public string LastName { get; set; } = null!;
    public string FirstMidName { get; set; } = null!;
    public DateTime EnrollmentDate { get; set; }

    // これはダメ。ASP.NET Core MVC での Create, Edit に失敗する
    public ICollection<Enrollment> Enrollments { get; set; } = null!;
}

なぜ失敗するかと言うと、上の定義の場合 Enrollments が ModelStateDictionary に含まれるようになり、値が null になるので ValidationState が Invalid となってしまうからのようです。下の画像を見てください。結果、ModelSate.IsValid が false になって SaveChangesAsync メソッドがスキップされてしまいます。

デバッグ結果

チュートリアルの次のステップ「チュートリアル: CRUD 機能を実装する - ASP.NET MVC と EF Core」で、Edit アクションメソッドに TryUpdateModelAsync<T> メソッドを使う方法が紹介されていますが、それも失敗します。

解決策は、リバースエンジニアリングで既存の同等なデータベースから生成されるコードをまねて、Enrollments プロパティを以下のようにすることだと思います。

public ICollection<Enrollment> Enrollments { get; } = 
                                          new List<Enrollment>();

そうすれば、下の画像の通り ModelStateDictionary には Enrollments は含まれなくなります。Edit アクションメソッドに TryUpdateModelAsync<T> メソッドを使った場合も期待通りの結果となります。

デバッグ結果

Tags: , , ,

CORE

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

EF Core で PK / Unique 制約違反例外をキャッチ

by WebSurfer 2021年3月25日 10:26

EF Core 5.0 を利用して SQL Server のテーブルの CRUD 操作を行う ASP.NET Core 5.0 MVC アプリで、新規レコードの Create の際に PK / Unique 制約違反例外をキャッチし、エラーメッセージを表示する方法を書きます。

PK / Unique 制約の検証

先の記事「EF6 で PK / Unique 制約違反例外をキャッチ」の Core 版です。ほとんど EF6 の記事と同じですが、いくつか違う点があるので以下まとめて書いておきます。

  1. EF Code First でのデータ注釈を使用したインデックスとユニーク制約の付与は EF Core 5.0 で導入されたそうです。詳しくは Microsoft のドキュメント Indexes を見てください。(この記事でアプリのプラットフォームを .NET 5.0 としたのはそれが理由です。ちなみに、EF Core 3.x 以前は NuGet パッケージ Toolbelt.EntityFrameworkCore.IndexAttribute を利用できるらしいです)
  2. DbUpdateException から SqlException を取得する方法が EF6 とは異なります。EF6 では DbUpdateException の InnerException のさらに下の InnerException から SqlException を取得していましたが、EF Core では DbUpdateException 直下の InnerException で取得することができます。(EF6 も EF Core もコードを書いて試した結果の話です。必ずそうなると明記した Microsoft のドキュメントは見つからないので将来変わるかもしれないという不安要素はあります)
  3. DbUpdateException の InnerException から取得できる SqlException は System.Data.SqlClient 名前空間でなく Microsoft.Data.SqlClient 名前空間に属するものになります。(EF6 の場合は System.Data.SqlClient 名前空間)

先の EF6 の記事と同様に PK / Unique 制約違反例外をキャッチし、エラーメッセージを表示する機能を実装してみました。それが以下のコードです。上の画像が実行結果で SQL Server のテーブルの ProductId 列に付与した PK 制約違反を補足してエラーメッセージを表示しています。

エンティティクラス (Model)

上にも書きましたが、IndexAttribute が使用できるのは EF Core 5.0 以降です。プロパティには付与できなくてクラスに付与する必要がある点が EF6 とは異なります。

Migration の際 decimal 型のプロパティには "This will cause values to be silently truncated ..." という警告が出るので No Type Was Specified for the Decimal Column を参考に ColumnAttribute を付与してみました。その必要はないと思いますが。

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

// IndexAttribute の属する名前空間
using Microsoft.EntityFrameworkCore;

namespace MvcCore5App2.Models
{
    [Index(nameof(ProductName), IsUnique = true)]
    public class PkUnique
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.None)]
        public int ProductId { get; set; }

        [Required]
        [StringLength(128)]
        public string ProductName { get; set; }

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

コンテキストクラス

using Microsoft.EntityFrameworkCore;
using MvcCore5App.Models;

namespace MvcCore5App.DAL
{
    public class PkUniqueContext : DbContext
    {
        public PkUniqueContext(DbContextOptions<PkUniqueContext> options)
            : base(options)
        {

        }

        public DbSet<PkUnique> PkUnique { set; get; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);

            // DB に生成されるテーブルの名前を Product にした
            modelBuilder.Entity<PkUnique>().ToTable("Product");
        }
    }
}

Startup.cs に追加

上のコンテキストクラス PkUniqueContext のインスタンスを Controller に DI できるよう Startup.cs の ConfigureServices メソッドに以下のコードを追加します。

コード内の "DefaultConnection" はテンプレートで生成されたプロジェクトの appsettings.json に含まれる ASP.NET Identity 用の接続文字列です。なので、ASP.NET Identity 用のデータベースの中に Product というテーブルが追加で生成されます。

services.AddDbContext<PkUniqueContext>(options =>
  options.UseSqlServer(
    Configuration.GetConnectionString("DefaultConnection")));

上のエンティティクラスとコンテキストクラスから、EF Code First の機能を利用して生成された SQL Server のテーブルの構造は以下の通りです。上のエンティティクラスの UnitPrice プロパティに [Column(TypeName = "decimal(18,4)")] 属性を付与したのでデータ型が decimal(18,4) になっています。ちなみに、属性を設定しないと decimal(18,2) になります。

生成されたテーブルの構造

Controller / Action Method

スキャフォールディング機能を利用して Controller と View を生成します。以下のコードは、その Controller の Create アクションメソッドに、上に紹介した記事の PK / Unique 制約違反例外を補足するコードを実装したものです。

上に書きましたが、DbUpdateException から SqlException を取得する方法、取得した SqlException は Microsoft.Data.SqlClient 名前空間に属することに注意してください。

using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MvcCore5App2.Data;
using MvcCore5App2.Models;

// SqlException が属する名前空間
using Microsoft.Data.SqlClient;

namespace MvcCore5App2.Controllers
{
    public class PkUniqueController : Controller
    {
        private readonly PkUniqueContext _context;

        public PkUniqueController(PkUniqueContext context)
        {
            _context = context;
        }

        // ・・・中略・・・

        // GET: PkUnique/Create
        public IActionResult Create()
        {
            return View();
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Create(
            [Bind("ProductId,ProductName,UnitPrice")] PkUnique pkUnique)
        {
            if (ModelState.IsValid)
            {
                _context.Add(pkUnique);

                try
                {
                    await _context.SaveChangesAsync();
                }
                catch (DbUpdateException e)
                when (e.InnerException is SqlException sqlEx &&
                      (sqlEx.Number == 2601 || sqlEx.Number == 2627))
                { 
                    if (sqlEx.Number == 2627)
                    {
                        ModelState.AddModelError("ProductId", 
                                                 "PK 制約違反");
                    }

                    if (sqlEx.Number == 2601)
                    {
                        ModelState.AddModelError("ProductName", 
                                                 "Unique 制約違反");
                    }

                    return View(pkUnique);
                }
                return RedirectToAction(nameof(Index));
            }
            return View(pkUnique);
        }

        // ・・・中略・・・
    }
}

View

スキャフォールディング機能を利用して生成した create.cshtml のコードそのままです。

@model MvcCore5App2.Models.PkUnique

@{
    ViewData["Title"] = "Create";
}

<h1>Create</h1>

<h4>PkUnique</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="Create">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="ProductId" class="control-label"></label>
                <input asp-for="ProductId" class="form-control" />
                <span asp-validation-for="ProductId" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="ProductName" class="control-label"></label>
                <input asp-for="ProductName" class="form-control" />
                <span asp-validation-for="ProductName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="UnitPrice" class="control-label"></label>
                <input asp-for="UnitPrice" class="form-control" />
                <span asp-validation-for="UnitPrice" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

<div>
    <a asp-action="Index">Back to List</a>
</div>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

EF6 と同様に PK / Unique 両方に制約違反がある場合は PK 制約違反だけしか補足できません。両方補足して両方のエラーメッセージを表示する方法は分かりません。分かったらこの記事に追記します。

Tags: , , ,

CORE

About this blog

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

Calendar

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

View posts in large calendar