Start Debugging

Eigene Pluralisierung von Tabellennamen für dotnet ef dbcontext scaffold mit IPluralizer hinzufügen

Eine Tabelle namens Gas wird zu einer Entität namens Ga gescaffoldet. Ersetzen Sie den eingebauten Humanizer-Pluralizer durch einen eigenen IPluralizer, registrieren Sie ihn über IDesignTimeServices im Startprojekt, und erfahren Sie, warum TryAddSingleton dort stillschweigend nichts tut.

Kurze Antwort: Schreiben Sie eine Klasse, die Microsoft.EntityFrameworkCore.Design.IPluralizer implementiert (zwei Methoden, Pluralize und Singularize), und registrieren Sie sie aus einer Klasse heraus, die IDesignTimeServices implementiert, in Ihrem Startprojekt mit services.AddSingleton<IPluralizer, MyPluralizer>(). Verwenden Sie AddSingleton, niemals TryAddSingleton: EF Core registriert HumanizerPluralizer, bevor Ihr Code läuft, ein TryAdd ist also ein stiller No-Op, und Sie verbringen eine Stunde damit, sich zu fragen, warum sich nichts geändert hat. dotnet ef dbcontext scaffold ruft dann Ihr Singularize für Entitätstypnamen und Ihr Pluralize für DbSet-Namen und Collection-Navigationen auf. Wenn Sie zusätzlich ein tbl_-Präfix entfernen müssen: Das ist ein anderer Service, ICandidateNamingService, und er läuft vor dem Pluralizer.

Alles Folgende wurde mit dem .NET 11 RC 1 SDK (11.0.100-rc.1.26425.128) ausgeführt, mit dotnet-ef 11.0.0-rc.1.26425.128, Microsoft.EntityFrameworkCore.Sqlite und Microsoft.EntityFrameworkCore.Design 11.0.0-rc.1.26425.128 sowie Humanizer.Core 3.0.10. Die Datenbank ist eine lokale SQLite-Datei, denn nichts in diesem Beitrag hängt vom Provider ab: Die Pluralisierung passiert in Microsoft.EntityFrameworkCore.Design, oberhalb der Provider-Schicht. Jeder hier zitierte generierte Name ist echte dotnet ef-Ausgabe, keine Rekonstruktion.

Eine Tabelle namens Gas wird zu einer Entität namens Ga

Hier ist ein Schema mit sechs Tabellen und genau den Namensarten, die echte Datenbanken tatsächlich enthalten: zwei deutsche Wörter, zwei englische Wörter, die auf s enden und trotzdem Singular sind, ein lateinischer Plural und ein historisches tbl_-Präfix.

-- 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);

Scaffolden Sie es ganz ohne Anpassung:

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

Die generierten Dateien sind Bestellung.cs, Canva.cs, Ga.cs, Kunde.cs, Medium.cs, TblPerson.cs, und der Kontext sieht so aus:

// 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; }

Vier verschiedene Probleme in sechs Tabellen. Gas und Canvas sind Substantive im Singular, die auf s enden, und der Singularizer schneidet das s trotzdem ab. Auf Kunde und Bestellung werden englische Pluralregeln angewendet, obwohl es deutsche Wörter sind. Media wird zu Medium, was korrektes Latein ist und fast nie das, was eine Media-Tabelle meint. Und tbl_person behält sein Präfix und wird dann zum wirklich überraschenden TblPeople pluralisiert.

Das ist weniger ein Bug als das unvermeidliche Ergebnis davon, die Morphologie einer Sprache auf beliebige Bezeichner anzuwenden. Es lohnt sich zu sehen, wie groß der Wirkungsradius ist. Ruft man HumanizerPluralizer direkt auf einer Wortliste auf, kommt das heraus:

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

Zwei Dinge lohnt es sich aus dieser Tabelle herauszuziehen. Erstens wird die -us- und -as-Familie in Singular-Richtung durchgehend verstümmelt: Jede Tabelle, deren Name auf ein nicht-plurales s endet, verliert ein Zeichen. Zweitens sind die lateinischen Plurale (Indices, Foci, Radii) technisch vertretbar und trotzdem falsch für die meisten Codebasen, die Indexes erwarten. Status gehörte früher zur kaputten Gruppe und wird inzwischen korrekt behandelt.

Versionshinweis: Diese Ergebnisse sind identisch mit Humanizer.Core 2.14.1, von dem Microsoft.EntityFrameworkCore.Design 8.0.x bis 10.0.x abhängen, und mit 3.0.10, auf das EF Core 11 RC 1 gewechselt ist. Ein Upgrade von EF Core behebt nichts davon für Sie.

Wo der Pluralizer in der Namens-Pipeline sitzt

Bevor Sie einen Ersatz schreiben, hilft es zu wissen, welchen String Ihre Methoden genau bekommen, denn das ist am Interface nicht offensichtlich.

RelationalScaffoldingModelFactory baut pro Scaffold-Lauf zwei Namer:

// 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);

Der Entitätstypname ist also Singularize(candidateIdentifier) und der DbSet-Eigenschaftsname ist Pluralize(candidateIdentifier), aus derselben Eingabe. Beide laufen durch CSharpUtilities.GenerateCSharpIdentifier, was die Reihenfolge der Schritte festlegt:

  1. ICandidateNamingService.GenerateCandidateIdentifier macht aus dem rohen Tabellennamen einen Pascal-Case-Kandidaten (tbl_person wird zu TblPerson), sofern nicht --use-database-names gesetzt ist.
  2. Zeichen, die in einem C#-Bezeichner nicht gültig sind, werden durch _ ersetzt.
  3. Ihr Pluralizer läuft, auf dem Ergebnis der Schritte 1 und 2.
  4. Ein führender _ wird vorangestellt, wenn das Ergebnis mit einer Ziffer beginnt oder ein C#-Schlüsselwort ist.
  5. Ein Uniquifier hängt 1, 2 und so weiter an, wenn der Name bereits vergeben ist.

Schritt 3 ist der wichtige: Sie bekommen einen vollständigen Pascal-Case-Bezeichner wie CustomerAddress oder TblPerson, kein einzelnes Wort. Ein Wörterbuch, das auf blanke Substantive schlüsselt, verfehlt fast alles, solange Sie den Bezeichner nicht vorher zerlegen.

Der Entitäts-Namer ist für die Eindeutigkeit case-insensitiv und der DbSet-Namer case-sensitiv, weshalb Media ohne Kollision sowohl Entitätstyp als auch DbSet-Name sein kann.

Collection-Navigationen werden separat pluralisiert, über einen expliziten _pluralizer.Pluralize-Aufruf auf dem Kandidatennamen der Navigation, und Referenz-Navigationen werden gar nicht singularisiert. Alle vier Aufrufstellen sind durch if (!_options.NoPluralize) abgesichert.

Ein IPluralizer, der ganze Bezeichner verarbeitet

Die folgende Implementierung behält Humanizer als Fallback (er liegt weit öfter richtig als falsch) und überschreibt nur die Wörter, die Ihr Schema tatsächlich enthält. Eine einzige Tabelle von Paaren treibt beide Richtungen, was mehr ausmacht, als es klingt: Meine erste Version hatte nur eine Singular-zu-Plural-Map, korrigierte jeden DbSet-Namen und ließ die Entitätstypen weiterhin Ga und Canva heißen, weil Singularize("Gas") nie einen Treffer fand.

// .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);
    }
}

Drei Design-Entscheidungen sind erwähnenswert. Jeden Singular in ToSingular auf sich selbst abzubilden (und jeden Plural in ToPlural auf sich selbst) macht die Methoden idempotent, was die Dokumentation des Interfaces verlangt: “Returns the same identifier if it is already pluralized.” Das Trennen an der letzten Pascal-Case-Grenze sorgt dafür, dass InvoiceStatus und ShippingStatus beide die Status-Regel aufgreifen, ohne eigene Einträge. Und SplitTrailingWord bricht auch an _, was nur unter --use-database-names eine Rolle spielt, wo das rohe tbl_person unverändert bei Ihrem Code ankommt.

Registrierung, damit dotnet ef ihn tatsächlich verwendet

Microsoft.EntityFrameworkCore.Design ist ein DevelopmentDependency-Paket, ein normales dotnet add package gibt Ihnen also eine Referenz, gegen die Sie nicht kompilieren können. Entfernen Sie die IncludeAssets-Metadaten:

<!-- 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" />

Referenzieren Sie Humanizer.Core explizit, wenn Sie es als Fallback verwenden. Es kommt heute transitiv mit, aber sich auf die Version zu verlassen, die EF Core zufällig festlegt, ist der Weg zu einer Überraschung beim nächsten Upgrade.

Dann die Registrierung selbst:

// .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>();
}

Verwenden Sie AddSingleton, nicht TryAddSingleton. DesignTimeServicesBuilder.CreateServiceCollection ruft services.AddEntityFrameworkDesignTimeServices(...) auf und erst danach ConfigureUserServices(services). EFs eigene Registrierung ist TryAddSingleton<IPluralizer, HumanizerPluralizer>(), IPluralizer liegt also bereits in der Collection, wenn Ihre Methode läuft. AddSingleton hängt an, und Microsoft.Extensions.DependencyInjection löst die letzte Registrierung auf, Ihre gewinnt also. TryAddSingleton sieht den vorhandenen Eintrag und tut nichts. Ich habe den Scaffold gegen dasselbe Schema auf beide Arten laufen lassen: Mit TryAddSingleton war die Ausgabe byte-identisch mit dem Standardlauf, inklusive der Entitätstypen Ga und Canva, und ohne jede Warnung.

Zwei weitere Discovery-Regeln, beide direkt aus DesignTimeServicesBuilder:

Um den Pluralizer über mehrere Projekte hinweg zu teilen, legen Sie ihn in eine eigene Assembly und verweisen mit einem Assembly-Attribut darauf. Diesen Pfad behandelt ConfigureReferencedServices, das sowohl die Startup-Assembly als auch die Ziel-Assembly nach DesignTimeServicesReferenceAttribute durchsucht:

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

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

Das läuft vor AddEntityFrameworkDesignTimeServices, auf diesem Pfad würde also auch ein TryAddSingleton funktionieren, weil stattdessen EFs eigenes TryAdd zum No-Op wird. Wenn Sie überall AddSingleton verwenden, müssen Sie diese Unterscheidung nicht im Kopf behalten.

Das Ergebnis

Gleiches Schema, gleicher Befehl, mit registriertem DomainPluralizer:

TabelleStandard-EntitätStandard-DbSetEigene EntitätEigenes DbSet
KundeKundeKundesKundeKunden
BestellungBestellungBestellungsBestellungBestellungen
GasGaGasGasGases
CanvasCanvaCanvasCanvasCanvases
MediaMediumMediaMediaMedia
tbl_personTblPersonTblPeopleTblPersonTblPersons

Die Collection-Navigation auf Kunde folgt dem DbSet-Namen, weil sie durch denselben Pluralize-Aufruf geht:

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

Ein tbl_-Präfix zu entfernen ist ein anderer Service

TblPerson ist immer noch da, und kein Pluralizer kann das beheben, denn wenn Ihr Code aufgerufen wird, ist das Präfix bereits im Kandidatenbezeichner eingebacken. Das Entfernen von Präfixen gehört zu ICandidateNamingService, der in Schritt 1 läuft. Leiten Sie von der eingebauten Implementierung ab und überschreiben Sie eine Methode:

// .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 liegt in einem Namespace, der auf .Internal endet, und ist entsprechend gekennzeichnet, Sie brauchen also #pragma warning disable EF1001 oder <NoWarn>$(NoWarn);EF1001</NoWarn>. Das ist der Preis für diesen Erweiterungspunkt, und dieselbe Warnung taucht in jeder ernsthaften Scaffolding-Anpassung auf, auch in den EF Core Power Tools, die dieselben Services über eine UI ansteuern.

Registrieren Sie ihn zusammen mit dem Pluralizer:

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

Jetzt landet auch die sechste Zeile dort, wo Sie sie haben wollen. Die generierte Entität ist Person, das DbSet ist Persons, und das Mapping zeigt weiterhin auf die echte Tabelle:

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");
});

Vier Arten, wie das stillschweigend nichts tut

--no-pluralize schaltet Ihren Service komplett ab. Jede Aufrufstelle ist in if (!_options.NoPluralize) eingepackt, mit gesetztem Flag entsprechen DbSet-Namen also den Tabellennamen, und Ihr Pluralize wird nie aufgerufen. Verifiziert: Mit registriertem DomainPluralizer und übergebenem --no-pluralize kam der Kontext mit DbSet<Gas> Gas, DbSet<Kunde> Kunde, DbSet<TblPerson> TblPerson heraus.

--use-database-names schaltet ihn nicht ab. Dieses Flag umgeht nur ICandidateNamingService; Singularizer und Pluralizer laufen weiterhin, jetzt auf dem rohen Datenbankbezeichner. Auf demselben Schema war das Ergebnis DbSet<tbl_Person> tbl_Persons, wobei die Schreibweise des Wörterbucheintrags (Person) das rohe person ersetzt. Wenn Sie rohe Namen wollen, brauchen Sie beide Flags.

Ein Pluralizer, der Namen zusammenfallen lässt, wird hinter Ihrem Rücken uniquifiziert. CSharpUniqueNamer macht while (_usedNames.Contains(name)) name = input + suffix++;, wenn also zwei Tabellen auf denselben Bezeichner abbilden, bekommen Sie Order und Order1 statt eines Fehlers. Entitätstypnamen werden case-insensitiv verglichen, DbSet-Namen case-sensitiv.

Nichts davon betrifft Code First. IPluralizer wird in der gesamten Design-Time-Assembly von genau einer Klasse konsumiert, von RelationalScaffoldingModelFactory. dotnet ef migrations add fasst ihn nie an, und EF Core pluralisiert Tabellennamen ohnehin nicht, wenn es ein Schema aus Ihrem Modell erzeugt. Wenn Sie die Namen steuern wollen, die EF Core in die Datenbank schreibt, ist das eine Konvention beim Modellaufbau, behandelt in eigene Namenskonventionen für Schlüssel, Fremdschlüssel und Indizes.

Eine kurze Historie, weil die Antworten im Internet alt sind

IPluralizer kam mit EF Core 2.0 als Hook ohne Implementierung dahinter. Noch in EF Core 3.1 lautete die Registrierung AddSingleton<IPluralizer, NullPluralizer>(), weshalb so viele Blogbeiträge aus jener Zeit Ihnen raten, ein Pluralizer-Paket eines Drittanbieters zu installieren, nur um Customers aus einer Customer-Tabelle zu bekommen. EF Core 5.0 änderte die Registrierung auf HumanizerPluralizer und nahm eine Abhängigkeit auf Humanizer.Core 2.8.26 auf, und der Hook funktioniert seitdem unverändert. Alles vor 2020 Geschriebene, das sagt, Scaffolding pluralisiere überhaupt nicht, stimmte zum Zeitpunkt des Schreibens.

Wenn Ihr dotnet ef-Aufruf scheitert, bevor er überhaupt in die Nähe der Benennung kommt, sind die beiden üblichen Ursachen ein DbContext, der zur Design Time nicht konstruiert werden kann und eine Tool-Version, die nicht zu den Runtime-Paketen passt. Speziell auf EF Core 11 hat das Tooling außerdem einen einzigen Befehl bekommen, der eine Migration erstellt und anwendet, und wenn Sie von einem älteren Major kommen, sind die Breaking Changes von EF Core 6 auf EF Core 11 zuerst einen Durchgang wert.

Die Regel, die das wartbar hält: Der Pluralizer ist ein Wörterbuch mit Fallback, kein Algorithmus. Jeder Eintrag, den Sie hinzufügen, ist ein Name, über den im Team einmal gestritten wurde, festgehalten dort, wo der nächste Scaffold-Lauf ihn beachtet.

Weiterlesen

Quellen

Comments

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

< Zurück