Skip to content

Commit 81a4f05

Browse files
authored
Merge pull request #17 from EFNext/feature/execute-update
Support EF Core ExecuteUpdate via IRewritableQueryable
2 parents 13911f0 + 1a856c3 commit 81a4f05

19 files changed

+701
-15
lines changed

docs/guide/ef-core-integration.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,38 @@ Supported chain-preserving operations:
152152
- `IgnoreQueryFilters()`, `IgnoreAutoIncludes()`
153153
- `TagWith(tag)`, `TagWithCallSite()`
154154

155+
## Bulk Updates with ExecuteUpdate
156+
157+
With the `ExpressiveSharp.EntityFrameworkCore.RelationalExtensions` package, you can use modern C# syntax inside `ExecuteUpdate` / `ExecuteUpdateAsync`:
158+
159+
```csharp
160+
// Requires: .UseExpressives(o => o.UseRelationalExtensions())
161+
ctx.Orders
162+
.ExecuteUpdate(s => s
163+
.SetProperty(o => o.Tag, o => o.Price switch
164+
{
165+
>= 100 => "Premium",
166+
>= 50 => "Standard",
167+
_ => "Budget"
168+
}));
169+
170+
// Async variant
171+
await ctx.Orders
172+
.ExecuteUpdateAsync(s => s.SetProperty(
173+
o => o.Tag,
174+
o => o.Customer?.Name ?? "Unknown"));
175+
```
176+
177+
Switch expressions and null-conditional operators inside `SetProperty` value lambdas are normally rejected by the C# compiler in expression tree contexts. The source generator converts them to `CASE WHEN` and `COALESCE` SQL expressions.
178+
179+
::: info
180+
`ExecuteDelete` works on `IRewritableQueryable<T>` / `ExpressiveDbSet<T>` without any additional setup — it has no lambda parameter, so no interception is needed.
181+
:::
182+
183+
::: warning
184+
This feature is available on EF Core 8 and 9. EF Core 10 changed the `ExecuteUpdate` API to use `Action<UpdateSettersBuilder<T>>`, which natively supports modern C# syntax in the outer lambda. For inner `SetProperty` value expressions on EF Core 10, use `ExpressionPolyfill.Create()`.
185+
:::
186+
155187
## Plugin Architecture
156188

157189
`UseExpressives()` accepts an optional configuration callback for registering plugins:
@@ -193,7 +225,7 @@ The built-in `RelationalExtensions` package (for window functions) uses this plu
193225
|---------|-------------|
194226
| [`ExpressiveSharp`](https://www.nuget.org/packages/ExpressiveSharp/) | Core runtime -- `[Expressive]` attribute, source generator, expression expansion, transformers |
195227
| [`ExpressiveSharp.EntityFrameworkCore`](https://www.nuget.org/packages/ExpressiveSharp.EntityFrameworkCore/) | EF Core integration -- `UseExpressives()`, `ExpressiveDbSet<T>`, Include/ThenInclude, async methods, analyzers and code fixes |
196-
| [`ExpressiveSharp.EntityFrameworkCore.RelationalExtensions`](https://www.nuget.org/packages/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/) | SQL window functions -- ROW_NUMBER, RANK, DENSE_RANK, NTILE (experimental) |
228+
| [`ExpressiveSharp.EntityFrameworkCore.RelationalExtensions`](https://www.nuget.org/packages/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/) | Relational extensions -- `ExecuteUpdate`/`ExecuteUpdateAsync` with modern syntax, SQL window functions (ROW_NUMBER, RANK, DENSE_RANK, NTILE) |
197229

198230
::: info
199231
The `ExpressiveSharp.EntityFrameworkCore` package bundles Roslyn analyzers and code fixes from `ExpressiveSharp.EntityFrameworkCore.CodeFixers`. These provide compile-time diagnostics and IDE quick-fix actions for common issues like missing `[Expressive]` attributes.

docs/guide/rewritable-queryable.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,36 @@ var orders = ctx.Set<Order>()
140140
.ToList();
141141
```
142142

143+
## EF Core: Bulk Updates with ExecuteUpdate
144+
145+
::: info
146+
Requires the `ExpressiveSharp.EntityFrameworkCore.RelationalExtensions` package and `.UseExpressives(o => o.UseRelationalExtensions())` configuration. Available on EF Core 8 and 9. On EF Core 10+, `ExecuteUpdate` natively accepts delegates — use `ExpressionPolyfill.Create()` for modern syntax in individual `SetProperty` value expressions.
147+
:::
148+
149+
`ExecuteUpdate` and `ExecuteUpdateAsync` are supported on `IRewritableQueryable<T>`, enabling modern C# syntax inside `SetProperty` value expressions — which is normally impossible in expression trees:
150+
151+
```csharp
152+
ctx.ExpressiveSet<Product>()
153+
.ExecuteUpdate(s => s
154+
.SetProperty(p => p.Tag, p => p.Price switch
155+
{
156+
> 100 => "premium",
157+
> 50 => "standard",
158+
_ => "budget"
159+
})
160+
.SetProperty(p => p.Category, p => p.Category ?? "Uncategorized"));
161+
```
162+
163+
This generates a single SQL `UPDATE` with `CASE WHEN` and `COALESCE` expressions — no entity loading required.
164+
165+
`ExecuteDelete` works out of the box on `IRewritableQueryable<T>` without any stubs (it has no lambda parameter):
166+
167+
```csharp
168+
ctx.ExpressiveSet<Product>()
169+
.Where(p => p.Price switch { < 10 => true, _ => false })
170+
.ExecuteDelete();
171+
```
172+
143173
## IAsyncEnumerable Support
144174

145175
`IRewritableQueryable<T>` supports `AsAsyncEnumerable()` for streaming results:
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#if !NET10_0_OR_GREATER
2+
using System.ComponentModel;
3+
using System.Diagnostics;
4+
using ExpressiveSharp;
5+
using Microsoft.EntityFrameworkCore.Query;
6+
7+
// ReSharper disable once CheckNamespace — intentionally in Microsoft.EntityFrameworkCore for discoverability
8+
namespace Microsoft.EntityFrameworkCore;
9+
10+
/// <summary>
11+
/// Extension methods on <see cref="IRewritableQueryable{T}"/> for EF Core bulk update operations.
12+
/// These stubs are intercepted by the ExpressiveSharp source generator via <see cref="PolyfillTargetAttribute"/>
13+
/// to forward to the appropriate EF Core ExecuteUpdate method.
14+
/// </summary>
15+
/// <remarks>
16+
/// Only available on EF Core 8/9. In EF Core 10+, <c>ExecuteUpdate</c> uses <c>Action&lt;UpdateSettersBuilder&lt;T&gt;&gt;</c>
17+
/// which natively supports modern C# syntax in the outer lambda. For inner <c>SetProperty</c> value expressions,
18+
/// use <c>ExpressionPolyfill.Create()</c> to enable modern C# syntax.
19+
/// </remarks>
20+
[EditorBrowsable(EditorBrowsableState.Never)]
21+
public static class RewritableQueryableRelationalExtensions
22+
{
23+
private const string InterceptedMessage =
24+
"This method must be intercepted by the ExpressiveSharp source generator. " +
25+
"Ensure the generator package is installed and the InterceptorsNamespaces MSBuild property is configured.";
26+
27+
// ── Bulk update methods (intercepted) ────────────────────────────────
28+
// EF Core 8: ExecuteUpdate lives on RelationalQueryableExtensions (Relational package)
29+
// EF Core 9: ExecuteUpdate moved to EntityFrameworkQueryableExtensions (Core package)
30+
31+
#if NET9_0
32+
[PolyfillTarget(typeof(EntityFrameworkQueryableExtensions))]
33+
#else
34+
[PolyfillTarget(typeof(RelationalQueryableExtensions))]
35+
#endif
36+
[EditorBrowsable(EditorBrowsableState.Never)]
37+
public static int ExecuteUpdate<TSource>(
38+
this IRewritableQueryable<TSource> source,
39+
Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>> setPropertyCalls)
40+
where TSource : class
41+
=> throw new UnreachableException(InterceptedMessage);
42+
43+
#if NET9_0
44+
[PolyfillTarget(typeof(EntityFrameworkQueryableExtensions))]
45+
#else
46+
[PolyfillTarget(typeof(RelationalQueryableExtensions))]
47+
#endif
48+
[EditorBrowsable(EditorBrowsableState.Never)]
49+
public static Task<int> ExecuteUpdateAsync<TSource>(
50+
this IRewritableQueryable<TSource> source,
51+
Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>> setPropertyCalls,
52+
CancellationToken cancellationToken = default)
53+
where TSource : class
54+
=> throw new UnreachableException(InterceptedMessage);
55+
}
56+
#endif

src/ExpressiveSharp.Generator/Emitter/ReflectionFieldCache.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,22 @@ public string EnsureMethodInfo(IMethodSymbol method)
6464
var paramCount = originalDef.Parameters.Length;
6565
var typeArgs = string.Join(", ", method.TypeArguments.Select(t =>
6666
$"typeof({ResolveTypeFqn(t)})"));
67-
return $"global::System.Linq.Enumerable.First(global::System.Linq.Enumerable.Where(typeof({typeFqn}).GetMethods({flags}), m => m.Name == \"{method.Name}\" && m.IsGenericMethodDefinition && m.GetGenericArguments().Length == {genericArity} && m.GetParameters().Length == {paramCount})).MakeGenericMethod({typeArgs})";
67+
68+
// Disambiguate overloads that share name, generic arity, and parameter count
69+
// (e.g., SetProperty<P>(Func<T,P>, P) vs SetProperty<P>(Func<T,P>, Func<T,P>))
70+
// by checking whether each parameter is a generic type or a type parameter.
71+
var paramChecksBuilder = new System.Text.StringBuilder();
72+
for (int i = 0; i < originalDef.Parameters.Length; i++)
73+
{
74+
var paramType = originalDef.Parameters[i].Type;
75+
if (paramType is ITypeParameterSymbol)
76+
paramChecksBuilder.Append($" && m.GetParameters()[{i}].ParameterType.IsGenericParameter && !m.GetParameters()[{i}].ParameterType.IsGenericType");
77+
else if (paramType is INamedTypeSymbol { IsGenericType: true })
78+
paramChecksBuilder.Append($" && m.GetParameters()[{i}].ParameterType.IsGenericType && !m.GetParameters()[{i}].ParameterType.IsGenericParameter");
79+
}
80+
var paramChecks = paramChecksBuilder.ToString();
81+
82+
return $"global::System.Linq.Enumerable.First(global::System.Linq.Enumerable.Where(typeof({typeFqn}).GetMethods({flags}), m => m.Name == \"{method.Name}\" && m.IsGenericMethodDefinition && m.GetGenericArguments().Length == {genericArity} && m.GetParameters().Length == {paramCount}{paramChecks})).MakeGenericMethod({typeArgs})";
6883
}
6984
else
7085
{
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
#if !NET10_0_OR_GREATER
2+
using ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.Tests.Models;
3+
using Microsoft.Data.Sqlite;
4+
using Microsoft.EntityFrameworkCore;
5+
6+
namespace ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.Tests;
7+
8+
/// <summary>
9+
/// End-to-end integration tests for ExecuteUpdate via IRewritableQueryable.
10+
/// These prove that modern C# syntax (null-conditional, switch expressions)
11+
/// inside SetProperty value expressions works with real SQL execution — functionality
12+
/// that is impossible with normal C# expression trees.
13+
/// </summary>
14+
[TestClass]
15+
public class ExecuteUpdateIntegrationTests
16+
{
17+
private SqliteConnection _connection = null!;
18+
19+
[TestInitialize]
20+
public void Setup()
21+
{
22+
_connection = new SqliteConnection("Data Source=:memory:");
23+
_connection.Open();
24+
}
25+
26+
[TestCleanup]
27+
public void Cleanup()
28+
{
29+
_connection.Dispose();
30+
}
31+
32+
private ExecuteUpdateTestDbContext CreateContext()
33+
{
34+
var options = new DbContextOptionsBuilder<ExecuteUpdateTestDbContext>()
35+
.UseSqlite(_connection)
36+
.UseExpressives(o => o.UseRelationalExtensions())
37+
.Options;
38+
var ctx = new ExecuteUpdateTestDbContext(options);
39+
ctx.Database.EnsureCreated();
40+
return ctx;
41+
}
42+
43+
private static void SeedProducts(ExecuteUpdateTestDbContext ctx)
44+
{
45+
ctx.Products.AddRange(
46+
new Product { Id = 1, Name = "Widget", Category = "A", Tag = "", Price = 150, Quantity = 10 },
47+
new Product { Id = 2, Name = "Gadget", Category = "B", Tag = "", Price = 75, Quantity = 5 },
48+
new Product { Id = 3, Name = "Doohickey", Category = null, Tag = "", Price = 30, Quantity = 20 });
49+
ctx.SaveChanges();
50+
}
51+
52+
/// <summary>
53+
/// Basic test: verify the generator intercepts ExecuteUpdate and forwards to EF Core.
54+
/// </summary>
55+
[TestMethod]
56+
public void ExecuteUpdate_BasicConstant_Works()
57+
{
58+
using var ctx = CreateContext();
59+
SeedProducts(ctx);
60+
61+
var affected = ctx.ExpressiveProducts
62+
.ExecuteUpdate(s => s.SetProperty(p => p.Tag, "basic"));
63+
64+
Assert.AreEqual(3, affected);
65+
// ExecuteUpdate bypasses change tracker — use AsNoTracking to see actual DB state
66+
var products = ctx.Products.AsNoTracking().OrderBy(p => p.Id).ToList();
67+
Assert.AreEqual("basic", products[0].Tag);
68+
Assert.AreEqual("basic", products[1].Tag);
69+
Assert.AreEqual("basic", products[2].Tag);
70+
}
71+
72+
/// <summary>
73+
/// Proves new capability: switch expression inside SetProperty value lambda.
74+
/// <c>o.Price switch { > 100 => "premium", > 50 => "standard", _ => "budget" }</c>
75+
/// is impossible in a normal C# expression tree context.
76+
/// </summary>
77+
[TestMethod]
78+
public void ExecuteUpdate_SwitchExpression_TranslatesToSql()
79+
{
80+
using var ctx = CreateContext();
81+
SeedProducts(ctx);
82+
83+
ctx.ExpressiveProducts
84+
.ExecuteUpdate(s => s.SetProperty(
85+
p => p.Tag,
86+
p => p.Price switch
87+
{
88+
> 100 => "premium",
89+
> 50 => "standard",
90+
_ => "budget"
91+
}));
92+
93+
var products = ctx.Products.AsNoTracking().OrderBy(p => p.Id).ToList();
94+
Assert.AreEqual("premium", products[0].Tag); // Price=150
95+
Assert.AreEqual("standard", products[1].Tag); // Price=75
96+
Assert.AreEqual("budget", products[2].Tag); // Price=30
97+
}
98+
99+
/// <summary>
100+
/// Proves new capability: null-coalescing operator inside SetProperty value lambda.
101+
/// </summary>
102+
[TestMethod]
103+
public void ExecuteUpdate_NullCoalescing_TranslatesToSql()
104+
{
105+
using var ctx = CreateContext();
106+
SeedProducts(ctx);
107+
108+
ctx.ExpressiveProducts
109+
.ExecuteUpdate(s => s.SetProperty(
110+
p => p.Tag,
111+
p => p.Category ?? "UNKNOWN"));
112+
113+
var products = ctx.Products.AsNoTracking().OrderBy(p => p.Id).ToList();
114+
Assert.AreEqual("A", products[0].Tag); // Category="A"
115+
Assert.AreEqual("B", products[1].Tag); // Category="B"
116+
Assert.AreEqual("UNKNOWN", products[2].Tag); // Category=null
117+
}
118+
119+
/// <summary>
120+
/// Proves that multiple SetProperty calls with modern C# syntax work together,
121+
/// producing multiple SET clauses in a single SQL UPDATE statement.
122+
/// </summary>
123+
[TestMethod]
124+
public void ExecuteUpdate_MultipleProperties_WithModernSyntax()
125+
{
126+
using var ctx = CreateContext();
127+
SeedProducts(ctx);
128+
129+
ctx.ExpressiveProducts
130+
.ExecuteUpdate(s => s
131+
.SetProperty(p => p.Tag, p => p.Price switch
132+
{
133+
> 100 => "expensive",
134+
_ => "moderate"
135+
})
136+
.SetProperty(p => p.Category, "updated"));
137+
138+
var products = ctx.Products.AsNoTracking().OrderBy(p => p.Id).ToList();
139+
Assert.AreEqual("expensive", products[0].Tag); // Price=150
140+
Assert.AreEqual("updated", products[0].Category);
141+
Assert.AreEqual("moderate", products[1].Tag); // Price=75
142+
Assert.AreEqual("updated", products[1].Category);
143+
Assert.AreEqual("moderate", products[2].Tag); // Price=30
144+
Assert.AreEqual("updated", products[2].Category);
145+
}
146+
147+
/// <summary>
148+
/// Proves async variant works end-to-end with modern C# syntax.
149+
/// </summary>
150+
[TestMethod]
151+
public async Task ExecuteUpdateAsync_SwitchExpression_TranslatesToSql()
152+
{
153+
using var ctx = CreateContext();
154+
SeedProducts(ctx);
155+
156+
await ctx.ExpressiveProducts
157+
.ExecuteUpdateAsync(s => s.SetProperty(
158+
p => p.Tag,
159+
p => p.Price switch
160+
{
161+
> 100 => "premium",
162+
> 50 => "standard",
163+
_ => "budget"
164+
}));
165+
166+
var products = await ctx.Products.AsNoTracking().OrderBy(p => p.Id).ToListAsync();
167+
Assert.AreEqual("premium", products[0].Tag);
168+
Assert.AreEqual("standard", products[1].Tag);
169+
Assert.AreEqual("budget", products[2].Tag);
170+
}
171+
}
172+
#endif
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.Tests.Models;
4+
5+
public class Product
6+
{
7+
public int Id { get; set; }
8+
public string Name { get; set; } = "";
9+
public string? Category { get; set; }
10+
public string Tag { get; set; } = "";
11+
public double Price { get; set; }
12+
public int Quantity { get; set; }
13+
}
14+
15+
public class ExecuteUpdateTestDbContext : DbContext
16+
{
17+
public DbSet<Product> Products => Set<Product>();
18+
public ExpressiveDbSet<Product> ExpressiveProducts => this.ExpressiveSet<Product>();
19+
20+
public ExecuteUpdateTestDbContext(DbContextOptions<ExecuteUpdateTestDbContext> options) : base(options)
21+
{
22+
}
23+
24+
protected override void OnModelCreating(ModelBuilder modelBuilder)
25+
{
26+
modelBuilder.Entity<Product>(entity =>
27+
{
28+
entity.HasKey(e => e.Id);
29+
});
30+
}
31+
}

0 commit comments

Comments
 (0)