Базы данных SQLite в памяти, тестирующие приложение EF Core с временными таблицами

#c# #sqlite #entity-framework-core #xunit #temporal-tables

#c# #sqlite #entity-framework-core #xunit #временные таблицы

Вопрос:

Мы используем временную таблицу с системной версией в нашем базовом приложении Entity Framework. Это работает очень хорошо, но у нас возникают проблемы при создании теста.

https://docs.microsoft.com/en-us/sql/relational-databases/tables/temporal-tables?view=sql-server-ver15

Я следовал этому руководству, используя базы данных SQLite в памяти для тестирования приложения EF Core от Microsoft.

https://docs.microsoft.com/en-us/ef/core/testing/sqlite#using-sqlite-in-memory-databases

Проблема в том, что это Sqlite вызовет исключение для SysStartTime . Это ожидаемо, поскольку свойство помечено как prop.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAddOrUpdate; in DbContext и обычно обрабатывается Sql Server. Есть ли способ заставить это работать в SQLite?

Исключение SQLiteException: ошибка 19 SQLite: «НЕ удалось выполнить ограничение NOT NULL: User.SysStartTime».

введите описание изображения здесь

Пользователь:

 public class User : IEntity
{
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }

    public DateTime SysStartTime { get; set; }

    public DateTime SysEndTime { get; set; }

    [Required]
    public string ExternalId { get; set; }
}
 

Тест xUnit:

 public class QuestionUpdateTest: IDisposable
{
    private readonly DbConnection _connection;
    private readonly ApplicationDbContext _context = null;
        
    public ChoiceSequencingQuestionUpdateTest()
    {
        var dbContextOptions = new DbContextOptionsBuilder<ApplicationDbContext>()
                        .UseSqlite(CreateInMemoryDatabase())
                        .Options;
                        
        _connection = RelationalOptionsExtension.Extract(dbContextOptions).Connection;

        _context = new ApplicationDbContext(dbContextOptions);
        
        _context.User.Add(new User()
        {
            ExternalId = "1"
        });

        _context.SaveChangesNoUser();
    }
    
    private static DbConnection CreateInMemoryDatabase()
    {
        var connection = new SqliteConnection("Filename=:memory:");

        connection.Open();

        return connection;
    }

    public void Dispose() => _connection.Dispose();
    
    [Fact]
    public void Test2()
    {
        
    }
}
 

ApplicationDbContext:

 public int SaveChangesNoUser()
{
    //Wont help since the property is marked as ValueGenerated
    foreach (var changedEntity in ChangeTracker.Entries())
    {
        if (changedEntity.Entity is IEntity entity)
        {
            switch (changedEntity.State)
            {
                case EntityState.Added:
                    entity.SysStartTime = DateTime.Now;
                    break;
            }
        }
    }

    return base.SaveChanges();
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    foreach (var property in modelBuilder.Model.GetEntityTypes()
        .SelectMany(t => t.GetProperties())
        .Where(p => p.ClrType == typeof(string)))
    {
        if (property.GetMaxLength() == null)
            property.SetMaxLength(256);
    }

    foreach (var property in modelBuilder.Model.GetEntityTypes()
        .SelectMany(t => t.GetProperties())
        .Where(p => p.ClrType == typeof(DateTime)))
    {
        property.SetColumnType("datetime2(0)");
    }

    foreach (var et in modelBuilder.Model.GetEntityTypes())
    {
        foreach (var prop in et.GetProperties())
        {
            if (prop.Name == "SysStartTime" || prop.Name == "SysEndTime")
            {
                prop.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAddOrUpdate;
            }
        }
    }

    base.OnModelCreating(modelBuilder);
}
 

Миграция:

 public partial class Temporaltablesforallentities : Migration
{
    List<string> tablesToUpdate = new List<string>
        {
           "User",
        };

    protected override void Up(MigrationBuilder migrationBuilder)
    {
        foreach (var table in tablesToUpdate)
        {
            string alterStatement = $@"ALTER TABLE [{table}] 
                ADD PERIOD FOR SYSTEM_TIME ([SysStartTime], [SysEndTime])";
            migrationBuilder.Sql(alterStatement);
            alterStatement = $@"ALTER TABLE [{table}] 
                SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = History.[{table}], DATA_CONSISTENCY_CHECK = ON));";
            migrationBuilder.Sql(alterStatement);
        }
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        foreach (var table in tablesToUpdate)
        {
            string alterStatement = $@"ALTER TABLE [{table}] SET (SYSTEM_VERSIONING = OFF);";
            migrationBuilder.Sql(alterStatement);
            alterStatement = $@"ALTER TABLE [{table}] DROP PERIOD FOR SYSTEM_TIME";
            migrationBuilder.Sql(alterStatement);
            alterStatement = $@"DROP TABLE History.[{table}]";
            migrationBuilder.Sql(alterStatement);
        }
    }
}
 

https://docs.microsoft.com/en-us/sql/relational-databases/tables/creating-a-system-versioned-temporal-table?view=sql-server-ver15#important-remarks-4

Комментарии:

1. Поддерживает ли SQLite временные таблицы?

2. Если вы проголосовали против, пожалуйста, скажите, почему!

3. @MitchWheat Нет, но мне не нужно тестировать эту функциональность, мне просто нужно значение для SysStartTime и SysEndTime для предотвращения исключения.

Ответ №1:

Решил это так в protected override void OnModelCreating(ModelBuilder modelBuilder) :

 foreach (var et in modelBuilder.Model.GetEntityTypes())
{
    foreach (var prop in et.GetProperties())
    {
        if (prop.Name == "SysStartTime" || prop.Name == "SysEndTime")
        {
            if (Database.IsSqlServer())
            {
                prop.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAddOrUpdate;
            }
            else
            {
                prop.SetDefaultValue(DateTime.Now);
            }

        }
    }
}
 

Ответ №2:

Попробуйте удалить SysStartTime и SysEndTime из ваших моделей. Вы можете добавить их, используя следующие фрагменты:

Создайте Constants.cs или аналогичный:

         public const string AddSystemVersioningFormatString = @"
            ALTER TABLE [dbo].[{0}] 
            ADD SysStartTime datetime2 GENERATED ALWAYS AS ROW START HIDDEN NOT NULL 
                CONSTRAINT DF_{0}_SysStartTime DEFAULT SYSUTCDATETIME(),
                SysEndTime datetime2 GENERATED ALWAYS AS ROW END HIDDEN NOT NULL 
                CONSTRAINT DF_{0}_SysEndTime DEFAULT CONVERT(datetime2, '9999-12-31 23:59:59.9999999'),
                PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime)
            ALTER TABLE [dbo].[{0}] 
            SET (SYSTEM_VERSIONING = ON (
                HISTORY_TABLE = [dbo].[{0}History],
                DATA_CONSISTENCY_CHECK = ON )
        )";
        public const string RemoveSystemVersioningFormatString = @"
            ALTER TABLE [dbo].[{0}] SET (SYSTEM_VERSIONING = OFF)
            ALTER TABLE [dbo].[{0}] DROP PERIOD FOR SYSTEM_TIME
            ALTER TABLE [dbo].[{0}] DROP CONSTRAINT DF_{0}_SysStartTime
            ALTER TABLE [dbo].[{0}] DROP CONSTRAINT DF_{0}_SysEndTime
            ALTER TABLE [dbo].[{0}] DROP COLUMN SysStartTime, SysEndTime
            DROP TABLE IF EXISTS [dbo].[{0}History]
        ";

 

Затем в вашей миграции:

 migrationBuilder.Sql(string.Format(Constants.AddSystemVersioningFormatString, "User"));
 

Таким образом, ваши модели не будут знать о дополнительных столбцах, и вам не придется явно задавать что-либо в EF, поскольку SQL Server обработает все настройки за вас.

Ответ №3:

Я решил это немного по-другому, сначала я добавил этот метод this method в свой контекстный класс db (в отдельный файл):

     public void CheckIfUsingInMemoryDatabase(ModelBuilder modelBuilder)
    {
        if (!Database.IsSqlServer())
        {
            modelBuilder.Entity<MyTemporalTable1>(b =>
            {
                b.Property<DateTime>("SysStartTime");
                b.Property<DateTime>("SysEndTime");
            });
            modelBuilder.Entity<MyTemporalTable2>(b =>
            {
                b.Property<DateTime>("SysStartTime");
                b.Property<DateTime>("SysEndTime");
            });
        }
    }
 

а затем я просто вызываю его в начале OnModelCreating:

     protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        CheckIfUsingInMemoryDatabase(modelBuilder);

        ...
 

Мне было немного неудобно, поскольку я создаю каркас базы данных, а не использую код — во-первых, таким образом мне нужно добавлять только одну строку в сгенерированный контекст после каждого повторного создания каркаса.