Добавление свойства типа DateTime в Модель Entity Framework

В процессе разработки проекта появилась следующая задача – добавить новое свойство в один из классов Модели. При этом он уже был связан с таблицей при помощи Entity Framework Code First. На первый взгляд, казалось, что все будет просто: изменить код и дать команду на изменение схемы базы данных. Но на практике оказалось все чуть-чуть сложнее из-за типа нового свойства - DateTime.

Моделируем ситуацию

Для примера создадим консольное приложение EFMigrationsDateTimeDemo. С помощью NuGet установим библиотеку Entity Framework, выполнив команду:

PM> install-package EntityFramework

Теперь добавим Модель UserProfile:

namespace EFMigrationsDateTimeDemo
{
    using System;

    public class UserProfile
    {
        public int Id { get; set; }

        public string Name { get; set; }
    }
}

Кроме того, потребуется класс контекста для работы с Entity Framework:

namespace EFMigrationsDateTimeDemo
{
    using System.Data.Entity;

    public class AppDbContext : DbContext
    {
        public DbSet<UserProfile> UserProfiles { get; set; }
    }
}

Затем разместим код работы с базой данных в методе Main:

namespace EFMigrationsDateTimeDemo
{
    using System;
    using System.Linq;

    class Program
    {
        static void Main(string[] args)
        {
            WorkWithDb();

            Console.WriteLine("Press any key ...");
            Console.ReadKey(true);
        }

        static void WorkWithDb()
        {
            using (var context = new AppDbContext()) {
                context.UserProfiles.Add(new UserProfile() { 
                    Name = "John Doe" 
                });
                context.SaveChanges();

                var userProfiles = context.UserProfiles.AsEnumerable();

                foreach (var user in userProfiles) {
                    Console.WriteLine("{0} - {1}", user.Id, user.Name);
                }
            }
        }
    }
}

Запустим приложение в первый раз для того, чтобы создать исходную базу данных. Обратите внимание, что используется соединение по умолчанию. При необходимости, можно задать параметры в App.Config.

Последним шагом в подготовке будет активация механизма автоматической миграции следующей командой в консоли NuGet:

PM> Enable-Migrations -EnableAutomaticMigrations

В результате будет создан файл Migrations\Configuration.cs.

Проблема

Давайте добавим свойство типа DateTime в UserProfile:

namespace EFMigrationsDateTimeDemo
{
    using System;

    public class UserProfile
    {
        public int Id { get; set; }

        public string Name { get; set; }

        public DateTime LastVisit { get; set; }
    }
}

Может возникнуть вопрос – почему не использовать Nullable<DateTime>? Здесь тип определяется бизнес-логикой задачи (дата обязательно должна быть указана). При этом, в дальнейшем, значения будут получены после импорта данных из других источников.

Теперь попробуем изменить схему базы данных командой Update-Database. И получим следующее сообщение об ошибке:

PM> Update-Database -Verbose
.........
The conversion of a varchar data type to a datetime data type resulted in an out-of-range value.
The statement has been terminated.

Как показало небольшое исследование, причин ошибки здесь две:

  1. Несоответствие диапазонов значений свойства типа DateTime и колонки типа datetime в таблице.
  2. Возможны отличия при передаче формата текстового представления даты, если различаются региональные настройки компьютера и таблицы в базе данных.

Первый вариант решения

В Entity Framework для создания SQL команд миграции используется класс MigrationSqlGenerator. Таким образом, необходимо предоставить его реализацию, которая преобразует DateTime с учетом диапазона и игнорируя текущие региональные настройки.

Задача может показаться слож��ой. Однако на практике все достаточно легко. В зависимости от того, какая база данных используется, необходимо в качестве базового класса взять:

  • SqlServerMigrationSqlGenerator – для SQL Server / SQL Server Express / LocalDb.
  • SqlCeMigrationSqlGenerator – для SQL Server Compact.

После этого остается только переопределить метод Generate(DateTime defaultValue).

Свойство типа DateTime будет представлено в базе данных полем datetime, которое может принимать значения от 1 января 1753 до 31 декабря 9999 года. Учтём это при формировании текстового представления даты. Кроме того, жестко определим его формат и используем CultureInfo.InvariantCulture, чтобы исключить влияние региональных настроек.

namespace EFMigrationsDateTimeDemo.Migrations
{
    using System;
    using System.Data.Entity.Migrations.Sql;
    using System.Globalization;

    internal class SqlServerMigrationSqlGeneratorFixed : SqlServerMigrationSqlGenerator
    {
        protected override string Generate(DateTime defaultValue)
        {
            var value = defaultValue.Ticks != 0 ?
                defaultValue.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) :
                "1753-01-01 00:00:00";

            return string.Format("'{0}'", value);
        }
    }
}

Остается только указать Entity Framework использовать исправленную версию SQL генератора. Для этого в конструкторе класса Configuration добавим следующую строку:

public Configuration()
{
    this.AutomaticMigrationsEnabled = true;
    this.SetSqlGenerator("System.Data.SqlClient", new SqlServerMigrationSqlGeneratorFixed());
}

Обратите внимание, что здесь указано имя провайдера для SQL Server. Для SQL Server Compact необходимо указать System.Data.SqlServerCe.4.0.

Давайте теперь повторим попытку модифицировать схему базы данных:

PM> Update-Database -Verbose
Using NuGet project 'EFMigrationsDateTimeDemo'.
Using StartUp project 'EFMigrationsDateTimeDemo'.
Target database is: 'EFMigrationsDateTimeDemo.AppDbContext' 
(DataSource: .\SQLEXPRESS, Provider: System.Data.SqlClient, Origin: Convention).
No pending explicit migrations.
Applying automatic migration: 201205020748408_AutomaticMigration.
ALTER TABLE [UserProfiles] ADD [LastVisit] [datetime] NOT NULL DEFAULT '1753-01-01 00:00:00'
[Inserting migration history record]

Миграция прошла успешно.

Второй вариант решения

При работе с Microsoft SQL Server, в качестве альтернативы datetime, можно использовать datetime2. Он может принимать значения от 1 января 0001 года до 31 декабря 9999, что совпадает с диапазоном DateTime. Задать данный тип для соответствующего свойства Модели можно с помощью FluentAPI:

namespace EFMigrationsDateTimeDemo
{
    using System.Data.Entity;

    public class AppDbContext : DbContext
    {
        public DbSet<UserProfile> UserProfiles { get; set; }

        protected override void OnModelCreating(DbModelBuilder modelBuilder)
        {
            modelBuilder.Entity<UserProfile>().
                Property(p => p.LastVisit)
                .HasColumnType("datetime2")
                .HasPrecision(0)
                .IsRequired();
        }
    }
}

В этом случае, создание SQL генератора может потребоваться только для определения формата текстового представления даты (что не всегда необходимо):

namespace EFMigrationsDateTimeDemo.Migrations
{
    using System;
    using System.Data.Entity.Migrations.Sql;
    using System.Globalization;

    internal class SqlServerMigrationSqlGeneratorFixed : SqlServerMigrationSqlGenerator
    {
        protected override string Generate(DateTime defaultValue)
        {
            return string.Format("'{0}'",
                defaultValue.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
        }
    }
}

Небольшой совет - автозапуск миграции при изменении Модели

При запуске проекта можно автоматически выполнять миграцию базы данных, если до этого была изменена модель. Для этого достаточно в начале работы приложения (метод Main() или Application_Start() для ASP.NET/ASP.NET MVC) добавить следующий код:

static void Main(string[] args)
{
    Database.SetInitializer(
        new MigrateDatabaseToLatestVersion<AppDbContext, Migrations.Configuration>());

    WorkWithDb();

    Console.WriteLine("Press any key ...");
    Console.ReadKey(true);
}

Комментарии (8) -

Денис 03.05.2012 15:44:11

Андрей, как всегда четко, по существу и в темуSmile

@ Денис: Спасибо.

Информация полезная. Хочу заметить по верстке этой страницы под мобильные устроиства. Сам текст хорошо сверстан, а вот вставки кода шире страницы и уползают за край и оттуда я не смог вынуть. Мне кажется уменьшение размера шрифта могло бы помочь

gothdotnet 04.05.2012 1:07:30

Если мне не изменяет память, можно сначала сделать nullable, потом в таблице сделать update для null значений на default value, а потом убрать nullable. Update-Database это съест Smile.

@ Alexander: На выходных погляжу что можно придумать. Просто ширины мобильных устройств маловато для исходников (часто и ширины шаблона для ПК мало).


@ gothdotnet: Можно. Но в результате телодвижений больше (нужно отдельно прописать default value). Да и сам принцип позволяет влиять на создание SQL и в других сценариях.

Я не работал с Entity Framework, поэтому могу ошибаться, но мне кажется, что если в первом варианте попытаться сохранить в базу значение меньше 1753 года, но больше 0, то произойдет ошибка.
По поводу верстки под мобильные, сейчас у меня код отобразился по ширине страницы, а вот лог успешного применения миграции вылез. Кстати первый лог нугета оформлен в виде кода, а второй простым текстом

@ yegres: Да вы правы. Это ограничение используемого типа - datetime. А контроль значений в процесее работы приложения - отдельная тема.

Артем Казанцев 05.05.2013 17:49:54

Андрей, огромное спасибо! Все работает. Выбрал первый вариант.

Добавить комментарий