WebSurfer's Home

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

整数型プロパティの検証、エラーメッセージ (CORE)

by WebSurfer 2023年1月26日 11:49

.NET 7.0 の ASP.NET Core MVC で、モデルのプロパティが整数型(int, short, long 等)の場合の検証とエラーメッセージに関する注意点を書きます。

検証結果

.NET Framework の MVC5 の場合については先の記事「int 型プロパティの検証、エラーメッセージ (MVC5)」に書きましたのでそちらを見てください。

検証に使ったのは Visual Studio 2022 v17.4.4 のテンプレートで作成した .NET 7.0 の ASP.NET Core MVC アプリです。View には Html ヘルパーではなく入力 Tag ヘルパーを使いました。

Model のプロパティが整数型の場合 (下に載せた Model のコード例では ContractID, Code プロパティ)、Required 属性を付与しなくても、View の入力 Tag ヘルパーから生成される html の input 要素には data-val-required="The xxx field is required." (xxx はプロパティ名) という属性が付与されます。

(プロパティが int? 型のような null 許容型の場合は data-val-required 属性は付与されません。.NET Framework MVC5 の場合に付与された data-val-number という属性は .NET 7.0 ASP.NET Core MVC では付与されません)

View で検証用の JavaScript ファイルが取り込まれるように設定されていると (下に載せた View のコード例では @section Scripts の中のコード)、未入力の場合はクライアント側での検証により data-val-required 属性に設定された "The xxx field is required." というメッセージが表示されます。

これを任意の文字列に書き換えるには、Model の当該プロパティに Required 属性を付与し ErrorMessage に書き換えたい文字列を設定します。そうすると、html の input 要素の data-val-required 属性に設定される文字列が ErrorMessage に置き換わり、検証 NG の場合はそれが表示されます。

未入力の検証については上記の対応だけで特に不都合はないと思います。

しかし、未入力の検証に加えて、数字か否かの検証を行う場合はいろいろ問題があります。それを以下に説明します。

入力 Tag ヘルパーが以下のようになっている場合、html の input 要素の type 属性が "number" となり、ブラウザによる入力の制約と、ASP.NET による検証がかかります。

<input asp-for="Price2" class="form-control"/>

入力の制約はブラウザ依存です。自分が試した限りですが、Edge 109.0.1518.61, Chrome 109.0.5414.120, Opera 94.0.4606.76 では数字と + - . 以外の文字は受け付けなくなります。Firefox 109.0 では何でも入力できてしまいます。

クライアント側での JavaScript による検証はフレームワークに組み込みの jquery.validate.min.js により行われます。数字以外の文字を入力して送信しようとすると、"Please enter a valid number." というエラーメッセージが表示され送信はキャンセルされます。("Please enter a valid number." というエラーメッセージは jquery.validate.min.js にハードコーディングされています。それを書き換えれば日本語化はできます)

問題は、(1) 文字 + - はブラウザは受け付けるが jquery.validate.min.js による検証で NG になるという不整合、(2) エラーメッセージが英語になること、(3) さらにそれを解決するためプロパティに RegularExpression 属性を追加しても無視されることです。

上記 (1) ~ (3) を解決するには、html の input 要素が type="number" ではなく type="text" になるようにします。具体的には、View の当該入力 Tag ヘルパーに以下のように type="text" を追加します。

<input asp-for="Price2" class="form-control" type="text"/>

それにより RegularExpression 属性に設定した正規表現が期待通り動くようになり、検証結果 NG の場合は ErrorMessage に設定したエラーメッセージが表示されるようになります。

以上をまとめると:

  1. 未入力の検証には、Model の当該プロパティに Required 属性を付与し ErrorMessage にエラーメッセージを設定。
  2. 数字か否かの検証には、当該入力 Tag ヘルパーに type="text" を追加して html の input 要素の type 属性が "text" となるようにし、さらに
  3. RegularExpression 属性をプロパティに付与して正規表現を使っての検証を行う。ErrorMessage にエラーメッセージを設定する。

 

以上はクライアント側での検証の話です。

サーバー側での検証の問題は、未入力または整数型にパースできない文字列を送信した場合、Model のプロパティに付与した Required 属性、RegularExpression 属性が働かないことです。上の画像の「価格2 (int)」のエラーメッセージを見てください。

上の画像の例では、クライアント側での検証を無効にして "2000x" という文字列を送信していますが「The value '2000x' is not valid for 価格2 (int).」というエラーメッセージが出ています。RegularExpression 属性に設定したエラーメッセージとは異なっています。

未入力の場合は「The value '' is invalid.」というエラーメッセージが表示されます。やはり Required 属性に設定したエラーメッセージとは異なります。

これは、"2000x" という文字列や空白は int 型にパースできないので、検証属性による検証が行われる前にエラーとなって、そのエラーメッセージが出ているようです。(想像です)

検証属性の ErrorMessage に設定したメッセージが表示されて欲しいのですが、int 型にパースできない文字列が送信された場合は何ともならないようです。ただし、このエラーメッセージを書き換える方法はあります。

ModelStateDictionary に含まれる ModelStateEntry は同じ Key でマージしすると上書きされます。具体例は下の Controller コードの通りです。上の画像の ID (int) のテキストボックスの下のエラーメッセージを書き換えています。

Model

using System.ComponentModel.DataAnnotations;

namespace MvcNet7App.Models
{
    public class Contract
    {
        [Display(Name = "ID (int)")]
        [Required(ErrorMessage = "{0} は必須")]
        [RegularExpression(@"^\d+$", ErrorMessage = "数字のみ")]
        public int ContractID { get; set; }

        [Display(Name = "契約日 (DateTime)")]
        [Required(ErrorMessage = "{0} は必須")]
        [RegularExpression(@"^\d{4}/\d{2}/\d{2}( \d{1,2}:\d{2}:\d{2})?$",
            ErrorMessage = "yyyy/MM/dd 形式")]
        public DateTime ContractDate { get; set; }

        [Display(Name = "価格 (decimal)")]
        [Required(ErrorMessage = "{0} は必須")]
        [RegularExpression(@"^\d{1,5}$", 
            ErrorMessage = "数字 1 ~ 5 文字")]
        public decimal Price { get; set; }

        [Display(Name = "価格2 (int)")]
        [Required(ErrorMessage = "{0} は必須")]
        [RegularExpression(@"^\d+$", ErrorMessage = "数字のみ")]
        [Range(100, 10000, 
            ErrorMessage = "{0}は{1}~{2}の間で入力してください。")]
        public int Price2 { get; set; }
    }
}

View

@model MvcNet7App.Models.Contract

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

<div class="row">
    <div class="col-md-4">
        <form asp-action="ContractEdit">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="ContractID" class="control-label"></label>
                <input asp-for="ContractID" class="form-control"/>
                <span asp-validation-for="ContractID" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="ContractDate" class="control-label"></label>
                <input asp-for="ContractDate" class="form-control" type="text"/>
                <span asp-validation-for="ContractDate" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Price" class="control-label"></label>
                <input asp-for="Price" class="form-control" type="text"/>
                <span asp-validation-for="Price" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Price2" class="control-label"></label>
                <input asp-for="Price2" class="form-control" type="text"/>
                <span asp-validation-for="Price2" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

@section Scripts {
    @{
        // コメントアウトするとクライアント側での検証がかからない
        await Html.RenderPartialAsync("_ValidationScriptsPartial");
    }
}

Controller

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using MvcNet7App.Models;
using System.Diagnostics;

namespace MvcNet7App.Controllers
{
    public class HomeController : Controller
    {
        private readonly ILogger<HomeController> _logger;

        public HomeController(ILogger<HomeController> logger)
        {
            _logger = logger;
        }

        public IActionResult ContractEdit()
        {
            var model = new Contract 
            { 
                ContractID = 1, 
                ContractDate = DateTime.Now, 
                Price = 1000m, 
                Price2 = 2000
            };

            return View(model);
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult ContractEdit(Contract model)
        {
            if (ModelState.IsValid)
            {
                return RedirectToAction("Index");
            }

            // div asp-validation-summary に表示するために追加
            var newDictionary = new ModelStateDictionary();
            newDictionary.AddModelError("", 
                "ValidationSummary に表示するために追加。");
            ModelState.Merge(newDictionary);

            // エラーメッセージを書き換えることはできる。
            // 同じ Key でマージした方に上書きされる
            ModelStateEntry? state = ModelState["ContractID"];

            if (state != null && state.Errors.Count > 0)
            {
                string msg = state.Errors[0].ErrorMessage;
                if (msg.StartsWith("The value"))
                {
                    // マージすると RawValue が null になるので書き戻すた
                    // めに取得しておく
                    object? value = state.RawValue;

                    var newDictionary2 = new ModelStateDictionary();
                    newDictionary2.AddModelError("ContractID",
                        "書き換え。元は The value '1.0' is not valid for ID (int).");
                    ModelState.Merge(newDictionary2);

                    // RawValue を書き戻す。そうしないと再描画されたとき
                    // 元のユーザー入力が表示されず 0 になってしまう
                    state.RawValue = value;
                }
            }

            return View(model);
        }
    }
}

上に書いた「The value '2000x' is not valid for 価格2 (int).」とか「The value '' is invalid.」は Model Binding Error Messages というそうで、ローカライズする方法があるようです。上のように書き換えるよりローカライズする方が現実的かもしれません。

Stackoverflow の記事 ASP.NET Core Model Binding Error Messages Localization に方法が書いてあるのを見つけました。別途検証してみます。 ⇒ 2022/1/27 追記: 別の記事「Model Binding Error Messages の差替 (CORE)」に書きましたのでそちらを見てください。

Tags: , , , , ,

Validation

ASP.NET Core で DateOnly 型を使用

by WebSurfer 2022年7月19日 13:28

先の記事「PostgreSQL で Movie チュートリアル (CORE)」で書きましたように、Npgsql 6.0 の timestamp with time zone 型と .NET の DateTime 型の扱いの不整合のため例外がスローされるという問題があります。

その記事では臨時処置的に Npgsql を 6.0 より前の動作に戻すオプションを追加して解決しましたが、恒久処置的には .NET の DateTime 型を DateOnly 型に変えて、対応する PostgreSQL の型を date 型にするべきではないかと思いました。

というわけでそれを試してみましたので以下に顛末を書いておきます。ASP.NET Core はまだ DateOnly 型を十分にサポートしてないようで、すんなりとはいかなかったです。ASP.NET Core で DateOnly 型を使うのは時期尚早なのかも。

(1) DateTime 型を DateOnly 型に変更

Movie クラスの ReleaseDateプロパティの DateTime 型を DateOnly 型に変更します。

DateTime 型を DateOnly 型に変更

(2) Add-Migration の実行

ソリューションをリビルドしてから、Visual Studio のパッケージマネージャーコンソールで Add-Migration DateOnly コマンドを実行します。結果、以下の画像の通り DateOnly クラスが Migrations フォルダに自動生成されます。

Add-Migration の実行結果

Movie クラスの ReleaseDate プロパティの DateOnly 型に該当する PostgreSQL の型は date となっています。

なお、Add-Migration DateOnly の DateOnly という名前は任意に指定できます。指定した名前で xxxxx_DateOnly.cs (xxxxx は作成日時) という名前のファイルが作成され、それに DateOnly という名前のクラスが定義されます。

(3) Update-Database の実行

Visual Studio のパッケージマネージャーコンソールで Update-Database コマンドを実行します。

Update-Database の実行結果

上の画像の ReleaseData 列を見てください。Update-Database の実行前は timestamp with time zone 型であったものが date 型に変更されています。

(4) プロジェクトの実行

以上で完了のはずなのですが、プロジェクトを実行して Index 画面を表示すると ReleaseDate の表示が変でした。html ソースは以下のとおりで、DataOnly のプロパティが表示されます。

<div class="display-label">Year</div>
<div class="display-field">2022</div>
<div class="display-label">Month</div>
<div class="display-field">7</div>
<div class="display-label">Day</div>
<div class="display-field">10</div>
<div class="display-label">DayOfWeek</div>
<div class="display-field">Sunday</div>
<div class="display-label">DayOfYear</div>
<div class="display-field">191</div>
<div class="display-label">DayNumber</div>
<div class="display-field">738345</div>

Movie クラスの ReleaseDate プロパティに DisplayFormat 属性を付与してみましたが何も効果はなかったです。

さらに、Create, Edit 操作では、ブラウザからは期待通りに ReleaseDate=2022-07-18 という形で送信されるものの、アクションメソッドの引数には 0001/01/01 が渡される、すなわちモデルバインディングできてないという問題がありました。

ググって調べてみると、DateOnlyTimeOnly.AspNet という NuGet パッケージが見つかりました。Web API 用とのことですが、とりあえず試してみることにしました。

DateOnlyTimeOnly.AspNet

NuGet パッケージをインストールしたら、Program.cs で UseDateOnlyTimeOnlyStringConverters を AddControllers のオプションとして登録します。

DateOnlyTimeOnly.AspNet

結果、Index 画面の ReleaseDate は 2022/07/18 という形で表示されるようになりました。Create, Edit 操作でのモデルバインディングも正しく行われるようになりました。

もちろん、AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); は削除しても例外は出ず、Create, Edit とも成功します。

ホントはこのような追加のパッケージなしでも DateTime 型を使った場合と同様に動くべきだと思うのですが・・・

Tags: , , , ,

CORE

PostgreSQL で Movie チュートリアル (CORE)

by WebSurfer 2022年7月18日 18:48

データベースに PostgreSQL を利用して、Micorsoft の ASP.NET Core MVC のチュートリアル「ASP.NET Core MVC の概要」および「パート 4、ASP.NET Core MVC アプリにモデルを追加する」に従ってアプリを作る方法を書きます。

Movie アプリ

先の記事「MySQL で Movie チュートリアル (CORE)」、「SQLite で Movie チュートリアル (CORE)」でデータベースにそれぞれ MySQL, SQLite を使った例を書きましたが、この記事はその PosrgreSQL 版です。

PostgreSQL 本体は先の記事「PostgreSQL をインストールしました」でインストールした v14.4 を使います。

(1) プロジェクトの作成

チュートリアル「ASP.NET Core MVC の概要」の通り、Visual Studio 2022 のテンプレートを利用して対象のフレームワークは .NET 6.0認証「なし」の ASP.NET Core MVC アプリを作成します。

対象のフレームワークを .NET Core 3.1 とか .NET 5.0 にした場合は NuGet パッケージのバージョンの選び方に注意してください。違うとスキャフォールディングでエラーになるかもしれません。

また、認証を「個別のアカウント」にすると SQL Server を利用した Entity Framework 関係のパッケージがインストールされ話がややこしくなりますので、まずは認証は「なし」でやってみることをお勧めします。

(2) モデルの定義とスキャフォールディングの実行

チュートリアル「パート 4、ASP.NET Core MVC アプリにモデルを追加する」に従って、モデル(エンティティ)クラスの定義をプロジェクトに既存の Models フォルダに追加します。

using System.ComponentModel.DataAnnotations;

namespace PosrgreSqlMovie.Models
{
    public class Movie
    {
        public int Id { get; set; }
        public string? Title { get; set; }

        [DataType(DataType.Date)]
        public DateTime ReleaseDate { get; set; }
        public string? Genre { get; set; }
        public decimal Price { get; set; }
    }
}

次に、Microsoft.EntityFrameworkCore.Design を NuGet からインストールします。この記事では、この記事を書いた時点での最新版 6.0.7 を使いました。対象のフレームワークが .NET Core 3.1 とか .NET 5.0 の場合は、Microsoft.EntityFrameworkCore.Design のバージョンもそれに合わせるのが良さそうです。バージョンが違うとスキャフォールディングでエラーになるかもしれません。

チュートリアルの通り Visual Studio でスキャフォールディングを実行すると以下の操作が自動的に行われます。(コードジェネレータ関係のエラーが出る場合がありますが、その際は下の追加 NuGet パッケージがインストールされていることを確認してから再度スキャフォールディングを行うと成功すると思います)

  1. 他に必要な NuGet パッケージ(Microsoft.EntityFrameworkCore.SqlServer, Microsoft.EntityFrameworkCore.Tools, Microsoft.VisualStudio.Web.CodeGeneration.Design)の追加
  2. Data フォルダにコンテキストクラスの作成
  3. Program.cs ファイル (.NET 5.0 以前の場合は Startup.cs ファイル) でコンテキストクラスの登録
  4. appsettings.json ファイルへの接続文字列の追加
  5. CRUD 操作に必要な Controller / View 一式の生成

SQL Server の場合は上記でプロジェクトは完成ですが、PostgreSQL を利用する場合は上の 1, 3, 4 に以下の変更を行う必要があります。

(3) NuGet パッケージの追加

NuGet パッケージ Npgsql.EntityFrameworkCore.PostgreSQL をインストールします。その結果が以下の画像です。

NuGet パッケージ

スキャフォールディングで自動的に追加される Microsoft.EntiryFrameworkCore.Tools のバージョンがランタイムのバージョンより古い場合は Migration 操作の際警告が出るのと思いますので更新してください。

注意: Microsoft.EntityFrameworkCore.SqlServer はモデルクラスの定義によってはスキャフォールディングで必要になるケースがあるようです。具体的にどのようなケースで必要になるかは調べ切れてませんが、データアノテーション属性が関係しているような感じです。なので、この先モデルの定義を変更して Migration ⇒ スキャフォールディング操作を繰り返すなら、Microsoft.EntityFrameworkCore.SqlServer は残しておいた方が良さそうです。

(4) Program.cs ファイルの修正

スキャフォールディング操作で Program.cs ファイル (.NET 5.0 以前の場合は Startup.cs ファイル) でサービスに自動的にコンテキストクラスが登録されますが、それは SQL Server 用なので、以下のように PostgreSQL 用に変更します。

UseSqlServer を UseNpgsql に変更

コンテキストクラスの登録はコントローラーへの DI に必要です。登録してあれば、フレームワークがクライアントからの要求を受けてコントローラーを初期化する際、コンテキストクラスを初期化してコンストラクタ経由で渡してくれます。

(5) 接続文字列を PostgreSQL 用に変更

スキャフォールディング操作で appsettings.json ファイルに接続文字列が自動生成されますが、それは SQL Server (LocalDB) 用なので PostgreSQL 用に変更します。

接続文字列の変更

データベース名は任意です。上の画像のように Database=Movie というようにデータベース名を指定すると、Entity Framework Code First の機能を使って Movie という名前のデータベースを新たに生成し、そこに必要なテーブルを生成してくれます。

(6) Add-Migration の実行

ソリューションをリビルドしてから、Visual Studio のパッケージマネージャーコンソールで Add-Migration InitialCreate コマンドを実行します。結果、以下の画像の通り InitialCreate クラスが Migrations フォルダに自動生成されます。

Add-Migration の実行結果

Movie クラスの ReleaseDate プロパティ、Price プロパティの型はそれぞれ DateTime、decimal ですが、PostgreSQL にはそれらに該当する型がないらしく、それぞれ timestamp with time zone, numeric となっています。(後述しますが、それにより問題が出ます)

Add-Migration InitialCreate の InitialCreate という名前は任意に指定できます。指定した名前で xxxxx_InitialCreate.cs (xxxxx は作成日時) という名前のファイルが作成され、それに InitialCreate という名前のクラスが定義されます。

(7) Update-Database の実行

Visual Studio のパッケージマネージャーコンソールで Update-Database コマンドを実行します。これにより以下の画像の通り PostgreSQL データベースが生成されます。

Update-Database の実行結果

接続文字列で Database=Movie とした通り Movie という名前でデータベースが生成され、InitialCreate クラスの name: "Movie" で指定された名のテーブルが生成されています。

Movie クラスの ReleaseDate プロパティ、Price プロパティに該当する Movie テーブルのフィールドの型は、上のコードの InitialCreate クラスの指定通りそれぞれ timestamp with time zone, numeric となっています。

(8) プロジェクトの実行

これで完成と思って、プロジェクトを実行し Create 画面を表示してレコードを追加しようとしたら・・・

InvalidCastException: Cannot write DateTime with Kind=Unspecified to PostgreSQL type 'timestamp with time zone', only UTC is supported. Note that it's not possible to mix DateTimes with different Kinds in an array/range. See the Npgsql.EnableLegacyTimestampBehavior AppContext switch to enable legacy behavior."

・・・という例外がスローされて失敗します。(汗)

エラーメッセージでググって調べてヒットした記事「Date and Time Handling」によると "Npgsql 6.0 introduced some important changes to how timestamps are mapped" とのことで変更があったようです。Npgsql 6.0 より前の動作に戻すには以下のコードを追加すると書いてあります。

AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);

ステップ (2) で Data フォルダに自動生成されたコンテキストクラスに上のコードを追加することで、臨時処置的な解決策かもしれませんが、例外はスローされなくなります。

レコードの Create 結果

上の修正を加えた後でプロジェクトを実行して、Create 画面でレコードを 3 件追加し Index 画面でその一覧を表示したのがこの記事の一番上の画像です。

上の例外に対する恒久処置ですが、Movie クラスの ReleaseDateプロパティの DateTime 型を DateOnly 型に変えて、対応する PostgreSQL の型を date 型にするという記事を目にしました。それをやってみた結果を別の記事「ASP.NET Core で DateOnly 型を使用」に書きましたので、興味があれば見てください。

Tags: , , , ,

CORE

About this blog

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

Calendar

<<  2024年3月  >>
252627282912
3456789
10111213141516
17181920212223
24252627282930
31123456

View posts in large calendar