Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

EF Core - Set Timestamp before save still uses the old value

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

  1. Get the entity with that should be updated from the web api with a HttpClient call.
  2. Make a request directly to the Context and get the same entity
  3. Change a property on the entity from step 2.
  4. Save the entity updated in step 3.
  5. Change a property on the entity from step 1.
  6. Send a put request with the new entity with HttpClient to the Web Api.
  7. In my web API I first get the entity from the database, sets the property and timestamp value from the one I got from the client. Now my entity object in the api controller has a different Timestamp value than the one in the database. Now I expect that savechanges would fail, but it doesn't. Instead it saves the entity to the database and generates a new Timestamp value. I checked with Sql Server Profiler to see the generated query and it turns out that is still use the old Timestamp value and not the one I assigned to the entity in my api controller.

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'
like image 682
Abris Avatar asked Feb 06 '16 15:02

Abris


2 Answers

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

  • Sql Server 2012
  • EF Core
    • 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')

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)
            {
            }
        }
    }
}
like image 200
Igor Avatar answered Oct 15 '22 09:10

Igor


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 original RowVersion property value in the OriginalValues 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 original RowVersion value), the Entity Framework throws a DbUpdateConcurrencyException exception.

like image 4
SpruceMoose Avatar answered Oct 15 '22 07:10

SpruceMoose