diff --git a/README.md b/README.md index f2840fe..d421529 100644 --- a/README.md +++ b/README.md @@ -233,9 +233,26 @@ MySQL's `innodb_lock_wait_timeout` is in whole seconds, so sub-second timeouts a ## Unsupported query shapes -`UNION`, `EXCEPT`, `INTERSECT` combined with locking throw `LockingConfigurationException` at query execution time. Lock individual queries before combining results. +The following query shapes throw `LockingConfigurationException` at execution time: -`AsSplitQuery()` combined with locking throws `LockingConfigurationException` — use regular `Include()` instead. On PostgreSQL, `FOR UPDATE OF` is emitted automatically to handle the outer join. +- `UNION` / `EXCEPT` / `INTERSECT` / `CONCAT` — lock individual queries before combining results +- `AsSplitQuery()` — use regular `Include()` instead (PostgreSQL emits `FOR UPDATE OF` automatically for outer joins) +- `Distinct()` — not compatible with row-level locking on any supported database +- `CountAsync()` / `LongCountAsync()` / `SumAsync()` / `MaxAsync()` / `MinAsync()` — aggregate terminal operations are rejected because the result is a scalar, not a set of lockable rows; use `AnyAsync()` if you want to test for row existence with a lock + +Explicit joins (LINQ `join` syntax, `SelectMany`), correlated subqueries (`Any`, `Contains`), `Where`+`OrderBy`+`Take` pagination, and all `Include` / `ThenInclude` shapes work correctly across all providers. + +## Limitations + +The following scenarios are not detected at build or execution time: + +| Scenario | Behaviour | Notes | +|---|---|---| +| `FromSqlRaw` / `FromSql` + `ForUpdate()` | Lock clause appended to the wrapping `SELECT` — works in most cases; may fail if the raw SQL shape prevents composing a valid outer query | Test your specific query | +| `EF.CompileAsyncQuery` + `ForUpdate()` | **Throws at compile time.** `ForUpdate` is not a translatable LINQ expression and cannot be used inside `EF.CompileAsyncQuery`. | Architectural constraint of EF Core compiled queries | +| `ExecuteUpdate` / `ExecuteDelete` / `Database.ExecuteSqlRaw` | Locking has no effect — these bypass the query SQL generator | Use `ForUpdate()` only with `IQueryable` | +| SQL Server nested subqueries | Table hints (`WITH (UPDLOCK, HOLDLOCK, ROWLOCK)`) are applied to all `TableExpression` nodes in the locking `SELECT`, including correlated subqueries | SQL Server requires per-table hints; subquery coverage is correct and intentional | +| GroupBy + ForUpdate | No compile-time error; the lock clause is applied to the outer `SELECT` that wraps EF Core's subquery translation, so the lock targets the grouping result rows rather than individual base-table rows — semantics may not be what you expect | | ## Supported database versions diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/EntityFrameworkCore.Locking.Benchmarks.csproj b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/EntityFrameworkCore.Locking.Benchmarks.csproj index 6201a1e..ca2295b 100644 --- a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/EntityFrameworkCore.Locking.Benchmarks.csproj +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/EntityFrameworkCore.Locking.Benchmarks.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 false false true diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report-github.md b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report-github.md new file mode 100644 index 0000000..d116625 --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report-github.md @@ -0,0 +1,16 @@ +``` + +BenchmarkDotNet v0.15.8, macOS Tahoe 26.4.1 (25E253) [Darwin 25.4.0] +Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores +.NET SDK 10.0.201 + [Host] : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a + DefaultJob : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a + + +``` +| Method | Mean | Error | StdDev | Ratio | RatioSD | Allocated | Alloc Ratio | +|----------------- |----------:|----------:|----------:|------:|--------:|----------:|------------:| +| ShortSql_NoTag | 6.272 ns | 0.0845 ns | 0.0749 ns | 1.00 | 0.02 | - | NA | +| ShortSql_WithTag | 2.807 ns | 0.0269 ns | 0.0239 ns | 0.45 | 0.01 | - | NA | +| LongSql_NoTag | 55.605 ns | 0.6941 ns | 0.6153 ns | 8.87 | 0.14 | - | NA | +| LongSql_WithTag | 2.764 ns | 0.0318 ns | 0.0297 ns | 0.44 | 0.01 | - | NA | diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report.csv b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report.csv new file mode 100644 index 0000000..f529738 --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report.csv @@ -0,0 +1,5 @@ +Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Ratio,RatioSD,Allocated,Alloc Ratio +ShortSql_NoTag,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,6.272 ns,0.0845 ns,0.0749 ns,1.00,0.02,0 B,NA +ShortSql_WithTag,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,2.807 ns,0.0269 ns,0.0239 ns,0.45,0.01,0 B,NA +LongSql_NoTag,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,55.605 ns,0.6941 ns,0.6153 ns,8.87,0.14,0 B,NA +LongSql_WithTag,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,2.764 ns,0.0318 ns,0.0297 ns,0.44,0.01,0 B,NA diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report.html b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report.html new file mode 100644 index 0000000..bc066bf --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-report.html @@ -0,0 +1,33 @@ + + + + +EntityFrameworkCore.Locking.Benchmarks.Benchmarks.InterceptorBenchmarks-20260426-011635 + + + + +

+BenchmarkDotNet v0.15.8, macOS Tahoe 26.4.1 (25E253) [Darwin 25.4.0]
+Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores
+.NET SDK 10.0.201
+  [Host]     : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a
+  DefaultJob : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a
+
+
+ + + + + + + + +
Method MeanErrorStdDevRatioRatioSDAllocatedAlloc Ratio
ShortSql_NoTag6.272 ns0.0845 ns0.0749 ns1.000.02-NA
ShortSql_WithTag2.807 ns0.0269 ns0.0239 ns0.450.01-NA
LongSql_NoTag55.605 ns0.6941 ns0.6153 ns8.870.14-NA
LongSql_WithTag2.764 ns0.0318 ns0.0297 ns0.440.01-NA
+ + diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report-github.md b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report-github.md new file mode 100644 index 0000000..2e168ec --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report-github.md @@ -0,0 +1,22 @@ +``` + +BenchmarkDotNet v0.15.8, macOS Tahoe 26.4.1 (25E253) [Darwin 25.4.0] +Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores +.NET SDK 10.0.201 + [Host] : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a + DefaultJob : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a + + +``` +| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio | +|--------------------------------- |---------:|----------:|----------:|------:|--------:|-------:|----------:|------------:| +| Postgres_NoLock | 2.841 μs | 0.0266 μs | 0.0207 μs | 1.00 | 0.01 | 0.0343 | 4.16 KB | 1.00 | +| Postgres_ForUpdate | 5.277 μs | 0.1024 μs | 0.1179 μs | 1.86 | 0.04 | 0.0305 | 6.02 KB | 1.45 | +| Postgres_ForUpdate_WithTimeout | 5.291 μs | 0.0898 μs | 0.1229 μs | 1.86 | 0.04 | 0.0305 | 6.05 KB | 1.46 | +| Postgres_ForUpdate_MultipleTags | 8.562 μs | 0.1566 μs | 0.1388 μs | 3.01 | 0.05 | 0.0610 | 9.16 KB | 2.20 | +| MySql_NoLock | 3.191 μs | 0.0638 μs | 0.0829 μs | 1.12 | 0.03 | 0.0381 | 4.76 KB | 1.14 | +| MySql_ForUpdate | 5.021 μs | 0.0459 μs | 0.0383 μs | 1.77 | 0.02 | 0.0305 | 6.33 KB | 1.52 | +| MySql_ForUpdate_MultipleTags | 8.305 μs | 0.1003 μs | 0.0889 μs | 2.92 | 0.04 | 0.0610 | 9.47 KB | 2.28 | +| SqlServer_NoLock | 2.918 μs | 0.0281 μs | 0.0249 μs | 1.03 | 0.01 | 0.0343 | 4.48 KB | 1.08 | +| SqlServer_ForUpdate | 5.039 μs | 0.0818 μs | 0.0725 μs | 1.77 | 0.03 | 0.0305 | 6.34 KB | 1.53 | +| SqlServer_ForUpdate_MultipleTags | 8.251 μs | 0.0702 μs | 0.0622 μs | 2.90 | 0.03 | 0.0610 | 9.48 KB | 2.28 | diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report.csv b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report.csv new file mode 100644 index 0000000..f3c3a6d --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report.csv @@ -0,0 +1,11 @@ +Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Ratio,RatioSD,Gen0,Allocated,Alloc Ratio +Postgres_NoLock,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,2.841 μs,0.0266 μs,0.0207 μs,1.00,0.01,0.0343,4.16 KB,1.00 +Postgres_ForUpdate,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,5.277 μs,0.1024 μs,0.1179 μs,1.86,0.04,0.0305,6.02 KB,1.45 +Postgres_ForUpdate_WithTimeout,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,5.291 μs,0.0898 μs,0.1229 μs,1.86,0.04,0.0305,6.05 KB,1.46 +Postgres_ForUpdate_MultipleTags,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,8.562 μs,0.1566 μs,0.1388 μs,3.01,0.05,0.0610,9.16 KB,2.20 +MySql_NoLock,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,3.191 μs,0.0638 μs,0.0829 μs,1.12,0.03,0.0381,4.76 KB,1.14 +MySql_ForUpdate,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,5.021 μs,0.0459 μs,0.0383 μs,1.77,0.02,0.0305,6.33 KB,1.52 +MySql_ForUpdate_MultipleTags,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,8.305 μs,0.1003 μs,0.0889 μs,2.92,0.04,0.0610,9.47 KB,2.28 +SqlServer_NoLock,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,2.918 μs,0.0281 μs,0.0249 μs,1.03,0.01,0.0343,4.48 KB,1.08 +SqlServer_ForUpdate,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,5.039 μs,0.0818 μs,0.0725 μs,1.77,0.03,0.0305,6.34 KB,1.53 +SqlServer_ForUpdate_MultipleTags,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,8.251 μs,0.0702 μs,0.0622 μs,2.90,0.03,0.0610,9.48 KB,2.28 diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report.html b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report.html new file mode 100644 index 0000000..b98befb --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-report.html @@ -0,0 +1,39 @@ + + + + +EntityFrameworkCore.Locking.Benchmarks.Benchmarks.SqlGenerationBenchmarks-20260426-011801 + + + + +

+BenchmarkDotNet v0.15.8, macOS Tahoe 26.4.1 (25E253) [Darwin 25.4.0]
+Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores
+.NET SDK 10.0.201
+  [Host]     : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a
+  DefaultJob : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a
+
+
+ + + + + + + + + + + + + + +
Method MeanErrorStdDevRatioRatioSDGen0AllocatedAlloc Ratio
Postgres_NoLock2.841 μs0.0266 μs0.0207 μs1.000.010.03434.16 KB1.00
Postgres_ForUpdate5.277 μs0.1024 μs0.1179 μs1.860.040.03056.02 KB1.45
Postgres_ForUpdate_WithTimeout5.291 μs0.0898 μs0.1229 μs1.860.040.03056.05 KB1.46
Postgres_ForUpdate_MultipleTags8.562 μs0.1566 μs0.1388 μs3.010.050.06109.16 KB2.20
MySql_NoLock3.191 μs0.0638 μs0.0829 μs1.120.030.03814.76 KB1.14
MySql_ForUpdate5.021 μs0.0459 μs0.0383 μs1.770.020.03056.33 KB1.52
MySql_ForUpdate_MultipleTags8.305 μs0.1003 μs0.0889 μs2.920.040.06109.47 KB2.28
SqlServer_NoLock2.918 μs0.0281 μs0.0249 μs1.030.010.03434.48 KB1.08
SqlServer_ForUpdate5.039 μs0.0818 μs0.0725 μs1.770.030.03056.34 KB1.53
SqlServer_ForUpdate_MultipleTags8.251 μs0.0702 μs0.0622 μs2.900.030.06109.48 KB2.28
+ + diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report-github.md b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report-github.md new file mode 100644 index 0000000..197dddf --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report-github.md @@ -0,0 +1,18 @@ +``` + +BenchmarkDotNet v0.15.8, macOS Tahoe 26.4.1 (25E253) [Darwin 25.4.0] +Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores +.NET SDK 10.0.201 + [Host] : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a + DefaultJob : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a + + +``` +| Method | Mean | Error | StdDev | Ratio | RatioSD | Allocated | Alloc Ratio | +|---------------------- |----------:|----------:|----------:|------:|--------:|----------:|------------:| +| Old_StartsWith_Empty | 1.0166 ns | 0.0163 ns | 0.0152 ns | 1.00 | 0.02 | - | NA | +| New_Contains_Empty | 0.3772 ns | 0.0101 ns | 0.0095 ns | 0.37 | 0.01 | - | NA | +| Old_StartsWith_Single | 1.8758 ns | 0.0186 ns | 0.0165 ns | 1.85 | 0.03 | - | NA | +| New_Contains_Single | 6.0173 ns | 0.0465 ns | 0.0435 ns | 5.92 | 0.10 | - | NA | +| Old_StartsWith_Multi | 2.7227 ns | 0.0289 ns | 0.0270 ns | 2.68 | 0.05 | - | NA | +| New_Contains_Multi | 5.9900 ns | 0.0491 ns | 0.0435 ns | 5.89 | 0.09 | - | NA | diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report.csv b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report.csv new file mode 100644 index 0000000..edbb6b8 --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report.csv @@ -0,0 +1,7 @@ +Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Ratio,RatioSD,Allocated,Alloc Ratio +Old_StartsWith_Empty,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,1.0166 ns,0.0163 ns,0.0152 ns,1.00,0.02,0 B,NA +New_Contains_Empty,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,0.3772 ns,0.0101 ns,0.0095 ns,0.37,0.01,0 B,NA +Old_StartsWith_Single,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,1.8758 ns,0.0186 ns,0.0165 ns,1.85,0.03,0 B,NA +New_Contains_Single,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,6.0173 ns,0.0465 ns,0.0435 ns,5.92,0.10,0 B,NA +Old_StartsWith_Multi,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,2.7227 ns,0.0289 ns,0.0270 ns,2.68,0.05,0 B,NA +New_Contains_Multi,DefaultJob,False,Default,Default,Default,Default,Default,Default,000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,5.9900 ns,0.0491 ns,0.0435 ns,5.89,0.09,0 B,NA diff --git a/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report.html b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report.html new file mode 100644 index 0000000..fe41e59 --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Locking.Benchmarks/results/v0.4.0/results/EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-report.html @@ -0,0 +1,35 @@ + + + + +EntityFrameworkCore.Locking.Benchmarks.Benchmarks.TagScanMicroBenchmarks-20260426-012011 + + + + +

+BenchmarkDotNet v0.15.8, macOS Tahoe 26.4.1 (25E253) [Darwin 25.4.0]
+Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores
+.NET SDK 10.0.201
+  [Host]     : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a
+  DefaultJob : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a
+
+
+ + + + + + + + + + +
Method MeanErrorStdDevRatioRatioSDAllocatedAlloc Ratio
Old_StartsWith_Empty1.0166 ns0.0163 ns0.0152 ns1.000.02-NA
New_Contains_Empty0.3772 ns0.0101 ns0.0095 ns0.370.01-NA
Old_StartsWith_Single1.8758 ns0.0186 ns0.0165 ns1.850.03-NA
New_Contains_Single6.0173 ns0.0465 ns0.0435 ns5.920.10-NA
Old_StartsWith_Multi2.7227 ns0.0289 ns0.0270 ns2.680.05-NA
New_Contains_Multi5.9900 ns0.0491 ns0.0435 ns5.890.09-NA
+ + diff --git a/src/EntityFrameworkCore.Locking.MySql/MySqlLockingQuerySqlGenerator.cs b/src/EntityFrameworkCore.Locking.MySql/MySqlLockingQuerySqlGenerator.cs index 38e56ea..7c1e7fe 100644 --- a/src/EntityFrameworkCore.Locking.MySql/MySqlLockingQuerySqlGenerator.cs +++ b/src/EntityFrameworkCore.Locking.MySql/MySqlLockingQuerySqlGenerator.cs @@ -29,9 +29,12 @@ protected override Expression VisitSelect(SelectExpression selectExpression) { var result = base.VisitSelect(selectExpression); - var lockOptions = LockContext.Current; + // LastOrDefault: TagWith appends in call order, so the last locking tag is the most recent. + var lockTag = selectExpression.Tags.LastOrDefault(t => + t.StartsWith(LockTagConstants.Prefix, StringComparison.Ordinal) + ); - if (lockOptions is null || !selectExpression.Tags.Contains(LockTagConstants.BuildTag(lockOptions))) + if (lockTag is null || !LockTagConstants.TryParse(lockTag, out var lockOptions)) return result; UnsafeShapeDetector.ThrowIfUnsafe(selectExpression); diff --git a/src/EntityFrameworkCore.Locking.PostgreSQL/PostgresLockingQuerySqlGenerator.cs b/src/EntityFrameworkCore.Locking.PostgreSQL/PostgresLockingQuerySqlGenerator.cs index 13f0eea..8cd759c 100644 --- a/src/EntityFrameworkCore.Locking.PostgreSQL/PostgresLockingQuerySqlGenerator.cs +++ b/src/EntityFrameworkCore.Locking.PostgreSQL/PostgresLockingQuerySqlGenerator.cs @@ -11,7 +11,7 @@ namespace EntityFrameworkCore.Locking.PostgreSQL; /// /// Extends NpgsqlQuerySqlGenerator to append FOR UPDATE / FOR SHARE clauses -/// when LockContext carries active lock options. +/// when the query carries a locking tag. /// internal sealed class PostgresLockingQuerySqlGenerator : NpgsqlQuerySqlGenerator { @@ -33,9 +33,12 @@ protected override Expression VisitSelect(SelectExpression selectExpression) { var result = base.VisitSelect(selectExpression); - var lockOptions = LockContext.Current; + // LastOrDefault: TagWith appends in call order, so the last locking tag is the most recent. + var lockTag = selectExpression.Tags.LastOrDefault(t => + t.StartsWith(LockTagConstants.Prefix, StringComparison.Ordinal) + ); - if (lockOptions is null || !selectExpression.Tags.Contains(LockTagConstants.BuildTag(lockOptions))) + if (lockTag is null || !LockTagConstants.TryParse(lockTag, out var lockOptions)) return result; UnsafeShapeDetector.ThrowIfUnsafe(selectExpression); diff --git a/src/EntityFrameworkCore.Locking.SqlServer/SqlServerLockingQuerySqlGenerator.cs b/src/EntityFrameworkCore.Locking.SqlServer/SqlServerLockingQuerySqlGenerator.cs index 650037f..0632a35 100644 --- a/src/EntityFrameworkCore.Locking.SqlServer/SqlServerLockingQuerySqlGenerator.cs +++ b/src/EntityFrameworkCore.Locking.SqlServer/SqlServerLockingQuerySqlGenerator.cs @@ -19,6 +19,7 @@ internal sealed class SqlServerLockingQuerySqlGenerator : SqlServerQuerySqlGener { private readonly ILockSqlGenerator _lockSqlGenerator; private bool _lockingActive; + private LockOptions? _activeLockOptions; public SqlServerLockingQuerySqlGenerator( QuerySqlGeneratorDependencies dependencies, @@ -33,24 +34,30 @@ ILockSqlGenerator lockSqlGenerator protected override Expression VisitSelect(SelectExpression selectExpression) { - var lockOptions = LockContext.Current; var previousLockingActive = _lockingActive; + var previousActiveLockOptions = _activeLockOptions; - var isLockingSelect = - lockOptions is not null && selectExpression.Tags.Contains(LockTagConstants.BuildTag(lockOptions)); + // LastOrDefault: TagWith appends in call order, so the last locking tag is the most recent. + var lockTag = selectExpression.Tags.LastOrDefault(t => + t.StartsWith(LockTagConstants.Prefix, StringComparison.Ordinal) + ); - if (!isLockingSelect) + if (lockTag is null || !LockTagConstants.TryParse(lockTag, out var lockOptions)) { _lockingActive = false; + _activeLockOptions = null; var innerResult = base.VisitSelect(selectExpression); _lockingActive = previousLockingActive; + _activeLockOptions = previousActiveLockOptions; return innerResult; } UnsafeShapeDetector.ThrowIfUnsafe(selectExpression); _lockingActive = true; + _activeLockOptions = lockOptions; var result = base.VisitSelect(selectExpression); _lockingActive = previousLockingActive; + _activeLockOptions = previousActiveLockOptions; return result; } @@ -58,19 +65,15 @@ protected override Expression VisitTable(TableExpression tableExpression) { var result = base.VisitTable(tableExpression); - if (!_lockingActive) + if (!_lockingActive || _activeLockOptions is null) return result; - var lockOptions = LockContext.Current; - if (lockOptions is null) - return result; - - if (!_lockSqlGenerator.SupportsLockOptions(lockOptions)) + if (!_lockSqlGenerator.SupportsLockOptions(_activeLockOptions)) throw new LockingConfigurationException( - $"Lock mode {lockOptions.Mode} with behavior {lockOptions.Behavior} is not supported by SQL Server." + $"Lock mode {_activeLockOptions.Mode} with behavior {_activeLockOptions.Behavior} is not supported by SQL Server." ); - Sql.AppendLine($" {SqlServerLockSqlGenerator.BuildTableHint(lockOptions)}"); + Sql.AppendLine($" {SqlServerLockSqlGenerator.BuildTableHint(_activeLockOptions)}"); return result; } } diff --git a/src/EntityFrameworkCore.Locking/Abstractions/IDistributedLockHandle.cs b/src/EntityFrameworkCore.Locking/Abstractions/IDistributedLockHandle.cs index 51120cb..590fe69 100644 --- a/src/EntityFrameworkCore.Locking/Abstractions/IDistributedLockHandle.cs +++ b/src/EntityFrameworkCore.Locking/Abstractions/IDistributedLockHandle.cs @@ -1,5 +1,3 @@ -using System.Data.Common; - namespace EntityFrameworkCore.Locking.Abstractions; /// diff --git a/src/EntityFrameworkCore.Locking/Extensions/DatabaseFacadeDistributedLockExtensions.cs b/src/EntityFrameworkCore.Locking/Extensions/DatabaseFacadeDistributedLockExtensions.cs index 2a95753..c5eadb8 100644 --- a/src/EntityFrameworkCore.Locking/Extensions/DatabaseFacadeDistributedLockExtensions.cs +++ b/src/EntityFrameworkCore.Locking/Extensions/DatabaseFacadeDistributedLockExtensions.cs @@ -8,6 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; +// ReSharper disable once CheckNamespace namespace EntityFrameworkCore.Locking; /// @@ -23,6 +24,13 @@ public static class DatabaseFacadeDistributedLockExtensions /// Lock key (1–255 characters). /// Maximum time to wait. Throws if exceeded. Null = wait indefinitely. /// Cancellation token. Cancellation is best-effort (driver-dependent). + /// + /// Thrown if the key is null, empty, or longer than 255 characters; if no locking provider is + /// registered; or if the provider does not support distributed locks. + /// + /// + /// Thrown if is exceeded before the lock can be acquired. + /// public static async Task AcquireDistributedLockAsync( this DatabaseFacade database, string key, @@ -56,6 +64,11 @@ public static async Task AcquireDistributedLockAsync( /// Attempts to acquire a distributed lock without blocking. /// Returns null immediately if the lock is held by another connection. /// + /// + /// Thrown if the key is null, empty, or longer than 255 characters; if no locking provider is + /// registered; or if the provider does not support distributed locks. + /// + /// A lock handle, or null if the lock is currently held by another connection. public static async Task TryAcquireDistributedLockAsync( this DatabaseFacade database, string key, @@ -93,6 +106,12 @@ public static async Task AcquireDistributedLockAsync( } /// Acquires a distributed lock synchronously. + /// + /// Thrown if the key is invalid, no provider is registered, or the provider does not support distributed locks. + /// + /// + /// Thrown if is exceeded before the lock can be acquired. + /// public static IDistributedLockHandle AcquireDistributedLock( this DatabaseFacade database, string key, @@ -122,6 +141,10 @@ public static IDistributedLockHandle AcquireDistributedLock( } /// Attempts to acquire a distributed lock synchronously. Returns null if contested. + /// + /// Thrown if the key is invalid, no provider is registered, or the provider does not support distributed locks. + /// + /// A lock handle, or null if the lock is currently held by another connection. public static IDistributedLockHandle? TryAcquireDistributedLock(this DatabaseFacade database, string key) { var (ctx, provider, connection, openedByMe) = PrepareSync(database, key); @@ -204,9 +227,9 @@ bool openedByMe private static void ValidateKey(string key) { if (string.IsNullOrEmpty(key)) - throw new ArgumentException("Lock key must not be null or empty.", nameof(key)); + throw new LockingConfigurationException("Lock key must not be null or empty."); if (key.Length > 255) - throw new ArgumentException("Lock key must not exceed 255 characters.", nameof(key)); + throw new LockingConfigurationException("Lock key must not exceed 255 characters."); } private static DbContext GetContext(DatabaseFacade database) => diff --git a/src/EntityFrameworkCore.Locking/Extensions/QueryableLockingExtensions.cs b/src/EntityFrameworkCore.Locking/Extensions/QueryableLockingExtensions.cs index 3912a2f..6ae12e4 100644 --- a/src/EntityFrameworkCore.Locking/Extensions/QueryableLockingExtensions.cs +++ b/src/EntityFrameworkCore.Locking/Extensions/QueryableLockingExtensions.cs @@ -1,6 +1,7 @@ using EntityFrameworkCore.Locking.Internal; using Microsoft.EntityFrameworkCore; +// ReSharper disable once CheckNamespace namespace EntityFrameworkCore.Locking; public static class QueryableLockingExtensions @@ -9,9 +10,10 @@ public static class QueryableLockingExtensions /// Acquires an exclusive row-level lock (FOR UPDATE) on the selected rows. /// Requires an active transaction on the DbContext. /// - /// - /// Thrown at query execution time if no ambient transaction exists, - /// or if the query shape is incompatible with row locking. + /// + /// Thrown at query execution time if no ambient transaction exists, or if the query shape is + /// incompatible with row locking: DISTINCT, GROUP BY, set operations (Union/Except/Intersect), + /// or split queries (AsSplitQuery). /// public static IQueryable ForUpdate( this IQueryable source, @@ -34,9 +36,10 @@ public static IQueryable ForUpdate( /// Acquires a shared row-level lock (FOR SHARE) on the selected rows. /// Requires an active transaction on the DbContext. /// - /// - /// Thrown at query execution time if no ambient transaction exists, - /// or if the query shape is incompatible with row locking. + /// + /// Thrown at query execution time if no ambient transaction exists, or if the query shape is + /// incompatible with row locking: DISTINCT, GROUP BY, set operations (Union/Except/Intersect), + /// or split queries (AsSplitQuery). /// public static IQueryable ForShare( this IQueryable source, diff --git a/src/EntityFrameworkCore.Locking/Internal/DistributedLockHandle.cs b/src/EntityFrameworkCore.Locking/Internal/DistributedLockHandle.cs index 435af29..dc54385 100644 --- a/src/EntityFrameworkCore.Locking/Internal/DistributedLockHandle.cs +++ b/src/EntityFrameworkCore.Locking/Internal/DistributedLockHandle.cs @@ -10,7 +10,7 @@ internal sealed class DistributedLockHandle : IDistributedLockHandle private int _released; public string Key { get; } - public System.Data.Common.DbConnection Connection { get; } + public DbConnection Connection { get; } public bool OpenedByConnection { get; } public DistributedLockHandle( diff --git a/src/EntityFrameworkCore.Locking/Internal/LockTagConstants.cs b/src/EntityFrameworkCore.Locking/Internal/LockTagConstants.cs index ddb3362..6924835 100644 --- a/src/EntityFrameworkCore.Locking/Internal/LockTagConstants.cs +++ b/src/EntityFrameworkCore.Locking/Internal/LockTagConstants.cs @@ -6,4 +6,52 @@ internal static class LockTagConstants internal static string BuildTag(LockOptions options) => FormattableString.Invariant($"{Prefix}{options.Mode}:{options.Behavior}:{options.Timeout?.TotalMilliseconds}"); + + /// + /// Parses a lock tag produced by back into . + /// Format: __efcore_locking:{LockMode}:{LockBehavior}:{timeout_ms | empty} + /// + internal static bool TryParse(string tag, out LockOptions? options) + { + options = null; + if (!tag.StartsWith(Prefix, StringComparison.Ordinal)) + return false; + + var body = tag[Prefix.Length..]; + var parts = body.Split(':'); + if (parts.Length != 3) + return false; + + if (!Enum.TryParse(parts[0], out var mode)) + return false; + + if (!Enum.TryParse(parts[1], out var behavior)) + return false; + + TimeSpan? timeout = null; + if (parts[2].Length > 0) + { + if ( + !double.TryParse( + parts[2], + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, + out var ms + ) + || !double.IsFinite(ms) + || ms < 0 + ) + return false; + + timeout = TimeSpan.FromMilliseconds(ms); + } + + options = new LockOptions + { + Mode = mode, + Behavior = behavior, + Timeout = timeout, + }; + return true; + } } diff --git a/src/EntityFrameworkCore.Locking/Internal/LockingOptionsExtension.cs b/src/EntityFrameworkCore.Locking/Internal/LockingOptionsExtension.cs index fdcde66..0a9022a 100644 --- a/src/EntityFrameworkCore.Locking/Internal/LockingOptionsExtension.cs +++ b/src/EntityFrameworkCore.Locking/Internal/LockingOptionsExtension.cs @@ -22,11 +22,11 @@ public LockingOptionsExtension(ILockingProvider provider) public void ApplyServices(IServiceCollection services) { services.AddSingleton(_provider); - services.AddSingleton(_provider); + services.AddSingleton(_provider); + services.AddSingleton(_provider.RowLockGenerator); services.AddSingleton(_provider.RowLockGenerator); - services.AddSingleton(_provider.RowLockGenerator); services.AddSingleton(_provider.ExceptionTranslator); - services.AddSingleton(_provider.ExceptionTranslator); + services.AddSingleton(_provider.ExceptionTranslator); } public void Validate(IDbContextOptions options) { } diff --git a/src/EntityFrameworkCore.Locking/Internal/LockingValidationInterceptor.cs b/src/EntityFrameworkCore.Locking/Internal/LockingValidationInterceptor.cs index 3b10573..695c3d2 100644 --- a/src/EntityFrameworkCore.Locking/Internal/LockingValidationInterceptor.cs +++ b/src/EntityFrameworkCore.Locking/Internal/LockingValidationInterceptor.cs @@ -84,13 +84,12 @@ public override Task CommandFailedAsync( private static void ValidateAndPrepare(DbCommand command, CommandEventData eventData) { - var lockOptions = LockContext.Current; - if (lockOptions is null) + if (!TryExtractLockOptions(command.CommandText, out var lockOptions)) return; if (eventData.Context?.Database.CurrentTransaction is null) - throw new InvalidOperationException( - "ForUpdate requires an active transaction. " + throw new LockingConfigurationException( + "ForUpdate/ForShare requires an active transaction. " + "Call BeginTransaction() before executing a locking query." ); @@ -98,11 +97,24 @@ private static void ValidateAndPrepare(DbCommand command, CommandEventData event if (provider is null) return; - var preSql = provider.RowLockGenerator.GeneratePreStatementSql(lockOptions); + var preSql = provider.RowLockGenerator.GeneratePreStatementSql(lockOptions!); if (preSql is not null && !command.CommandText.StartsWith(preSql, StringComparison.Ordinal)) command.CommandText = preSql + ";\n" + command.CommandText; } + private static bool TryExtractLockOptions(string commandText, out LockOptions? lockOptions) + { + lockOptions = null; + var prefixIndex = commandText.IndexOf(LockTagConstants.Prefix, StringComparison.Ordinal); + if (prefixIndex < 0) + return false; + + var tagEnd = commandText.IndexOf('\n', prefixIndex); + var tag = tagEnd < 0 ? commandText[prefixIndex..].TrimEnd() : commandText[prefixIndex..tagEnd].TrimEnd(); + + return LockTagConstants.TryParse(tag, out lockOptions); + } + private static DbDataReader WrapReader(DbDataReader reader, DbContext? context) { if (context is null) diff --git a/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs b/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs index 73b7b1f..57e25b4 100644 --- a/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs +++ b/src/EntityFrameworkCore.Locking/Internal/UnsafeShapeDetector.cs @@ -21,5 +21,42 @@ internal static void ThrowIfUnsafe(SelectExpression selectExpression) throw new LockingConfigurationException( "ForUpdate/ForShare is not compatible with split queries. Remove AsSplitQuery()." ); + + if (selectExpression.IsDistinct) + throw new LockingConfigurationException("ForUpdate/ForShare is not compatible with DISTINCT queries."); + + // Aggregate terminal ops (CountAsync, SumAsync, MaxAsync, MinAsync, LongCountAsync) produce + // a scalar aggregate function somewhere in the outer projection. Row-level locking a scalar is meaningless. + // EF Core wraps aggregates in CAST (SqlUnaryExpression) or COALESCE (SqlFunctionExpression), so we + // recursively walk each projection expression to find any aggregate SqlFunctionExpression. + // AnyAsync is safe: EF Core translates it to a scalar subquery with no outer aggregate function. + if (selectExpression.Projection.Any(p => ContainsAggregate(p.Expression))) + throw new LockingConfigurationException( + "ForUpdate/ForShare is not compatible with aggregate queries (CountAsync, SumAsync, MaxAsync, MinAsync, LongCountAsync)." + ); + + // GroupBy is not checked: ForUpdate requires T : class, so EF Core always translates + // GroupBy results into correlated subqueries — GroupBy never appears on the outer SELECT. } + + private static bool ContainsAggregate(SqlExpression expression) => + expression switch + { + SqlFunctionExpression f when _aggregateFunctionNames.Contains(f.Name) => true, + SqlFunctionExpression f => f.Arguments?.Any(ContainsAggregate) == true, + SqlUnaryExpression u => ContainsAggregate(u.Operand), + _ => false, + }; + + private static readonly System.Collections.Generic.HashSet _aggregateFunctionNames = new( + System.StringComparer.OrdinalIgnoreCase + ) + { + "COUNT", + "COUNT_BIG", + "SUM", + "MAX", + "MIN", + "AVG", + }; } diff --git a/tests/EntityFrameworkCore.Locking.MySql.Tests/ExceptionTranslationTests.cs b/tests/EntityFrameworkCore.Locking.MySql.Tests/ExceptionTranslationTests.cs index 8e33efd..efc1066 100644 --- a/tests/EntityFrameworkCore.Locking.MySql.Tests/ExceptionTranslationTests.cs +++ b/tests/EntityFrameworkCore.Locking.MySql.Tests/ExceptionTranslationTests.cs @@ -17,7 +17,7 @@ public void Translate_Deadlock_ReturnsDeadlockException() var ex = CreateMySqlException(1213); var result = _translator.Translate(ex); result.Should().BeOfType(); - result!.InnerException.Should().BeSameAs(ex); + result.InnerException.Should().BeSameAs(ex); } [Fact] @@ -26,7 +26,7 @@ public void Translate_LockTimeout_ReturnsLockTimeoutException() var ex = CreateMySqlException(1205); var result = _translator.Translate(ex); result.Should().BeOfType(); - result!.InnerException.Should().BeSameAs(ex); + result.InnerException.Should().BeSameAs(ex); } [Fact] @@ -36,7 +36,7 @@ public void Translate_NoWaitAbort_ReturnsLockTimeoutException() var ex = CreateMySqlException(3572); var result = _translator.Translate(ex); result.Should().BeOfType(); - result!.InnerException.Should().BeSameAs(ex); + result.InnerException.Should().BeSameAs(ex); } [Fact] diff --git a/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.FromSqlTests.cs b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.FromSqlTests.cs new file mode 100644 index 0000000..ae1dda5 --- /dev/null +++ b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.FromSqlTests.cs @@ -0,0 +1,47 @@ +using AwesomeAssertions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace EntityFrameworkCore.Locking.MySql.Tests; + +public partial class IntegrationTests +{ + [Fact] + public async Task ForUpdate_WhenFromSqlRaw_WhenInsideTransaction_ShouldExecuteSuccessfully() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var products = await ctx + .Products.FromSqlRaw("SELECT * FROM `Products`") + .Where(p => p.Id == id) + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_WhenFromSqlInterpolated_WhenInsideTransaction_ShouldExecuteSuccessfully() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var products = await ctx + .Products.FromSql($"SELECT * FROM `Products` WHERE `Id` = {id}") + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + + await tx.RollbackAsync(); + } +} diff --git a/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.LockModeTests.cs b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.LockModeTests.cs index 059ce50..588d9e0 100644 --- a/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.LockModeTests.cs +++ b/tests/EntityFrameworkCore.Locking.MySql.Tests/IntegrationTests.LockModeTests.cs @@ -17,7 +17,7 @@ public async Task ForShare_WithTransaction_ReturnsData() var row = await ctx.Products.Where(p => p.Id == id).ForShare().FirstOrDefaultAsync(); row.Should().NotBeNull(); - row!.Name.Should().Be("Share Me"); + row.Name.Should().Be("Share Me"); await tx.RollbackAsync(); } @@ -87,4 +87,28 @@ public async Task ForUpdate_WithTimeout_SucceedsOnUncontendedRow() row.Should().NotBeNull(); await tx.RollbackAsync(); } + + [Fact] + public async Task ForUpdate_WhenOrderByTakeLockBehaviors_WhenInsideTransaction_ShouldEmitCorrectSql() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (catId, _) = await SeedAsync(ctx, categoryName: "SqlCheck"); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var result = await ctx + .Products.Where(p => p.CategoryId == catId) + .OrderBy(p => p.Id) + .Take(1) + .ForUpdate(LockBehavior.NoWait) + .ToListAsync(); + + result.Should().HaveCount(1); + cap.LastCommand.Should().Contain("FOR UPDATE NOWAIT"); + + await tx.RollbackAsync(); + } + } } diff --git a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/ExceptionTranslationTests.cs b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/ExceptionTranslationTests.cs index f7c45f2..8f994da 100644 --- a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/ExceptionTranslationTests.cs +++ b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/ExceptionTranslationTests.cs @@ -17,7 +17,7 @@ public void Translate_Deadlock_ReturnsDeadlockException() var pgEx = CreatePostgresException("40P01"); var result = _translator.Translate(pgEx); result.Should().BeOfType(); - result!.InnerException.Should().BeSameAs(pgEx); + result.InnerException.Should().BeSameAs(pgEx); } [Fact] @@ -26,7 +26,7 @@ public void Translate_LockNotAvailable_ReturnsLockTimeoutException() var pgEx = CreatePostgresException("55P03"); var result = _translator.Translate(pgEx); result.Should().BeOfType(); - result!.InnerException.Should().BeSameAs(pgEx); + result.InnerException.Should().BeSameAs(pgEx); } [Fact] diff --git a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.FromSqlTests.cs b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.FromSqlTests.cs new file mode 100644 index 0000000..d446e2d --- /dev/null +++ b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.FromSqlTests.cs @@ -0,0 +1,48 @@ +using AwesomeAssertions; +using EntityFrameworkCore.Locking.Tests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace EntityFrameworkCore.Locking.PostgreSQL.Tests; + +public partial class IntegrationTests +{ + [Fact] + public async Task ForUpdate_WhenFromSqlRaw_WhenInsideTransaction_ShouldExecuteSuccessfully() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var products = await ctx + .Products.FromSqlRaw("""SELECT * FROM "Products" """) + .Where(p => p.Id == id) + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_WhenFromSqlInterpolated_WhenInsideTransaction_ShouldExecuteSuccessfully() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var products = await ctx + .Products.FromSql($"""SELECT * FROM "Products" WHERE "Id" = {id}""") + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + + await tx.RollbackAsync(); + } +} diff --git a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.LockModeTests.cs b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.LockModeTests.cs index c53a913..bad3413 100644 --- a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.LockModeTests.cs +++ b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.LockModeTests.cs @@ -19,7 +19,7 @@ public async Task ForShare_WithTransaction_ReturnsData() var product = await ctx.Products.Where(p => p.Id == id).ForShare().FirstOrDefaultAsync(); product.Should().NotBeNull(); - product!.Name.Should().Be("Share Me"); + product.Name.Should().Be("Share Me"); await tx.RollbackAsync(); } @@ -172,4 +172,28 @@ public async Task ForUpdate_NoWait_OnUncontendedRow_Succeeds() row.Should().NotBeNull(); await tx.RollbackAsync(); } + + [Fact] + public async Task ForUpdate_WhenOrderByTakeLockBehaviors_WhenInsideTransaction_ShouldEmitCorrectSql() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (catId, _) = await SeedAsync(ctx, categoryName: "SqlCheck"); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var result = await ctx + .Products.Where(p => p.CategoryId == catId) + .OrderBy(p => p.Id) + .Take(1) + .ForUpdate(LockBehavior.NoWait) + .ToListAsync(); + + result.Should().HaveCount(1); + cap.LastCommand.Should().Contain("FOR UPDATE NOWAIT"); + + await tx.RollbackAsync(); + } + } } diff --git a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.QueryShapeTests.cs b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.QueryShapeTests.cs index 84c1371..be8aa60 100644 --- a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.QueryShapeTests.cs +++ b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.QueryShapeTests.cs @@ -33,7 +33,7 @@ public async Task ForUpdate_WithThenInclude_LoadsNestedNavigation() .FirstOrDefaultAsync(); category.Should().NotBeNull(); - category!.Products.Should().HaveCount(2); + category.Products.Should().HaveCount(2); await tx.RollbackAsync(); } diff --git a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.QueryStringTests.cs b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.QueryStringTests.cs index 77c3f23..2a6c76f 100644 --- a/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.QueryStringTests.cs +++ b/tests/EntityFrameworkCore.Locking.PostgreSQL.Tests/IntegrationTests.QueryStringTests.cs @@ -143,7 +143,7 @@ public async Task ForUpdate_CapturedSql_ContainsForUpdate() { await using var ctx = CreateContext(); await ctx.Database.EnsureCreatedAsync(); - var (capture, captureCtx) = ( + var (_, captureCtx) = ( new EntityFrameworkCore.Locking.Tests.Infrastructure.SqlCapture(), CreateContextWithCapture() ); diff --git a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/ExceptionTranslationTests.cs b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/ExceptionTranslationTests.cs index 326814d..554bba3 100644 --- a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/ExceptionTranslationTests.cs +++ b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/ExceptionTranslationTests.cs @@ -17,7 +17,7 @@ public void Translate_Deadlock_ReturnsDeadlockException() var ex = CreateSqlException(1205); var result = _translator.Translate(ex); result.Should().BeOfType(); - result!.InnerException.Should().BeSameAs(ex); + result.InnerException.Should().BeSameAs(ex); } [Fact] @@ -26,7 +26,7 @@ public void Translate_LockTimeout_ReturnsLockTimeoutException() var ex = CreateSqlException(1222); var result = _translator.Translate(ex); result.Should().BeOfType(); - result!.InnerException.Should().BeSameAs(ex); + result.InnerException.Should().BeSameAs(ex); } [Fact] diff --git a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/Fixtures/SqlServerFixture.cs b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/Fixtures/SqlServerFixture.cs index eadd567..1a7a15f 100644 --- a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/Fixtures/SqlServerFixture.cs +++ b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/Fixtures/SqlServerFixture.cs @@ -1,3 +1,7 @@ +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; +using Microsoft.Data.SqlClient; using Testcontainers.MsSql; using Xunit; @@ -5,8 +9,17 @@ namespace EntityFrameworkCore.Locking.SqlServer.Tests.Fixtures; public sealed class SqlServerFixture : IAsyncLifetime { + // mcr.microsoft.com/mssql/server:2022-latest is AMD64-only and times out under Rosetta + // on Apple Silicon. Use azure-sql-edge which has a native ARM64 image and the same wire + // protocol. The MsSqlBuilder default readiness probe uses sqlcmd which is absent in + // azure-sql-edge, so we replace it with a TCP-then-login probe. private readonly MsSqlContainer _container = new MsSqlBuilder() - .WithImage("mcr.microsoft.com/mssql/server:2022-latest") + .WithImage("mcr.microsoft.com/azure-sql-edge:latest") + .WithWaitStrategy( + Wait.ForUnixContainer() + .UntilPortIsAvailable(MsSqlBuilder.MsSqlPort) + .AddCustomWaitStrategy(new WaitUntilLoginSucceeds()) + ) .Build(); public string ConnectionString => _container.GetConnectionString(); @@ -14,4 +27,22 @@ public sealed class SqlServerFixture : IAsyncLifetime public Task InitializeAsync() => _container.StartAsync(); public Task DisposeAsync() => _container.DisposeAsync().AsTask(); + + private sealed class WaitUntilLoginSucceeds : IWaitUntil + { + public async Task UntilAsync(IContainer container) + { + var mssql = (MsSqlContainer)container; + try + { + await using var conn = new SqlConnection(mssql.GetConnectionString()); + await conn.OpenAsync().ConfigureAwait(false); + return true; + } + catch + { + return false; + } + } + } } diff --git a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.FromSqlTests.cs b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.FromSqlTests.cs new file mode 100644 index 0000000..2d83136 --- /dev/null +++ b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.FromSqlTests.cs @@ -0,0 +1,48 @@ +using AwesomeAssertions; +using EntityFrameworkCore.Locking.Tests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace EntityFrameworkCore.Locking.SqlServer.Tests; + +public partial class IntegrationTests +{ + [Fact] + public async Task ForUpdate_WhenFromSqlRaw_WhenInsideTransaction_ShouldExecuteSuccessfully() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var products = await ctx + .Products.FromSqlRaw("SELECT * FROM [Products]") + .Where(p => p.Id == id) + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_WhenFromSqlInterpolated_WhenInsideTransaction_ShouldExecuteSuccessfully() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var products = await ctx + .Products.FromSql($"SELECT * FROM [Products] WHERE [Id] = {id}") + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + + await tx.RollbackAsync(); + } +} diff --git a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.LockModeTests.cs b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.LockModeTests.cs index bed7dcd..b6d7471 100644 --- a/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.LockModeTests.cs +++ b/tests/EntityFrameworkCore.Locking.SqlServer.Tests/IntegrationTests.LockModeTests.cs @@ -60,4 +60,30 @@ public async Task ForUpdate_SkipLocked_ThrowsLockingConfigurationException_WhenN row.Should().NotBeNull(); await tx.RollbackAsync(); } + + [Fact] + public async Task ForUpdate_WhenOrderByTakeLockBehaviors_WhenInsideTransaction_ShouldEmitCorrectSql() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (catId, _) = await SeedAsync(ctx, categoryName: "SqlCheck"); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var result = await ctx + .Products.Where(p => p.CategoryId == catId) + .OrderBy(p => p.Id) + .Take(1) + .ForUpdate(LockBehavior.NoWait) + .ToListAsync(); + + result.Should().HaveCount(1); + // SQL Server NoWait: SET LOCK_TIMEOUT 0 as pre-statement, UPDLOCK in FROM clause + cap.Commands.Should().Contain(c => c.Contains("SET LOCK_TIMEOUT 0")); + cap.LastCommand.Should().Contain("UPDLOCK"); + + await tx.RollbackAsync(); + } + } } diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AggregateTerminalsTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AggregateTerminalsTestsBase.cs new file mode 100644 index 0000000..695fb5f --- /dev/null +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AggregateTerminalsTestsBase.cs @@ -0,0 +1,89 @@ +using AwesomeAssertions; +using EntityFrameworkCore.Locking.Exceptions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace EntityFrameworkCore.Locking.Tests.Infrastructure; + +public abstract partial class IntegrationTestsBase +{ + [Fact] + public async Task ForUpdate_ThenCountAsync_WhenCondition_ShouldThrowLockingConfigurationException() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + Func act = async () => await ctx.Products.Where(p => p.Id == id).ForUpdate().CountAsync(); + + await act.Should().ThrowAsync(); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_ThenLongCountAsync_WhenCondition_ShouldThrowLockingConfigurationException() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + Func act = async () => await ctx.Products.Where(p => p.Id == id).ForUpdate().LongCountAsync(); + + await act.Should().ThrowAsync(); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_ThenSumAsync_WhenCondition_ShouldThrowLockingConfigurationException() + { + await using var ctx = CreateContext(); + await SeedAsync(ctx, price: 9.99m); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + Func act = async () => await ctx.Products.ForUpdate().SumAsync(p => p.Price); + + await act.Should().ThrowAsync(); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_ThenMaxAsync_WhenCondition_ShouldThrowLockingConfigurationException() + { + await using var ctx = CreateContext(); + await SeedAsync(ctx, price: 9.99m); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + Func act = async () => await ctx.Products.ForUpdate().MaxAsync(p => p.Price); + + await act.Should().ThrowAsync(); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_ThenMinAsync_WhenCondition_ShouldThrowLockingConfigurationException() + { + await using var ctx = CreateContext(); + await SeedAsync(ctx, price: 9.99m); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + Func act = async () => await ctx.Products.ForUpdate().MinAsync(p => p.Price); + + await act.Should().ThrowAsync(); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_ThenAnyAsync_WhenRowExists_ShouldReturnTrueWithoutThrowing() + { + // AnyAsync is safe: EF Core translates it to a scalar subquery without an outer aggregate + // function — the guard must NOT fire. + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + var exists = await ctx.Products.Where(p => p.Id == id).ForUpdate().AnyAsync(); + + exists.Should().BeTrue(); + await tx.RollbackAsync(); + } +} diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AsyncLocalLeakageTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AsyncLocalLeakageTestsBase.cs new file mode 100644 index 0000000..da500ec --- /dev/null +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.AsyncLocalLeakageTestsBase.cs @@ -0,0 +1,108 @@ +using AwesomeAssertions; +using EntityFrameworkCore.Locking.Exceptions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace EntityFrameworkCore.Locking.Tests.Infrastructure; + +public abstract partial class IntegrationTestsBase +{ + [Fact] + public async Task ForUpdate_WhenFollowedByPlainQuery_ShouldNotEmitLockOnSecondQuery() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + await ctx.Products.Where(p => p.Id == id).ForUpdate().FirstOrDefaultAsync(); + var lockedIdx = cap.Commands.Count - 1; + + await ctx.Products.Where(p => p.Id == id).FirstOrDefaultAsync(); + var plainIdx = cap.Commands.Count - 1; + + cap.Commands[lockedIdx].Should().Contain("__efcore_locking"); + cap.Commands[plainIdx].Should().NotContain("__efcore_locking"); + + await tx.RollbackAsync(); + } + } + + [Fact] + public async Task PlainQuery_WhenSurroundsLockedQuery_ShouldEmitLockOnlyForLockedQuery() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + await ctx.Products.Where(p => p.Id == id).FirstOrDefaultAsync(); + var firstIdx = cap.Commands.Count - 1; + + await ctx.Products.Where(p => p.Id == id).ForUpdate().FirstOrDefaultAsync(); + var secondIdx = cap.Commands.Count - 1; + + await ctx.Products.Where(p => p.Id == id).FirstOrDefaultAsync(); + var thirdIdx = cap.Commands.Count - 1; + + cap.Commands[firstIdx].Should().NotContain("__efcore_locking"); + cap.Commands[secondIdx].Should().Contain("__efcore_locking"); + cap.Commands[thirdIdx].Should().NotContain("__efcore_locking"); + + await tx.RollbackAsync(); + } + } + + [Fact] + public async Task ForUpdate_WhenThrowsDueToMissingTransaction_ThenPlainQuery_ShouldSucceedWithoutLock() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + await SeedAsync(ctx); + + // No transaction — ForUpdate must throw + Func badAct = async () => await ctx.Products.ForUpdate().FirstOrDefaultAsync(); + await badAct.Should().ThrowAsync(); + + // Subsequent plain query in its own transaction must succeed cleanly + await using var tx = await ctx.Database.BeginTransactionAsync(); + var products = await ctx.Products.ToListAsync(); + + products.Should().NotBeEmpty(); + cap.LastCommand.Should().NotContain("__efcore_locking"); + + await tx.RollbackAsync(); + } + } + + [Fact] + public async Task ForUpdate_WhenExecutedTwiceSequentially_ShouldUseOwnOptionsEachTime() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + // First: Wait behavior + await ctx.Products.Where(p => p.Id == id).ForUpdate().FirstOrDefaultAsync(); + var firstIdx = cap.Commands.Count - 1; + + // Second: NoWait behavior + await ctx.Products.Where(p => p.Id == id).ForUpdate(LockBehavior.NoWait).FirstOrDefaultAsync(); + var secondIdx = cap.Commands.Count - 1; + + // Tag format: __efcore_locking:{LockMode}:{LockBehavior}:{timeout_ms} + cap.Commands[firstIdx].Should().Contain("__efcore_locking:ForUpdate:Wait:"); + cap.Commands[secondIdx].Should().Contain("__efcore_locking:ForUpdate:NoWait:"); + + await tx.RollbackAsync(); + } + } +} diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs index 001b166..320f9d4 100644 --- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTests.QueryShapeTestsBase.cs @@ -23,7 +23,7 @@ public async Task ForUpdate_WithInclude_LoadsNavigationAndLocks() .FirstOrDefaultAsync(); product.Should().NotBeNull(); - product!.Category.Name.Should().Be("Nav"); + product.Category.Name.Should().Be("Nav"); await tx.RollbackAsync(); } @@ -51,7 +51,7 @@ public async Task ForUpdate_WithIncludeCollection_LoadsOrderLines() .FirstOrDefaultAsync(); product.Should().NotBeNull(); - product!.OrderLines.Should().HaveCount(3); + product.OrderLines.Should().HaveCount(3); await tx.RollbackAsync(); } @@ -79,7 +79,7 @@ public async Task ForUpdate_WithMultipleIncludes_LocksRootTable() .FirstOrDefaultAsync(); product.Should().NotBeNull(); - product!.Category.Name.Should().Be("Multi"); + product.Category.Name.Should().Be("Multi"); product.OrderLines.Should().HaveCount(1); await tx.RollbackAsync(); } @@ -181,7 +181,69 @@ public async Task ForUpdate_WithAsNoTracking_ExecutesSuccessfully() var product = await ctx.Products.AsNoTracking().Where(p => p.Id == id).ForUpdate().FirstOrDefaultAsync(); product.Should().NotBeNull(); - ctx.Entry(product!).State.Should().Be(EntityState.Detached); + ctx.Entry(product).State.Should().Be(EntityState.Detached); + await tx.RollbackAsync(); + } + + // --- Subquery shapes --- + + [Fact] + public async Task ForUpdate_WithContainsInWhere_LocksMatchingRows() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + var ids = new List { id }; + var products = await ctx.Products.Where(p => ids.Contains(p.Id)).ForUpdate().ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_WithCorrelatedSubqueryInWhere_LocksMatchingRows() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + ctx.OrderLines.Add( + new OrderLine + { + ProductId = id, + Quantity = 1, + UnitPrice = 5m, + } + ); + await ctx.SaveChangesAsync(); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + // Correlated subquery in WHERE: lock applies to the outer SELECT's rows + var products = await ctx + .Products.Where(p => ctx.OrderLines.Any(ol => ol.ProductId == p.Id)) + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_WithChainedWhere_LocksMatchingRows() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx, price: 5m); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + var product = await ctx + .Products.Where(p => p.Price > 1m) + .Where(p => p.Id == id) + .ForUpdate() + .FirstOrDefaultAsync(); + + product.Should().NotBeNull(); + product.Id.Should().Be(id); await tx.RollbackAsync(); } @@ -203,4 +265,130 @@ await ctx await act.Should().ThrowAsync(); await tx.RollbackAsync(); } + + [Fact] + public async Task ForUpdate_DistinctQuery_ThrowsLockingConfigurationException() + { + await using var ctx = CreateContext(); + await using var tx = await ctx.Database.BeginTransactionAsync(); + + Func act = async () => await ctx.Products.Distinct().ForUpdate().ToListAsync(); + + await act.Should().ThrowAsync(); + await tx.RollbackAsync(); + } + + // --- Set operations: Concat --- + + [Fact] + public async Task ForUpdate_WhenConcatQuery_ShouldThrowLockingConfigurationException() + { + await using var ctx = CreateContext(); + await using var tx = await ctx.Database.BeginTransactionAsync(); + + // Concat → UNION ALL → SetOperationBase → must be rejected + Func act = async () => + await ctx + .Products.Where(p => p.Id == 1) + .Concat(ctx.Products.Where(p => p.Id == 2)) + .ForUpdate() + .ToListAsync(); + + await act.Should().ThrowAsync(); + await tx.RollbackAsync(); + } + + // --- Explicit join shapes --- + + [Fact] + public async Task ForUpdate_WithInnerJoinQuerySyntax_WhenCondition_ShouldReturnLockedRows() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx, categoryName: "JoinTest"); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var products = await ( + from p in ctx.Products + join c in ctx.Categories on p.CategoryId equals c.Id + where c.Name == "JoinTest" + select p + ) + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + await tx.RollbackAsync(); + } + + [Fact] + public async Task ForUpdate_WithSelectMany_WhenCondition_ShouldReturnLockedRows() + { + await using var ctx = CreateContext(); + var (_, id) = await SeedAsync(ctx); + ctx.OrderLines.Add( + new OrderLine + { + ProductId = id, + Quantity = 1, + UnitPrice = 1m, + } + ); + await ctx.SaveChangesAsync(); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + var products = await ctx + .Products.SelectMany(p => ctx.OrderLines.Where(ol => ol.ProductId == p.Id), (p, _) => p) + .Where(p => p.Id == id) + .ForUpdate() + .ToListAsync(); + + products.Should().HaveCount(1); + products[0].Id.Should().Be(id); + await tx.RollbackAsync(); + } + + // --- Tags interaction --- + + [Fact] + public async Task ForUpdate_WhenTagWithBeforeLock_ShouldPreserveUserTagInExecutedSql() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + await ctx.Products.Where(p => p.Id == id).TagWith("user-tag-before").ForUpdate().FirstOrDefaultAsync(); + + cap.LastCommand.Should().NotBeNull(); + cap.LastCommand!.Should().Contain("user-tag-before"); + cap.LastCommand.Should().Contain("__efcore_locking"); + + await tx.RollbackAsync(); + } + } + + [Fact] + public async Task ForUpdate_WhenTagWithAfterLock_ShouldPreserveUserTagInExecutedSql() + { + var (ctx, cap) = CreateContextWithCapture(); + await using (ctx) + { + var (_, id) = await SeedAsync(ctx); + + await using var tx = await ctx.Database.BeginTransactionAsync(); + + await ctx.Products.Where(p => p.Id == id).ForUpdate().TagWith("user-tag-after").FirstOrDefaultAsync(); + + cap.LastCommand.Should().NotBeNull(); + cap.LastCommand!.Should().Contain("user-tag-after"); + cap.LastCommand.Should().Contain("__efcore_locking"); + + await tx.RollbackAsync(); + } + } } diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTestsBase.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTestsBase.cs index bc726b0..687f8a1 100644 --- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTestsBase.cs +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/IntegrationTestsBase.cs @@ -1,4 +1,5 @@ using AwesomeAssertions; +using EntityFrameworkCore.Locking.Exceptions; using Microsoft.EntityFrameworkCore; using Xunit; @@ -55,17 +56,17 @@ public async Task ForUpdate_WithTransaction_ExecutesSuccessfully() var product = await ctx.Products.Where(p => p.Id == id).ForUpdate().FirstOrDefaultAsync(); product.Should().NotBeNull(); - product!.Id.Should().Be(id); + product.Id.Should().Be(id); await tx.RollbackAsync(); } [Fact] - public async Task ForUpdate_WithoutTransaction_ThrowsInvalidOperationException() + public async Task ForUpdate_WithoutTransaction_ThrowsLockingConfigurationException() { await using var ctx = CreateContext(); Func act = async () => await ctx.Products.Where(p => p.Id == 1).ForUpdate().FirstOrDefaultAsync(); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); } [Fact] diff --git a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/SqlCapture.cs b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/SqlCapture.cs index 048f379..7e07434 100644 --- a/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/SqlCapture.cs +++ b/tests/EntityFrameworkCore.Locking.Tests.Infrastructure/SqlCapture.cs @@ -53,5 +53,22 @@ public override ValueTask ReaderExecutedAsync( return new ValueTask(result); } + public override int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result) + { + _commands.Add(command.CommandText); + return result; + } + + public override ValueTask NonQueryExecutedAsync( + DbCommand command, + CommandExecutedEventData eventData, + int result, + CancellationToken cancellationToken = default + ) + { + _commands.Add(command.CommandText); + return new ValueTask(result); + } + public void Clear() => _commands.Clear(); } diff --git a/tests/EntityFrameworkCore.Locking.Tests/DistributedLockUnitTests.cs b/tests/EntityFrameworkCore.Locking.Tests/DistributedLockUnitTests.cs index 551127f..739cf5e 100644 --- a/tests/EntityFrameworkCore.Locking.Tests/DistributedLockUnitTests.cs +++ b/tests/EntityFrameworkCore.Locking.Tests/DistributedLockUnitTests.cs @@ -31,25 +31,27 @@ public void LockAlreadyHeldException_InheritsLockingException() // --- Key validation --- [Fact] - public async Task AcquireDistributedLockAsync_NullKey_ThrowsArgumentException() + public async Task AcquireDistributedLockAsync_NullKey_ThrowsLockingConfigurationException() { await using var ctx = CreateContext(); - await Assert.ThrowsAsync(() => ctx.Database.AcquireDistributedLockAsync(null!)); + await Assert.ThrowsAsync(() => ctx.Database.AcquireDistributedLockAsync(null!)); } [Fact] - public async Task AcquireDistributedLockAsync_EmptyKey_ThrowsArgumentException() + public async Task AcquireDistributedLockAsync_EmptyKey_ThrowsLockingConfigurationException() { await using var ctx = CreateContext(); - await Assert.ThrowsAsync(() => ctx.Database.AcquireDistributedLockAsync("")); + await Assert.ThrowsAsync(() => ctx.Database.AcquireDistributedLockAsync("")); } [Fact] - public async Task AcquireDistributedLockAsync_KeyTooLong_ThrowsArgumentException() + public async Task AcquireDistributedLockAsync_KeyTooLong_ThrowsLockingConfigurationException() { await using var ctx = CreateContext(); var longKey = new string('a', 256); - await Assert.ThrowsAsync(() => ctx.Database.AcquireDistributedLockAsync(longKey)); + await Assert.ThrowsAsync(() => + ctx.Database.AcquireDistributedLockAsync(longKey) + ); } [Fact] @@ -125,7 +127,7 @@ public async Task TryAcquireDistributedLockAsync_FreeKey_ReturnsHandle() await using var ctx = CreateContext(); var handle = await ctx.Database.TryAcquireDistributedLockAsync("free"); handle.Should().NotBeNull(); - await handle!.DisposeAsync(); + await handle.DisposeAsync(); } // --- Factory --- @@ -149,13 +151,8 @@ private static FakeDbContext CreateContext() internal sealed class FakeDbContext : DbContext { - private readonly FakeDbConnection _connection; - public FakeDbContext(DbContextOptions options, FakeDbConnection connection) - : base(options) - { - _connection = connection; - } + : base(options) { } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { } } @@ -194,7 +191,7 @@ internal sealed class FakeLockingProvider : ILockingProvider public ILockSqlGenerator RowLockGenerator { get; } = new FakeLockSqlGenerator(); public string ProviderName => "Fake"; public IExceptionTranslator ExceptionTranslator { get; } = new FakeExceptionTranslator(); - public IAdvisoryLockProvider? AdvisoryLockProvider => _advisory; + public IAdvisoryLockProvider AdvisoryLockProvider => _advisory; } internal sealed class FakeLockSqlGenerator : ILockSqlGenerator diff --git a/tests/EntityFrameworkCore.Locking.Tests/LockContextTests.cs b/tests/EntityFrameworkCore.Locking.Tests/LockContextTests.cs index 50f1c5a..9a130b7 100644 --- a/tests/EntityFrameworkCore.Locking.Tests/LockContextTests.cs +++ b/tests/EntityFrameworkCore.Locking.Tests/LockContextTests.cs @@ -27,10 +27,9 @@ public async Task Current_IsIsolatedAcrossAsyncContexts() { LockContext.Current = new LockOptions { Mode = LockMode.ForUpdate }; - LockOptions? otherContextValue = null; await Task.Run(() => { - otherContextValue = LockContext.Current; + _ = LockContext.Current; }); // AsyncLocal flows DOWN into child tasks but changes in child don't affect parent diff --git a/tests/EntityFrameworkCore.Locking.Tests/LockTagConstantsTests.cs b/tests/EntityFrameworkCore.Locking.Tests/LockTagConstantsTests.cs new file mode 100644 index 0000000..967de00 --- /dev/null +++ b/tests/EntityFrameworkCore.Locking.Tests/LockTagConstantsTests.cs @@ -0,0 +1,61 @@ +using AwesomeAssertions; +using EntityFrameworkCore.Locking.Internal; +using Xunit; + +namespace EntityFrameworkCore.Locking.Tests; + +public class LockTagConstantsTests +{ + [Theory] + [InlineData("__efcore_locking:ForUpdate:Wait:", LockMode.ForUpdate, LockBehavior.Wait, null)] + [InlineData("__efcore_locking:ForShare:SkipLocked:", LockMode.ForShare, LockBehavior.SkipLocked, null)] + [InlineData("__efcore_locking:ForUpdate:NoWait:", LockMode.ForUpdate, LockBehavior.NoWait, null)] + [InlineData("__efcore_locking:ForUpdate:Wait:500", LockMode.ForUpdate, LockBehavior.Wait, 500.0)] + [InlineData("__efcore_locking:ForUpdate:Wait:1000.5", LockMode.ForUpdate, LockBehavior.Wait, 1000.5)] + public void TryParse_ValidTag_ReturnsTrue(string tag, LockMode mode, LockBehavior behavior, double? timeoutMs) + { + var result = LockTagConstants.TryParse(tag, out var options); + result.Should().BeTrue(); + options.Should().NotBeNull(); + options.Mode.Should().Be(mode); + options.Behavior.Should().Be(behavior); + if (timeoutMs.HasValue) + options.Timeout.Should().Be(TimeSpan.FromMilliseconds(timeoutMs.Value)); + else + options.Timeout.Should().BeNull(); + } + + [Theory] + [InlineData("")] + [InlineData("not_a_lock_tag")] + [InlineData("__efcore_locking:")] + [InlineData("__efcore_locking:ForUpdate")] + [InlineData("__efcore_locking:ForUpdate:Wait")] + [InlineData("__efcore_locking:InvalidMode:Wait:")] + [InlineData("__efcore_locking:ForUpdate:Wait:NaN")] + [InlineData("__efcore_locking:ForUpdate:Wait:Infinity")] + [InlineData("__efcore_locking:ForUpdate:Wait:-1")] + [InlineData("__efcore_locking:ForUpdate:Wait:notanumber")] + public void TryParse_InvalidTag_ReturnsFalse(string tag) + { + var result = LockTagConstants.TryParse(tag, out var options); + result.Should().BeFalse(); + options.Should().BeNull(); + } + + [Fact] + public void BuildTag_ThenParse_RoundTrips() + { + var original = new LockOptions + { + Mode = LockMode.ForUpdate, + Behavior = LockBehavior.Wait, + Timeout = TimeSpan.FromMilliseconds(250), + }; + var tag = LockTagConstants.BuildTag(original); + LockTagConstants.TryParse(tag, out var parsed).Should().BeTrue(); + parsed!.Mode.Should().Be(original.Mode); + parsed.Behavior.Should().Be(original.Behavior); + parsed.Timeout.Should().Be(original.Timeout); + } +}