Programação Por Equipe DevBras

Capa do artigo: Coluna gerada pelo banco no EF Core: id, data de criação e total sem dois donos

Coluna gerada pelo banco no EF Core: id, data de criação e total sem dois donos

Configure identidade, valor padrão, coluna computada, data de atualização e rowversion no EF Core para o banco gerar esses valores e a aplicação apenas lê-los de volta.

O relatório de vendas soma o campo Total dos pedidos e não bate com a soma de subtotal, frete e desconto. A causa: o endpoint de checkout calcula o total, mas a tela de edição de frete atualiza só o frete. Dois códigos, uma regra, e um deles esqueceu. A resposta curta: valores que o banco consegue gerar sozinho, como id, data de criação, total calculado e data de atualização, devem ter um único dono, o banco, e a aplicação apenas lê esses valores depois de salvar. No EF Core, isso se configura com HasDefaultValueSql, HasComputedColumnSql, ValueGeneratedOnAddOrUpdate e IsRowVersion.

Este guia monta a tabela Pedidos no SQL Server, configura o EF Core, mostra como o valor volta para o objeto e trata o caso que mais confunde: a data de atualização. O lado da tela, por que esses campos não aparecem num formulário editável, está em coluna gerada não volta no formulário.

Por que dois donos estragam o dado

Quando a aplicação manda o id no insert, dois processos podem escolher o mesmo número. Quando cada servidor de aplicação grava a hora da própria máquina, um relógio adiantado muda a ordem dos pedidos. Quando o total é calculado em C# em três lugares, basta um esquecer para a linha ficar incoerente. O banco resolve isso porque é o único ponto por onde toda escrita passa, inclusive scripts de manutenção e outros sistemas.

O EF Core precisa saber quem é o dono de cada coluna. Se ele não souber, inclui o campo no INSERT com o valor padrão do C# (zero, DateTime.MinValue) e nunca lê o valor que o banco gerou.

A tabela no SQL Server

CREATE TABLE Pedidos (
    Id INT IDENTITY(1,1) PRIMARY KEY,
    ClienteId INT NOT NULL,
    Subtotal DECIMAL(12,2) NOT NULL,
    Frete DECIMAL(12,2) NOT NULL,
    Desconto DECIMAL(12,2) NOT NULL,
    Total AS (Subtotal + Frete - Desconto) PERSISTED,
    CriadoEm DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
    AtualizadoEm DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
    Versao ROWVERSION
);

Total é uma coluna computada persistida: o banco calcula na escrita e guarda o resultado, o que permite indexar. Versao é um contador que o SQL Server muda a cada atualização da linha, útil para concorrência. Se você usa migrações, essa tabela sai da configuração abaixo; o SQL aqui é para enxergar o resultado.

Como configurar no EF Core passo a passo

  1. Deixe as propriedades geradas com setter privado na entidade, para o código da aplicação não atribuir valor por engano.
  2. Id inteiro chamado Id já é identidade por convenção; não precisa configurar.
  3. Data de criação: HasDefaultValueSql("SYSUTCDATETIME()").
  4. Total: HasComputedColumnSql(..., stored: true).
  5. Data de atualização: valor padrão mais ValueGeneratedOnAddOrUpdate(), e um gatilho no banco que a atualiza.
  6. Declare o gatilho no modelo com HasTrigger.
  7. Versão de concorrência: IsRowVersion().

A entidade

public class Pedido
{
    public int Id { get; private set; }
    public int ClienteId { get; set; }
    public decimal Subtotal { get; set; }
    public decimal Frete { get; set; }
    public decimal Desconto { get; set; }

    public decimal Total { get; private set; }
    public DateTime CriadoEm { get; private set; }
    public DateTime AtualizadoEm { get; private set; }
    public byte[] Versao { get; private set; } = Array.Empty<byte>();
}

A configuração

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Pedido>(e =>
    {
        e.ToTable("Pedidos", t => t.HasTrigger("TR_Pedidos_AtualizadoEm"));

        e.Property(p => p.Subtotal).HasPrecision(12, 2);
        e.Property(p => p.Frete).HasPrecision(12, 2);
        e.Property(p => p.Desconto).HasPrecision(12, 2);

        e.Property(p => p.Total)
            .HasPrecision(12, 2)
            .HasComputedColumnSql("[Subtotal] + [Frete] - [Desconto]", stored: true);

        e.Property(p => p.CriadoEm)
            .HasDefaultValueSql("SYSUTCDATETIME()");

        e.Property(p => p.AtualizadoEm)
            .HasDefaultValueSql("SYSUTCDATETIME()")
            .ValueGeneratedOnAddOrUpdate();

        e.Property(p => p.Versao).IsRowVersion();
    });
}

O valor que volta depois do save

var pedido = new Pedido { ClienteId = 42, Subtotal = 180.00m, Frete = 25.90m, Desconto = 10.00m };
db.Pedidos.Add(pedido);
await db.SaveChangesAsync();

Console.WriteLine($"Pedido {pedido.Id}: total {pedido.Total}, criado em {pedido.CriadoEm:O}");
// Pedido 1057: total 195.90, criado em 2026-10-17T21:44:03.1234567

O EF Core não manda Id, Total, CriadoEm, AtualizadoEm nem Versao no INSERT, e lê esses valores de volta no mesmo comando. Sem essa configuração, a tela mostraria id 0 e total 0 para um pedido que já existe com outro valor no banco.

Um detalhe de HasDefaultValueSql: o EF Core só deixa o banco preencher quando a propriedade está com o valor padrão do C#. Se o código atribuir uma data, essa data vai no insert e o padrão do banco é ignorado. O setter privado existe para evitar isso.

Data de atualização: o caso que engana

No SQL Server, DEFAULT só age no insert. Não existe um "atualize sozinho" na definição da coluna. Marcar a propriedade como ValueGeneratedOnAddOrUpdate só avisa o EF Core para não mandar o valor e para lê-lo de volta; quem muda o valor precisa ser um gatilho:

CREATE TRIGGER TR_Pedidos_AtualizadoEm ON Pedidos
AFTER UPDATE
AS
BEGIN
    SET NOCOUNT ON;
    UPDATE p
    SET AtualizadoEm = SYSUTCDATETIME()
    FROM Pedidos p
    INNER JOIN inserted i ON i.Id = p.Id;
END;

A partir do EF Core 7, declarar o gatilho com HasTrigger é obrigatório para tabelas com gatilho no SQL Server. O EF Core passou a usar uma forma de OUTPUT que o SQL Server não aceita em tabela com gatilho; com a declaração, ele usa outra estratégia. Sem ela, o SaveChanges falha.

No MySQL, a coluna resolve sozinha com DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP. Repare que ela só muda quando algum valor da linha realmente muda; um UPDATE que grava os mesmos valores não mexe na data.

Se preferir que a aplicação seja a dona da data de atualização, um interceptor de SaveChanges que preenche o campo é aceitável. O importante é ser um dono só, documentado, e não os dois.

Rowversion e concorrência

Com IsRowVersion(), o EF Core inclui Versao no WHERE de cada UPDATE. Se outra pessoa salvou o pedido entre a leitura e a gravação, nenhuma linha é afetada e o EF Core lança DbUpdateConcurrencyException. Trate essa exceção mostrando que o pedido mudou, em vez de sobrescrever o trabalho da outra pessoa.

O que não vira coluna gerada

Regra que depende de serviço externo (cotação, frete de transportadora), de dado de outra tabela que muda por conta própria ou de texto livre não cabe numa expressão de coluna. Mudar a expressão de uma coluna computada persistida também reescreve a tabela inteira; em tabela grande, planeje como em migração em dois passos. E dinheiro continua em DECIMAL, nunca em FLOAT.

Erros comuns

  • Calcular o total na aplicação e também no banco. Os dois divergem e o relatório não bate.
  • Não reler o valor gerado. A tela mostra id 0 ou total desatualizado logo depois de salvar.
  • Atribuir data de criação no código com HasDefaultValueSql configurado. O padrão do banco é ignorado e cada servidor grava a própria hora.
  • Achar que DEFAULT atualiza a data no SQL Server. A data de atualização fica para sempre igual à de criação.
  • Criar gatilho sem HasTrigger no EF Core 7 ou mais novo. O SaveChanges passa a falhar nessa tabela.
  • Escolher o id na aplicação "só desta vez". O insert falha ou colide com o próximo número da identidade.

Checklist

  • Cada coluna gerada tem um único dono, documentado no modelo.
  • Propriedades geradas têm setter privado.
  • Data de criação usa HasDefaultValueSql com hora UTC do banco.
  • Total e similares são colunas computadas, não cálculo espalhado no código.
  • A data de atualização muda de fato num UPDATE; foi testado.
  • Tabelas com gatilho estão declaradas com HasTrigger.
  • Concorrência usa rowversion e a exceção é tratada.

Rode um UPDATE em um pedido de teste mudando só o frete e consulte a linha de novo. O Total acompanhou, e o AtualizadoEm mudou sozinho ou ficou igual ao CriadoEm?

Avalie este artigo

Referência: https://docs.microsoft.com/pt-br/ef/core/modeling/generated-properties