【问题标题】:EF Core - Set Timestamp before save still uses the old valueEF Core - 在保存之前设置时间戳仍然使用旧值
【发布时间】:2017-12-08 22:41:30
【问题描述】:

我有一个带有时间戳(并发令牌)列的模型。我正在尝试编写一个集成测试,在其中检查它是否按预期工作但没有成功。我的测试如下所示

  1. 使用 HttpClient 调用从 Web api 获取应更新的实体。
  2. 直接向 Context 发出请求,获取相同的实体
  3. 从步骤 2 更改实体的属性。
  4. 保存在步骤 3 中更新的实体。
  5. 从步骤 1 更改实体的属性。
  6. 使用 HttpClient 向 Web Api 发送带有新实体的 put 请求。
  7. 在我的 Web API 中,我首先从数据库中获取实体,根据从客户端获取的实体设置属性和时间戳值。现在我在 api 控制器中的实体对象的时间戳值与数据库中的不同。现在我预计 savechanges 会失败,但事实并非如此。相反,它将实体保存到数据库并生成新的时间戳值。我检查了 Sql Server Profiler 以查看生成的查询,结果发现它仍然使用旧的时间戳值,而不是我分配给我的 api 控制器中的实体的那个。

这是什么原因?它与 Timestamp 是数据库生成的值是否使 EF 忽略从业务层对其所做的更改有什么关系?

完整的测试应用可以在这里找到:https://github.com/Abrissirba/EfTimestampBug

    public class BaseModel
    {
        [Timestamp]
        public byte[] Timestamp { get; set; }
    }

    public class Person : BaseModel
    {
        public int Id { get; set; }

        public String Title { get; set; }
    }

    public class Context : DbContext
    {
        public Context()
        {}

        public Context(DbContextOptions options) : base(options)
        {}

        public DbSet<Person> Persons{ get; set; }
    }

    protected override void BuildModel(ModelBuilder modelBuilder)
    {
        modelBuilder
            .HasAnnotation("ProductVersion", "7.0.0-rc1-16348")
            .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);

        modelBuilder.Entity("EFTimestampBug.Models.Person", b =>
            {
                b.Property<int>("Id")
                    .ValueGeneratedOnAdd();

                b.Property<byte[]>("Timestamp")
                    .IsConcurrencyToken()
                    .ValueGeneratedOnAddOrUpdate();

                b.Property<string>("Title");

                b.HasKey("Id");
            });
    }

    // PUT api/values/5
    [HttpPut("{id}")]
    public Person Put(int id, [FromBody]Person personDTO)
    {
        // 7
        var person = db.Persons.SingleOrDefault(x => x.Id == id);
        person.Title = personDTO.Title;
        person.Timestamp = personDTO.Timestamp;
        db.SaveChanges();
        return person;
    }

    [Fact]
    public async Task Fail_When_Timestamp_Differs()
    {
        using (var client = server.CreateClient().AcceptJson())
        {
            await client.PostAsJsonAsync(ApiEndpoint, Persons[0]);
            // 1
            var getResponse = await client.GetAsync(ApiEndpoint);
            var fetched = await getResponse.Content.ReadAsJsonAsync<List<Person>>();

            Assert.True(getResponse.IsSuccessStatusCode);
            Assert.NotEmpty(fetched);

            var person = fetched.First();
            // 2
            var fromDb = await db.Persons.SingleOrDefaultAsync(x => x.Id == person.Id);
            // 3
            fromDb.Title = "In between";
            // 4
            await db.SaveChangesAsync();


            // 5
            person.Title = "After - should fail";
            // 6
            var postResponse = await client.PutAsJsonAsync(ApiEndpoint + person.Id, person);
            var created = await postResponse.Content.ReadAsJsonAsync<Person>();

            Assert.False(postResponse.IsSuccessStatusCode);
        }
    }


    // generated sql - @p1 has the original timestamp from the entity and not the assigned and therefore the save succeed which was not intended
    exec sp_executesql N'SET NOCOUNT OFF;
    UPDATE[Person] SET[Title] = @p2
    OUTPUT INSERTED.[Timestamp]
    WHERE [Id] = @p0 AND[Timestamp] = @p1;
    ',N'@p0 int,@p1 varbinary(8),@p2 nvarchar(4000)',@p0=21,@p1=0x00000000000007F4,@p2=N'After - should fail'

【问题讨论】:

  • 请包含您的实体框架映射代码,它可能与此有关。您还可以发布从 Sql Profiler 生成和执行的查询吗?

标签: entity-framework entity-framework-core


【解决方案1】:

编辑 4 - 修复

我收到了 GitHub 存储库网站上一位成员的回复,issue 4512。您必须更新实体上的原始值。可以这样做。

var passedInTimestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 120 };  // a hard coded value but normally included in a postback
var entryProp = db.Entry(person).Property(u => u.Timestamp);
entryProp.OriginalValue = passedInTimestamp;

我更新了失败的原始单元测试,你和我都无法抛出 DbUpdateConcurrencyException,现在它可以按预期工作了。

我将更新 GitHub 票,询问他们是否可以进行更改,以便当列标记为 TimestampIsConcurrencyToken 时,生成的底层 sql 使用新值而不是原始值,以便它行为类似于以前版本的实体框架。

目前看来,这似乎是处理分离实体的方法。


编辑#3

谢谢你,我错过了。再次调试后,我完全理解了这个问题,尽管不知道为什么会发生。不过,我们可能应该将 Web API 去掉,减少移动部分,我认为 EF Core 和 Web API 之间没有直接依赖关系。我已经通过以下说明问题的测试重现了该问题。 我不愿将其称为错误,因为自 EF6 以来强制 EF Core 使用传入的 timestamp 值的约定可能已经改变。

我在项目的 GitHub 站点上创建了一套完整的工作最小代码和created an issue/question。我将在下面再次包含测试以供参考。收到回复后,我会尽快回复此答案并告知您。

依赖关系

  • Sql Server 2012
  • EF 核心
    • EntityFramework.Commands 7.0.0-rc1-final
    • EntityFramework.MicrosoftSqlServer 7.0.0-rc1-final

DDL

CREATE TABLE [dbo].[Person](
    [Id] [int] IDENTITY NOT NULL,
    [Title] [varchar](50) NOT NULL,
    [Timestamp] [rowversion] NOT NULL,
 CONSTRAINT [PK_Person] PRIMARY KEY CLUSTERED 
(
    [Id] ASC
))
INSERT INTO Person (title) values('user number 1')

实体

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

    public String Title { get; set; }

    // [Timestamp], tried both with & without annotation
    public byte[] Timestamp { get; set; }
}

数据库上下文

public class Context : DbContext
{
    public Context(DbContextOptions options)
        : base(options)
    {
    }

    public DbSet<Person> Persons { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Person>().HasKey(x => x.Id);

        modelBuilder.Entity<Person>().Property(x => x.Id)
            .UseSqlServerIdentityColumn()
            .ValueGeneratedOnAdd()
            .ForSqlServerHasColumnName("Id");

        modelBuilder.Entity<Person>().Property(x => x.Title)
            .ForSqlServerHasColumnName("Title");

        modelBuilder.Entity<Person>().Property(x => x.Timestamp)
            .IsConcurrencyToken(true)
            .ValueGeneratedOnAddOrUpdate()
            .ForSqlServerHasColumnName("Timestamp");

        base.OnModelCreating(modelBuilder);
    }
}

单元测试

public class UnitTest
{
    private string dbConnectionString = "DbConnectionStringOrConnectionName";
    public EFTimestampBug.Models.Context CreateContext()
    {
        var options = new DbContextOptionsBuilder();
        options.UseSqlServer(dbConnectionString);
        return new EFTimestampBug.Models.Context(options.Options);
    }

    [Fact] // this test passes
    public async Task TimestampChangedExternally()
    {
        using (var db = CreateContext())
        {
            var person = await db.Persons.SingleAsync(x => x.Id == 1);
            person.Title = "Update 2 - should fail";

            // update the database manually after we have a person instance
            using (var connection = new System.Data.SqlClient.SqlConnection(dbConnectionString))
            {
                var command = connection.CreateCommand();
                command.CommandText = "update person set title = 'changed title' where id = 1";
                connection.Open();
                await command.ExecuteNonQueryAsync();
                command.Dispose();
            }

            // should throw exception
            try
            {
                await db.SaveChangesAsync();
                throw new Exception("should have thrown exception");
            }
            catch (DbUpdateConcurrencyException)
            {
            }
        }
    }

    [Fact]
    public async Task EmulateAspPostbackWhereTimestampHadBeenChanged()
    {
        using (var db = CreateContext())
        {
            var person = await db.Persons.SingleAsync(x => x.Id == 1);
            person.Title = "Update 2 - should fail " + DateTime.Now.Second.ToString();

            // This emulates post back where the timestamp is passed in from the web page
            // the Person entity attached dbcontext does have the latest timestamp value but
            // it needs to be changed to what was posted
            // this way the user would see that something has changed between the time that their screen initially loaded and the time they posted the form back
            var passedInTimestamp = new byte[] { 0, 0, 0, 0, 0, 0, 0, 120 };  // a hard coded value but normally included in a postback
            //person.Timestamp = passedInTimestamp;
            var entry = db.Entry(person).Property(u => u.Timestamp);
            entry.OriginalValue = passedInTimestamp;
            try
            {
                await db.SaveChangesAsync(); // EF ignores the set Timestamp value and uses its own value in the outputed sql
                throw new Exception("should have thrown DbUpdateConcurrencyException");
            }
            catch (DbUpdateConcurrencyException)
            {
            }
        }
    }
}

【讨论】:

  • 当然我在put方法中设置了断点,检查了时间戳,可以确认它们不一样。我分配给 person 的值是在我查看对象时真正分配的,但不会在查询中使用。我没有用过一个类,我只是在这里把它放在一起。我将很快链接到带有测试应用程序的 github 存储库。
  • @Abris - 谢谢,这帮助很大。我能够解决您的问题,请参阅更新的代码。一旦你做了这个小改动,你的单元测试也应该可以工作了。
  • 不确定我是否关注这里。我只使用了注释,不是吗?我检查了 Context.cs 我没有在那里使用流利的 API。删除注释时会出现什么样的异常?我得到一个异常,但不是预期的,它说“无法将显式值插入时间戳列”。也尝试使用 fluent API 并得到与使用注释时相同的结果
  • @Arbis - 谢谢,我完全错过了(抛出的异常类型)。我已经浓缩了这个问题并更新了我的答案,并在 GitHub 存储库上创建了一个问题/问题。
  • 了不起的伊戈尔!非常感激你的帮助。呵呵,我什至不把它放在 EF 团队,所以别担心。我知道 EF Core 是新的,因此目前有这些怪癖。您的 github issue 非常好,显示了我非常喜欢微软所采取的开源路径。
【解决方案2】:

Microsoft 已在 Handling concurrency conflicts - EF Core with ASP.NET Core MVC tutorial 中更新了他们的教程。它特别说明了以下有关更新的内容:

在你打电话给SaveChanges之前,你必须把那个原件 OriginalValues 集合中的 RowVersion 属性值 实体。

_context.Entry(entityToUpdate).Property("RowVersion").OriginalValue = rowVersion;

那么当实体框架创建一个 SQL UPDATE 命令时, 命令将包含一个 WHERE 子句,该子句查找具有 原始RowVersion 值。如果没有行受 UPDATE 影响 命令(没有行具有原始 RowVersion 值),实体 框架抛出 DbUpdateConcurrencyException 异常。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2013-10-15
    • 2014-12-02
    • 1970-01-01
    • 2014-12-18
    • 2015-03-10
    • 1970-01-01
    • 2018-01-22
    相关资源
    最近更新 更多