Mapeamento de Herança: TPH, TPT e TPC
Objective
Uma hierarquia C# (Payment, CardPayment, PixPayment) não tem forma natural
em um banco relacional. O EF Core oferece três jeitos de achatá-la: uma tabela
para a hierarquia inteira (TPH), uma tabela por tipo ligadas pela chave (TPT) ou
uma tabela autossuficiente por tipo concreto (TPC). Este conceito mostra o schema
e o SQL que cada estratégia gera de verdade no EF Core 10 com SQLite, o que dá
errado em silêncio quando um tipo derivado não é declarado, como chaves e foreign
keys se comportam e como escolher.
Use Cases
- Um modelo de pagamentos, notificações ou documentos em que os subtipos compartilham quase todas as colunas e diferem em duas ou três.
- Um banco legado em que
Paymentsjá tem uma colunaKindque precisa virar discriminador. - Decidir se uma hierarquia consultada quase sempre pelos tipos folha deve ser TPH ou TPC.
- Revisar uma migration que adiciona um subtipo novo e entender o que ela fará nas tabelas de produção.
Deep Dive
Tipos derivados precisam ser declarados
Os exemplos usam este modelo:
csharppublic class Payment { public int Id { get; set; } public decimal Amount { get; set; } }
public class CardPayment : Payment { public string Last4 { get; set; } = ""; }
public class PixPayment : Payment { public string Key { get; set; } = ""; }O EF Core não procura subclasses sozinho. Se o modelo só contém Payment, uma
instância de CardPayment é aceita por Add e salva como um Payment comum:
csharpb.Entity<Payment>(); // CardPayment nunca declarado
db.Add(new CardPayment { Amount = 10, Last4 = "4242" });
db.SaveChanges(); // salvou, sem erro
// tabela "Payments": ("Id", "Amount") <- Last4 não existe em lugar nenhumNão há exceção e o Last4 some. Declare os tipos derivados com um DbSet ou com
b.Entity<CardPayment>() e b.Entity<PixPayment>(). O resto do conceito assume
isso.
TPH: uma tabela e um discriminador
TPH é o padrão. O schema para os três tipos:
plaintextCREATE TABLE "Payments" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_Payments" PRIMARY KEY AUTOINCREMENT, "Amount" TEXT NOT NULL, "Discriminator" TEXT NOT NULL, "Last4" TEXT NULL, "Key" TEXT NULL )
Last4 e Key são string não anulável no C#, mas as colunas são anuláveis: uma
linha de PixPayment não tem Last4. Esse é o custo do TPH: o banco não consegue
impor com NOT NULL que "pagamento em cartão tem Last4". Uma consulta pelo tipo
base lê tudo; uma consulta por uma folha acrescenta um filtro:
csharpdb.Payments.OfType<CardPayment>().Where(c => c.Last4 == "4242")plaintextSELECT "p"."Id", "p"."Amount", "p"."Discriminator", "p"."Last4" FROM "Payments" AS "p" WHERE "p"."Discriminator" = 'CardPayment' AND "p"."Last4" = '4242'
Dá para renomear a coluna e usar valores legíveis:
csharpb.Entity<Payment>().HasDiscriminator<string>("Kind")
.HasValue<Payment>("base").HasValue<CardPayment>("card").HasValue<PixPayment>("pix");A coluna passa a ser "Kind" TEXT NOT NULL. O discriminador também pode ser
mapeado para uma propriedade de verdade. Se a tabela tem um valor que nenhum tipo
mapeia, ler o tipo base lança exceção ("No discriminators matched the
discriminator value 'Mystery'"). HasDiscriminator().IsComplete(false) faz o EF
Core filtrar até a consulta do tipo base, então os tipos desconhecidos são
ignorados em vez de falhar:
plaintextWHERE "p"."Discriminator" IN ('Payment', 'CardPayment', 'PixPayment')
Propriedades irmãs e colunas compartilhadas
Se CardPayment e PixPayment declaram ambos string Reference, o TPH cria duas
colunas, Reference e PixPayment_Reference. Quando os dois são o mesmo dado,
aponte para uma coluna só:
csharpb.Entity<CardPayment>().Property(x => x.Reference).HasColumnName("Reference");
b.Entity<PixPayment>().Property(x => x.Reference).HasColumnName("Reference");
// uma coluna anulável "Reference" em "Payments"A documentação avisa que um cast como (p as PixPayment).Reference sobre coluna
compartilhada não adiciona o predicado do discriminador, então a consulta também
pode devolver valores de linhas dos tipos irmãos.
TPT: uma tabela por tipo
csharpb.Entity<Payment>().UseTptMappingStrategy();
A tabela base guarda as colunas comuns e cada tabela derivada tem as suas colunas, mais uma chave primária que também é foreign key para a base:
plaintextCREATE TABLE "CardPayment" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_CardPayment" PRIMARY KEY, "Last4" TEXT NOT NULL, CONSTRAINT "FK_CardPayment_Payments_Id" FOREIGN KEY ("Id") REFERENCES "Payments" ("Id") ON DELETE CASCADE )
Agora Last4 é NOT NULL. O preço aparece nas consultas. Ler a hierarquia
inteira faz join com todas as tabelas derivadas, e o tipo é deduzido pelo join que
casou:
plaintextSELECT "p"."Id", "p"."Amount", "c"."Last4", "p0"."Key", CASE WHEN "p0"."Id" IS NOT NULL THEN 'PixPayment' WHEN "c"."Id" IS NOT NULL THEN 'CardPayment' END AS "Discriminator" FROM "Payments" AS "p" LEFT JOIN "CardPayment" AS "c" ON "p"."Id" = "c"."Id" LEFT JOIN "PixPayment" AS "p0" ON "p"."Id" = "p0"."Id"
Até OfType<CardPayment>() parte de Payments e faz join. O número de joins cresce
com o número de subtipos.
TPC: uma tabela por tipo concreto
csharpb.Entity<Payment>().UseTpcMappingStrategy();
Cada tabela repete as colunas herdadas (Amount aparece em CardPayment,
PixPayment e Payments) e não existe Discriminator. Se a classe base é
abstract, ela não ganha tabela nenhuma. Consultar o tipo base vira um
UNION ALL, mas uma folha lê uma tabela só, sem filtro:
plaintextSELECT "c"."Id", "c"."Amount", "c"."Last4", 'CardPayment' AS "Discriminator" FROM "CardPayment" AS "c"
Duas consequências importam. Primeiro, as chaves precisam ser únicas na hierarquia
inteira, e uma coluna identity não consegue isso porque não há tabela
compartilhada. O SQL Server usa uma sequence compartilhada por padrão; o SQLite
não tem sequences nem seed e incremento, então no SQLite a primeira tentativa de
salvar três entidades novas com Id = 0 falhou com "another instance with the same
key value for {'Id'} is already being tracked". Atribua as chaves você mesmo ou
use GUIDs. Segundo, uma foreign key que aponta para o tipo base não pode ser
imposta:
csharppublic class Refund { public int Id { get; set; } public int PaymentId { get; set; } public Payment Payment { get; set; } = null!; }
// tabela "Refund": ("Id", "PaymentId") sem nenhuma FOREIGN KEY
db.Add(new Refund { Payment = new CardPayment { Id = 7, ... } }); // nenhuma reclamação sobre linha inexistenteNo TPT, a mesma tabela Refund ganha FOREIGN KEY ("PaymentId") REFERENCES "Payments" ("Id"). No TPC, a integridade depende de todo escritor passar pelo EF
Core.
Como escolher
A documentação do EF Core diz que o TPH é um bom padrão e que o TPT é inferior na maioria dos benchmarks, porque faz joins. O benchmark de exemplo deles (35000 linhas, hierarquia de 7 tipos, no hardware deles) carregou tudo em cerca de 149 ms no TPH, 313 ms no TPT e 158 ms no TPC, e eles insistem que você meça as suas próprias consultas. O EF Core 10 não mudou as estratégias de herança; o TPC chegou no EF Core 7.
Trade-offs
- O TPH abre mão do NOT NULL. Uma tabela larga, sem joins, mas as colunas
derivadas são anuláveis e regras como "pagamento em cartão exige
Last4" vão para o código ou para umaCHECKque você escreve na migration.csharpb.Entity<Payment>().ToTable(t => t.HasCheckConstraint("CK_Card", "Discriminator <> 'CardPayment' OR Last4 IS NOT NULL")); - O TPT tem o schema mais limpo e costuma ser o mais lento. Toda consulta pelo tipo base faz join com todas as tabelas derivadas. Escolha quando algo fora do seu controle exigir, como um banco normalizado já existente.
- O TPC é rápido para folhas e fraco em integridade. Consultar o tipo base une todas as tabelas, não há foreign key para a base e as chaves precisam de uma estratégia que valha entre tabelas.
- A evolução do schema muda. Um subtipo novo são colunas anuláveis novas no TPH e uma tabela nova no TPT e no TPC. Uma coluna nova na classe base é uma coluna no TPH e no TPT, mas uma coluna em cada tabela concreta no TPC.
- Valores de discriminador são dados gravados. Renomear a classe não renomeia
as strings que já estão na coluna, então dê nomes explícitos com
HasValuequando a tabela vai durar mais que o código.