Start Debugging

O que decide se o EF Core faz o scaffold de uma coluna `date` do SQL Server como `DateOnly` ou `DateTime`?

A versão do EF Core do provedor SQL Server no seu projeto de inicialização decide: o EF Core 7 e anteriores fazem o scaffold de date como DateTime, o EF Core 8 até o 11 fazem como DateOnly. Aqui está o mapeamento de tipos que toma a decisão, a saída medida do EF Core 7, 8, 10 e 11 RC 2, e as duas formas de recuperar DateTime sem remapear a coluna silenciosamente para datetime2.

Resposta curta: uma única coisa decide, a versão de Microsoft.EntityFrameworkCore.SqlServer (e de Microsoft.EntityFrameworkCore.Design) que o seu projeto de inicialização referencia quando você executa dotnet ef dbcontext scaffold. O scaffolder pergunta à fonte de mapeamento de tipos do provedor qual é o tipo CLR padrão de cada tipo de armazenamento. Até o EF Core 7, essa busca retorna DateTime para date e TimeSpan para time. A partir do EF Core 8, passando pelo EF Core 10 e pelos release candidates do EF Core 11, ela retorna DateOnly e TimeOnly. Não existe nenhuma opção do dotnet ef para mudar isso. Se você precisa de DateTime de volta, sobrescreva o mapeamento de tipos em tempo de design por meio de IDesignTimeServices, ou edite o template T4. Se você usar a correção de template das notas oficiais de breaking changes exatamente como escrita, suas colunas date acabam mapeadas como datetime2 em runtime. O restante deste post mostra por quê, e como corrigir.

Medi tudo abaixo no macOS com o SDK 10.0.302 contra Microsoft.EntityFrameworkCore.SqlServer 7.0.20, 8.0.32, 10.0.13 e 11.0.0-rc.2.26504.105 (no SDK 11.0.100-rc.1). Não há SQL Server nesta máquina, então construí manualmente o DatabaseModel que o scaffolder normalmente lê de INFORMATION_SCHEMA e o passei pelos serviços reais de design-time do SQL Server. O caminho de geração de código (fábrica de modelo de scaffolding, gerador de C#, templates T4) é o mesmo que o dotnet ef dbcontext scaffold executa depois de ler o esquema.

A fonte de mapeamento de tipos decide, não a ferramenta

Quando você faz o scaffold, o EF Core lê o tipo de armazenamento de cada coluna (date, datetime2, nvarchar(50) e assim por diante) e precisa escolher um tipo CLR. Ele não tem uma tabela separada de regras de scaffolding para isso. Ele chama IScaffoldingTypeMapper.FindMapping(storeType, ...), que pergunta ao IRelationalTypeMappingSource do provedor qual é o mapeamento padrão daquele tipo de armazenamento. Qualquer tipo CLR que o provedor registre primeiro para date é o que vai parar na sua entidade.

No EF Core 8, o provedor SQL Server ganhou suporte nativo a DateOnly e TimeOnly (dotnet/efcore#24507) e os tornou os mapeamentos padrão de date e time. A página de breaking changes do EF Core 8 lista isso como uma mudança de impacto médio que afeta apenas quem usa database-first e refaz o scaffold.

Aqui está uma sonda que pergunta diretamente ao mapeador:

// .NET 10, EF Core 10.0.13 (same code compiles against 7.0.20, 8.0.32, 11.0.0-rc.2)
// Reference Microsoft.EntityFrameworkCore.Design with IncludeAssets="all" so the code can see its types.
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Scaffolding.Internal;
using Microsoft.EntityFrameworkCore.SqlServer.Design.Internal;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
new SqlServerDesignTimeServices().ConfigureDesignTimeServices(services);
services.AddEntityFrameworkDesignTimeServices();
var mapper = services.BuildServiceProvider().GetRequiredService<IScaffoldingTypeMapper>();

foreach (var storeType in new[] { "date", "time", "datetime", "datetime2", "smalldatetime", "datetimeoffset" })
    Console.WriteLine($"{storeType,-15} -> {mapper.FindMapping(storeType, keyOrIndex: false, rowVersion: false)?.ClrType.Name}");

Os resultados, uma coluna por versão do provedor:

Tipo de armazenamentoEF Core 7.0.20EF Core 8.0.32EF Core 10.0.13EF Core 11.0 RC 2
dateDateTimeDateOnlyDateOnlyDateOnly
timeTimeSpanTimeOnlyTimeOnlyTimeOnly
datetimeDateTimeDateTimeDateTimeDateTime
datetime2DateTimeDateTimeDateTimeDateTime
smalldatetimeDateTimeDateTimeDateTimeDateTime
datetimeoffsetDateTimeOffsetDateTimeOffsetDateTimeOffsetDateTimeOffset

Então estes são os fatos que decidem:

Mais nada importa: nem a versão do SQL Server, nem o nível de compatibilidade do banco de dados, nem --use-database-names, nem --data-annotations.

Como o código gerado fica antes e depois

Fiz o scaffold de uma tabela com uma chave int, um date NOT NULL, um date anulável, um time e um datetime2. O EF Core 7.0.20 escreve isto:

// Scaffolded by EF Core 7.0.20
public partial class Order
{
    public int Id { get; set; }
    public DateTime OrderDate { get; set; }
    public DateTime? ShippedOn { get; set; }
    public TimeSpan CutoffTime { get; set; }
    public DateTime CreatedAt { get; set; }
}

// In ShopContext.OnModelCreating
modelBuilder.Entity<Order>(entity =>
{
    entity.Property(e => e.Id).ValueGeneratedNever();
    entity.Property(e => e.OrderDate).HasColumnType("date");
    entity.Property(e => e.ShippedOn).HasColumnType("date");
});

O EF Core 8.0.32, 10.0.13 e 11.0 RC 2 escrevem isto, idêntico nos três:

// Scaffolded by EF Core 8.0.32 / 10.0.13 / 11.0.0-rc.2
public partial class Order
{
    public int Id { get; set; }
    public DateOnly OrderDate { get; set; }
    public DateOnly? ShippedOn { get; set; }
    public TimeOnly CutoffTime { get; set; }
    public DateTime CreatedAt { get; set; }
}

// In ShopContext.OnModelCreating
modelBuilder.Entity<Order>(entity =>
{
    entity.Property(e => e.Id).ValueGeneratedNever();
});

Veja o que desapareceu de OnModelCreating. No EF Core 7, DateTime é mapeado para datetime2 por convenção, então o scaffolder precisava escrever HasColumnType("date") para manter a coluna como date. No EF Core 8+, DateOnly é mapeado para date por convenção, então o scaffolder trata HasColumnType("date") como redundante e o remove. O tipo da coluna não está mais em lugar nenhum do código gerado. Ele é implícito pelo tipo CLR. Esse detalhe é o motivo de o caminho óbvio de volta para DateTime estar quebrado.

A correção de template da documentação perde o tipo da coluna

A página de breaking changes sugere uma mitigação: execute dotnet new ef-templates, abra CodeTemplates/EFCore/EntityType.t4 e troque o tipo CLR quando a coluna for date ou time:

// EntityType.t4 snippet from the EF Core 8 breaking-changes page
var clrType = property.GetColumnType() switch
{
    "date" when property.ClrType == typeof(DateOnly) => typeof(DateTime),
    "date" when property.ClrType == typeof(DateOnly?) => typeof(DateTime?),
    "time" when property.ClrType == typeof(TimeOnly) => typeof(TimeSpan),
    "time" when property.ClrType == typeof(TimeOnly?) => typeof(TimeSpan?),
    _ => property.ClrType
};

Apliquei essa edição aos templates do EF Core 10.0.13 (Microsoft.EntityFrameworkCore.Templates 10.0.13) e fiz o scaffold de novo. A entidade agora diz DateTime OrderDate. Mas o DbContext.t4 ainda renderiza a fluent API a partir do modelo, onde a propriedade é DateOnly, então ele continua omitindo HasColumnType("date"). Você obtém uma propriedade DateTime sem tipo de coluna em lugar nenhum.

Em runtime, o EF Core então aplica a convenção de DateTime, que é datetime2. Aqui está um contexto equivalente ao que o template produziu, ao lado de outro com o tipo da coluna restaurado:

// .NET 10, EF Core 10.0.13
var day = new DateTime(2026, 10, 16);
foreach (DbContext db in new DbContext[] { new ImplicitContext(), new ExplicitContext() })
{
    var p = db.Model.FindEntityType(typeof(Order))!.FindProperty(nameof(Order.OrderDate))!;
    Console.WriteLine($"{db.GetType().Name}: {p.GetColumnType()}");
    Console.WriteLine(db.Set<Order>().Where(o => o.OrderDate == day).ToQueryString());
}

public class Order
{
    public int Id { get; set; }
    public DateTime OrderDate { get; set; }
}

public class ImplicitContext() : ShopContext(false); // what the docs' template produces
public class ExplicitContext() : ShopContext(true);  // with HasColumnType("date")

public abstract class ShopContext(bool explicitType) : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseSqlServer("Server=.;Database=Shop");
    protected override void OnModelCreating(ModelBuilder b) => b.Entity<Order>(e =>
    {
        e.Property(x => x.Id).ValueGeneratedNever();
        if (explicitType) e.Property(x => x.OrderDate).HasColumnType("date");
    });
}

Saída:

ImplicitContext: datetime2
DECLARE @day datetime2 = '2026-10-16T00:00:00.0000000';

SELECT [o].[Id], [o].[OrderDate]
FROM [Order] AS [o]
WHERE [o].[OrderDate] = @day
ExplicitContext: date
DECLARE @day date = '2026-10-16';

SELECT [o].[Id], [o].[OrderDate]
FROM [Order] AS [o]
WHERE [o].[OrderDate] = @day

Sem o tipo da coluna, todo parâmetro comparado com essa coluna vai pela rede como datetime2. datetime2 tem precedência de tipo de dados maior que date, então o SQL Server converte a coluna para datetime2 na comparação. Um DateTime com parte de horário nunca é igual a uma data armazenada, enquanto um parâmetro date teria descartado o horário antes. Se o projeto também usa migrações, GenerateCreateScript() agora emite [OrderDate] datetime2 NOT NULL, e o próximo migrations add que tocar a tabela vai querer alterar a coluna. A metade de time do trecho é inofensiva, porque TimeSpan já é mapeado para time por convenção.

A correção é mais um bloco no mesmo template, logo depois do switch, para que a propriedade trocada carregue seu tipo de armazenamento junto:

// CodeTemplates/EFCore/EntityType.t4, EF Core 10.0.13 templates, after the clrType switch
        usings.AddRange(code.GetRequiredUsings(clrType));

        if (clrType != property.ClrType)
        {
            // Keep the store type: DateTime/TimeSpan would otherwise map to datetime2/time by convention.
            usings.Add("System.ComponentModel.DataAnnotations.Schema");
#>
    [Column(TypeName = "<#= property.GetColumnType() #>")]
<#
        }

Depois substitua property.ClrType por clrType nas linhas de needsNullable, needsInitializer e da propriedade, como a documentação mostra. Com o scaffold refeito, a entidade sai assim:

// Scaffolded by EF Core 10.0.13 with the patched EntityType.t4
using System.ComponentModel.DataAnnotations.Schema;

public partial class Order
{
    public int Id { get; set; }

    [Column(TypeName = "date")]
    public DateTime OrderDate { get; set; }

    [Column(TypeName = "date")]
    public DateTime? ShippedOn { get; set; }

    [Column(TypeName = "time")]
    public TimeSpan CutoffTime { get; set; }

    public DateTime CreatedAt { get; set; }
}

O atributo é emitido independentemente de você passar --data-annotations, porque ele é escrito pelo código do seu template, não pelo gerador de anotações. É o que você quer aqui, já que o lado da fluent API nunca vai escrevê-lo.

A reversão mais limpa: sobrescrever o mapeamento de tipos em tempo de design

A abordagem por template corrige a saída depois que o EF já decidiu que a propriedade é DateOnly. Uma opção mais robusta é mudar a própria decisão, o que faz o EF Core 8+ se comportar exatamente como o EF Core 7 para date e time, fluent API incluída. O dotnet ef descobre qualquer implementação de IDesignTimeServices no projeto de inicialização e a executa depois dos registros do provedor e do próprio EF (veja DesignTimeServicesBuilder.CreateServiceCollection), então um AddSingleton posterior vence:

// .NET 10, EF Core 10.0.13 / 11.0. Lives in the startup project; only used by dotnet ef.
#pragma warning disable EF1001 // SqlServerTypeMappingSource is an internal API
using System.Data;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.SqlServer.Infrastructure.Internal;
using Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection;

public sealed class ShopDesignTimeServices : IDesignTimeServices
{
    public void ConfigureDesignTimeServices(IServiceCollection services)
        => services.AddSingleton<IRelationalTypeMappingSource, DateTimeScaffoldingTypeMappingSource>();
}

public sealed class DateTimeScaffoldingTypeMappingSource(
    TypeMappingSourceDependencies dependencies,
    RelationalTypeMappingSourceDependencies relationalDependencies,
    ISqlServerSingletonOptions sqlServerSingletonOptions)
    : SqlServerTypeMappingSource(dependencies, relationalDependencies, sqlServerSingletonOptions)
{
    protected override RelationalTypeMapping? FindMapping(in RelationalTypeMappingInfo mappingInfo)
        => mappingInfo.StoreTypeNameBase switch
        {
            "date" => new SqlServerDateTimeTypeMapping("date", DbType.Date),
            "time" => new SqlServerTimeSpanTypeMapping("time"),
            _ => base.FindMapping(mappingInfo)
        };
}

Com isso registrado, o EF Core 10.0.13 faz o scaffold da mesma tabela com a saída do EF Core 7, byte por byte: DateTime OrderDate, DateTime? ShippedOn, TimeSpan CutoffTime e HasColumnType("date") de volta em OnModelCreating. Como a comparação agora acontece entre DateTime e seu padrão datetime2, o gerador de código sabe que o tipo explícito da coluna é necessário. Uma coluna time(0) também sai certa, como HasColumnType("time(0)"), onde o EF Core 10 padrão escreve TimeOnly com HasPrecision(0).

Três coisas a saber sobre essa rota:

Se você faz o scaffold pelo EF Core Power Tools ou pela CLI efcpt, isso já vem pronto: o motor de engenharia reversa dele inclui uma sobrescrita de SqlServerTypeMappingSource que faz exatamente o acima, controlada por type-mappings / use-DateOnly-TimeOnly em efcpt-config.json. O esquema de configuração publicado lista essa opção com padrão false, então confira seu arquivo de configuração em vez de presumir qual tipo você vai obter.

Vale a pena reverter?

Normalmente não. Para uma coluna que guarda um dia de calendário, DateOnly é o tipo honesto. Um DateTime mapeado para date carrega um horário e um Kind que o banco de dados descarta, e essa incompatibilidade está por trás de muitos bugs de diferença de um dia quando valores cruzam fusos horários ou fronteiras de JSON. O EF Core 8+ traduz os membros comuns de DateOnly no SQL Server, o System.Text.Json o serializa como "2026-10-16" de fábrica, e o Dapper trata DateOnly nativamente de novo desde a 2.1.86.

Reverta quando você não conseguir migrar os consumidores na mesma versão: um assembly de DTOs compartilhado, uma camada de relatórios que faz aritmética com DateTime em todo lugar, ou uma equipe que refaz o scaffold toda semana e não consegue absorver algumas centenas de erros de compilação de uma vez. Nesse caso, escolha uma das duas correções acima e coloque-a no repositório, para que a próxima pessoa que refizer o scaffold obtenha os mesmos tipos.

Armadilhas que aparecem depois de refazer o scaffold

Relacionados

Fontes

Comments

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

< Voltar