Start Debugging

Cómo agregar pluralización personalizada de nombres de tabla a dotnet ef dbcontext scaffold con IPluralizer

Una tabla llamada Gas genera una entidad llamada Ga. Reemplaza el pluralizador integrado de Humanizer por tu propio IPluralizer, regístralo mediante IDesignTimeServices en el proyecto de inicio y entiende por qué TryAddSingleton ahí no hace nada en silencio.

Respuesta corta: escribe una clase que implemente Microsoft.EntityFrameworkCore.Design.IPluralizer (dos métodos, Pluralize y Singularize), y luego regístrala desde una clase que implemente IDesignTimeServices en tu proyecto de inicio con services.AddSingleton<IPluralizer, MyPluralizer>(). Usa AddSingleton, nunca TryAddSingleton: EF Core registra HumanizerPluralizer antes de que se ejecute tu código, así que un TryAdd no hace nada en silencio y vas a pasar una hora preguntándote por qué no cambió nada. dotnet ef dbcontext scaffold llama entonces a tu Singularize para los nombres de los tipos de entidad y a tu Pluralize para los nombres de DbSet y las navegaciones de colección. Si además necesitas quitar un prefijo tbl_, eso es otro servicio, ICandidateNamingService, y se ejecuta antes que el pluralizador.

Todo lo que sigue se ejecutó con el SDK de .NET 11 RC 1 (11.0.100-rc.1.26425.128) con dotnet-ef 11.0.0-rc.1.26425.128, Microsoft.EntityFrameworkCore.Sqlite y Microsoft.EntityFrameworkCore.Design 11.0.0-rc.1.26425.128, y Humanizer.Core 3.0.10. La base de datos es un archivo SQLite local, porque nada en este artículo depende del proveedor: la pluralización ocurre en Microsoft.EntityFrameworkCore.Design, por encima de la capa del proveedor. Cada nombre generado que se cita aquí es salida real de dotnet ef, no una reconstrucción.

Una tabla llamada Gas genera una entidad llamada Ga

Este es un esquema de seis tablas con la clase de nombres que las bases de datos reales contienen de verdad: dos palabras en alemán, dos palabras en inglés que terminan en s aunque son singulares, un plural latino y un prefijo heredado tbl_.

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

Genera el scaffold sin ninguna personalización:

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

Los archivos generados son Bestellung.cs, Canva.cs, Ga.cs, Kunde.cs, Medium.cs, TblPerson.cs, y el contexto queda así:

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

Cuatro problemas distintos en seis tablas. Gas y Canvas son sustantivos singulares que terminan en s, y el singularizador les quita la s de todos modos. A Kunde y Bestellung se les aplican reglas de plural del inglés sobre palabras alemanas. Media se convierte en Medium, que es latín correcto y casi nunca es lo que significa una tabla Media. Y tbl_person conserva su prefijo, y después se pluraliza en el genuinamente sorprendente TblPeople.

Esto no es tanto un error como el resultado inevitable de aplicar la morfología de un idioma a identificadores arbitrarios. Vale la pena ver qué tan amplio es el radio de impacto. Llamar a HumanizerPluralizer directamente sobre una lista de palabras da:

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

Hay dos cosas que vale la pena destacar de esa tabla. Primero, la familia -us y -as se estropea de forma consistente en la dirección singular: cualquier tabla cuyo nombre termine en una s que no sea de plural pierde un carácter. Segundo, los plurales latinos (Indices, Foci, Radii) son técnicamente defendibles y aun así están mal para la mayoría de las bases de código, que esperan Indexes. Status estaba en el grupo roto y ahora se maneja correctamente.

Nota sobre versiones: estos resultados son idénticos en Humanizer.Core 2.14.1, que es de lo que dependen Microsoft.EntityFrameworkCore.Design 8.0.x hasta 10.0.x, y en 3.0.10, al que pasó EF Core 11 RC 1. Actualizar EF Core no te va a arreglar nada de esto.

Dónde se ubica el pluralizador en la cadena de nombres

Antes de escribir un reemplazo conviene saber exactamente qué cadena reciben tus métodos, porque eso no es obvio a partir de la interfaz.

RelationalScaffoldingModelFactory construye dos nombradores por cada ejecución del scaffold:

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

Así que el nombre del tipo de entidad es Singularize(candidateIdentifier) y el nombre de la propiedad DbSet es Pluralize(candidateIdentifier), a partir de la misma entrada. Ambos pasan por CSharpUtilities.GenerateCSharpIdentifier, que fija el orden de las operaciones:

  1. ICandidateNamingService.GenerateCandidateIdentifier convierte el nombre crudo de la tabla en un candidato en Pascal case (tbl_person se convierte en TblPerson), salvo que se pase --use-database-names.
  2. Los caracteres que no son válidos en un identificador de C# se reemplazan por _.
  3. Se ejecuta tu pluralizador, sobre el resultado de los pasos 1 y 2.
  4. Se antepone un _ si el resultado empieza con un dígito o es una palabra clave de C#.
  5. Un uniquificador agrega 1, 2, y así sucesivamente si el nombre ya está tomado.

El paso 3 es el importante: recibes un identificador completo en Pascal case como CustomerAddress o TblPerson, no una sola palabra. Un diccionario indexado por sustantivos sueltos se va a perder casi todo a menos que primero dividas el identificador.

El nombrador de entidades no distingue mayúsculas para la unicidad y el nombrador de DbSet sí, y por eso Media puede ser a la vez el tipo de entidad y el nombre del DbSet sin colisión.

Las navegaciones de colección se pluralizan por separado, con una llamada explícita a _pluralizer.Pluralize sobre el nombre candidato de la navegación, y las navegaciones de referencia no se singularizan en absoluto. Los cuatro puntos de llamada están protegidos por if (!_options.NoPluralize).

Un IPluralizer que maneja identificadores completos

La implementación de abajo mantiene Humanizer como respaldo (acierta muchas más veces de las que falla) y sobrescribe solo las palabras que tu esquema realmente contiene. Una sola tabla de pares maneja ambas direcciones, y eso importa más de lo que parece: la primera versión que escribí solo tenía un mapa de singular a plural, arregló todos los nombres de DbSet y dejó los tipos de entidad llamados Ga y Canva, porque Singularize("Gas") nunca encontraba coincidencia.

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

Tres decisiones de diseño que vale la pena señalar. Mapear cada singular a sí mismo en ToSingular (y cada plural a sí mismo en ToPlural) es lo que hace idempotentes a los métodos, que es lo que pide la documentación de la interfaz: “Devuelve el mismo identificador si ya está pluralizado”. Dividir en el último límite de Pascal case hace que InvoiceStatus y ShippingStatus tomen la regla de Status sin necesidad de entradas separadas. Y SplitTrailingWord también corta en _, lo que solo importa con --use-database-names, donde el tbl_person crudo llega a tu código sin cambios.

Cómo registrarlo para que dotnet ef lo use de verdad

Microsoft.EntityFrameworkCore.Design es un paquete DevelopmentDependency, así que un dotnet add package por defecto te da una referencia contra la que no puedes compilar. Quita los metadatos 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" />

Referencia Humanizer.Core de forma explícita si lo usas como respaldo. Hoy llega de forma transitiva, pero depender de la versión que EF Core fije por casualidad es la manera de llevarte una sorpresa cuando actualices.

Y luego el registro en sí:

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

Usa AddSingleton, no TryAddSingleton. DesignTimeServicesBuilder.CreateServiceCollection llama a services.AddEntityFrameworkDesignTimeServices(...) y solo después a ConfigureUserServices(services). El registro propio de EF es TryAddSingleton<IPluralizer, HumanizerPluralizer>(), así que para cuando corre tu método, IPluralizer ya está en la colección. AddSingleton agrega al final, y Microsoft.Extensions.DependencyInjection resuelve el último registro, así que gana el tuyo. TryAddSingleton ve la entrada existente y no hace nada. Ejecuté el scaffold de las dos maneras contra el mismo esquema: con TryAddSingleton la salida fue idéntica byte a byte a la ejecución por defecto, con los tipos de entidad Ga y Canva incluidos, y sin ninguna advertencia.

Dos reglas más de descubrimiento, las dos salidas directamente de DesignTimeServicesBuilder:

Para compartir el pluralizador entre varios proyectos, ponlo en su propio ensamblado y apunta a él con un atributo a nivel de ensamblado. Ese camino lo maneja ConfigureReferencedServices, que escanea tanto el ensamblado de inicio como el ensamblado destino en busca de DesignTimeServicesReferenceAttribute:

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

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

Eso corre antes de AddEntityFrameworkDesignTimeServices, así que por este camino un TryAddSingleton también funcionaría, porque el que queda sin efecto es el TryAdd propio de EF. Usar AddSingleton en todas partes significa que no tienes que andar cargando esa distinción en la cabeza.

El resultado

Mismo esquema, mismo comando, con DomainPluralizer registrado:

TablaEntidad por defectoDbSet por defectoEntidad personalizadaDbSet personalizado
KundeKundeKundesKundeKunden
BestellungBestellungBestellungsBestellungBestellungen
GasGaGasGasGases
CanvasCanvaCanvasCanvasCanvases
MediaMediumMediaMediaMedia
tbl_personTblPersonTblPeopleTblPersonTblPersons

La navegación de colección en Kunde sigue el nombre del DbSet, porque pasa por la misma llamada a Pluralize:

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

Quitar un prefijo tbl_ es otro servicio

TblPerson sigue ahí, y ningún pluralizador puede arreglarlo, porque para cuando se llama a tu código el prefijo ya está incorporado en el identificador candidato. Quitar prefijos le corresponde a ICandidateNamingService, que se ejecuta en el paso 1. Deriva de la implementación integrada y sobrescribe un método:

// .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 vive en un namespace que termina en .Internal y está decorado en consecuencia, así que necesitas #pragma warning disable EF1001 o <NoWarn>$(NoWarn);EF1001</NoWarn>. Ese es el precio del punto de extensión, y la misma advertencia aparece en toda personalización seria de scaffolding, incluida EF Core Power Tools, que maneja los mismos servicios desde una interfaz gráfica.

Regístralo junto al pluralizador:

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

Ahora la sexta fila cae donde la quieres. La entidad generada es Person, el DbSet es Persons, y el mapeo sigue apuntando a la tabla real:

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

Cuatro maneras en que esto no hace nada en silencio

--no-pluralize apaga tu servicio por completo. Cada punto de llamada está envuelto en if (!_options.NoPluralize), así que con la opción puesta los nombres de DbSet son iguales a los nombres de tabla y tu Pluralize nunca se invoca. Verificado: con DomainPluralizer registrado y --no-pluralize pasado, el contexto salió con DbSet<Gas> Gas, DbSet<Kunde> Kunde, DbSet<TblPerson> TblPerson.

--use-database-names no lo apaga. Esa opción solo evita ICandidateNamingService; el singularizador y el pluralizador siguen ejecutándose, ahora sobre el identificador crudo de la base de datos. Sobre el mismo esquema el resultado fue DbSet<tbl_Person> tbl_Persons, con las mayúsculas de la entrada del diccionario (Person) reemplazando al person crudo. Si quieres nombres crudos, necesitas las dos opciones.

Un pluralizador que colapsa nombres recibe un sufijo de unicidad a tus espaldas. CSharpUniqueNamer hace while (_usedNames.Contains(name)) name = input + suffix++;, así que si dos tablas se mapean al mismo identificador obtienes Order y Order1 en lugar de un error. Los nombres de los tipos de entidad se comparan sin distinguir mayúsculas, y los nombres de DbSet distinguiéndolas.

Nada de esto afecta a code-first. IPluralizer lo consume exactamente una clase en todo el ensamblado de tiempo de diseño, RelationalScaffoldingModelFactory. dotnet ef migrations add nunca lo toca, y EF Core, de entrada, no pluraliza los nombres de tabla cuando genera un esquema a partir de tu modelo. Si lo que quieres es controlar los nombres que EF Core escribe en la base de datos, eso es una convención de construcción del modelo, cubierta en convenciones de nombres personalizadas para claves, claves foráneas e índices.

Un poco de historia, porque las respuestas en internet son viejas

IPluralizer llegó en EF Core 2.0 como un hook sin ninguna implementación detrás. Todavía en EF Core 3.1 el registro seguía siendo AddSingleton<IPluralizer, NullPluralizer>(), y por eso tantos artículos de esa época te dicen que instales un paquete de pluralización de terceros solo para obtener Customers de una tabla Customer. EF Core 5.0 cambió el registro a HumanizerPluralizer y tomó una dependencia de Humanizer.Core 2.8.26, y el hook funciona igual desde entonces. Cualquier cosa escrita antes de 2020 que diga que el scaffolding no pluraliza en absoluto era cierta cuando se escribió.

Si tu invocación de dotnet ef falla antes de acercarse siquiera a los nombres, las dos causas habituales son un DbContext de tiempo de diseño que no se puede construir y una versión de la herramienta que no coincide con los paquetes del runtime. En EF Core 11 en concreto, el tooling también ganó un solo comando que crea y aplica una migración, y si vienes de una versión mayor más antigua, vale la pena repasar primero los cambios incompatibles de EF Core 6 a 11.

La regla que mantiene esto sostenible: el pluralizador es un diccionario con un respaldo, no un algoritmo. Cada entrada que agregas es un nombre que alguien del equipo discutió una vez, escrito donde la próxima ejecución del scaffold lo va a respetar.

Lee a continuación

Fuentes

Comments

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

< Volver