From f1290d98587a1de9b8bf7b9395cc6eb2caf6c3fc Mon Sep 17 00:00:00 2001 From: mnbuhl Date: Thu, 14 May 2026 23:48:41 +0200 Subject: [PATCH] fix: preserve provider typed reader values Delegate typed DbDataReader reads through TranslatingDbDataReader so provider-specific materialization paths, including Npgsql DateTimeOffset reads, are preserved for locking queries. Fixes #12 Co-authored-by: Codex --- .../Internal/TranslatingDbDataReader.cs | 5 + .../IntegrationTests.cs | 1 + .../IntegrationTests.cs | 4 +- .../IntegrationTests.cs | 2 + ...rkCore.Locking.Tests.Infrastructure.csproj | 1 + .../IntegrationTestsBase.cs | 28 ++++ .../TestDbContext.cs | 13 ++ .../TranslatingDbDataReaderTests.cs | 128 ++++++++++++++++++ 8 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 tests/EntityFrameworkCore.Locking.Tests/TranslatingDbDataReaderTests.cs diff --git a/src/EntityFrameworkCore.Locking/Internal/TranslatingDbDataReader.cs b/src/EntityFrameworkCore.Locking/Internal/TranslatingDbDataReader.cs index cc10870..1ed7299 100644 --- a/src/EntityFrameworkCore.Locking/Internal/TranslatingDbDataReader.cs +++ b/src/EntityFrameworkCore.Locking/Internal/TranslatingDbDataReader.cs @@ -91,6 +91,11 @@ public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int public override Type GetFieldType(int ordinal) => _inner.GetFieldType(ordinal); + public override T GetFieldValue(int ordinal) => Wrap(() => _inner.GetFieldValue(ordinal)); + + public override Task GetFieldValueAsync(int ordinal, CancellationToken cancellationToken) => + WrapAsync(() => _inner.GetFieldValueAsync(ordinal, cancellationToken)); + public override float GetFloat(int ordinal) => _inner.GetFloat(ordinal); public override Guid GetGuid(int ordinal) => _inner.GetGuid(ordinal); diff --git a/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.cs b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.cs index 2b05605..3ac7d3f 100644 --- a/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.cs +++ b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.cs @@ -35,6 +35,7 @@ protected override (TestDbContext ctx, SqlCapture capture) CreateContextWithCapt protected override async Task ResetDatabaseAsync(TestDbContext ctx) { + await ctx.Database.ExecuteSqlRawAsync("DELETE FROM `ModelsWithDateTimeOffset`"); await ctx.Database.ExecuteSqlRawAsync("DELETE FROM `OrderLines`"); await ctx.Database.ExecuteSqlRawAsync("DELETE FROM `Products`"); await ctx.Database.ExecuteSqlRawAsync("DELETE FROM `Categories`"); diff --git a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.cs b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.cs index e37749f..7e9555f 100644 --- a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.cs +++ b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.cs @@ -25,5 +25,7 @@ protected override (TestDbContext ctx, SqlCapture capture) CreateContextWithCapt } protected override Task ResetDatabaseAsync(TestDbContext ctx) => - ctx.Database.ExecuteSqlRawAsync("""TRUNCATE "OrderLines", "Products", "Categories" RESTART IDENTITY CASCADE"""); + ctx.Database.ExecuteSqlRawAsync( + """TRUNCATE "OrderLines", "Products", "Categories", "ModelsWithDateTimeOffset" RESTART IDENTITY CASCADE""" + ); } diff --git a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.cs b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.cs index bcb90d4..ddc5bb2 100644 --- a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.cs +++ b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.cs @@ -26,9 +26,11 @@ protected override (TestDbContext ctx, SqlCapture capture) CreateContextWithCapt protected override async Task ResetDatabaseAsync(TestDbContext ctx) { + await ctx.Database.ExecuteSqlRawAsync("DELETE FROM [ModelsWithDateTimeOffset]"); await ctx.Database.ExecuteSqlRawAsync("DELETE FROM [OrderLines]"); await ctx.Database.ExecuteSqlRawAsync("DELETE FROM [Products]"); await ctx.Database.ExecuteSqlRawAsync("DELETE FROM [Categories]"); + await ctx.Database.ExecuteSqlRawAsync("DBCC CHECKIDENT ('[ModelsWithDateTimeOffset]', RESEED, 0)"); await ctx.Database.ExecuteSqlRawAsync("DBCC CHECKIDENT ('[OrderLines]', RESEED, 0)"); await ctx.Database.ExecuteSqlRawAsync("DBCC CHECKIDENT ('[Products]', RESEED, 0)"); await ctx.Database.ExecuteSqlRawAsync("DBCC CHECKIDENT ('[Categories]', RESEED, 0)"); diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/EntityFrameworkCore.Locking.Tests.Infrastructure.csproj b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/EntityFrameworkCore.Locking.Tests.Infrastructure.csproj index 49493fb..6e1ff22 100644 --- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/EntityFrameworkCore.Locking.Tests.Infrastructure.csproj +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/EntityFrameworkCore.Locking.Tests.Infrastructure.csproj @@ -2,6 +2,7 @@ $(NoWarn);EF1001 false + false net8.0;net9.0;net10.0 diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTestsBase.cs index 687f8a1..ec7c5d9 100644 --- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTestsBase.cs +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTestsBase.cs @@ -156,4 +156,32 @@ public async Task ForUpdate_ToList_ReturnsAllMatchingRows() products.Should().HaveCount(3); await tx.RollbackAsync(); } + + [Fact] + public async Task ForUpdate_WithDateTimeOffsetColumn_MaterializesProviderValue() + { + await using var ctx = CreateContext(); + var expected = DateTimeOffset.UtcNow.AddMinutes(-10); + var entity = new ModelWithDateTimeOffset { Date = expected }; + ctx.ModelsWithDateTimeOffset.Add(entity); + await ctx.SaveChangesAsync(); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + var result = ctx + .ModelsWithDateTimeOffset.ForUpdate() + .Where(m => m.Date < DateTimeOffset.UtcNow.AddMinutes(-1)) + .FirstOrDefault(m => m.Id == entity.Id); + + result.Should().NotBeNull(); + result!.Date.Should().BeCloseTo(expected, TimeSpan.FromMilliseconds(1)); + + result = await ctx + .ModelsWithDateTimeOffset.Where(m => m.Date < DateTimeOffset.UtcNow.AddMinutes(-1)) + .ForUpdate() + .FirstOrDefaultAsync(m => m.Id == entity.Id); + + result.Should().NotBeNull(); + result!.Date.Should().BeCloseTo(expected, TimeSpan.FromMilliseconds(1)); + await tx.RollbackAsync(); + } } diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/TestDbContext.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/TestDbContext.cs index 3c6604b..da3145a 100644 --- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/TestDbContext.cs +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/TestDbContext.cs @@ -7,6 +7,7 @@ public class TestDbContext(DbContextOptions options) : DbContext( public DbSet Categories => Set(); public DbSet Products => Set(); public DbSet OrderLines => Set(); + public DbSet ModelsWithDateTimeOffset => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -30,6 +31,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(ol => ol.UnitPrice).HasColumnType("decimal(18,2)"); b.HasOne(ol => ol.Product).WithMany(p => p.OrderLines).HasForeignKey(ol => ol.ProductId); }); + + modelBuilder.Entity(b => + { + b.HasKey(m => m.Id); + b.Property(m => m.Date).IsRequired(); + }); } } @@ -59,3 +66,9 @@ public class OrderLine public decimal UnitPrice { get; set; } public Product Product { get; set; } = null!; } + +public class ModelWithDateTimeOffset +{ + public int Id { get; set; } + public DateTimeOffset Date { get; set; } +} diff --git a/tests/EntityFrameworkCore.Locking.Tests/TranslatingDbDataReaderTests.cs b/tests/EntityFrameworkCore.Locking.Tests/TranslatingDbDataReaderTests.cs new file mode 100644 index 0000000..fe8d7c3 --- /dev/null +++ b/tests/EntityFrameworkCore.Locking.Tests/TranslatingDbDataReaderTests.cs @@ -0,0 +1,128 @@ +using System.Collections; +using System.Data.Common; +using AwesomeAssertions; +using EntityFrameworkCore.Locking.Abstractions; +using EntityFrameworkCore.Locking.Exceptions; +using EntityFrameworkCore.Locking.Internal; +using Xunit; + +namespace EntityFrameworkCore.Locking.Tests; + +public class TranslatingDbDataReaderTests +{ + [Fact] + public void GetFieldValue_DelegatesProviderSpecificTypedReadToInnerReader() + { + var expected = new DateTimeOffset(2026, 5, 14, 10, 15, 30, TimeSpan.Zero); + var reader = new TranslatingDbDataReader( + new ProviderSpecificDateTimeOffsetReader(expected), + new NoopTranslator() + ); + + var value = reader.GetFieldValue(0); + + value.Should().Be(expected); + } + + [Fact] + public async Task GetFieldValueAsync_DelegatesProviderSpecificTypedReadToInnerReader() + { + var expected = new DateTimeOffset(2026, 5, 14, 10, 15, 30, TimeSpan.Zero); + var reader = new TranslatingDbDataReader( + new ProviderSpecificDateTimeOffsetReader(expected), + new NoopTranslator() + ); + + var value = await reader.GetFieldValueAsync(0); + + value.Should().Be(expected); + } + + private sealed class NoopTranslator : IExceptionTranslator + { + public LockingException? Translate(Exception exception) => null; + } + + private sealed class ProviderSpecificDateTimeOffsetReader(DateTimeOffset value) : DbDataReader + { + public override int FieldCount => 1; + public override object this[int ordinal] => GetValue(ordinal); + public override object this[string name] => GetValue(GetOrdinal(name)); + public override int Depth => 0; + public override bool HasRows => true; + public override bool IsClosed => false; + public override int RecordsAffected => 1; + + public override bool Read() => true; + + public override Task ReadAsync(CancellationToken cancellationToken) => Task.FromResult(true); + + public override bool NextResult() => false; + + public override Task NextResultAsync(CancellationToken cancellationToken) => Task.FromResult(false); + + public override bool GetBoolean(int ordinal) => throw new NotSupportedException(); + + public override byte GetByte(int ordinal) => throw new NotSupportedException(); + + public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length) => + throw new NotSupportedException(); + + public override char GetChar(int ordinal) => throw new NotSupportedException(); + + public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length) => + throw new NotSupportedException(); + + public override string GetDataTypeName(int ordinal) => "timestamp with time zone"; + + public override DateTime GetDateTime(int ordinal) => value.UtcDateTime; + + public override decimal GetDecimal(int ordinal) => throw new NotSupportedException(); + + public override double GetDouble(int ordinal) => throw new NotSupportedException(); + + public override Type GetFieldType(int ordinal) => typeof(DateTime); + + public override T GetFieldValue(int ordinal) + { + if (typeof(T) == typeof(DateTimeOffset)) + return (T)(object)value; + + return base.GetFieldValue(ordinal); + } + + public override Task GetFieldValueAsync(int ordinal, CancellationToken cancellationToken) => + Task.FromResult(GetFieldValue(ordinal)); + + public override float GetFloat(int ordinal) => throw new NotSupportedException(); + + public override Guid GetGuid(int ordinal) => throw new NotSupportedException(); + + public override short GetInt16(int ordinal) => throw new NotSupportedException(); + + public override int GetInt32(int ordinal) => throw new NotSupportedException(); + + public override long GetInt64(int ordinal) => throw new NotSupportedException(); + + public override string GetName(int ordinal) => ordinal == 0 ? "Date" : throw new IndexOutOfRangeException(); + + public override int GetOrdinal(string name) => name == "Date" ? 0 : throw new IndexOutOfRangeException(name); + + public override string GetString(int ordinal) => throw new NotSupportedException(); + + public override object GetValue(int ordinal) => value.UtcDateTime; + + public override int GetValues(object[] values) + { + values[0] = GetValue(0); + return 1; + } + + public override bool IsDBNull(int ordinal) => false; + + public override IEnumerator GetEnumerator() + { + yield break; + } + } +}