Como aplicar convenções de nomenclatura personalizadas para chaves primárias, chaves estrangeiras e índices nas migrações do EF Core 11
Renomeie todos os PK_, FK_, AK_ e IX_ que o EF Core 11 gera com uma única IModelFinalizingConvention, mantenha a prioridade dos nomes explícitos, respeite o limite de tamanho de identificadores e evite a reconstrução do índice clusterizado que a próxima migração gera em um banco de dados existente.
Resposta curta: escreva uma classe que implemente IModelFinalizingConvention, percorra as chaves, chaves estrangeiras e índices declarados de cada tipo de entidade e defina os nomes pelos builders de convenção (key.Builder.HasName(...), fk.Builder.HasConstraintName(...), index.Builder.HasDatabaseName(...)). Registre-a em ConfigureConventions com configurationBuilder.Conventions.Add(_ => new ConstraintNamingConvention()). Como os builders registram o nome com origem Convention, qualquer nome que você defina explicitamente com a Fluent API ou com [Index(Name = ...)] continua prevalecendo. Em um banco de dados novo, isso é todo o trabalho. Em um existente, a próxima migração remove e recria cada chave primária e chave estrangeira só para renomeá-la, então edite essa migração à mão para transformá-la em renomeações.
Tudo neste post foi executado no SDK do .NET 11 RC 1 (11.0.100-rc.1.26425.128) com Microsoft.EntityFrameworkCore.SqlServer 11.0.0-rc.1.26425.128. O DDL e o SQL de migração mostrados são a saída real de Database.GenerateCreateScript() e IMigrationsSqlGenerator sobre um modelo SQL Server. Nenhum servidor de banco de dados foi usado, então não há medições de tempo aqui, apenas o SQL que o EF Core enviaria.
Os nomes que o EF Core 11 escolhe por padrão
Comece com um modelo pequeno: um Blog com uma chave alternativa única Slug, Post apontando para Blog e opcionalmente para Author, um índice único em Author.Email, um índice composto em Post e um relacionamento muitos-para-muitos com skip navigation entre Post e Tag.
// .NET 11, C# 14, EF Core 11.0.0-rc.1
public class Blog
{
public int Id { get; set; }
public string Slug { get; set; } = "";
public List<Post> Posts { get; } = [];
}
public class Post
{
public int Id { get; set; }
public string Title { get; set; } = "";
public int BlogId { get; set; }
public Blog Blog { get; set; } = null!;
public int? AuthorId { get; set; }
public Author? Author { get; set; }
public List<Tag> Tags { get; } = [];
}
public class Author { public int Id { get; set; } public string Email { get; set; } = ""; }
public class Tag { public int Id { get; set; } public string Name { get; set; } = ""; public List<Post> Posts { get; } = []; }
public class AppDb : DbContext
{
public DbSet<Blog> Blogs => Set<Blog>();
public DbSet<Post> Posts => Set<Post>();
public DbSet<Author> Authors => Set<Author>();
public DbSet<Tag> Tags => Set<Tag>();
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<Blog>().HasAlternateKey(b => b.Slug);
mb.Entity<Author>().HasIndex(a => a.Email).IsUnique();
mb.Entity<Post>().HasIndex(p => new { p.BlogId, p.Title });
}
}
O DDL gerado para SQL Server usa quatro padrões:
-- EF Core 11.0.0-rc.1, SQL Server provider, default names
CONSTRAINT [PK_Blogs] PRIMARY KEY ([Id]),
CONSTRAINT [AK_Blogs_Slug] UNIQUE ([Slug])
CONSTRAINT [FK_Posts_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([Id]) ON DELETE CASCADE
CONSTRAINT [FK_PostTag_Tags_TagsId] FOREIGN KEY ([TagsId]) REFERENCES [Tags] ([Id]) ON DELETE CASCADE
CREATE UNIQUE INDEX [IX_Authors_Email] ON [Authors] ([Email]);
CREATE INDEX [IX_Posts_BlogId_Title] ON [Posts] ([BlogId], [Title]);
Então os padrões são PK_{table}, AK_{table}_{columns}, FK_{dependent table}_{principal table}_{columns} e IX_{table}_{columns}, e um índice único recebe o mesmo prefixo IX_ que um não único. Observe que o padrão usa o nome da tabela (Blogs, vindo do DbSet), não o nome do tipo CLR. Partes da documentação do Microsoft Learn descrevem o padrão da chave primária como PK_<type name>, o que só é verdade quando os dois coincidem.
As equipes normalmente querem mudar isso por um de três motivos: um padrão de DBA (pk_, fk_, ux_ para índices únicos), um banco de dados PostgreSQL em que todo o resto está em minúsculas, ou um esquema existente criado por outra ferramenta cujos nomes o EF Core deveria adotar em vez de combater.
Nomes pontuais: HasName, HasConstraintName, HasDatabaseName
Se apenas alguns objetos precisam de um nome específico, a Fluent API tem um método para cada tipo de objeto:
// .NET 11, EF Core 11 - per-object names
mb.Entity<Blog>().HasKey(b => b.Id).HasName("pk_blog");
mb.Entity<Blog>().HasAlternateKey(b => b.Slug).HasName("ak_blog_slug");
mb.Entity<Post>()
.HasOne(p => p.Blog).WithMany(b => b.Posts)
.HasForeignKey(p => p.BlogId)
.HasConstraintName("fk_post_blog");
mb.Entity<Author>().HasIndex(a => a.Email).IsUnique().HasDatabaseName("ux_author_email");
Para índices também existe a forma de atributo, [Index(nameof(Email), IsUnique = true, Name = "ux_author_email")]. O Name do atributo se torna o nome no banco de dados.
Isso não escala. Cada nova entidade precisa das mesmas três chamadas, a tabela de junção de uma skip navigation é fácil de esquecer, e no dia em que alguém adicionar um índice sem a chamada, você volta para IX_. É para isso que serve uma convenção.
Uma convenção de finalização do modelo que nomeia tudo
A documentação do EF Core sobre configuração em massa do modelo descreve dois tipos de convenções personalizadas. As interativas reagem a cada mudança do modelo no momento em que ela acontece. As de finalização do modelo rodam uma única vez, depois que OnModelCreating e todas as convenções internas terminaram, e enxergam o modelo praticamente final. Os nomes de restrições dependem dos nomes de tabelas e de colunas, que podem mudar até o fim da construção do modelo, então uma convenção de finalização é o ponto de extensão certo. Rodar antes significa nomear um índice a partir de uma coluna que um HasColumnName posterior renomeia.
// .NET 11, C# 14, EF Core 11.0.0-rc.1
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Metadata.Conventions;
public sealed class ConstraintNamingConvention : IModelFinalizingConvention
{
public void ProcessModelFinalizing(
IConventionModelBuilder modelBuilder,
IConventionContext<IConventionModelBuilder> context)
{
var maxLength = modelBuilder.Metadata.GetMaxIdentifierLength();
foreach (var entityType in modelBuilder.Metadata.GetEntityTypes())
{
var table = entityType.GetTableName();
if (table is null) continue; // views, keyless query types, TPC abstract roots
var store = StoreObjectIdentifier.Table(table, entityType.GetSchema());
foreach (var key in entityType.GetDeclaredKeys())
{
var name = key.IsPrimaryKey()
? $"pk_{table}"
: $"ak_{table}_{Columns(key.Properties, store)}";
key.Builder.HasName(Truncate(name, maxLength));
}
foreach (var fk in entityType.GetDeclaredForeignKeys())
{
var principalTable = fk.PrincipalEntityType.GetTableName();
if (principalTable is null) continue;
var name = $"fk_{table}_{principalTable}_{Columns(fk.Properties, store)}";
fk.Builder.HasConstraintName(Truncate(name, maxLength));
}
foreach (var index in entityType.GetDeclaredIndexes())
{
var prefix = index.IsUnique ? "ux" : "ix";
var name = $"{prefix}_{table}_{Columns(index.Properties, store)}";
index.Builder.HasDatabaseName(Truncate(name, maxLength));
}
}
}
static string Columns(IEnumerable<IConventionPropertyBase> props, StoreObjectIdentifier store)
=> string.Join("_", props.Select(p =>
(p as IConventionProperty)?.GetColumnName(store) ?? p.Name));
static string Truncate(string name, int maxLength)
{
if (name.Length <= maxLength) return name;
// keep names unique after truncation: prefix + 8 hex chars of a stable hash
var hash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(name)))[..8].ToLowerInvariant();
return $"{name[..(maxLength - 9)]}_{hash}";
}
}
Registre-a no contexto:
// .NET 11, EF Core 11
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
=> configurationBuilder.Conventions.Add(_ => new ConstraintNamingConvention());
Conventions.Add recebe uma factory em vez de uma instância para que uma convenção possa obter serviços do provedor de serviços interno do EF Core. Esta não tem dependências, daí o parâmetro descartado _.
O mesmo modelo agora produz:
-- EF Core 11.0.0-rc.1, SQL Server provider, with ConstraintNamingConvention
CONSTRAINT [pk_Blogs] PRIMARY KEY ([Id]),
CONSTRAINT [ak_Blogs_Slug] UNIQUE ([Slug])
CONSTRAINT [fk_Posts_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([Id]) ON DELETE CASCADE
CONSTRAINT [pk_PostTag] PRIMARY KEY ([PostsId], [TagsId]),
CONSTRAINT [fk_PostTag_Tags_TagsId] FOREIGN KEY ([TagsId]) REFERENCES [Tags] ([Id]) ON DELETE CASCADE
CREATE INDEX [ix_Posts_BlogId_Title] ON [Posts] ([BlogId], [Title]);
CREATE INDEX [ix_PostTag_TagsId] ON [PostTag] ([TagsId]);
A tabela de junção implícita PostTag é coberta sem nenhum código extra porque ela é um tipo de entidade real (de tipo compartilhado) no modelo, e GetEntityTypes() a retorna.
Alguns detalhes nesse código são intencionais.
Use os builders de convenção, não os setters. key.Builder.HasName(...) define o nome com ConfigurationSource.Convention. O EF Core rastreia de onde veio cada parte da configuração, e um valor com origem em convenção nunca sobrescreve um valor DataAnnotation ou Explicit. Na reprodução, mantive o índice único nomeado explicitamente com .HasDatabaseName("UX_Authors_Email_Legacy") em OnModelCreating, e a saída ainda contém CREATE UNIQUE INDEX [UX_Authors_Email_Legacy], enquanto todos os outros índices receberam o tratamento ix_/ux_. Se em vez disso você chamar os setters mutáveis (IMutableKey.SetName) em um loop no final de OnModelCreating, perde essa precedência e sobrescreve silenciosamente nomes que um colega definiu de propósito.
Use o nome da coluna para o objeto de armazenamento, não o nome da propriedade. GetColumnName(StoreObjectIdentifier) retorna o que realmente está na tabela, incluindo substituições feitas com HasColumnName e prefixos de tipos owned como Where_City. Nomear um índice a partir da propriedade CLR gera nomes que não correspondem às colunas que ele cobre.
Properties é IConventionPropertyBase no EF Core 11. No EF Core 11 RC 1, IConventionKey.Properties é tipado como IReadOnlyList<IConventionPropertyBase>, então um método auxiliar declarado como IEnumerable<IConventionProperty> falha ao compilar com CS1503. O cast em Columns resolve isso e recorre ao nome do membro para qualquer coisa que não seja uma propriedade escalar simples.
Colocando em produção: banco de dados novo vs banco de dados existente
A convenção de nomenclatura altera o modelo, então dotnet ef migrations add enxerga uma diferença. O conteúdo dessa diferença é a parte que dá problema.
- Projeto novo ou nenhum banco de dados implantado ainda. Adicione a convenção antes da primeira migração.
InitialCreatecontém os novos nomes e nada mais precisa ser feito. - Banco de dados existente, tabelas pequenas. Gere a migração, leia-a e aplique-a. O EF Core reconstrói as chaves, o que não é problema quando as tabelas são pequenas.
- Banco de dados existente, tabelas grandes. Gere a migração e, antes que alguém a aplique, substitua os pares de remoção/adição por renomeações.
Para ver exatamente do que se trata o passo 3, comparei o modelo com nomes padrão ao modelo com nomes da convenção usando IMigrationsModelDiffer, o mesmo componente que migrations add usa. Os índices saem como renomeações baratas:
-- EF Core 11.0.0-rc.1: RenameIndexOperation on SQL Server
EXEC sp_rename N'[Posts].[IX_Posts_BlogId_Title]', N'ix_Posts_BlogId_Title', 'INDEX';
EXEC sp_rename N'[PostTag].[IX_PostTag_TagsId]', N'ix_PostTag_TagsId', 'INDEX';
Chaves primárias, chaves alternativas e chaves estrangeiras não. Não existe operação de migração RenamePrimaryKey ou RenameForeignKey, então o differ emite uma remoção e uma adição para cada uma, 24 operações para este modelo de cinco tabelas:
-- EF Core 11.0.0-rc.1: what the scaffolded migration does to keys
ALTER TABLE [Posts] DROP CONSTRAINT [FK_Posts_Blogs_BlogId];
ALTER TABLE [Posts] DROP CONSTRAINT [PK_Posts];
ALTER TABLE [Blogs] DROP CONSTRAINT [AK_Blogs_Slug];
ALTER TABLE [Blogs] DROP CONSTRAINT [PK_Blogs];
-- ...
ALTER TABLE [Posts] ADD CONSTRAINT [pk_Posts] PRIMARY KEY ([Id]);
ALTER TABLE [Blogs] ADD CONSTRAINT [ak_Blogs_Slug] UNIQUE ([Slug]);
ALTER TABLE [Blogs] ADD CONSTRAINT [pk_Blogs] PRIMARY KEY ([Id]);
ALTER TABLE [Posts] ADD CONSTRAINT [fk_Posts_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([Id]) ON DELETE CASCADE;
No SQL Server, a chave primária é o índice clusterizado por padrão. Removê-la converte a tabela em um heap e reescreve todos os índices não clusterizados; adicioná-la de volta ordena e reescreve a tabela novamente, e depois reescreve os índices não clusterizados uma segunda vez. Recriar cada chave estrangeira valida todas as linhas existentes. Em uma tabela com dezenas de milhões de linhas, essa é uma operação longa e pesada no log, dentro da transação da migração, só para mudar a caixa de um prefixo. O mesmo padrão de remover e adicionar aparece quando você renomeia uma tabela em uma migração do EF Core 11, e a solução é a mesma: renomear as restrições no lugar.
No SQL Server, substitua as chamadas DropForeignKey/DropPrimaryKey/DropUniqueConstraint geradas e as chamadas Add* correspondentes em Up por sp_rename, que renomeia uma restrição como uma alteração apenas de metadados. Renomear uma chave primária ou restrição única com sp_rename também renomeia o índice que a sustenta.
// .NET 11, EF Core 11 - hand-edited Up() for SQL Server
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("EXEC sp_rename N'[dbo].[PK_Blogs]', N'pk_Blogs', 'OBJECT';");
migrationBuilder.Sql("EXEC sp_rename N'[dbo].[AK_Blogs_Slug]', N'ak_Blogs_Slug', 'OBJECT';");
migrationBuilder.Sql("EXEC sp_rename N'[dbo].[PK_Posts]', N'pk_Posts', 'OBJECT';");
migrationBuilder.Sql("EXEC sp_rename N'[dbo].[FK_Posts_Blogs_BlogId]', N'fk_Posts_Blogs_BlogId', 'OBJECT';");
// ...one line per key and foreign key
// the scaffolded index renames are already fine, keep them
migrationBuilder.RenameIndex(
name: "IX_Posts_BlogId_Title", table: "Posts", newName: "ix_Posts_BlogId_Title");
}
No PostgreSQL, o equivalente é ALTER TABLE "Posts" RENAME CONSTRAINT "PK_Posts" TO "pk_Posts";, que também renomeia o índice por trás de uma chave primária ou restrição única. O Npgsql já gera ALTER INDEX ... RENAME TO para os índices comuns.
Escreva também as chamadas inversas de sp_rename em Down. O Down gerado ainda contém pares de remoção/adição, e deixá-lo assim significa que um rollback executa a reconstrução que você acabou de evitar. O snapshot do modelo não é afetado por essa edição manual: ele registra os novos nomes de qualquer forma, então o próximo migrations add produz uma diferença vazia. Se não produzir, você esqueceu uma restrição, e a verificação na inicialização vai avisar com a exceção de alterações pendentes no modelo. Aplique a migração editada por meio de um script revisado ou de um migrations bundle, não a partir de Database.Migrate() na inicialização do app.
Armadilhas: tabelas compartilhadas, limites de tamanho e o pacote snake_case
Derive o nome da tabela, nunca do tipo de entidade. Um tipo owned armazenado na tabela do seu dono, table splitting e TPH colocam vários tipos de entidade em uma mesma tabela, e cada um deles tem seus próprios metadados de chave primária. Eles precisam concordar quanto ao nome da restrição. Na reprodução, troquei o padrão da chave primária para pk_{entityType.ClrType.Name} em um modelo com um Address owned dentro de Media, e a validação do modelo falhou imediatamente:
InvalidOperationException: The table 'Media' cannot be used for entity type 'Media' since it is being used
for entity type 'Address' and the name 'pk_Media' of the primary key {'Id'} does not match the name
'pk_Address' of the primary key {'MediaId'}.
Derivar o nome de GetTableName() evita isso, porque todo tipo de entidade na tabela compartilhada resolve para a mesma tabela. A mesma reprodução com pk_{table} produziu uma única restrição pk_Media, e as chaves estrangeiras de TPH declaradas nos tipos derivados Photo e Clip saíram como fk_Media_Author_PhotographerId e fk_Media_Author_EditorId na tabela compartilhada. O guia de mapeamento TPH explica por que as colunas de tipos derivados acabam anuláveis ali.
Respeite o limite de tamanho de identificadores e mantenha únicos os nomes truncados. IConventionModel.GetMaxIdentifierLength() retorna o limite do provedor: 128 no SQL Server e 32767 no SQLite na minha reprodução. O PostgreSQL trunca identificadores em 63 bytes. Um índice composto sobre nomes de coluna longos passa de 63 com facilidade, e se você simplesmente cortar a string, dois índices que diferem só no final colapsam para o mesmo nome. O EF Core então falha na validação porque dois índices em uma mesma tabela mapeiam para o mesmo nome com colunas diferentes. O método auxiliar Truncate mantém um prefixo e acrescenta oito caracteres hexadecimais de um SHA-256 do nome completo. Com um limite de 40 caracteres, ix_customer_order_line_items_warehouse_location_id_created_at e ..._updated_at viraram ix_customer_order_line_items_wa_0e2c7d55 e ix_customer_order_line_items_wa_a5c1910c. Use um hash estável, nunca string.GetHashCode(), que é aleatorizado por processo no .NET e produziria um nome diferente, e uma nova migração, a cada build.
As convenções de finalização rodam na ordem em que você as adiciona. Se você também tem uma convenção que renomeia tabelas ou colunas (por exemplo, para snake_case), adicione-a antes da convenção de nomenclatura de restrições em ConfigureConventions. Caso contrário, os nomes das restrições são calculados a partir dos nomes antigos das tabelas.
EFCore.NamingConventions ainda não é um pacote para EF Core 11. O popular pacote da comunidade que transforma tudo em snake_case, incluindo nomes de chaves e índices, está na versão 10.0.1 até hoje, e seu nuspec fixa Microsoft.EntityFrameworkCore.Relational em [10.0.1, 11.0.0). Referenciá-lo junto com o EF Core 11 gera o aviso NU1608 do NuGet, “outside of dependency constraint”, e um pacote que nunca foi testado com a API de metadados do 11.0, que, como mostra a mudança para IConventionPropertyBase, de fato mudou. Uma convenção de 60 linhas que é sua não tem esse problema.
Modelos gerados por scaffolding (database-first) ignoram tudo isso. dotnet ef dbcontext scaffold lê os nomes reais do banco de dados e escreve chamadas explícitas de HasName/HasDatabaseName, e explícito vence convenção. Esse é o comportamento correto, mas não espere que a convenção “conserte” um modelo obtido por engenharia reversa.
Verifique o resultado, não o código. Database.GenerateCreateScript() em um contexto com uma connection string fictícia imprime o DDL completo sem tocar em um servidor, e dotnet ef migrations script mostra o que uma migração pendente vai executar. Ambos são mais rápidos do que ler o snapshot do modelo. Do lado do runtime, registrar em log o SQL que o EF Core 11 gera mostra os nomes das restrições em qualquer DbUpdateException que as referencie.
Relacionados
- Como renomear uma tabela em uma migração do EF Core 11 sem perder dados
- Correção: o modelo do contexto ‘X’ tem alterações pendentes no EF Core 11
- Como aplicar migrações do EF Core 11 em produção com migrations bundles
- Como configurar o mapeamento de herança table-per-hierarchy (TPH) no EF Core 11
- Complex types vs entidades owned no EF Core 11
Fontes
- Model bulk configuration no Microsoft Learn:
ConfigureConventions,IModelFinalizingConvention, origens de configuração e builders de convenção - Keys e Indexes and constraints no Microsoft Learn para
HasNameeHasDatabaseName - sys.sp_rename para renomear restrições no lugar no SQL Server
- ALTER TABLE (
RENAME CONSTRAINT) e tamanho de identificadores na documentação do PostgreSQL - EFCore.NamingConventions no NuGet, faixas de dependência da versão 10.0.1
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.