コンテンツにスキップ

付録 EF Core 2:モデル定義(キー・採番・SQL Server 固有)

このページは 第8章:データベースアクセスと ORM (Entity Framework Core) の付録です。キーとインデックス、採番と履歴、SQL Server 固有のマッピング、モデル全体にかかる構成を扱います。

本編を先に読んでから、必要な項目をここで参照してください。

第8章のほかの付録


  1. キーとインデックス
  2. 採番と履歴
  3. SQL Server 固有のマッピング
  4. モデル全体にかかる構成
  5. 参考ドキュメント

主キー以外の列を「もう 1 つの一意な識別子」として扱いたい場合は 代替キー (alternate key) を構成します。

modelBuilder.Entity<User>().HasAlternateKey(u => u.Email);

次は、SQL Server での生成 DDL の確認例です。AK_ で始まる名前の一意制約が含まれています。

[Email] nvarchar(450) NOT NULL,
CONSTRAINT [AK_Users_Email] UNIQUE ([Email])

代替キーは外部キーの参照先にできます。

modelBuilder.Entity<Order>()
.HasOne(o => o.User)
.WithMany(u => u.Orders)
.HasForeignKey(o => o.UserEmail)
.HasPrincipalKey(u => u.Email);

SQL Server では、外部キーの参照先が主キーではなく代替キーになります。

CONSTRAINT [FK_Orders_Users_UserEmail] FOREIGN KEY ([UserEmail]) REFERENCES [Users] ([Email]) ON DELETE CASCADE

次は、参照先に存在しないメールアドレスで Order を保存する確認例のエラーです。

The INSERT statement conflicted with the FOREIGN KEY constraint "FK_Orders_Users_UserEmail".

代替キーを結合テーブルの参照先にする

Section titled “代替キーを結合テーブルの参照先にする”

多対多の結合テーブルの外部キーは、既定では両側の主キーを参照します。これを代替キーに向けることもできます。UsingEntity に右側 (r) と左側 (l) の 2 つのラムダを渡し、それぞれで HasPrincipalKey を指定します。

modelBuilder.Entity<Post>()
.HasMany(e => e.Tags)
.WithMany(e => e.Posts)
.UsingEntity(
r => r.HasOne(typeof(Tag)).WithMany().HasPrincipalKey(nameof(Tag.AlternateKey)),
l => l.HasOne(typeof(Post)).WithMany().HasPrincipalKey(nameof(Post.AlternateKey)));

次は、SQL Server 2022 向けの生成 DDL の確認例です。HasAlternateKey を書いていないのに AK_Posts_AlternateKey が作られている点に注目してください。前述のとおり HasPrincipalKey が代替キーを自動で導入します。

CREATE TABLE [PostTag] (
[PostsAlternateKey] int NOT NULL,
[TagsAlternateKey] int NOT NULL,
CONSTRAINT [PK_PostTag] PRIMARY KEY ([PostsAlternateKey], [TagsAlternateKey]),
CONSTRAINT [FK_PostTag_Posts_PostsAlternateKey] FOREIGN KEY ([PostsAlternateKey]) REFERENCES [Posts] ([AlternateKey]) ON DELETE CASCADE,
CONSTRAINT [FK_PostTag_Tag_TagsAlternateKey] FOREIGN KEY ([TagsAlternateKey]) REFERENCES [Tag] ([AlternateKey]) ON DELETE CASCADE
);

結合テーブルの列名も主キー由来の PostsId ではなく PostsAlternateKey になります。Include で読み込むと、結合も代替キーで行われます(SQL Server で実測)。

LEFT JOIN (
SELECT [p0].[PostsAlternateKey], [p0].[TagsAlternateKey], [t].[Id], [t].[AlternateKey]
FROM [PostTag] AS [p0]
INNER JOIN [Tag] AS [t] ON [p0].[TagsAlternateKey] = [t].[AlternateKey]
) AS [s] ON [p].[AlternateKey] = [s].[PostsAlternateKey]

1 つのテーブルを複数のエンティティで共有する

Section titled “1 つのテーブルを複数のエンティティで共有する”

大きな列を含むテーブルを扱うとき、「一覧では軽い列だけ読みたい」という要求があります。EF Core は 2 つのエンティティ型を同じテーブルにマップできます。これを テーブル分割 (table splitting) と呼びます。

modelBuilder.Entity<Order>(b =>
{
b.ToTable("Orders");
b.Property(o => o.Status).HasColumnName("Status");
});
modelBuilder.Entity<DetailedOrder>(b =>
{
b.ToTable("Orders");
b.Property(o => o.Status).HasColumnName("Status");
});
modelBuilder.Entity<Order>()
.HasOne(o => o.DetailedOrder)
.WithOne()
.HasForeignKey<DetailedOrder>(o => o.Id);

テーブルは 1 つだけ作られ、それぞれのエンティティは自分がマップされた列だけを読みます。SQL Server で実測したクエリは次のとおりです。

-- context.Orders
SELECT [o].[Id], [o].[Status] FROM [Orders] AS [o]
-- context.Detailed
SELECT [o].[Id], [o].[BillingAddress], [o].[ShippingAddress], [o].[Status]
FROM [Orders] AS [o]
WHERE [o].[BillingAddress] IS NOT NULL OR [o].[ShippingAddress] IS NOT NULL

1 つのエンティティを複数のテーブルに分けることもできます。こちらは エンティティ分割 (entity splitting) です。

modelBuilder.Entity<Customer>(b =>
{
b.ToTable("Customers");
b.SplitToTable("CustomerAddresses", t =>
{
t.Property(c => c.Street);
t.Property(c => c.City);
});
});

このモデルの SQL Server での確認結果では、テーブルは 2 つ、クエリは INNER JOIN で、1 件の保存による影響行数は 2 です。

SELECT TOP(@p) [c].[Id], [c0].[City], [c].[Name], [c0].[Street]
FROM [Customers] AS [c]
INNER JOIN [CustomerAddresses] AS [c0] ON [c].[Id] = [c0].[Id]

キーなしエンティティ型でビューや集計結果を読む

Section titled “キーなしエンティティ型でビューや集計結果を読む”

主キーを持たないデータ、たとえばデータベースビューや集計結果を読むための仕組みが キーなしエンティティ型 (keyless entity type) です。HasNoKey() または [Keyless] 属性で構成します。

public class BlogPostCount
{
public string Name { get; set; } = "";
public int PostCount { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<BlogPostCount>()
.HasNoKey()
.ToView("View_BlogPostCounts");

DbSet<BlogPostCount> を定義すれば、通常のエンティティと同じように LINQ で問い合わせられます。SQL Server で実測したクエリと結果は次のとおりです。

SELECT [v].[Name], [v].[PostCount] FROM [View_BlogPostCounts] AS [v]

公式のキーなしエンティティの説明では、変更追跡の対象にならず、挿入・更新・削除にも使われません。上の確認例でもクエリ後の追跡エントリー数は 0 です。次は、Add を試みる場合の例外の確認例です。

Unable to track an instance of type 'BlogPostCount' because it does not have a primary key.
Only entity types with a primary key may be tracked.

公式ドキュメントが挙げている主な用途は次の 4 つです。

  • 生 SQL クエリの戻り値の型として使う
  • 主キーを持たないデータベースビューにマップする
  • 主キーが定義されていないテーブルにマップする
  • モデル内で定義したクエリにマップする

チェック制約で不正な値をデータベース側で弾く

Section titled “チェック制約で不正な値をデータベース側で弾く”

チェック制約 (Check Constraint) は、テーブルのすべての行が満たすべき条件を SQL 式で定義するリレーショナルデータベースの標準機能です。NOT NULL 制約や一意制約と似ていますが、任意の SQL 式を書ける点が違います。EF Core では ToTable の中で HasCheckConstraint を使って構成します。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.ToTable(t => t.HasCheckConstraint("CK_Product_Price", "[Price] > 0"));
}

生成される DDL(SQL Server)にはテーブル定義の一部として制約が含まれます。

CREATE TABLE [Products] (
[Id] int NOT NULL IDENTITY,
[Name] nvarchar(max) NOT NULL,
[Price] decimal(18,2) NOT NULL,
CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
CONSTRAINT [CK_Product_Price] CHECK ([Price] > 0)
);

同じテーブルに複数のチェック制約を、それぞれ別の名前で定義できます。

シャドウプロパティとバッキングフィールド

Section titled “シャドウプロパティとバッキングフィールド”

エンティティクラスに書きたくない情報や、外部に公開したくない情報をデータベースに持たせたい場面があります。EF Core はそのための仕組みを 2 つ用意しています。

シャドウプロパティ (Shadow Property) は、C# のエンティティクラスには存在せず、EF Core のモデルにだけ存在するプロパティです。公式ドキュメントは「値と状態は変更追跡機能の中だけで保持される」と説明しています。監査用の更新日時のように、ドメインモデルには出したくないがテーブルには持たせたい列に向いています。

modelBuilder.Entity<Blog>()
.Property<DateTime>("LastUpdated");

生成される DDL には、通常のプロパティと同じように列が現れます(SQLite で実測)。

CREATE TABLE "Blogs" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_Blogs" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NOT NULL,
"LastUpdated" TEXT NOT NULL
);

C# のクラスにプロパティがないため、読み書きは ChangeTracker 経由で行います。クエリでは EF.Property<T> を使います。

// 書き込み
context.Entry(blog).Property("LastUpdated").CurrentValue = DateTime.UtcNow;
// クエリ
var recent = await context.Blogs
.Where(b => EF.Property<DateTime>(b, "LastUpdated") > threshold)
.ToListAsync(cancellationToken);

バッキングフィールド (Backing Field) は、プロパティではなくフィールドを直接読み書きするように EF Core を構成する機能です。公式ドキュメントは「クラス側のカプセル化でアクセスを制限・拡張している場合に、その制限を経由せずにデータベースと読み書きしたいときに有用」と説明しています。

public class Product
{
private decimal _price;
public int Id { get; set; }
public decimal Price => _price; // 読み取り専用
public void SetPrice(decimal value) // アプリケーションからの変更用
{
if (value < 0) throw new ArgumentOutOfRangeException(nameof(value));
_price = value;
}
}
modelBuilder.Entity<Product>()
.Property(p => p.Price)
.HasField("_price");

この構成では、読込時に SetPrice を経由せず _price に書き込まれます。1 件を再読込する確認例でも、SetPrice の呼出回数は保存前の 1 回のままです。このメソッド内の検証は、アプリケーションからメソッドを呼ぶ変更に適用され、EF Core によるフィールドへの復元時には実行されません。

プロパティとフィールドのどちらを使うかを指定する

Section titled “プロパティとフィールドのどちらを使うかを指定する”

アクセスモードの既定は PreferField です。上のようにバッキングフィールドがある場合は、プロパティを経由せずフィールドを読み書きします。UsePropertyAccessMode ではこの方針を明示・変更できます。次の Field は、フィールドを必ず使用する指定です。この Product では既定と同じ経路になりますが、フィールドを利用できない場合にプロパティへ切り替えず、例外になります。

modelBuilder.Entity<Product>()
.Property(p => p.Price)
.HasField("_price")
.UsePropertyAccessMode(PropertyAccessMode.Field);

別の方針として、PreferFieldDuringConstruction はマテリアライズ(データベースから復元)するときにフィールドを優先し、それ以外はプロパティを優先します。利用できない場合は他方へ切り替わります。上の Price にはセッターがないため、この指定だけでプロパティ経由の書き込みや SetPrice の呼出しに変わるわけではありません。

CLR プロパティを一切持たず、フィールドだけでデータを保持する「フィールドのみのプロパティ」も定義できます。公式ドキュメントは、エンティティがプロパティではなくメソッドで値を出し入れする場合や、主キーのようにドメインモデルへ一切公開したくない場合の用途を挙げています。

modelBuilder.Entity<Article>().Property("_validatedUrl");

これは変更追跡側にデータを持つシャドウプロパティとは異なり、エンティティ側のフィールドにデータを持ちます。 LINQ から参照するには EF.Property を使います。SQL Server で実測したクエリは次のとおりです。

var sorted = db.Articles.OrderBy(x => EF.Property<string>(x, "_validatedUrl"));
SELECT [a].[Id], [a].[Url], [a].[_validatedUrl] FROM [Articles] AS [a] ORDER BY [a].[_validatedUrl]

IDENTITY はテーブルごとに独立した採番です。複数のテーブルで連番を共有したい場合は シーケンス (Sequence) を使います。公式ドキュメントは「シーケンスは特定のテーブルに結び付いておらず、複数のテーブルが同じシーケンスから値を引くように構成できる」と説明しています。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasSequence<int>("DocumentNumbers")
.StartsAt(1000)
.IncrementsBy(5);
modelBuilder.Entity<Order>()
.Property(o => o.OrderNo)
.HasDefaultValueSql("NEXT VALUE FOR DocumentNumbers");
modelBuilder.Entity<Invoice>()
.Property(i => i.DocNo)
.HasDefaultValueSql("NEXT VALUE FOR DocumentNumbers");
}

次は、SQL Server 2022 向けの生成 DDL の確認例です。

CREATE SEQUENCE [DocumentNumbers] AS int START WITH 1000 INCREMENT BY 5 NO CYCLE;
CREATE TABLE [Orders] (
[Id] int NOT NULL IDENTITY,
[OrderNo] int NOT NULL DEFAULT (NEXT VALUE FOR DocumentNumbers),
CONSTRAINT [PK_Orders] PRIMARY KEY ([Id])
);

主キーの Id は従来どおり IDENTITY で、OrderNo だけがシーケンスから採番されます。Invoice を 1 件、Order を 3 件保存した実測結果は次のとおりで、2 つのテーブルにまたがって連番が振られていることが確認できます。

テーブルId採番された番号
Invoice11000
Order11005
Order21010
Order31015

HiLo では保存前に採番の問い合わせが発生する

Section titled “HiLo では保存前に採番の問い合わせが発生する”

SQL Server の UseHiLo は、シーケンスから値のブロックを取得し、その範囲内でキーを割り当てます。使い切ったら次のブロックを要求します。通常の Add は追跡を始めるだけですが、公式のAdd と AddAsync の違いは、HiLo の採番をデータベースアクセスが発生し得る例外として挙げています。HiLo は既定の採番方式ではありません。

// OnModelCreating 内。SQL Server の検証用モデルの採番を構成する
modelBuilder.UseHiLo("AuditHiLo");

次は、EF Core 10.0.11 / SQL Server 2022 で増分 10 のシーケンスを使い、新しいデータベースごとに 21 件を追加する確認例です。SaveChangesAsync() より前の SELECT NEXT VALUE FOR [AuditHiLo] の累計を示します。

追加 API10 件追加後11 件追加後21 件追加後採番 SQL の実行経路
Add1 回2 回3 回同期
AddAsync1 回2 回3 回非同期
AddRangeAsync1 回2 回3 回非同期

この確認例の行数は、保存前は 0、SaveChangesAsync() 後は 21 です。エンティティの INSERT がまだ行われていないことと、データベースへ一度もアクセスしていないことは別です。 この結果はブロックの補充の確認であり、並列実行や再起動後の欠番なしを保証するものではありません。

主キーの採番方法を細かく制御する

Section titled “主キーの採番方法を細かく制御する”

整数の主キーは、規約により SQL Server では IDENTITY(1, 1)、SQLite では AUTOINCREMENT になります。ここでは、その既定を変えたい場面と、既定のままでは詰まる場面を扱います。

開始値と増分を変える(SQL Server)

Section titled “開始値と増分を変える(SQL Server)”

UseIdentityColumn に開始値 (seed) と増分 (increment) を渡すと、IDENTITY の引数がそのまま変わります。

modelBuilder.Entity<Blog>()
.Property(b => b.Id)
.UseIdentityColumn(seed: 1000, increment: 10);

SQL Server 2022 に対して生成された DDL と、実際に採番された値は次のとおりです。

CREATE TABLE [Blogs] (
[Id] int NOT NULL IDENTITY(1000, 10),
...
);
採番された Id: 1000, 1010

IDENTITY 列に明示的な値を入れる

Section titled “IDENTITY 列に明示的な値を入れる”

IDENTITY 列を持つエンティティに自分で Id を設定して SaveChanges すると、次のように失敗します。論理削除した行を元の ID のまま復元したい、といった場面で必ず踏みます。

SqlException: Cannot insert explicit value for identity column in table 'Blogs'
when IDENTITY_INSERT is set to OFF.

公式が案内している回避策は、SET IDENTITY_INSERT を自分で切り替える方法です。この設定はトランザクションではなく接続のセッションに対して働くため、EF Core の操作と同じ接続で実行する必要があります。次の例では、トランザクションによって接続を開いたままにし、その接続上で SQL の発行と保存を行います。

await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken);
await context.Database.ExecuteSqlRawAsync("SET IDENTITY_INSERT dbo.Blogs ON", cancellationToken);
try
{
context.Blogs.Add(new Blog { Id = 999, Name = "復元した行", Url = "https://example.com" });
await context.SaveChangesAsync(cancellationToken);
}
finally
{
// 保存の失敗やキャンセル後も OFF を試みる
await context.Database.ExecuteSqlRawAsync("SET IDENTITY_INSERT dbo.Blogs OFF", CancellationToken.None);
}
await transaction.CommitAsync(cancellationToken);

ON が成功した後は、保存が失敗・キャンセルされても finally で OFF を試みます。後始末には、キャンセル済みの可能性がある要求のトークンではなく CancellationToken.None を渡します。

EF Core 10.0.11 / Azure SQL Database に UseSqlServer で接続し、EF Core の自動再試行を有効にしない構成の確認例では、成功・保存時の一意制約違反・キャンセルのいずれでも、同じセッションでの後続の明示的 ID 挿入が拒否され、別テーブルへの IDENTITY_INSERT ON は成功することで、元のテーブルが OFF に戻ったことを確認できています。Id = 999 の行は成功時だけ残り、失敗・キャンセル時はロールバックされています。ただし、接続障害などで OFF 自体が失敗する可能性はあるため、例外時はこの処理を中断し、同じコンテキストで処理を続行しないでください。

SQL Server の Guid 主キーは順序を考慮して生成される

Section titled “SQL Server の Guid 主キーは順序を考慮して生成される”

SQL Server プロバイダーで Guid 主キーを既定の自動生成にすると、EF Core は値をクライアント側で生成します。SequentialGuidValueGenerator は、SQL Server のクラスター化キーやインデックス向けに順序を考慮した GUID を作ります。次は、EF Core 10.0.11 で同じコンテキスト・モデル・生成器を使い、Add で逐次生成する確認例の先頭 5 件です。

d0ed177a-06dc-467e-0c0b-08df0e98ef11
ccf230bc-240f-4ca3-0c0c-08df0e98ef11
8f2aac5c-4f85-4704-0c0d-08df0e98ef11
0604f1c1-f7b3-4559-0c0e-08df0e98ef11
8d9e01a3-6d16-44fd-0c0f-08df0e98ef11

この 5 件では末尾のブロックが共通で、その手前が 0c0b → 0c0f と増えています。同じ条件の 100,001 個を SQL Server 2022 の uniqueidentifier として比較する確認例でも、隣接する 100,000 組の逆転はなく、ORDER BY Id と生成順の一致を確認できています。値の順序の確認であり、EF Core による 100,001 行の保存や、インデックスの断片化率の測定ではありません。

SQLite では整数の主キーに AUTOINCREMENT が付きます。EF Core 10 からは、これを無効化できるようになりました。

// 方法 1: SQLite 固有の設定で止める
modelBuilder.Entity<Blog>()
.Property(b => b.Id)
.Metadata.SetValueGenerationStrategy(
Microsoft.EntityFrameworkCore.Metadata.SqliteValueGenerationStrategy.None);
// 方法 2: EF Core で値生成の対象にしない(アプリケーションが値を設定する)
modelBuilder.Entity<Blog>()
.Property(b => b.Id)
.ValueGeneratedNever();

どちらの構成でも、生成 DDL に AUTOINCREMENT が含まれないことを確認できています。

-- 既定
"Id" INTEGER NOT NULL CONSTRAINT "PK_Blogs" PRIMARY KEY AUTOINCREMENT,
-- 上記いずれかを指定した場合
"Id" INTEGER NOT NULL CONSTRAINT "PK_Blogs" PRIMARY KEY,

なお AUTOINCREMENT を外しても、SQLite は INTEGER PRIMARY KEY を rowid の別名として扱うため、値を指定しなければ自動採番されます。完全に止めるには列型を INTEGER から INT に変える必要がある、と公式は説明しています。

監査履歴を自動で残す(テンポラルテーブル)

Section titled “監査履歴を自動で残す(テンポラルテーブル)”

SQL Server の テンポラルテーブル (temporal table) は、テーブルに加えられたすべての変更を自動的に履歴テーブルへ退避する機能です。更新前の行や削除された行が残るため、監査や誤操作からの復元に使えます。EF Core は SQL Server プロバイダーでこれを直接サポートしています。

modelBuilder
.Entity<Employee>()
.ToTable("Employees", b => b.IsTemporal());

公式のテンポラルテーブルの構成例は、IsTemporal により期間列と履歴テーブルを構成する方法を示しています。次は、上の指定を使う生成 DDL の確認例です。

DECLARE @historyTableSchema nvarchar(max) = QUOTENAME(SCHEMA_NAME())
EXEC(N'CREATE TABLE [Employees] (
[Id] uniqueidentifier NOT NULL,
[Name] nvarchar(max) NOT NULL,
[Position] nvarchar(max) NOT NULL,
[Salary] decimal(10,2) NOT NULL,
[PeriodEnd] datetime2 GENERATED ALWAYS AS ROW END HIDDEN NOT NULL,
[PeriodStart] datetime2 GENERATED ALWAYS AS ROW START HIDDEN NOT NULL,
CONSTRAINT [PK_Employees] PRIMARY KEY ([Id]),
PERIOD FOR SYSTEM_TIME([PeriodStart], [PeriodEnd])
) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = ' + @historyTableSchema + N'.[EmployeesHistory]))');

PeriodStart と PeriodEnd という 2 つの期間列と、EmployeesHistory という履歴テーブルが自動で作られます。期間列と履歴テーブルの名前は HasPeriodStart / HasPeriodEnd / UseHistoryTable で変更できます。

modelBuilder
.Entity<Employee>()
.ToTable(
"Employees",
b => b.IsTemporal(
b =>
{
b.HasPeriodStart("ValidFrom");
b.HasPeriodEnd("ValidTo");
b.UseHistoryTable("EmployeeHistoricalData");
}));

EF Core は履歴を含めて読むための専用の演算子を用意しています。

演算子生成される T-SQL意味
TemporalAsOf(t)FOR SYSTEM_TIME AS OF 't'その時刻に有効だった行
TemporalAll()FOR SYSTEM_TIME ALL履歴に存在するすべての行
TemporalFromTo(a, b)FOR SYSTEM_TIME FROM 'a' TO 'b'2 つの時刻の間に有効だった行
TemporalBetween(a, b)FOR SYSTEM_TIME BETWEEN 'a' AND 'b'FromTo と同じだが上限で有効になった行も含む
TemporalContainedIn(a, b)FOR SYSTEM_TIME CONTAINED IN ('a', 'b')2 つの時刻の内側で有効になり、かつ有効でなくなった行

次は、1 件の追加、役職と給与の更新、削除という順で操作する確認例です。結果は 現在のテーブルが 0 件、TemporalAll が 2 件です。

現在のテーブル件数: 0
TemporalAll 件数: 2
開発/500000.00 2026-09-04 08:50:14.592 〜 2026-09-04 08:50:17.069
リード/700000.00 2026-09-04 08:50:17.069 〜 2026-09-04 08:50:19.502

期間列は既定でシャドウプロパティにマップされるため、値を取り出すには EF.Property を使って射影します。

var history = await db.Employees
.TemporalAll()
.OrderBy(x => EF.Property<DateTime>(x, "PeriodStart"))
.Select(x => new
{
x.Position,
From = EF.Property<DateTime>(x, "PeriodStart"),
To = EF.Property<DateTime>(x, "PeriodEnd")
})
.ToListAsync();

次は、同じ境界時刻を指定した TemporalFromTo、TemporalBetween、TemporalContainedIn の確認例です。境界の扱いによる件数の違いを確認できています。

FromTo 件数=2 Between 件数=2 ContainedIn 件数=0

公式の復元例では、追跡されていない履歴の行を Add して現在のテーブルへ再挿入します。このサンプルは GUID 主キーを使用します。IDENTITY 主キーでは、前述の明示値挿入への対応が必要です。

var employee = await db.Employees
.TemporalAsOf(timeStamp)
.SingleAsync(e => e.Name == "佐藤");
db.Add(employee);
await db.SaveChangesAsync();

この復元例では、現在のテーブルが 1 件、TemporalAll が 3 件となることを確認できています。

システムバージョン管理中の履歴は直接更新できない

Section titled “システムバージョン管理中の履歴は直接更新できない”

SQL Server の公式のテンポラルテーブルの制限では、履歴テーブルのデータを直接変更できないとされています。SYSTEM_VERSIONING = ON の状態で直接 UPDATE する対照でも、次の拒否を確認できています。

Cannot update rows in a temporal history table 'V46T3.dbo.EmployeesHistory'.

ここで確認したのは、システムバージョン管理が有効な間の直接更新の拒否です。この結果を、管理操作も含めた改ざん防止全般の保証とは扱わないでください。運用設計では、システムバージョン管理を停止する場合の公式の注意事項も確認してください。

位置や図形を扱う 空間データ (spatial data) は、EF Core では NetTopologySuite (NTS) ライブラリを介してマップします。プロバイダーごとに対応するパッケージが用意されており、SQL Server では Microsoft.EntityFrameworkCore.SqlServer.NetTopologySuite を追加します。

Terminal window
dotnet add package Microsoft.EntityFrameworkCore.SqlServer.NetTopologySuite
options.UseSqlServer(connectionString, o => o.UseNetTopologySuite());

モデルでは NetTopologySuite.Geometries 名前空間の型を使います。

public class Shop
{
public int Id { get; set; }
public string Name { get; set; } = "";
public Point? Location { get; set; }
}

次は、SQL Server の geography 列を含む生成 DDL の確認例です。

[Location] geography NULL,

距離での並べ替えは LINQ で書けます。次の確認例では、STDistance への翻訳を確認できています。

var origin = new Point(139.7454, 35.6586) { SRID = 4326 };
var q = db.Shops
.OrderBy(s => s.Location!.Distance(origin))
.Select(s => new { s.Name, D = s.Location!.Distance(origin) });
SELECT [s].[Name], [s].[Location].STDistance(@origin) AS [D]
FROM [Shops] AS [s]
ORDER BY [s].[Location].STDistance(@origin)

東京タワー付近を基準に東京駅と大阪駅を並べる確認例の結果は、それぞれ約 3,186 m と約 401,307 m です。このクエリでは距離をデータベース側で計算するため、全件取得後にアプリケーションで計算する必要はありません。

列の型は既定で geography(地球を球とみなす座標系)になります。平面座標として扱いたい場合は HasColumnType で geometry に変更します。次は INFORMATION_SCHEMA.COLUMNS による列型の確認例です。

モデルの記述生成された列型
public Point? Location { get; set; }geography
.Property(z => z.Shape).HasColumnType("geometry")geometry

geography を選んだ場合、SQL Server は多角形の頂点の並び順に制約を課します。公式ドキュメントは外周は反時計回り、内側の穴は時計回りでなければならず、NTS がデータベースに送る前に検証すると説明しています。次は時計回りの多角形を保存する確認例の例外です。同じ多角形を geometry 列に保存する対照では、保存成功を確認できています。

System.ArgumentException: When writing a SQL Server geography value,
the shell of a polygon must be oriented counter-clockwise.

組織図やカテゴリーツリーのような階層構造は、親を指す外部キーで表現するのが一般的ですが、「ある部署の配下すべて」を取るには再帰クエリが要ります。SQL Server の hierarchyid 型を使うと、階層内の位置そのものを 1 つの列に格納でき、配下の判定を単純な述語で書けます。

EF Core から使うには専用のパッケージを追加します。

Terminal window
dotnet add package Microsoft.EntityFrameworkCore.SqlServer.HierarchyId

UseSqlServer のオプションで UseHierarchyId を呼び、プロパティの型に HierarchyId を使います。

using Microsoft.EntityFrameworkCore;
public class Node
{
public int Id { get; set; }
public string Name { get; set; } = "";
public HierarchyId Path { get; set; } = null!;
}
// OnConfiguring / DI 登録側
options.UseSqlServer(connectionString, x => x.UseHierarchyId());

列は SQL Server の hierarchyid 型にマップされます(実測で sys.types を確認)。

CREATE TABLE [Nodes] (
[Id] int NOT NULL IDENTITY,
[Name] nvarchar(max) NOT NULL,
[Path] hierarchyid NOT NULL,
CONSTRAINT [PK_Nodes] PRIMARY KEY ([Id])
);

値は HierarchyId.Parse で作ります。/ がルート、/1/ がその 1 番目の子、/1/1/ がさらにその子、という表記です。

db.AddRange(
new Node { Name = "全社", Path = HierarchyId.Parse("/") },
new Node { Name = "開発部", Path = HierarchyId.Parse("/1/") },
new Node { Name = "第一課", Path = HierarchyId.Parse("/1/1/") },
new Node { Name = "営業部", Path = HierarchyId.Parse("/2/") });

公式の hierarchyid ガイドは、IsDescendantOf や GetLevel が SQL Server のメソッド呼び出しへ翻訳される例を示しています。次は、本節のモデルでの確認例です。

var devPath = HierarchyId.Parse("/1/");
var under = await db.Nodes.Where(n => n.Path.IsDescendantOf(devPath)).ToListAsync();
DECLARE @devPath hierarchyid = hierarchyid::Parse('/1/');
SELECT [n].[Id], [n].[Name], [n].[Path]
FROM [Nodes] AS [n]
WHERE [n].[Path].IsDescendantOf(@devPath) = CAST(1 AS bit)

公式の hierarchyid ガイドは、IsDescendantOf が自分自身にも true を返すと明記しています。上の確認例の結果も「開発部, 第一課」です。「開発部より下だけ」がほしい場合は、公式のクエリ例と同様に自分自身を除外します。

var strictly = await db.Nodes
.Where(n => n.Path.IsDescendantOf(devPath) && n.Path != devPath)
.ToListAsync();
-- SQL Server
WHERE [n].[Path].IsDescendantOf(@devPath) = CAST(1 AS bit) AND [n].[Path] <> @devPath

この確認例の結果は「第一課」のみです。GetLevel() の確認結果は、全社=0 / 開発部=1 / 第一課=2 / 営業部=1 で、階層の深さに対応しています。

スパース列 (sparse column) は、NULL の格納を最適化する代わりに、NULL でない値の取得コストが上がる列です。TPH 継承(付録1の「継承のマッピング」)のように「一部の型にしか存在しない列」がテーブルの大半で NULL になるケースで効きます。

modelBuilder.Entity<SpecialPost>()
.Property(x => x.Extra)
.IsSparse();

SQL Server 2019 以降は char / varchar 列に UTF-8 の照合順序を指定でき、Unicode を nvarchar より小さく格納できる場合があります。EF Core からは、列の型を varchar にしたうえで _UTF8 で終わる照合順序を指定し、あわせて IsUnicode() を呼びます。照合順序がクエリの結果そのものを変える点は付録3の「大文字小文字の区別は照合順序が決める」で扱います。

この組み合わせは公式の UTF-8 構成例に従っています。IsUnicode() は、HasColumnType で明示した varchar(max) を nvarchar(max) に置き換える指定ではありません。

modelBuilder.Entity<SpecialPost>()
.Property(b => b.Name)
.HasColumnType("varchar(max)")
.UseCollation("LATIN1_GENERAL_100_CI_AS_SC_UTF8")
.IsUnicode();

EF Core 10.0.11 / Azure SQL Database でこの構成を適用した確認例でも、実際の列型は varchar のままで、日本語と絵文字を含む 日本語🌸 café を保存して再読込した値が一致しています。

次は、この 2 つの構成を組み合わせた生成 DDL の確認例です。

CREATE TABLE [Posts] (
[Id] int NOT NULL IDENTITY,
[Name] varchar(max) COLLATE LATIN1_GENERAL_100_CI_AS_SC_UTF8 NOT NULL,
[Extra] nvarchar(max) SPARSE NULL,
CONSTRAINT [PK_Posts] PRIMARY KEY ([Id])
);

この例の sys.columns の確認結果は、Extra が is_sparse=True、Name が varchar 型・照合順序 Latin1_General_100_CI_AS_SC_UTF8 です。日本語の保存と再読込も、この varchar 列で確認できています。

テーブル全体をメモリに常駐させる メモリ最適化テーブル (memory-optimized table) も、モデル側から指定できます。

modelBuilder.Entity<MemItem>().ToTable(t => t.IsMemoryOptimized());

公式のメモリ最適化テーブルの説明は、IsMemoryOptimized を指定したエンティティについて、マイグレーションなどでメモリ最適化テーブルを作成するとしています。本節のモデルの生成 DDL でも、ファイルグループを準備するスクリプトと、次のテーブル定義を確認できています。この定義の主キーは 非クラスター化 です。

CREATE TABLE [Items] (
[Id] int NOT NULL IDENTITY,
[Name] nvarchar(max) NOT NULL,
CONSTRAINT [PK_Items] PRIMARY KEY NONCLUSTERED ([Id])
) WITH (MEMORY_OPTIMIZED = ON);

この生成スクリプトでは、ファイルグループの準備に SERVERPROPERTY('IsXTPSupported') = 1 などの条件が含まれています。ただし、後続の CREATE TABLE ... WITH (MEMORY_OPTIMIZED = ON) はその条件の外側です。非対応エディションで DDL 全体が何もせず終了する、という意味ではありません。 生成 DDL の確認であり、非対応エディションへの適用成功の確認ではありません。

データベース側で他の列から値を導出する 計算列 (computed column) は、HasComputedColumnSql で構成します。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Person>()
.Property(p => p.DisplayName)
.HasComputedColumnSql("[FirstName] + ' ' + [LastName]");
modelBuilder.Entity<Person>()
.Property(p => p.PersistedName)
.HasComputedColumnSql("[LastName] + ', ' + [FirstName]", stored: true);
}

公式の計算列の説明にあるとおり、第 2 引数 stored を省略すると 仮想 (virtual) 計算列 になり、値は取得のたびに計算されます。stored: true を指定すると 格納 (stored / persisted) 計算列 になり、行の更新のたびに計算されて他の列と同じようにディスクに保存されます。次は SQL Server 2022 向けの生成 DDL の確認例です。

CREATE TABLE [People] (
[Id] int NOT NULL IDENTITY,
[FirstName] nvarchar(max) NOT NULL,
[LastName] nvarchar(max) NOT NULL,
[DisplayName] AS [FirstName] + ' ' + [LastName],
[PersistedName] AS [LastName] + ', ' + [FirstName] PERSISTED,
CONSTRAINT [PK_People] PRIMARY KEY ([Id])
);

FirstName = "Taro" / LastName = "Yamada" を保存して再読込する確認例では、DisplayName は Taro Yamada、PersistedName は Yamada, Taro です。

Azure SQL の価格レベルをマイグレーションで指定する

Section titled “Azure SQL の価格レベルをマイグレーションで指定する”

Azure SQL Database のサービスレベルや最大サイズは、通常 Azure ポータルや Azure CLI で設定します。ただしスキーマをマイグレーションで管理している場合は、モデル側で指定してマイグレーションと一緒に適用することもできます。

API指定するもの生成される T-SQL の句
HasServiceTierサービスレベルEDITION
HasDatabaseMaxSizeデータベースの最大サイズMAXSIZE
HasPerformanceLevelパフォーマンスレベルSERVICE_OBJECTIVE
HasPerformanceLevelSqlエラスティックプールなど、文字列リテラルにならない値SERVICE_OBJECTIVE
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasServiceTier("GeneralPurpose");
modelBuilder.HasDatabaseMaxSize("10 GB");
modelBuilder.HasPerformanceLevel("GP_S_Gen5_1");
// エラスティックプールに入れる場合は HasPerformanceLevelSql を使う
// modelBuilder.HasPerformanceLevelSql("ELASTIC_POOL ( name = myelasticpool )");
}

このモデルからマイグレーションを生成すると、テーブル作成の前に次の ALTER DATABASE が入ります(実測で確認)。

-- Azure SQL
BEGIN
DECLARE @db_name nvarchar(max) = QUOTENAME(DB_NAME());
EXEC(N'ALTER DATABASE ' + @db_name + ' MODIFY (
MAXSIZE = 10 GB, EDITION = ''GeneralPurpose'', SERVICE_OBJECTIVE = ''GP_S_Gen5_1'' );');
END

Azure SQL Database の GP_S_Gen5_1 / EF Core 10.0.11 の Initial マイグレーションの確認例では、適用前の最大サイズは 1 GB、適用後の maxSizeBytes(最大サイズをバイト単位で表す設定) は 10,737,418,240 バイト(10 GB) です。サービス目標名は前後とも GP_S_Gen5_1 で、容量上限の変更を確認できています。使用済み容量や処理性能の比較ではありません。


SQL Server の互換性レベルを明示する

Section titled “SQL Server の互換性レベルを明示する”

EF Core の SQL Server プロバイダーは、接続先のバージョンを見に行くのではなく、構成された互換性レベルに基づいて SQL を生成します。 そして UseSqlServer の既定値は、接続先が SQL Server 2022 であっても最新にはなりません。

options.UseSqlServer(connectionString, o => o.UseCompatibilityLevel(160));

公式ドキュメントは「互換性レベルを明示的に構成しなければ、最新機能を活用しない妥当な既定値が選ばれる。そのため明示的に構成することを推奨する」と述べています。EF Core 10.0.11 の公式実装で確認できる既定値は次のとおりです。

メソッド既定の互換性レベル相当する製品
UseSqlServer150SQL Server 2019
UseAzureSql170Azure SQL Database

この差は生成 SQL に影響します。Math.Max / Math.Min は互換性レベル 160 以上で GREATEST / LEAST に翻訳されます。次の Where 内の Math.Max で、EF Core 側の互換性レベルだけを変える確認例では、160 未満の ToQueryString() は SQL 翻訳時の例外です。SQL 翻訳の確認であり、データベースへのクエリ実行ではありません。最終 Select で許されるクライアント評価とは区別してください。

// Rating と Rating2 の大きいほうが 4 を超えるブログ
var sql = db.Blogs.Where(b => Math.Max(b.Rating, b.Rating2) > 4).ToQueryString();
EF Core 側の構成結果
未指定(既定の 150)InvalidOperationException(翻訳できない)
UseCompatibilityLevel(120) 〜 (150)同上
UseCompatibilityLevel(160)GREATEST に翻訳される
-- SQL Server(互換性レベル 160 以上)
SELECT [b].[Id], [b].[Name], [b].[Rating], [b].[Rating2]
FROM [Blogs] AS [b]
WHERE GREATEST([b].[Rating], [b].[Rating2]) > 4

EF Core の CommandTimeout は 既定では未設定 (null) で、この場合は ADO.NET プロバイダーの既定値が使われます。Microsoft.Data.SqlClient の SqlCommand.CommandTimeout は、公式ドキュメントに「既定は 30 秒」と記載されています。既定構成の確認例でも、db.Database.GetCommandTimeout() は null、ログは CommandTimeout='30' です。

設定方法は 2 つあります。

// 1. DbContext 全体の既定として構成する
builder.Services.AddDbContext<BloggingContext>(options =>
options.UseSqlServer(connectionString, sqlOptions => sqlOptions.CommandTimeout(120)));
// 2. 特定の処理だけ実行時に変更する
context.Database.SetCommandTimeout(180);

次は、CommandTimeout を 3 秒に設定し、10 秒待機するコマンドを実行する確認例です。この条件での経過時間は約 3.2 秒で、SqlException のエラー番号 -2(タイムアウト)を確認できています。

SqlException Number=-2: 実行タイムアウトの期限が切れました。
操作完了前にタイムアウト期間が過ぎたか、サーバーが応答していません。

HasMaxLength や HasPrecision を全エンティティのプロパティに個別に書いていくのは現実的ではありません。EF Core には、CLR の型ごとにマッピング設定を一度だけ指定し、モデルの構築時にその型のすべてのプロパティへ自動適用する仕組みがあります。公式ドキュメントではこれを 規約前のモデル構成 (pre-convention model configuration) と呼び、DbContext の ConfigureConventions をオーバーライドして記述します。

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
// すべての string プロパティを nvarchar(200) にする
configurationBuilder.Properties<string>()
.HaveMaxLength(200);
// すべての decimal プロパティの精度を固定する
configurationBuilder.Properties<decimal>()
.HavePrecision(18, 2);
}

OnModelCreating の Property(...) 系メソッドが Has で始まるのに対し、ConfigureConventions 側は Have で始まる 点に注意してください(HasMaxLength ではなく HaveMaxLength)。

実際に SQL Server 向けの DDL を生成して比較すると、文字列列の型が変わることを確認できます。

-- ConfigureConventions なし
[Url] nvarchar(max) NOT NULL,
[Title] nvarchar(max) NOT NULL,
-- ConfigureConventions で HaveMaxLength(200) を指定
[Url] nvarchar(200) NOT NULL,
[Title] nvarchar(200) NOT NULL,

グローバルクエリフィルターと名前付きクエリフィルター

Section titled “グローバルクエリフィルターと名前付きクエリフィルター”

論理削除(ソフトデリート)やマルチテナントのように、「すべてのクエリに自動的に適用したい条件」はグローバルクエリフィルターで表現します。

public class Blog
{
public int Id { get; set; }
public required string Name { get; set; }
public bool IsDeleted { get; set; }
public int TenantId { get; set; }
}

このエンティティに対して、論理削除された行を除外するフィルターを設定します。

modelBuilder.Entity<Blog>().HasQueryFilter(b => !b.IsDeleted);

これ以降、context.Blogs に対するクエリはすべて WHERE [b].[IsDeleted] = CAST(0 AS bit) が自動的に付与されます。フィルターを無効にしたい特定のクエリでは IgnoreQueryFilters() を呼びます。

EF Core 10 では 名前付きクエリフィルター (Named Query Filters) が導入され、1 つのエンティティ型に複数のフィルターを設定して個別に無効化できるようになりました。

テナント ID は、公式のマルチテナントの例と同じく、コンテキストのインスタンスに保持させます。次の tenantId には、認証済みユーザーなどの信頼できる情報から決めた現在のテナント ID を、コンテキストの作成時に渡します。

public class TenantBlogContext(
DbContextOptions<TenantBlogContext> options,
int tenantId) : DbContext(options)
{
public DbSet<Blog> Blogs => Set<Blog>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>()
.HasQueryFilter("SoftDeletionFilter", b => !b.IsDeleted)
.HasQueryFilter("TenantFilter", b => b.TenantId == tenantId);
}
}

この TenantBlogContext は、本編の通常の AddDbContext<TenantBlogContext> だけでは DI から解決できません。DI は未登録の int tenantId を用意できないためです。この例では、オプションを渡して new で生成する公式の方法を使い、テナント ID を引数に取るファクトリを用意します。

public static class TenantBlogContextFactory
{
public static TenantBlogContext Create(
string connectionString, int authenticatedTenantId)
{
var options = new DbContextOptionsBuilder<TenantBlogContext>()
.UseSqlServer(connectionString)
.Options;
return new TenantBlogContext(options, authenticatedTenantId);
}
}

呼び出し側では、アプリケーションの構成から取得した connectionString と、信頼できる認証情報から決めた authenticatedTenantId を渡します。リクエストに含まれるテナント ID を、そのユーザーが利用できるか確認せずに渡してはいけません。ここでは TenantBlogContext を DI から直接受け取るのではなく、次のように生成し、呼び出し側で await using により破棄します。

await using var context = TenantBlogContextFactory.Create(
connectionString, authenticatedTenantId);

この context に対して、名前を付けたフィルターのうち無効化したいものだけを個別に選べます。

// 論理削除のフィルターだけを無効化し、テナントのフィルターは維持する
var allBlogs = await context.Blogs
.IgnoreQueryFilters(["SoftDeletionFilter"])
.ToListAsync(cancellationToken);

DbSet.Remove を呼んだときに実際の削除ではなく IsDeleted を立てたい場合は、公式の論理削除の例と同様に、保存前に Deleted を Modified へ変換します。次のメソッドを TenantBlogContext に追加すると、同期・非同期のどちらの保存経路でも同じ変換を適用できます。

オーバーライドするのは、acceptAllChangesOnSuccess を受け取る SaveChanges(bool) と SaveChangesAsync(bool, CancellationToken) です。DbContext の公式実装では、SaveChanges() と SaveChangesAsync(CancellationToken) もそれぞれこれらのオーバーロードへ処理を委譲します。

public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
ApplySoftDelete();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess,
CancellationToken cancellationToken = default)
{
ApplySoftDelete();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private void ApplySoftDelete()
{
ChangeTracker.DetectChanges();
foreach (var entry in ChangeTracker.Entries<Blog>().Where(e => e.State == EntityState.Deleted))
{
entry.State = EntityState.Modified;
entry.CurrentValues[nameof(Blog.IsDeleted)] = true;
}
}

ここからは、公式の必須ナビゲーションの例と同じく、BlogId / Url を持つ別の Blog モデルと、b => b.Url.Contains("fish") のフィルターを使います。前述の Id / Name / IsDeleted を持つモデルとは別です。fish を含む Blog と含まない Blog に投稿を 3 件ずつ持たせた SQL Server 2022 の確認例では、Include の追加により取得件数が減ることを確認できています。

var withoutInclude = await context.Posts.ToListAsync(); // 6 件
var withInclude = await context.Posts.Include(p => p.Blog).ToListAsync(); // 3 件

必須ナビゲーションは「関連エンティティが必ず存在する」ことを意味するため、EF Core は INNER JOIN を組み立てます。フィルターで除外された Blog に紐づく Post は、この結合で一緒に消えます。

-- SQL Server
SELECT [p].[PostId], [p].[BlogId], [p].[Title], [b0].[BlogId], [b0].[Url]
FROM [Posts] AS [p]
INNER JOIN (
SELECT [b].[BlogId], [b].[Url]
FROM [Blogs] AS [b]
WHERE [b].[Url] LIKE N'%fish%'
) AS [b0] ON [p].[BlogId] = [b0].[BlogId]

公式ドキュメントが挙げている 2 つの対処を、それぞれ実測した結果です。

構成Include なしInclude あり
必須ナビゲーションのまま(既定)6 件3 件
IsRequired(false) で省略可能にする6 件6 件
Post 側にも同じフィルターを定義する3 件3 件

省略可能にすると LEFT JOIN になるため件数は減りませんが、Post.Blog が null になり得ます。件数を揃えたいのか、親を必ず取りたいのかで選び分けてください。


コード分析ルールがエンティティ定義と衝突する

Section titled “コード分析ルールがエンティティ定義と衝突する”

プロジェクトで <AnalysisLevel>latest-all</AnalysisLevel> のように厳しいコード分析を有効にすると、上のエンティティ定義に対して次のような警告が出ます(実測で確認)。

ルール内容
CA1002List<Post> ではなく Collection<T> を公開すべき
CA2227コレクションプロパティのセッターを削除して読み取り専用にすべき
CA1056Url プロパティは string ではなく Uri にすべき

EF Core のエンティティだから、これらのルールを一律に無効化する必要があるわけではありません。 公式のナビゲーションの説明は、コレクションナビゲーションにセッターは不要としています。また Uri には組み込みの値コンバーターがあり、標準でマッピングできない型ではありません。

セッターのない List<Post> と明示的なコンバーター構成のない Uri を持つモデルの確認例(EF Core 10.0.11 / SQLite)でも、別の DbContext での再読込時に、子のコレクション・親子の参照・URI の値を確認できています。警告への対応は公開設計に合わせ、意図して現在の形を維持する場合だけ、次のように抑制を対象フォルダーへ限定します。

# プロジェクト直下の Models フォルダーを対象にする場合
[Models/*.cs]
dotnet_diagnostic.CA1002.severity = none
dotnet_diagnostic.CA2227.severity = none
dotnet_diagnostic.CA1056.severity = none

公式のアナライザー構成の説明では、.editorconfig の配置とセクションヘッダーで適用対象を選びます。上の例は、プロジェクト直下に .editorconfig と Models フォルダーを置く構成です。実際の配置に合わせ、意図したファイルに抑制が適用されているかをビルドで確認してください。