I try to use optimistic concurrency check in EF Core with SQLite.
The simplest positive scenario (even without concurrency itself) gives me
Microsoft.EntityFrameworkCore.
Looks like EF Core SQLite provider does not handle properly [TimeStamp]
(or IsRowVersion()
) marked byte[]
properties when binding them to SQL query parameters. It uses the default byte[]
to hex string
conversion which is not applicable in this case - the byte[]
actually is a string
.
First consider reporting it to their issue tracker. Then, until it gets resolved (if ever), as a workaround you can use the following custom ValueConverter:
class SqliteTimestampConverter : ValueConverter<byte[], string>
{
public SqliteTimestampConverter() : base(
v => v == null ? null : ToDb(v),
v => v == null ? null : FromDb(v))
{ }
static byte[] FromDb(string v) =>
v.Select(c => (byte)c).ToArray(); // Encoding.ASCII.GetString(v)
static string ToDb(byte[] v) =>
new string(v.Select(b => (char)b).ToArray()); // Encoding.ASCII.GetBytes(v))
}
Unfortunately there is no way to tell EF Core to use it only for parameters, so after assigning it with .HasConversion(new SqliteTimestampConverter())
, now the db type is considered string
, so you need to add .HasColumnType("BLOB")
.
The final working mapping is
modelBuilder.Entity<Blog>()
.Property(p => p.Timestamp)
.IsRowVersion()
.HasConversion(new SqliteTimestampConverter())
.HasColumnType("BLOB")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
You can avoid all that by adding the following custom SQLite RowVersion "convention" at the end of your OnModelCreating
:
if (Database.IsSqlite())
{
var timestampProperties = modelBuilder.Model
.GetEntityTypes()
.SelectMany(t => t.GetProperties())
.Where(p => p.ClrType == typeof(byte[])
&& p.ValueGenerated == ValueGenerated.OnAddOrUpdate
&& p.IsConcurrencyToken);
foreach (var property in timestampProperties)
{
property.SetValueConverter(new SqliteTimestampConverter());
property.Relational().DefaultValueSql = "CURRENT_TIMESTAMP";
}
}
so your property configuration could be trimmed down to
modelBuilder.Entity<Blog>()
.Property(p => p.Timestamp)
.IsRowVersion();
or totally removed and replaced with data annotation
public class Blog
{
public Guid Id { get; set; }
public string Name { get; set; }
[Timestamp]
public byte[] Timestamp { get; set; }
}
This is because you use Guid
:
public Guid Id { get; set; }
This issue is discussed and reproduced in Gitub:
The error here is due to ApplicationUser.ConcurrencyStamp property. ApplicationUser in identity uses ConcurrencyStamp of type Guid for concurrency. When creating new class it sets the value to NewGuid(). When you create new ApplicationUser like that and set its state to Modified EF Core does not have data about what was ConcurrencyStamp in database. Hence it will use whatever is the value set on the item (which will be NewGuid()) Since this value differ from value in database and it is used in where clause of update statement, exception is thrown that 0 rows modified when expected 1.
When updating entity with concurrency token you cannot create new object and send update directly. You must retrieve record from database (so that you have value of ConcurrencyStamp) then update the record and call SaveChanges. Since the ApplicationUser.ConcurrencyStamp is client side concurrency token you also need to generate a NewGuid() while updating the record. So it can update the value in database.
Find more info about how to deal with ApplicationUser.ConcurrencyStamp
here.
I have been using Ivan's answer with great success up to this point. When I updating to EntitiyFrameworkCore 3.1, though, I started getting this warning:
The property '{column name}' on entity type '{entity name}' is a collection or enumeration type with a value converter but with no value comparer. Set a value comparer to ensure the collection/enumeration elements are compared correctly.
To address this, I enhanced his solution by adding:
property.SetValueComparer(new ValueComparer<byte[]>(
(c1, c2) => c1.SequenceEqual(c2),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => c.ToArray()));
(based off of a response to a GitHub issue)
Inspired by this thread on GitHub and the Ivan's answer I wrote this code to ensure on my unit testing to mimic the SQL Server concurrency.
var connection = new SqliteConnection("DataSource=:memory:");
var options = new DbContextOptionsBuilder<ActiveContext>()
.UseSqlite(connection)
.Options;
var ctx = new ActiveContext(options);
if (connection.State != System.Data.ConnectionState.Open)
{
connection.Open();
ctx.Database.EnsureCreated();
var tables = ctx.Model.GetEntityTypes();
foreach (var table in tables)
{
var props = table.GetProperties()
.Where(p => p.ClrType == typeof(byte[])
&& p.ValueGenerated == Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAddOrUpdate
&& p.IsConcurrencyToken);
var tableName = table.Relational().TableName;
foreach (var field in props)
{
string[] SQLs = new string[] {
$@"CREATE TRIGGER Set{tableName}_{field.Name}OnUpdate
AFTER UPDATE ON {tableName}
BEGIN
UPDATE {tableName}
SET RowVersion = randomblob(8)
WHERE rowid = NEW.rowid;
END
",
$@"CREATE TRIGGER Set{tableName}_{field.Name}OnInsert
AFTER INSERT ON {tableName}
BEGIN
UPDATE {tableName}
SET RowVersion = randomblob(8)
WHERE rowid = NEW.rowid;
END
"
};
foreach (var sql in SQLs)
{
using (var command = connection.CreateCommand())
{
command.CommandText = sql;
command.ExecuteNonQuery();
}
}
}
}
}