Start Debugging

Como adicionar pluralização personalizada de nomes de tabela ao dotnet ef dbcontext scaffold com IPluralizer

Uma tabela chamada Gas vira uma entidade chamada Ga. Substitua o pluralizador padrão baseado no Humanizer pelo seu próprio IPluralizer, registre-o por meio de IDesignTimeServices no projeto de inicialização e entenda por que TryAddSingleton simplesmente não faz nada ali.

Resposta curta: escreva uma classe que implemente Microsoft.EntityFrameworkCore.Design.IPluralizer (dois métodos, Pluralize e Singularize) e depois registre-a a partir de uma classe que implemente IDesignTimeServices no seu projeto de inicialização com services.AddSingleton<IPluralizer, MyPluralizer>(). Use AddSingleton, nunca TryAddSingleton: o EF Core registra HumanizerPluralizer antes de o seu código rodar, então um TryAdd vira uma operação silenciosamente inócua e você vai passar uma hora se perguntando por que nada mudou. O dotnet ef dbcontext scaffold então chama o seu Singularize para os nomes dos tipos de entidade e o seu Pluralize para os nomes de DbSet e para as navegações de coleção. Se você também precisar remover um prefixo tbl_, isso é outro serviço, ICandidateNamingService, e ele roda antes do pluralizador.

Tudo abaixo foi executado no SDK do .NET 11 RC 1 (11.0.100-rc.1.26425.128) com dotnet-ef 11.0.0-rc.1.26425.128, Microsoft.EntityFrameworkCore.Sqlite e Microsoft.EntityFrameworkCore.Design 11.0.0-rc.1.26425.128, e Humanizer.Core 3.0.10. O banco de dados é um arquivo SQLite local, porque nada neste post depende do provedor: a pluralização acontece em Microsoft.EntityFrameworkCore.Design, acima da camada do provedor. Todo nome gerado citado aqui é saída real do dotnet ef, não uma reconstrução.

Uma tabela chamada Gas vira uma entidade chamada Ga

Aqui está um esquema de seis tabelas com os tipos de nome que bancos de dados reais de fato contêm: duas palavras alemãs, duas palavras inglesas que terminam em s sendo singulares, um plural latino e um prefixo tbl_ legado.

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

Faça o scaffold dele sem nenhuma personalização:

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

Os arquivos gerados são Bestellung.cs, Canva.cs, Ga.cs, Kunde.cs, Medium.cs, TblPerson.cs, e o contexto fica assim:

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

Quatro problemas distintos em seis tabelas. Gas e Canvas são substantivos singulares terminados em s, e o singularizador corta o s mesmo assim. Kunde e Bestellung recebem regras de plural do inglês aplicadas a palavras alemãs. Media vira Medium, que é latim correto e quase nunca é o que uma tabela Media significa. E tbl_person mantém o prefixo e depois é pluralizado no genuinamente surpreendente TblPeople.

Isso não é tanto um bug quanto o resultado inevitável de aplicar a morfologia de um idioma a identificadores arbitrários. Vale a pena ver o tamanho do raio de impacto. Chamar HumanizerPluralizer diretamente sobre uma lista de palavras dá:

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

Duas coisas merecem destaque nessa tabela. Primeiro, a família -us e -as é consistentemente deturpada na direção singular: qualquer tabela cujo nome termine em um s não plural perde um caractere. Segundo, os plurais latinos (Indices, Foci, Radii) são tecnicamente defensáveis e ainda assim errados para a maioria das bases de código, que esperam Indexes. Status já esteve no grupo quebrado e hoje é tratado corretamente.

Nota de versão: esses resultados são idênticos no Humanizer.Core 2.14.1, que é do que o Microsoft.EntityFrameworkCore.Design 8.0.x até 10.0.x depende, e no 3.0.10, para o qual o EF Core 11 RC 1 migrou. Atualizar o EF Core não vai corrigir nada disso para você.

Onde o pluralizador fica no pipeline de nomenclatura

Antes de escrever um substituto ajuda saber exatamente qual string os seus métodos recebem, porque isso não é óbvio a partir da interface.

RelationalScaffoldingModelFactory monta dois nomeadores por execução do 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);

Ou seja, o nome do tipo de entidade é Singularize(candidateIdentifier) e o nome da propriedade DbSet é Pluralize(candidateIdentifier), a partir da mesma entrada. Ambos passam por CSharpUtilities.GenerateCSharpIdentifier, que fixa a ordem das operações:

  1. ICandidateNamingService.GenerateCandidateIdentifier transforma o nome bruto da tabela em um candidato em Pascal case (tbl_person vira TblPerson), a menos que --use-database-names esteja definido.
  2. Caracteres que não são válidos em um identificador C# são substituídos por _.
  3. O seu pluralizador roda, sobre o resultado dos passos 1 e 2.
  4. Um _ inicial é acrescentado se o resultado começar com um dígito ou for uma palavra-chave do C#.
  5. Um unificador acrescenta 1, 2 e assim por diante se o nome já estiver em uso.

O passo 3 é o importante: você recebe um identificador inteiro em Pascal case, como CustomerAddress ou TblPerson, não uma única palavra. Um dicionário indexado por substantivos isolados vai errar quase tudo, a menos que você divida o identificador antes.

O nomeador de entidades não diferencia maiúsculas de minúsculas para a unicidade e o nomeador de DbSet diferencia, e é por isso que Media pode ser ao mesmo tempo o tipo de entidade e o nome do DbSet sem colisão.

As navegações de coleção são pluralizadas separadamente, por uma chamada explícita a _pluralizer.Pluralize sobre o nome candidato da navegação, e as navegações de referência não são singularizadas de forma alguma. Todos os quatro pontos de chamada são protegidos por if (!_options.NoPluralize).

Um IPluralizer que lida com identificadores inteiros

A implementação abaixo mantém o Humanizer como fallback (ele acerta muito mais vezes do que erra) e sobrescreve apenas as palavras que o seu esquema realmente contém. Uma única tabela de pares comanda as duas direções, o que importa mais do que parece: a primeira versão que escrevi só tinha um mapa de singular para plural, corrigiu todos os nomes de DbSet e deixou os tipos de entidade chamados Ga e Canva, porque Singularize("Gas") nunca encontrava correspondência.

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

Três decisões de projeto merecem menção. Mapear cada singular para si mesmo em ToSingular (e cada plural para si mesmo em ToPlural) é o que torna os métodos idempotentes, algo que a documentação da interface pede: “Returns the same identifier if it is already pluralized.” Dividir no último limite de Pascal case faz com que InvoiceStatus e ShippingStatus peguem a regra de Status sem entradas separadas. E SplitTrailingWord também quebra em _, o que só importa sob --use-database-names, onde o tbl_person bruto chega ao seu código inalterado.

Registrando para que o dotnet ef realmente use

Microsoft.EntityFrameworkCore.Design é um pacote DevelopmentDependency, então um dotnet add package padrão te dá uma referência contra a qual você não consegue compilar. Remova os metadados 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" />

Referencie o Humanizer.Core explicitamente se você o usa como fallback. Ele chega transitivamente hoje, mas depender da versão que o EF Core por acaso fixa é como você ganha uma surpresa ao atualizar.

Então o registro em si:

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

Use AddSingleton, não TryAddSingleton. DesignTimeServicesBuilder.CreateServiceCollection chama services.AddEntityFrameworkDesignTimeServices(...) e só então ConfigureUserServices(services). O registro do próprio EF é TryAddSingleton<IPluralizer, HumanizerPluralizer>(), então, quando o seu método roda, IPluralizer já está na coleção. AddSingleton acrescenta ao final, e Microsoft.Extensions.DependencyInjection resolve o último registro, então o seu vence. TryAddSingleton vê a entrada existente e não faz nada. Rodei o scaffold das duas formas contra o mesmo esquema: com TryAddSingleton a saída foi byte a byte idêntica à da execução padrão, tipos de entidade Ga e Canva inclusos, sem aviso de nenhum tipo.

Mais duas regras de descoberta, ambas tiradas diretamente do DesignTimeServicesBuilder:

Para compartilhar o pluralizador entre vários projetos, coloque-o em um assembly próprio e aponte para ele com um atributo em nível de assembly. Esse caminho é tratado por ConfigureReferencedServices, que varre tanto o assembly de inicialização quanto o assembly alvo em busca de DesignTimeServicesReferenceAttribute:

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

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

Isso roda antes de AddEntityFrameworkDesignTimeServices, então, nesse caminho, um TryAddSingleton também funcionaria, porque o TryAdd do próprio EF é que vira a operação inócua. Usar AddSingleton em todo lugar significa que você não precisa manter essa distinção na cabeça.

O resultado

Mesmo esquema, mesmo comando, com DomainPluralizer registrado:

TabelaEntidade padrãoDbSet padrãoEntidade personalizadaDbSet personalizado
KundeKundeKundesKundeKunden
BestellungBestellungBestellungsBestellungBestellungen
GasGaGasGasGases
CanvasCanvaCanvasCanvasCanvases
MediaMediumMediaMediaMedia
tbl_personTblPersonTblPeopleTblPersonTblPersons

A navegação de coleção em Kunde segue o nome do DbSet, porque passa pela mesma chamada a Pluralize:

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

Remover um prefixo tbl_ é outro serviço

TblPerson continua lá, e nenhum pluralizador consegue corrigir isso, porque, quando o seu código é chamado, o prefixo já está incorporado ao identificador candidato. A remoção de prefixo pertence a ICandidateNamingService, que roda no passo 1. Derive da implementação padrão e sobrescreva um 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 mora em um namespace terminado em .Internal e é decorado de acordo, então você precisa de #pragma warning disable EF1001 ou <NoWarn>$(NoWarn);EF1001</NoWarn>. Esse é o preço do ponto de extensão, e o mesmo aviso aparece em toda personalização séria de scaffolding, incluindo o EF Core Power Tools, que aciona os mesmos serviços a partir de uma interface gráfica.

Registre-o junto com o pluralizador:

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

Agora a sexta linha cai onde você quer. A entidade gerada é Person, o DbSet é Persons, e o mapeamento continua apontando para a tabela 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");
});

Quatro formas de isso silenciosamente não fazer nada

--no-pluralize desliga o seu serviço por completo. Todo ponto de chamada é envolvido por if (!_options.NoPluralize), então, com a flag definida, os nomes de DbSet são iguais aos nomes das tabelas e o seu Pluralize nunca é invocado. Verificado: com DomainPluralizer registrado e --no-pluralize passado, o contexto saiu com DbSet<Gas> Gas, DbSet<Kunde> Kunde, DbSet<TblPerson> TblPerson.

--use-database-names não o desliga. Essa flag só contorna o ICandidateNamingService; o singularizador e o pluralizador continuam rodando, agora sobre o identificador bruto do banco de dados. No mesmo esquema o resultado foi DbSet<tbl_Person> tbl_Persons, com a caixa da entrada do dicionário (Person) substituindo o person bruto. Se você quer nomes brutos, precisa das duas flags.

Um pluralizador que colapsa nomes gera unificação pelas suas costas. CSharpUniqueNamer faz while (_usedNames.Contains(name)) name = input + suffix++;, então, se duas tabelas mapeiam para o mesmo identificador, você recebe Order e Order1 em vez de um erro. Nomes de tipo de entidade são comparados sem diferenciar maiúsculas de minúsculas, nomes de DbSet com diferenciação.

Nada disso afeta o code-first. IPluralizer é consumido por exatamente uma classe em todo o assembly de tempo de design, RelationalScaffoldingModelFactory. O dotnet ef migrations add nunca o toca, e o EF Core não pluraliza nomes de tabela ao gerar um esquema a partir do seu modelo, para começo de conversa. Se o que você quer é controlar os nomes que o EF Core escreve no banco de dados, isso é uma convenção de construção de modelo, coberta em convenções de nomenclatura personalizadas para chaves, chaves estrangeiras e índices.

Um breve histórico, porque as respostas na internet são antigas

IPluralizer chegou no EF Core 2.0 como um hook sem nenhuma implementação por trás. Ainda no EF Core 3.1 o registro era AddSingleton<IPluralizer, NullPluralizer>(), e é por isso que tantos posts de blog daquela época mandam você instalar um pacote de pluralização de terceiros só para conseguir Customers a partir de uma tabela Customer. O EF Core 5.0 mudou o registro para HumanizerPluralizer e passou a depender do Humanizer.Core 2.8.26, e o hook funciona da mesma forma desde então. Qualquer coisa escrita antes de 2020 dizendo que o scaffolding não pluraliza nada era verdade quando foi escrita.

Se a sua invocação do dotnet ef falha antes de chegar perto da nomenclatura, as duas causas usuais são um DbContext de tempo de design que não pode ser construído e uma versão da ferramenta que não corresponde aos pacotes de runtime. No EF Core 11 especificamente, a tooling também ganhou um único comando que cria e aplica uma migração, e, se você está chegando de uma major mais antiga, vale passar antes pelas mudanças incompatíveis do EF Core 6 para o 11.

A regra que mantém isso sustentável: o pluralizador é um dicionário com um fallback, não um algoritmo. Cada entrada que você adiciona é um nome sobre o qual alguém do time discutiu uma vez, anotado onde a próxima execução do scaffold vai respeitá-lo.

Leia em seguida

Fontes

Comments

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

< Voltar