I have a Model with a Timestamp (Concurrency Token) column. I'm trying to write an integration test where I check that it works as I expect but with no success. My test looks like following
What is the reason to this? Does it have anything to do with Timestamp being a database generated value that makes EF ignore changes made to it from the business layer?
The full test application can be found here: 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'
Edit 4 - Fix
I heard back from a member on the GitHub repo site, issue 4512. You have to update the original value on the entity. This can be done like so.
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;
I have updated the original unit test that failed where you and I could not get the DbUpdateConcurrencyException
to be thrown, it now works as expected.
I will update the GitHub ticket to ask if they can make a change so that the underlying sql that is generated uses the new value instead of the original value when the column is marked as Timestamp
or IsConcurrencyToken
so that it behaves similar to the previous versions of Entity Framework.
For now though this appears to be the way to go about doing it with detached entities.
Edit #3
Thank you, I missed that. After more debugging once more I completely understand the issue although not why it is occurring. We should probably take Web API out of it though, less moving parts and I do not think there is a direct dependency between EF Core and Web API. I have reproduced the issue with the following tests which illustrate the issue. I am hesitant to call it a bug as maybe the convention for forcing EF Core to use the passed in timestamp
value has changed since EF6.
I have created a complete set of working minimal code and created an issue/question on the project's GitHub site. I will include the test once more below for reference. As soon as I hear back I will post back on this answer and let you know.
Dependencies
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')
Entity
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 Context
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);
}
}
Unit Test
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)
{
}
}
}
}
Microsoft have updated their tutorial for this in Handling concurrency conflicts - EF Core with ASP.NET Core MVC tutorial. It specifically states the following regarding updates:
Before you call
SaveChanges
, you have to put that originalRowVersion
property value in theOriginalValues
collection for the entity.
_context.Entry(entityToUpdate).Property("RowVersion").OriginalValue = rowVersion;
Then when the Entity Framework creates a SQL UPDATE command, that command will include a WHERE clause that looks for a row that has the original
RowVersion
value. If no rows are affected by the UPDATE command (no rows have the originalRowVersion
value), the Entity Framework throws aDbUpdateConcurrencyException
exception.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With