保存之前设置时间戳仍然使用旧值
我有一个带时间戳(并发令牌)列的模型。 我试图编写一个集成测试,我检查它是否按我的预期工作,但没有成功。 我的测试看起来像下面
这是什么原因? 它是否与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'
编辑4 - 修复
我从GitHub回购站点的成员那里听到了问题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票证以询问他们是否可以进行更改,以便当列标记为Timestamp
或IsConcurrencyToken
时,生成的基础SQL使用新值而不是原始值,以便它的行为与以前版本的实体框架。
目前,尽管这似乎是与分离实体一起做的一种方式。
编辑#3
谢谢,我错过了。 经过更多的调试后,我完全理解了这个问题,尽管不是为什么会发生。 我们或许应该将Web API从中移除,但移动较少的部分,我认为EF Core和Web API之间没有直接的依赖关系。 我用以下说明问题的测试重现了这个问题。 我很犹豫,把它称为一个错误,因为也许强制EF Core使用传入的timestamp
值的惯例自EF6以来已经改变。
我创建了一套完整的工作最小代码,并在项目的GitHub站点上创建了一个问题/问题。 我将再次将这个测试包括在内以供参考。 只要我听到回复,我会回复这个答案,并让你知道。
依赖
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; }
}
Db上下文
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)
{
}
}
}
}
微软已经在处理并发冲突 - EF Core with ASP.NET Core MVC教程中更新了他们的教程。 它特别声明关于更新的以下内容:
在调用SaveChanges
之前,必须将该原始RowVersion
属性值放入实体的OriginalValues
集合中。
_context.Entry(entityToUpdate).Property("RowVersion").OriginalValue = rowVersion;
然后,当Entity Framework创建SQL UPDATE命令时,该命令将包含一个WHERE子句,该子句将查找具有原始RowVersion
值的行。 如果没有行受到UPDATE命令的影响(没有行具有原始RowVersion
值),Entity Framework将抛出DbUpdateConcurrencyException
异常。