Start Debugging

IPluralizer で dotnet ef dbcontext scaffold のテーブル名複数形化をカスタマイズする方法

Gas という名前のテーブルは Ga というエンティティにスキャフォールドされます。組み込みの Humanizer 複数形化サービスを独自の IPluralizer に置き換え、スタートアッププロジェクトの IDesignTimeServices から登録する方法と、そこで TryAddSingleton が黙って何もしない理由を解説します。

手短な答え: Microsoft.EntityFrameworkCore.Design.IPluralizer を実装するクラス (メソッドは PluralizeSingularize の 2 つ) を書き、それを スタートアップ プロジェクト内の IDesignTimeServices 実装クラスから services.AddSingleton<IPluralizer, MyPluralizer>() で登録します。使うのは AddSingleton であり、TryAddSingleton では決してありません。EF Core は自分のコードが走る前に HumanizerPluralizer を登録済みなので、TryAdd は黙って何もしない no-op になり、何も変わらない理由を 1 時間悩むことになります。登録さえすれば、dotnet ef dbcontext scaffold はエンティティ型名に対して Singularize を、DbSet 名とコレクションナビゲーションに対して Pluralize を呼び出します。さらに tbl_ プレフィックスも取り除きたい場合、それは別のサービスである ICandidateNamingService の仕事で、複数形化サービスより前に実行されます。

以下の内容はすべて .NET 11 RC 1 SDK (11.0.100-rc.1.26425.128)、dotnet-ef 11.0.0-rc.1.26425.128Microsoft.EntityFrameworkCore.SqliteMicrosoft.EntityFrameworkCore.Design11.0.0-rc.1.26425.128、そして Humanizer.Core 3.0.10 で実行したものです。データベースはローカルの SQLite ファイルです。この記事の内容はプロバイダーに一切依存しないからです。複数形化はプロバイダー層より上の Microsoft.EntityFrameworkCore.Design で行われます。ここで引用している生成名はすべて実際の dotnet ef の出力であり、再構成したものではありません。

Gas という名前のテーブルは Ga というエンティティになる

実際のデータベースに本当に含まれている類の名前を持つ、6 テーブルのスキーマを用意しました。ドイツ語が 2 つ、単数形なのに s で終わる英単語が 2 つ、ラテン語の複数形が 1 つ、そしてレガシーな tbl_ プレフィックスが 1 つです。

-- SQLite, shop.db
CREATE TABLE Kunde (KundeId INTEGER PRIMARY KEY, Name TEXT NOT NULL);
CREATE TABLE Bestellung (BestellungId INTEGER PRIMARY KEY, KundeId INTEGER NOT NULL REFERENCES Kunde(KundeId), Summe REAL NOT NULL);
CREATE TABLE Gas (GasId INTEGER PRIMARY KEY, Formula TEXT NOT NULL);
CREATE TABLE Canvas (CanvasId INTEGER PRIMARY KEY, Width INTEGER NOT NULL);
CREATE TABLE Media (MediaId INTEGER PRIMARY KEY, Url TEXT NOT NULL);
CREATE TABLE tbl_person (person_id INTEGER PRIMARY KEY, full_name TEXT NOT NULL);

カスタマイズを一切せずにスキャフォールドします。

dotnet ef dbcontext scaffold "Data Source=shop.db" Microsoft.EntityFrameworkCore.Sqlite -o Gen --context ShopContext -f

生成されるファイルは Bestellung.csCanva.csGa.csKunde.csMedium.csTblPerson.cs で、コンテキストは次のようになります。

// Generated by dotnet ef 11.0.0-rc.1.26425.128, default services
public virtual DbSet<Bestellung> Bestellungs { get; set; }
public virtual DbSet<Canva> Canvas { get; set; }     // entity type is Canva
public virtual DbSet<Ga> Gas { get; set; }           // entity type is Ga
public virtual DbSet<Kunde> Kundes { get; set; }
public virtual DbSet<Medium> Media { get; set; }
public virtual DbSet<TblPerson> TblPeople { get; set; }

6 テーブルで別種の問題が 4 つ出ています。GasCanvass で終わる単数形の名詞ですが、単数形化処理はおかまいなしに s を切り落とします。KundeBestellung はドイツ語なのに英語の複数形ルールを適用されます。MediaMedium になりますが、これはラテン語としては正しくても Media テーブルの意味するところではほぼ確実にありません。そして tbl_person はプレフィックスをそのまま保ったうえで、本当に驚くような TblPeople に複数形化されます。

これはバグというより、ある 1 つの言語の形態論を任意の識別子に当てはめた必然の結果です。被害範囲がどれだけ広いかは見ておく価値があります。単語リストに対して HumanizerPluralizer を直接呼ぶと、こうなります。

input           Singularize     Pluralize
Gas             Ga              Gas
Canvas          Canva           Canvas
Atlas           Atla            Atlas
Census          Censu           Census
Corpus          Corpu           Corpus
Bonus           Bonu            Bonus
Nexus           Nexu            Nexus
GPS             GP              GPs
Media           Medium          Media
Data            Datum           Data
Criteria        Criterion       Criteria
Index           Index           Indices
Focus           Focus           Foci
Radius          Radius          Radii
Status          Status          Statuses
Bestellung      Bestellung      Bestellungs
Usuario         Usuario         Usuarios

この表から取り出しておきたい点が 2 つあります。1 つ目は、-us-as の系統が単数形方向で一貫して壊れることです。複数形ではない s で終わる名前のテーブルは、どれも 1 文字失います。2 つ目は、ラテン語由来の複数形 (IndicesFociRadii) は理屈のうえでは擁護できても、Indexes を期待するたいていのコードベースにとっては依然として誤りだということです。Status はかつて壊れているグループに入っていましたが、現在は正しく扱われます。

バージョンに関する注意: これらの結果は Humanizer.Core 2.14.1 でも同一です。2.14.1 は Microsoft.EntityFrameworkCore.Design 8.0.x から 10.0.x が依存しているバージョンで、EF Core 11 RC 1 が移行した 3.0.10 でも同じです。EF Core をアップグレードしても、この問題はどれ 1 つ解決しません。

複数形化サービスが命名パイプラインのどこに位置するか

置き換えを書く前に、自分のメソッドがどんな文字列を受け取るのかを正確に知っておくと役立ちます。インターフェースの定義からは自明ではないからです。

RelationalScaffoldingModelFactory はスキャフォールド実行ごとに 2 つの名前付け担当を構築します。

// efcore, src/EFCore.Design/Scaffolding/Internal/RelationalScaffoldingModelFactory.cs, v11.0.0-rc.1.26425.128
_tableNamer = new CSharpUniqueNamer<DatabaseTable>(
    options.UseDatabaseNames ? (t => t.Name) : _candidateNamingService.GenerateCandidateIdentifier,
    _cSharpUtilities,
    options.NoPluralize ? null : _pluralizer.Singularize,
    caseSensitive: false);
_dbSetNamer = new CSharpUniqueNamer<DatabaseTable>(
    options.UseDatabaseNames ? (t => t.Name) : _candidateNamingService.GenerateCandidateIdentifier,
    _cSharpUtilities,
    options.NoPluralize ? null : _pluralizer.Pluralize,
    caseSensitive: true);

つまりエンティティ型名は Singularize(candidateIdentifier)DbSet プロパティ名は Pluralize(candidateIdentifier) で、どちらも同じ入力から作られます。両方が CSharpUtilities.GenerateCSharpIdentifier を通り、そこで処理の順序が確定します。

  1. ICandidateNamingService.GenerateCandidateIdentifier が生のテーブル名を Pascal ケースの候補名に変えます (tbl_personTblPerson になります)。ただし --use-database-names が指定されている場合を除きます。
  2. C# の識別子として有効でない文字が _ に置き換えられます。
  3. ここで自作の複数形化サービスが走ります。手順 1 と 2 の結果に対してです。
  4. 結果が数字で始まるか C# のキーワードである場合、先頭に _ が付加されます。
  5. その名前がすでに使われていれば、一意化のために 12 のように連番が付きます。

重要なのは手順 3 です。受け取るのは CustomerAddressTblPerson のような Pascal ケースの識別子全体であって、単語 1 つではありません。裸の名詞をキーにした辞書では、識別子を先に分割しない限りほとんど何にもマッチしません。

エンティティ名の名前付けは一意性判定が大文字小文字を区別せず、DbSet の名前付けは区別します。そのため Media は衝突を起こさずにエンティティ型名と DbSet 名の両方になれます。

コレクションナビゲーションは別途、候補ナビゲーション名に対する明示的な _pluralizer.Pluralize 呼び出しで複数形化されます。参照ナビゲーションは単数形化されません。呼び出し箇所は 4 つとも if (!_options.NoPluralize) でガードされています。

識別子全体を扱う IPluralizer

下の実装は Humanizer をフォールバックとして残し (間違うよりも正しいことのほうがはるかに多いためです)、自分のスキーマに実際に含まれる単語だけを上書きします。1 つのペア表が両方向を駆動する点は、聞こえ以上に重要です。最初に書いた版は単数形から複数形へのマップしか持っておらず、DbSet 名はすべて直ったものの、エンティティ型は GaCanva のままでした。Singularize("Gas") が一致を見つけられなかったからです。

// .NET 11, C# 14. EF Core 11.0.0-rc.1.26425.128, Humanizer.Core 3.0.10.
using Humanizer;
using Microsoft.EntityFrameworkCore.Design;

namespace Shared;

public sealed class DomainPluralizer : IPluralizer
{
    // One row per word the English rules get wrong. Both directions come from this list.
    private static readonly (string Singular, string Plural)[] Words =
    [
        ("Kunde", "Kunden"),
        ("Bestellung", "Bestellungen"),
        ("Gas", "Gases"),
        ("Canvas", "Canvases"),
        ("Bonus", "Bonuses"),
        ("Status", "Statuses"),
        ("Media", "Media"),
        ("Aircraft", "Aircraft"),
        ("Person", "Persons")
    ];

    private static readonly Dictionary<string, string> ToPlural = BuildToPlural();
    private static readonly Dictionary<string, string> ToSingular = BuildToSingular();

    private static Dictionary<string, string> BuildToPlural()
    {
        var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var (singular, plural) in Words)
        {
            map[singular] = plural;
            map[plural] = plural;
        }

        return map;
    }

    private static Dictionary<string, string> BuildToSingular()
    {
        var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var (singular, plural) in Words)
        {
            map[plural] = singular;
            map[singular] = singular;
        }

        return map;
    }

    public string Pluralize(string identifier)
        => Transform(identifier, ToPlural, word => word.Pluralize(inputIsKnownToBeSingular: false));

    public string Singularize(string identifier)
        => Transform(identifier, ToSingular, word => word.Singularize(inputIsKnownToBePlural: false));

    private static string Transform(
        string identifier,
        IReadOnlyDictionary<string, string> overrides,
        Func<string, string> fallback)
    {
        if (string.IsNullOrEmpty(identifier))
        {
            return identifier;
        }

        if (overrides.TryGetValue(identifier, out var whole))
        {
            return whole;
        }

        var (prefix, trailing) = SplitTrailingWord(identifier);
        return overrides.TryGetValue(trailing, out var mapped)
            ? prefix + mapped
            : prefix + fallback(trailing);
    }

    private static (string Prefix, string Trailing) SplitTrailingWord(string identifier)
    {
        for (var i = identifier.Length - 1; i > 0; i--)
        {
            if (identifier[i] == '_')
            {
                return (identifier[..(i + 1)], identifier[(i + 1)..]);
            }

            if (char.IsUpper(identifier[i]) && !char.IsUpper(identifier[i - 1]))
            {
                return (identifier[..i], identifier[i..]);
            }
        }

        return ("", identifier);
    }
}

触れておく価値のある設計上の判断が 3 つあります。ToSingular で各単数形を自分自身に (そして ToPlural で各複数形を自分自身に) マップしているのは、メソッドを冪等にするためです。インターフェースのドキュメントもそれを求めています。“Returns the same identifier if it is already pluralized.” 最後の Pascal ケース境界で分割することで、InvoiceStatusShippingStatus の両方が個別のエントリなしに Status のルールを拾えます。そして SplitTrailingWord_ でも分割します。これが効くのは --use-database-names を指定した場合だけで、そのときは生の tbl_person がそのままコードに届きます。

dotnet ef に実際に使わせるための登録方法

Microsoft.EntityFrameworkCore.DesignDevelopmentDependency パッケージなので、既定の dotnet add package では、コンパイル時に参照できない参照が入るだけです。IncludeAssets のメタデータを取り除きます。

<!-- Shared.csproj, .NET 11 -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="11.0.0-rc.1.26425.128">
  <PrivateAssets>none</PrivateAssets>
</PackageReference>
<PackageReference Include="Humanizer.Core" Version="3.0.10" />

Humanizer.Core をフォールバックとして使うなら、明示的に参照してください。今日は推移的に入ってきますが、EF Core がたまたま固定しているバージョンに頼るのは、アップグレード時に驚かされる典型的なやり方です。

次に登録そのものです。

// .NET 11, C# 14. EF Core 11.0.0-rc.1.26425.128.
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.DependencyInjection;

namespace Shared;

public sealed class SharedDesignTimeServices : IDesignTimeServices
{
    public void ConfigureDesignTimeServices(IServiceCollection services)
        => services.AddSingleton<IPluralizer, DomainPluralizer>();
}

使うのは AddSingleton であって TryAddSingleton ではありません。 DesignTimeServicesBuilder.CreateServiceCollectionservices.AddEntityFrameworkDesignTimeServices(...) を呼び、その後でようやく ConfigureUserServices(services) を呼びます。EF 自身の登録は TryAddSingleton<IPluralizer, HumanizerPluralizer>() なので、自分のメソッドが走る時点で IPluralizer はすでにコレクションに入っています。AddSingleton は末尾に追加し、Microsoft.Extensions.DependencyInjection は最後の登録を解決するので、自分の実装が勝ちます。TryAddSingleton は既存のエントリを見つけて何もしません。同じスキーマに対して両方のやり方でスキャフォールドを実行してみました。TryAddSingleton の場合、出力は既定の実行とバイト単位で同一で、エンティティ型 GaCanva もそのまま、警告の類は一切出ませんでした。

さらに 2 つの探索ルールがあり、どちらも DesignTimeServicesBuilder からそのまま読み取れます。

複数のプロジェクトで複数形化サービスを共有するには、専用のアセンブリに置き、アセンブリレベルの属性で指し示します。この経路を扱うのは ConfigureReferencedServices で、スタートアップアセンブリと対象アセンブリの両方から DesignTimeServicesReferenceAttribute を探します。

// AssemblyInfo.cs in the project you scaffold into
using Microsoft.EntityFrameworkCore.Design;

[assembly: DesignTimeServicesReference("Shared.SharedDesignTimeServices, Shared")]

これは AddEntityFrameworkDesignTimeServices より に走るので、この経路なら TryAddSingleton でも動きます。今度は EF 自身の TryAdd のほうが no-op になるからです。どこでも AddSingleton を使うようにしておけば、この区別を頭に入れておく必要がなくなります。

結果

同じスキーマ、同じコマンドで、DomainPluralizer を登録した場合です。

テーブル既定のエンティティ既定の DbSetカスタムのエンティティカスタムの DbSet
KundeKundeKundesKundeKunden
BestellungBestellungBestellungsBestellungBestellungen
GasGaGasGasGases
CanvasCanvaCanvasCanvasCanvases
MediaMediumMediaMediaMedia
tbl_personTblPersonTblPeopleTblPersonTblPersons

Kunde のコレクションナビゲーションは DbSet 名に従います。同じ Pluralize 呼び出しを通るからです。

public virtual ICollection<Bestellung> Bestellungen { get; set; } = new List<Bestellung>();

tbl_ プレフィックスの除去は別のサービスの仕事

TblPerson はまだ残っていますし、どんな複数形化サービスでも直せません。自分のコードが呼ばれる時点で、プレフィックスはすでに候補識別子に焼き込まれているからです。プレフィックスの除去は ICandidateNamingService の担当で、これは手順 1 で実行されます。組み込みの実装から派生して、メソッドを 1 つオーバーライドします。

// .NET 11, C# 14. EF Core 11.0.0-rc.1.26425.128.
#pragma warning disable EF1001
using Microsoft.EntityFrameworkCore.Scaffolding.Internal;
using Microsoft.EntityFrameworkCore.Scaffolding.Metadata;

namespace Shared;

public sealed class TablePrefixNamingService : CandidateNamingService
{
    private static readonly string[] Prefixes = ["tbl_", "tbl"];

    public override string GenerateCandidateIdentifier(DatabaseTable table)
    {
        var name = table.Name;
        foreach (var prefix in Prefixes)
        {
            if (name.Length > prefix.Length
                && name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
            {
                name = name[prefix.Length..];
                break;
            }
        }

        return GenerateCandidateIdentifier(name);
    }
}

CandidateNamingService.Internal で終わる名前空間にあり、それに応じた属性が付いているため、#pragma warning disable EF1001<NoWarn>$(NoWarn);EF1001</NoWarn> が必要です。これはこの拡張ポイントを使う代償で、同じ警告は本格的なスキャフォールディングのカスタマイズならどれでも顔を出します。同じサービス群を UI から駆動する EF Core Power Tools もそうです。

複数形化サービスと並べて登録します。

public void ConfigureDesignTimeServices(IServiceCollection services)
{
    services.AddSingleton<IPluralizer, DomainPluralizer>();
    services.AddSingleton<ICandidateNamingService, TablePrefixNamingService>();
}

これで 6 行目も望みどおりの場所に着地します。生成されるエンティティは PersonDbSetPersons で、マッピングは引き続き実際のテーブルを指しています。

modelBuilder.Entity<Person>(entity =>
{
    entity.ToTable("tbl_person");

    entity.Property(e => e.PersonId)
        .ValueGeneratedNever()
        .HasColumnName("person_id");
    entity.Property(e => e.FullName).HasColumnName("full_name");
});

黙って何も起こらなくなる 4 つのパターン

--no-pluralize は自作サービスを完全に無効化します。 呼び出し箇所はすべて if (!_options.NoPluralize) で包まれているので、このフラグを付けると DbSet 名はテーブル名と等しくなり、Pluralize は一度も呼ばれません。検証済みです。DomainPluralizer を登録したうえで --no-pluralize を渡すと、コンテキストは DbSet<Gas> GasDbSet<Kunde> KundeDbSet<TblPerson> TblPerson になりました。

--use-database-names は無効化しません。 このフラグが迂回するのは ICandidateNamingService だけで、単数形化と複数形化は引き続き走ります。ただし今度は生のデータベース識別子に対してです。同じスキーマでの結果は DbSet<tbl_Person> tbl_Persons で、辞書エントリの表記 (Person) が生の person を置き換えています。生の名前が欲しいなら、両方のフラグが必要です。

名前を衝突させる複数形化サービスは、裏で勝手に一意化されます。 CSharpUniqueNamerwhile (_usedNames.Contains(name)) name = input + suffix++; を実行するので、2 つのテーブルが同じ識別子にマップされてもエラーにはならず、OrderOrder1 が得られます。エンティティ型名の比較は大文字小文字を区別せず、DbSet 名は区別します。

これはコードファーストには一切影響しません。 IPluralizer を利用しているのは、デザインタイムアセンブリ全体でただ 1 つのクラス、RelationalScaffoldingModelFactory だけです。dotnet ef migrations add はこれに触れませんし、そもそも EF Core はモデルからスキーマを生成するときにテーブル名を複数形化しません。EF Core がデータベースに 書き込む 名前を制御したいのであれば、それはモデル構築時の規約の話で、キー、外部キー、インデックスのカスタム命名規約 で扱っています。

短い歴史: ネット上の答えが古いので

IPluralizer は EF Core 2.0 で、実装の伴わないフックとして登場しました。EF Core 3.1 の時点でも登録は依然として AddSingleton<IPluralizer, NullPluralizer>() でした。当時のブログ記事の多くが、Customer テーブルから Customers を得るためだけにサードパーティの複数形化パッケージを入れろと言っているのはそのためです。EF Core 5.0 で登録が HumanizerPluralizer に変わり、Humanizer.Core 2.8.26 への依存が入り、それ以来このフックは同じ挙動を保っています。2020 年より前に書かれた、スキャフォールディングは複数形化を一切しないという記述は、書かれた時点では正しかったのです。

dotnet ef の実行が命名に到達する手前で失敗しているなら、よくある原因は 2 つ、構築できないデザインタイム DbContextランタイムパッケージと一致しないツールのバージョン です。EF Core 11 に限って言えば、ツール側には マイグレーションの作成と適用を 1 コマンドで行う機能 も加わりました。古いメジャーバージョンから移ってくるなら、EF Core 6 から 11 への破壊的変更 に先に目を通しておくとよいでしょう。

これを保守可能に保つルールはこうです。複数形化サービスはアルゴリズムではなく、フォールバック付きの辞書である、ということ。追加する 1 行 1 行が、チームの誰かが一度は議論した名前であり、次のスキャフォールド実行がそれを尊重してくれる場所に書き留められたものです。

次に読む

参考資料

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る