EFコア - 保存前のタイムスタンプを設定しても古い値が使用される

entity-framework entity-framework-core

質問

私は、タイムスタンプ(同時実行トークン)列を持つモデルを持っています。私は期待通りに動作することを確認する統合テストを書こうとしていますが、成功しません。私のテストは以下のように見えます

  1. HttpClient呼び出しでWeb APIから更新するエンティティを取得します。
  2. コンテキストに直接リクエストを行い、同じエンティティを取得する
  3. ステップ2からエンティティのプロパティを変更します。
  4. 手順3で更新したエンティティを保存します。
  5. ステップ1からエンティティのプロパティを変更します。
  6. HttpClientを使用して新しいエンティティとput要求をWeb APIに送信します。
  7. 私のWeb APIでは、まずデータベースからエンティティを取得し、クライアントから取得したプロパティとタイムスタンプの値を設定します。今、私のエンティティオブジェクトのAPIコントローラでは、データベースとは異なるタイムスタンプ値を持っています。今私はsavechangesが失敗すると思うが、そうではない。代わりにエンティティをデータベースに保存し、新しいタイムスタンプ値を生成します。私は生成されたクエリを見るためにSql Server Profilerでチェックしましたが、それは古いTimestamp値を使用していて、私のAPIのエンティティに割り当てられた値ではありません。

この理由は何ですか?タイムスタンプとは、ビジネス層からの変更を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としてマークされたときに元の値の代わりに新しい値が使用されるように変更できるかどうかを尋ねて、以前のバージョンと同様に動作するようにします。エンティティフレームワーク。

今のところ、これは分離されたエンティティでそれをやり遂げる方法のようです。


編集#3

ありがとう、私はそれを逃した。もう一度デバッグを行った後、なぜそれが起こっているのではないのか、私は完全に問題を理解しています。おそらくWeb APIをそれから取り除くべきですが、動く部品は少なく、EF CoreとWeb APIの間に直接の依存関係はないと思います。私はこの問題を説明する次のテストで問題を再現しました。 私は、EF Coreが渡されたtimestamp値をEF6以降に変更したことを強制するための規約が変更されている可能性があるため、バグと呼ぶことを躊躇しています。

私は最小限のコードを作成し、プロジェクトのGitHubサイトで問題/質問作成しました 。参考のためにもう少し下にテストを追加します。私が返事を聞くとすぐに、私はこの回答を返信してお知らせします。

依存関係

  • 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; }
}

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コアとASP.NETコアMVCチュートリアル)を更新しました。更新については、特に次のように述べています。

SaveChangesを呼び出す前に、元のRowVersionプロパティ値をエンティティのOriginalValuesコレクションに配置する必要があります。

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

エンティティフレームワークがSQL UPDATEコマンドを作成すると、そのコマンドには元のRowVersion値を持つ行を探すWHERE句が含まれます。 UPDATEコマンドの影響を受ける行がない場合(行に元のRowVersion値がない場合)、Entity FrameworkはDbUpdateConcurrencyException例外をスローします。



Related

ライセンスを受けた: CC-BY-SA with attribution
所属していない Stack Overflow
このKBは合法ですか? はい、理由を学ぶ
ライセンスを受けた: CC-BY-SA with attribution
所属していない Stack Overflow
このKBは合法ですか? はい、理由を学ぶ