Start Debugging

O que é uma shadow property no EF Core 11, e por que uma apareceu na minha migração?

Uma shadow property é uma coluna que o EF Core rastreia sem uma propriedade CLR correspondente. Veja por que o EF Core 11 as cria (propriedades de FK ausentes, FKs com nome errado, tipos incompatíveis, relacionamentos duplicados), como identificar o tipo BlogId1 em uma migração, como corrigir cada causa e como usar shadow properties de propósito.

Resposta curta: uma shadow property é uma propriedade que existe no modelo do EF Core, e normalmente como coluna no banco de dados, mas não tem uma propriedade correspondente na sua classe de entidade. O EF Core 11 cria uma para você sempre que um relacionamento precisa de uma chave estrangeira e ele não encontra uma propriedade CLR para usar. Quando isso acontece porque sua classe simplesmente não tem uma propriedade de FK, a coluna shadow é inofensiva. Quando acontece porque você tem uma propriedade de FK que o EF Core não conseguiu usar (nome errado, tipo errado, [NotMapped], ou um relacionamento configurado duas vezes), você ganha uma coluna como BlogId1 ou OwnerId ao lado da que você pretendia, e a correção é dizer ao EF Core qual propriedade é a chave estrangeira com HasForeignKey.

Tudo abaixo foi executado com Microsoft.EntityFrameworkCore.Sqlite 11.0.0-rc.1.26425.128 no SDK .NET 11 RC1 (11.0.100-rc.1.26425.128), C# 14. A saída do modelo e os avisos foram copiados de execuções reais, não parafraseados.

O que o EF Core quer dizer com “shadow”

Toda propriedade em um modelo do EF Core tem metadados: um nome, um tipo CLR, anulabilidade, se é uma chave ou FK. Para a maioria das propriedades também existe um membro de apoio na classe, uma propriedade ou campo C#, que o EF Core lê e escreve ao materializar entidades ou salvar alterações. Uma shadow property tem os metadados, mas não tem o membro. Seu valor existe apenas no change tracker.

Essa é a definição completa, conforme a documentação de shadow and indexer properties. O EF Core não se importa com a forma como a propriedade passou a existir. Você pode declarar uma deliberadamente, ou as convenções podem criar uma durante a construção do modelo. O segundo caso é o que surpreende as pessoas, porque o primeiro lugar em que ele fica visível é uma migração que adiciona uma coluna que você nunca escreveu.

Você pode ver quais propriedades são shadow properties despejando o modelo. Model.ToDebugString() as marca com (no field, ...) e Shadow:

// .NET 11, EF Core 11.0.0-rc.1
using Microsoft.EntityFrameworkCore.Infrastructure;

using var db = new AppDbContext();
Console.WriteLine(db.Model.ToDebugString(MetadataDebugStringOptions.ShortDefault));

Guarde essa linha. É a forma mais rápida de responder “de onde veio essa coluna?” sem ler o snapshot da migração.

Caso 1: a navegação não tem propriedade de FK (esperado, inofensivo)

A shadow property mais comum é a que o EF Core cria quando você modela um relacionamento apenas com navegações:

// .NET 11, EF Core 11.0.0-rc.1
public class Blog
{
    public int Id { get; set; }
    public List<Post> Posts { get; set; } = [];
}

public class Post
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
}

Aqui há um relacionamento um-para-muitos, então a tabela Post precisa de uma coluna de chave estrangeira. Post não tem BlogId, então o EF Core inventa uma. A visão de depuração mostra:

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    BlogId (no field, int?) Shadow FK Index
    Title (string) Required
  Foreign keys:
    Post {'BlogId'} -> Blog {'Id'} ClientSetNull ToDependent: Posts

E a tabela gerada:

CREATE TABLE "Post" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Post" PRIMARY KEY AUTOINCREMENT,
    "Title" TEXT NOT NULL,
    "BlogId" INTEGER NULL,
    CONSTRAINT "FK_Post_Blogs_BlogId" FOREIGN KEY ("BlogId") REFERENCES "Blogs" ("Id")
);

O nome segue a convenção <nome da navegação ou do tipo principal><nome da chave principal>, aqui Blog + Id. Dois detalhes importam. Primeiro, a FK shadow é int?, então o relacionamento é opcional e o comportamento de exclusão é ClientSetNull, não Cascade. Se você esperava semântica obrigatória, adicione uma propriedade int BlogId de verdade ou chame .IsRequired() no relacionamento. Segundo, o EF Core registra isso apenas no nível Debug, como CoreEventId.ShadowPropertyCreated (evento 10600):

The property 'Post.BlogId' was created in shadow state because there are no eligible CLR members with a matching name.

Você não verá isso em um log de console padrão. Isso é intencional: é uma escolha de modelagem legítima, e muitas bases de código mantêm os valores de FK fora de suas classes de domínio.

Caso 2: a propriedade de FK tem um nome não convencional (silencioso, e errado)

Este é o que produz uma “coluna misteriosa” em uma migração, sem nenhum aviso:

// .NET 11, EF Core 11.0.0-rc.1
public class User
{
    public int Id { get; set; }
}

public class Post
{
    public int Id { get; set; }
    public int OwnerUserId { get; set; }
    public User Owner { get; set; } = null!;
}

Você queria que OwnerUserId fosse a chave estrangeira de Owner. A convenção de descoberta de FK do EF Core só reconhece nomes da forma <nome da navegação><nome da chave principal> (OwnerId), <nome do tipo principal><nome da chave principal> (UserId), ou <nome do tipo de entidade principal>Id. OwnerUserId não se encaixa em nenhuma delas, então o EF Core o trata como uma coluna int comum e cria uma FK shadow OwnerId:

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    OwnerId (no field, int) Shadow Required FK Index
    OwnerUserId (int) Required
CREATE TABLE "Posts" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Posts" PRIMARY KEY AUTOINCREMENT,
    "OwnerUserId" INTEGER NOT NULL,
    "OwnerId" INTEGER NOT NULL,
    CONSTRAINT "FK_Posts_User_OwnerId" FOREIGN KEY ("OwnerId") REFERENCES "User" ("Id") ON DELETE CASCADE
);

Seu código define post.OwnerUserId = 42, salva, e nada se conecta. O relacionamento vive em OwnerId, que seu código nunca toca. Mais uma vez, o EF Core registra apenas o evento ShadowPropertyCreated no nível Debug, então o primeiro sintoma costuma ser um join que não retorna nada ou um diff de migração que alguém por acaso leu com atenção.

A correção é nomear a FK explicitamente:

// .NET 11, EF Core 11.0.0-rc.1
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasOne(p => p.Owner)
        .WithMany()
        .HasForeignKey(p => p.OwnerUserId);
}

ou com a data annotation na navegação: [ForeignKey(nameof(OwnerUserId))] public User Owner { get; set; }. Depois disso, o modelo tem uma única FK, OwnerUserId, e a coluna shadow desaparece. Se uma migração com OwnerId já foi publicada, a próxima migração removerá OwnerId e adicionará a constraint de FK em OwnerUserId. Verifique se alguma linha foi gravada pela coluna antiga antes de deixá-la ser removida.

Caso 3: a propriedade de FK tem o tipo errado (BlogId1)

Agora o famoso sufixo 1:

// .NET 11, EF Core 11.0.0-rc.1
public class Blog
{
    public int Id { get; set; }
    public List<Post> Posts { get; set; } = [];
}

public class Post
{
    public int Id { get; set; }
    public string BlogId { get; set; } = "";   // principal key is int
    public Blog Blog { get; set; } = null!;
}

BlogId tem o nome convencional, mas é uma string e Blog.Id é um int. O EF Core não pode usar uma propriedade incompatível como FK. Ele também não pode chamar a shadow property de BlogId, porque esse nome já está em uso, então a torna única como BlogId1. Desta vez o EF Core registra um Warning, CoreEventId.ShadowForeignKeyPropertyCreated (evento 10625):

warn: CoreEventId.ShadowForeignKeyPropertyCreated[10625]
      The foreign key property 'Post.BlogId1' was created in shadow state because a conflicting property
      with the simple name 'BlogId' exists in the entity type, but is either not mapped, is already used
      for another relationship, or is incompatible with the associated primary key type.

A mensagem lista as três causas que levam a uma FK shadow numerada. Incompatibilidade de tipo é uma delas. As outras duas vêm a seguir.

Caso 4: o relacionamento foi configurado duas vezes (BlogId e BlogId1)

Este vem de uma Fluent API que nomeia apenas um lado do relacionamento:

// .NET 11, EF Core 11.0.0-rc.1
public class Post
{
    public int Id { get; set; }
    public int BlogId { get; set; }
    public Blog Blog { get; set; } = null!;
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .HasMany(b => b.Posts)
        .WithOne()                       // no navigation passed
        .HasForeignKey(p => p.BlogId);
}

WithOne() sem argumento diz ao EF Core “este relacionamento não tem navegação no lado de Post”. Mas Post.Blog existe, então as convenções constroem um segundo relacionamento a partir dela. BlogId já é usado pelo primeiro, então o segundo recebe BlogId1:

Foreign keys:
  Post {'BlogId'} -> Blog {'Id'} Required Cascade ToDependent: Posts
  Post {'BlogId1'} -> Blog {'Id'} Required Cascade ToPrincipal: Blog
CREATE TABLE "Post" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Post" PRIMARY KEY AUTOINCREMENT,
    "BlogId" INTEGER NOT NULL,
    "BlogId1" INTEGER NOT NULL,
    CONSTRAINT "FK_Post_Blogs_BlogId" FOREIGN KEY ("BlogId") REFERENCES "Blogs" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_Post_Blogs_BlogId1" FOREIGN KEY ("BlogId1") REFERENCES "Blogs" ("Id") ON DELETE CASCADE
);

Duas FKs obrigatórias para a mesma tabela, e blog.Posts e post.Blog deixam de descrever o mesmo vínculo. A correção é passar a navegação para que as duas pontas pertençam a um único relacionamento: .WithOne(p => p.Blog). Com essa mudança o modelo volta a ter uma única FK BlogId com Inverse: Posts.

Uma diferença útil: se você remover a chamada HasForeignKey(p => p.BlogId) dessa configuração quebrada, o EF Core 11 não cria BlogId1 silenciosamente. Em vez disso, ele lança uma exceção durante a finalização do modelo:

System.InvalidOperationException: Both relationships between 'Post' and 'Blog.Posts' and between 'Post.Blog'
and 'Blog' could use {'BlogId'} as the foreign key. To resolve this, configure the foreign key properties
explicitly in 'OnModelCreating' on at least one of the relationships.

Então, quando você encontrar essa exceção, a reação correta não é adicionar um HasForeignKey até ela sumir. Isso converte a exceção no esquema com BlogId1 mostrado acima. Encontre o relacionamento que está sem a navegação e corrija esse.

Caso 5: a propriedade de FK não está mapeada

A terceira causa no aviso é uma propriedade que o EF Core não tem permissão para usar:

// .NET 11, EF Core 11.0.0-rc.1
public class Post
{
    public int Id { get; set; }
    [NotMapped] public int BlogId { get; set; }
    public Blog Blog { get; set; } = null!;
}

O resultado é uma tabela com apenas uma coluna BlogId1, mais o mesmo aviso 10625. A mesma coisa acontece com modelBuilder.Entity<Post>().Ignore(p => p.BlogId). Se você quer que a propriedade CLR seja a FK, remova o ignore. Se quer que ela seja um auxiliar não mapeado, renomeie-a para que não colida com o nome convencional da FK.

Como pegar FKs shadow acidentais antes de publicar

Ler cada diff de migração funciona até deixar de funcionar. Duas proteções mais baratas:

Transforme o aviso em exceção. Os casos 3, 4 e 5 disparam ShadowForeignKeyPropertyCreated, e dá para mandar o EF Core lançar uma exceção nele:

// .NET 11, EF Core 11.0.0-rc.1
using Microsoft.EntityFrameworkCore.Diagnostics;

protected override void OnConfiguring(DbContextOptionsBuilder options) => options
    .UseSqlite("Data Source=app.db")
    .ConfigureWarnings(w => w.Throw(CoreEventId.ShadowForeignKeyPropertyCreated));

Acessar db.Model agora falha com An error was generated for warning 'Microsoft.EntityFrameworkCore.Model.Validation.ShadowForeignKeyPropertyCreated', e como dotnet ef migrations add também constrói o modelo, a migração ruim nunca é criada. Não faça o mesmo para ShadowPropertyCreated a menos que você não tenha nenhuma shadow property intencional, porque ele também dispara no caso 1, que é inofensivo.

Faça uma asserção sobre o modelo em um teste. O caso 2 nunca gera um aviso, então um teste de unidade que percorre o modelo é a única rede de segurança automática:

// .NET 11, EF Core 11.0.0-rc.1, xUnit
[Fact]
public void No_unexpected_shadow_foreign_keys()
{
    using var db = new AppDbContext();
    var allowed = new HashSet<string> { "Post.BlogId" };   // the ones you chose on purpose

    var shadowFks = db.Model.GetEntityTypes()
        .SelectMany(e => e.GetProperties())
        .Where(p => p.IsShadowProperty() && p.IsForeignKey())
        .Select(p => $"{p.DeclaringType.ClrType.Name}.{p.Name}")
        .Where(name => !allowed.Contains(name))
        .ToList();

    Assert.Empty(shadowFks);
}

IsShadowProperty() e IsForeignKey() fazem parte da API pública de metadados em IReadOnlyProperty, então isso não precisa de acesso interno nem de banco de dados.

Usando shadow properties de propósito

Depois que você sabe o que são, as shadow properties são uma ferramenta limpa para dados que pertencem à tabela, mas não ao objeto de domínio. Timestamps de auditoria são o caso clássico:

// .NET 11, EF Core 11.0.0-rc.1
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>().Property<DateTime>("LastUpdated");
}

public override int SaveChanges()
{
    foreach (var entry in ChangeTracker.Entries<Post>()
                 .Where(e => e.State is EntityState.Added or EntityState.Modified))
    {
        entry.Property("LastUpdated").CurrentValue = DateTime.UtcNow;
    }
    return base.SaveChanges();
}

A classe Post fica livre de preocupações de persistência, e a tabela ganha uma coluna "LastUpdated" TEXT NOT NULL (no SQLite). A leitura e a escrita passam pelo change tracker, db.Entry(post).Property<DateTime>("LastUpdated").CurrentValue, e a consulta passa por EF.Property:

// .NET 11, EF Core 11.0.0-rc.1
var recent = await db.Posts
    .Where(p => EF.Property<DateTime>(p, "LastUpdated") > DateTime.UtcNow.AddDays(-1))
    .OrderBy(p => EF.Property<DateTime>(p, "LastUpdated"))
    .ToListAsync();

que é traduzida para uma referência de coluna simples:

SELECT "p"."Id", "p"."LastUpdated", "p"."Title"
FROM "Posts" AS "p"
WHERE "p"."LastUpdated" > rtrim(rtrim(strftime('%Y-%m-%d %H:%M:%f', 'now', CAST(-1.0 AS TEXT) || ' days'), '0'), '.')

Para qualquer coisa além de uma única entidade, coloque a lógica de carimbo em um SaveChangesInterceptor em vez de sobrescrever SaveChanges em cada contexto.

Pegadinhas que vale conhecer

Veja também

Fontes

Comments

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

< Voltar