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:
ICandidateNamingService.GenerateCandidateIdentifiermacht aus dem rohen Tabellennamen einen Pascal-Case-Kandidaten (tbl_personwird zuTblPerson), sofern nicht--use-database-namesgesetzt ist.- Zeichen, die in einem C#-Bezeichner nicht gültig sind, werden durch
_ersetzt. - Ihr Pluralizer läuft, auf dem Ergebnis der Schritte 1 und 2.
- Ein führender
_wird vorangestellt, wenn das Ergebnis mit einer Ziffer beginnt oder ein C#-Schlüsselwort ist. - Ein Uniquifier hängt
1,2und 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:
ConfigureUserServicesdurchsucht nur die Startup-Assembly. Wenn IhreIDesignTimeServices-Klasse in der Klassenbibliothek liegt, die Sie an--projectübergeben haben, aber nicht in der, die Sie an--startup-projectübergeben haben, wird sie nie gefunden.- Es ruft
.FirstOrDefault()auf den passenden Typen auf. Eine zweiteIDesignTimeServicesin derselben Assembly ergibt keinen Fehler, sie wird stillschweigend ignoriert, und welche überlebt, entscheidet die Reflection-Reihenfolge.
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:
| Tabelle | Standard-Entität | Standard-DbSet | Eigene Entität | Eigenes DbSet |
|---|---|---|---|---|
Kunde | Kunde | Kundes | Kunde | Kunden |
Bestellung | Bestellung | Bestellungs | Bestellung | Bestellungen |
Gas | Ga | Gas | Gas | Gases |
Canvas | Canva | Canvas | Canvas | Canvases |
Media | Medium | Media | Media | Media |
tbl_person | TblPerson | TblPeople | TblPerson | TblPersons |
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
- Eigene Namenskonventionen für Primärschlüssel, Fremdschlüssel und Indizes in EF Core 11 Migrationen anwenden
- Fix: dotnet ef migrations add scheitert mit ‘Unable to create an object of type DbContext’
- Fix: MissingMethodException ‘AbstractionsStrings.ArgumentIsEmpty’ nach dem Upgrade der EF Core Tools
- EF Core 11 lässt Sie eine Migration in einem Befehl erstellen und anwenden
- Von EF Core 6 auf EF Core 11 migrieren: die Breaking Changes, die wirklich wehtun
Quellen
- Design-time services, EF Core Dokumentation
- Reverse Engineering, EF Core Dokumentation
IPluralizer, .NET API BrowserRelationalScaffoldingModelFactory.csbeim Tagv11.0.0-rc.1.26425.128, dotnet/efcoreDesignTimeServicesBuilder.csbeim Tagv11.0.0-rc.1.26425.128, dotnet/efcoreDesignTimeServiceCollectionExtensions.csbeim Tagv11.0.0-rc.1.26425.128, dotnet/efcoreCSharpUtilities.csbeim Tagv11.0.0-rc.1.26425.128, dotnet/efcore- Reverse Engineering: Enable pluralization/singularization when scaffolding a model from a database, dotnet/efcore Issue 3060
- Humanizer, die Bibliothek hinter dem Standard-Pluralizer
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.