Start Debugging

Что определяет, создаст ли EF Core для столбца SQL Server `date` тип `DateOnly` или `DateTime` при scaffold?

Это определяет версия EF Core у провайдера SQL Server в вашем стартовом проекте: EF Core 7 и более ранние создают для date тип DateTime, EF Core с 8 по 11 создают DateOnly. Здесь показано сопоставление типов, которое принимает решение, измеренный результат для EF Core 7, 8, 10 и 11 RC 2, а также два способа вернуть DateTime, не переключив столбец незаметно на datetime2.

Короткий ответ: решает одно: версия Microsoft.EntityFrameworkCore.SqlServer (и Microsoft.EntityFrameworkCore.Design), на которую ссылается ваш стартовый проект при запуске dotnet ef dbcontext scaffold. Scaffolder спрашивает у источника сопоставления типов провайдера, какой тип CLR используется по умолчанию для каждого типа хранилища. До EF Core 7 включительно для date возвращается DateTime, а для time возвращается TimeSpan. Начиная с EF Core 8, через EF Core 10 и release candidate EF Core 11, возвращаются DateOnly и TimeOnly. Переключателя dotnet ef, который это меняет, нет. Если нужно вернуть DateTime, либо переопределите сопоставление типов на этапе проектирования через IDesignTimeServices, либо отредактируйте шаблон T4. Если применить исправление шаблона из официальных заметок о критических изменениях как написано, ваши столбцы date во время выполнения окажутся сопоставлены с datetime2. Ниже показано, почему так происходит и как это исправить.

Всё ниже я измерял на macOS с SDK 10.0.302 для Microsoft.EntityFrameworkCore.SqlServer 7.0.20, 8.0.32, 10.0.13 и 11.0.0-rc.2.26504.105 (на SDK 11.0.100-rc.1). SQL Server на этой машине нет, поэтому DatabaseModel, который scaffolder обычно читает из INFORMATION_SCHEMA, я собрал вручную и пропустил через настоящие службы этапа проектирования SQL Server. Путь генерации кода (фабрика модели scaffolding, генератор C#, шаблоны T4) тот же, что выполняет dotnet ef dbcontext scaffold после чтения схемы.

Решение принимает источник сопоставления типов, а не инструмент

При scaffold EF Core читает тип хранилища каждого столбца (date, datetime2, nvarchar(50) и так далее) и должен выбрать тип CLR. Отдельной таблицы правил scaffolding для этого нет. Вызывается IScaffoldingTypeMapper.FindMapping(storeType, ...), который запрашивает у IRelationalTypeMappingSource провайдера сопоставление по умолчанию для этого типа хранилища. Тип CLR, который провайдер регистрирует для date первым, и попадает в вашу сущность.

В EF Core 8 провайдер SQL Server получил встроенную поддержку DateOnly и TimeOnly (dotnet/efcore#24507) и сделал их сопоставлениями по умолчанию для date и time. Страница критических изменений EF Core 8 описывает это как изменение среднего влияния, которое затрагивает только пользователей database-first, повторно выполняющих scaffold.

Вот проба, которая напрямую обращается к mapper:

// .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}");

Результаты, по одному столбцу на версию провайдера:

Тип хранилищаEF Core 7.0.20EF Core 8.0.32EF Core 10.0.13EF Core 11.0 RC 2
dateDateTimeDateOnlyDateOnlyDateOnly
timeTimeSpanTimeOnlyTimeOnlyTimeOnly
datetimeDateTimeDateTimeDateTimeDateTime
datetime2DateTimeDateTimeDateTimeDateTime
smalldatetimeDateTimeDateTimeDateTimeDateTime
datetimeoffsetDateTimeOffsetDateTimeOffsetDateTimeOffsetDateTimeOffset

Итак, вот факты, которые это определяют:

Больше ничто не влияет: ни версия SQL Server, ни уровень совместимости базы данных, ни --use-database-names, ни --data-annotations.

Как выглядит сгенерированный код до и после

Я выполнил scaffold таблицы с ключом int, столбцом date NOT NULL, столбцом date, допускающим null, столбцом time и столбцом datetime2. EF Core 7.0.20 пишет следующее:

// 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");
});

EF Core 8.0.32, 10.0.13 и 11.0 RC 2 пишут следующее, идентично во всех трёх:

// 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();
});

Обратите внимание, что исчезло из OnModelCreating. В EF Core 7 DateTime по соглашению сопоставляется с datetime2, поэтому scaffolder должен был записать HasColumnType("date"), чтобы столбец остался date. В EF Core 8+ DateOnly по соглашению сопоставляется с date, поэтому scaffolder считает HasColumnType("date") избыточным и убирает его. Тип столбца больше нигде не присутствует в сгенерированном коде. Он подразумевается типом CLR. Именно из-за этой детали очевидный способ вернуться к DateTime оказывается неработающим.

Исправление шаблона из документации теряет тип столбца

Страница критических изменений предлагает способ смягчения: выполнить dotnet new ef-templates, открыть CodeTemplates/EFCore/EntityType.t4 и подменять тип CLR, когда столбец имеет тип date или 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
};

Я применил эту правку к шаблонам EF Core 10.0.13 (Microsoft.EntityFrameworkCore.Templates 10.0.13) и снова выполнил scaffold. Теперь сущность содержит DateTime OrderDate. Но DbContext.t4 по-прежнему формирует fluent API из модели, где свойство имеет тип DateOnly, поэтому он по-прежнему не добавляет HasColumnType("date"). В итоге вы получаете свойство DateTime без типа столбца где-либо.

Во время выполнения EF Core применяет соглашение для DateTime, то есть datetime2. Вот контекст, соответствующий тому, что выдал шаблон, рядом с контекстом, где тип столбца восстановлен:

// .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");
    });
}

Вывод:

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

Без типа столбца каждый параметр, сравниваемый с этим столбцом, передаётся по сети как datetime2. У datetime2 более высокий приоритет типов данных, чем у date, поэтому SQL Server преобразует столбец в datetime2 для сравнения. Значение DateTime с временной частью тогда никогда не равно сохранённой дате, тогда как параметр date сначала отбросил бы время. Если проект также использует миграции, GenerateCreateScript() теперь выдаёт [OrderDate] datetime2 NOT NULL, и следующий migrations add, затрагивающий таблицу, захочет изменить столбец. Половина сниппета про time безвредна, потому что TimeSpan и так сопоставляется с time по соглашению.

Исправление состоит ещё из одного блока в том же шаблоне, сразу после switch, чтобы подменённое свойство несло с собой свой тип хранилища:

// 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() #>")]
<#
        }

Затем замените property.ClrType на clrType в строках needsNullable, needsInitializer и в строках свойств, как показано в документации. После повторного scaffold сущность выглядит так:

// 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; }
}

Атрибут выводится независимо от того, передаёте ли вы --data-annotations, потому что его пишет код вашего шаблона, а не генератор кода аннотаций. Это именно то, что нужно, так как fluent-часть его никогда не запишет.

Более чистый откат: переопределить сопоставление типов на этапе проектирования

Подход с шаблоном исправляет результат уже после того, как EF решил, что свойство имеет тип DateOnly. Более надёжный вариант меняет само решение, из-за чего EF Core 8+ ведёт себя в точности как EF Core 7 для date и time, включая fluent API. dotnet ef находит любую реализацию IDesignTimeServices в стартовом проекте и запускает её после собственных регистраций провайдера и EF (см. DesignTimeServicesBuilder.CreateServiceCollection), поэтому более поздний AddSingleton побеждает:

// .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)
        };
}

С этой регистрацией EF Core 10.0.13 создаёт для той же таблицы результат EF Core 7 байт в байт: DateTime OrderDate, DateTime? ShippedOn, TimeSpan CutoffTime и HasColumnType("date") снова в OnModelCreating. Поскольку теперь сравнение происходит между DateTime и его значением по умолчанию datetime2, генератор кода знает, что явный тип столбца нужен. Столбец time(0) тоже обрабатывается правильно, как HasColumnType("time(0)"), тогда как стандартный EF Core 10 пишет TimeOnly с HasPrecision(0).

Три вещи, которые стоит знать об этом способе:

Если вы выполняете scaffold через EF Core Power Tools или его CLI efcpt, это уже встроено: его механизм обратной разработки поставляется с переопределением SqlServerTypeMappingSource, делающим ровно описанное выше, которое включается параметрами type-mappings / use-DateOnly-TimeOnly в efcpt-config.json. Опубликованная схема конфигурации указывает для этого параметра значение по умолчанию false, поэтому проверьте свой файл конфигурации, а не предполагайте, какой тип получите.

Нужно ли вообще возвращаться к DateTime?

Обычно нет. Для столбца, хранящего календарный день, DateOnly является честным типом. DateTime, сопоставленный с date, несёт время и Kind, которые база данных отбрасывает, и это несоответствие стоит за множеством ошибок на один день, когда значения пересекают часовые пояса или границы JSON. EF Core 8+ транслирует распространённые члены DateOnly для SQL Server, System.Text.Json из коробки сериализует его как "2026-10-16", а Dapper снова нативно поддерживает DateOnly начиная с 2.1.86.

Возвращайтесь к DateTime, когда не можете перевести потребителей в том же релизе: общая сборка DTO, слой отчётности, который везде выполняет арифметику над DateTime, или команда, которая выполняет повторный scaffold еженедельно и не может разом справиться с сотнями ошибок компиляции. В таком случае выберите одно из двух описанных выше исправлений и сделайте его частью репозитория, чтобы следующий, кто выполнит повторный scaffold, получил те же типы.

Подводные камни после повторного scaffold

Связанные материалы

Источники

Comments

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

< Назад