Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -372,4 +372,6 @@ ReadmeSample.db
# VitePress docs
docs/node_modules/
docs/.vitepress/cache/
docs/.vitepress/dist/
docs/.vitepress/dist/
# Worktrees
.worktrees/
34 changes: 33 additions & 1 deletion docs/guide/ef-core-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,38 @@ Supported chain-preserving operations:
- `IgnoreQueryFilters()`, `IgnoreAutoIncludes()`
- `TagWith(tag)`, `TagWithCallSite()`

## Bulk Updates with ExecuteUpdate

With the `ExpressiveSharp.EntityFrameworkCore.RelationalExtensions` package, you can use modern C# syntax inside `ExecuteUpdate` / `ExecuteUpdateAsync`:

```csharp
// Requires: .UseExpressives(o => o.UseRelationalExtensions())
ctx.Orders
.ExecuteUpdate(s => s
.SetProperty(o => o.Tag, o => o.Price switch
{
>= 100 => "Premium",
>= 50 => "Standard",
_ => "Budget"
}));

// Async variant
await ctx.Orders
.ExecuteUpdateAsync(s => s.SetProperty(
o => o.Tag,
o => o.Customer?.Name ?? "Unknown"));
```

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.

::: info
`ExecuteDelete` works on `IRewritableQueryable<T>` / `ExpressiveDbSet<T>` without any additional setup — it has no lambda parameter, so no interception is needed.
:::

::: warning
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()`.
:::

## Plugin Architecture

`UseExpressives()` accepts an optional configuration callback for registering plugins:
Expand Down Expand Up @@ -193,7 +225,7 @@ The built-in `RelationalExtensions` package (for window functions) uses this plu
|---------|-------------|
| [`ExpressiveSharp`](https://www.nuget.org/packages/ExpressiveSharp/) | Core runtime -- `[Expressive]` attribute, source generator, expression expansion, transformers |
| [`ExpressiveSharp.EntityFrameworkCore`](https://www.nuget.org/packages/ExpressiveSharp.EntityFrameworkCore/) | EF Core integration -- `UseExpressives()`, `ExpressiveDbSet<T>`, Include/ThenInclude, async methods, analyzers and code fixes |
| [`ExpressiveSharp.EntityFrameworkCore.RelationalExtensions`](https://www.nuget.org/packages/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/) | SQL window functions -- ROW_NUMBER, RANK, DENSE_RANK, NTILE (experimental) |
| [`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) |

::: info
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.
Expand Down
30 changes: 30 additions & 0 deletions docs/guide/rewritable-queryable.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,36 @@ var orders = ctx.Set<Order>()
.ToList();
```

## EF Core: Bulk Updates with ExecuteUpdate

::: info
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.
:::

`ExecuteUpdate` and `ExecuteUpdateAsync` are supported on `IRewritableQueryable<T>`, enabling modern C# syntax inside `SetProperty` value expressions — which is normally impossible in expression trees:

```csharp
ctx.ExpressiveSet<Product>()
.ExecuteUpdate(s => s
.SetProperty(p => p.Tag, p => p.Price switch
{
> 100 => "premium",
> 50 => "standard",
_ => "budget"
})
.SetProperty(p => p.Category, p => p.Category ?? "Uncategorized"));
```

This generates a single SQL `UPDATE` with `CASE WHEN` and `COALESCE` expressions — no entity loading required.

`ExecuteDelete` works out of the box on `IRewritableQueryable<T>` without any stubs (it has no lambda parameter):

```csharp
ctx.ExpressiveSet<Product>()
.Where(p => p.Price switch { < 10 => true, _ => false })
.ExecuteDelete();
```

## IAsyncEnumerable Support

`IRewritableQueryable<T>` supports `AsAsyncEnumerable()` for streaming results:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#if !NET10_0_OR_GREATER
using System.ComponentModel;
using System.Diagnostics;
using ExpressiveSharp;
using Microsoft.EntityFrameworkCore.Query;

// ReSharper disable once CheckNamespace — intentionally in Microsoft.EntityFrameworkCore for discoverability
namespace Microsoft.EntityFrameworkCore;

/// <summary>
/// Extension methods on <see cref="IRewritableQueryable{T}"/> for EF Core bulk update operations.
/// These stubs are intercepted by the ExpressiveSharp source generator via <see cref="PolyfillTargetAttribute"/>
/// to forward to the appropriate EF Core ExecuteUpdate method.
/// </summary>
/// <remarks>
/// Only available on EF Core 8/9. In EF Core 10+, <c>ExecuteUpdate</c> uses <c>Action&lt;UpdateSettersBuilder&lt;T&gt;&gt;</c>
/// which natively supports modern C# syntax in the outer lambda. For inner <c>SetProperty</c> value expressions,
/// use <c>ExpressionPolyfill.Create()</c> to enable modern C# syntax.
/// </remarks>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class RewritableQueryableRelationalExtensions
{
private const string InterceptedMessage =
"This method must be intercepted by the ExpressiveSharp source generator. " +
"Ensure the generator package is installed and the InterceptorsNamespaces MSBuild property is configured.";

// ── Bulk update methods (intercepted) ────────────────────────────────
// EF Core 8: ExecuteUpdate lives on RelationalQueryableExtensions (Relational package)
// EF Core 9: ExecuteUpdate moved to EntityFrameworkQueryableExtensions (Core package)

#if NET9_0
[PolyfillTarget(typeof(EntityFrameworkQueryableExtensions))]
#else
[PolyfillTarget(typeof(RelationalQueryableExtensions))]
#endif
[EditorBrowsable(EditorBrowsableState.Never)]
public static int ExecuteUpdate<TSource>(
this IRewritableQueryable<TSource> source,
Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>> setPropertyCalls)
where TSource : class
=> throw new UnreachableException(InterceptedMessage);

#if NET9_0
[PolyfillTarget(typeof(EntityFrameworkQueryableExtensions))]
#else
[PolyfillTarget(typeof(RelationalQueryableExtensions))]
#endif
[EditorBrowsable(EditorBrowsableState.Never)]
public static Task<int> ExecuteUpdateAsync<TSource>(
this IRewritableQueryable<TSource> source,
Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>> setPropertyCalls,
CancellationToken cancellationToken = default)
where TSource : class
=> throw new UnreachableException(InterceptedMessage);
}
#endif
17 changes: 16 additions & 1 deletion src/ExpressiveSharp.Generator/Emitter/ReflectionFieldCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,22 @@ public string EnsureMethodInfo(IMethodSymbol method)
var paramCount = originalDef.Parameters.Length;
var typeArgs = string.Join(", ", method.TypeArguments.Select(t =>
$"typeof({ResolveTypeFqn(t)})"));
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})";

// Disambiguate overloads that share name, generic arity, and parameter count
// (e.g., SetProperty<P>(Func<T,P>, P) vs SetProperty<P>(Func<T,P>, Func<T,P>))
// by checking whether each parameter is a generic type or a type parameter.
var paramChecksBuilder = new System.Text.StringBuilder();
for (int i = 0; i < originalDef.Parameters.Length; i++)
{
var paramType = originalDef.Parameters[i].Type;
if (paramType is ITypeParameterSymbol)
paramChecksBuilder.Append($" && !m.GetParameters()[{i}].ParameterType.IsGenericType");
else if (paramType is INamedTypeSymbol { IsGenericType: true })
paramChecksBuilder.Append($" && m.GetParameters()[{i}].ParameterType.IsGenericType");
}
var paramChecks = paramChecksBuilder.ToString();

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})";
}
else
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#if !NET10_0_OR_GREATER
using ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.Tests.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

namespace ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.Tests;

/// <summary>
/// End-to-end integration tests for ExecuteUpdate via IRewritableQueryable.
/// These prove that modern C# syntax (null-conditional, switch expressions)
/// inside SetProperty value expressions works with real SQL execution — functionality
/// that is impossible with normal C# expression trees.
/// </summary>
[TestClass]
public class ExecuteUpdateIntegrationTests
{
private SqliteConnection _connection = null!;

[TestInitialize]
public void Setup()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
}

[TestCleanup]
public void Cleanup()
{
_connection.Dispose();
}

private ExecuteUpdateTestDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ExecuteUpdateTestDbContext>()
.UseSqlite(_connection)
.UseExpressives(o => o.UseRelationalExtensions())
.Options;
var ctx = new ExecuteUpdateTestDbContext(options);
ctx.Database.EnsureCreated();
return ctx;
}

private static void SeedProducts(ExecuteUpdateTestDbContext ctx)
{
ctx.Products.AddRange(
new Product { Id = 1, Name = "Widget", Category = "A", Tag = "", Price = 150, Quantity = 10 },
new Product { Id = 2, Name = "Gadget", Category = "B", Tag = "", Price = 75, Quantity = 5 },
new Product { Id = 3, Name = "Doohickey", Category = null, Tag = "", Price = 30, Quantity = 20 });
ctx.SaveChanges();
}

/// <summary>
/// Basic test: verify the generator intercepts ExecuteUpdate and forwards to EF Core.
/// </summary>
[TestMethod]
public void ExecuteUpdate_BasicConstant_Works()
{
using var ctx = CreateContext();
SeedProducts(ctx);

var affected = ctx.ExpressiveProducts
.ExecuteUpdate(s => s.SetProperty(p => p.Tag, "basic"));

Assert.AreEqual(3, affected);
// ExecuteUpdate bypasses change tracker — use AsNoTracking to see actual DB state
var products = ctx.Products.AsNoTracking().OrderBy(p => p.Id).ToList();
Assert.AreEqual("basic", products[0].Tag);
Assert.AreEqual("basic", products[1].Tag);
Assert.AreEqual("basic", products[2].Tag);
}

/// <summary>
/// Proves new capability: switch expression inside SetProperty value lambda.
/// <c>o.Price switch { > 100 => "premium", > 50 => "standard", _ => "budget" }</c>
/// is impossible in a normal C# expression tree context.
/// </summary>
[TestMethod]
public void ExecuteUpdate_SwitchExpression_TranslatesToSql()
{
using var ctx = CreateContext();
SeedProducts(ctx);

ctx.ExpressiveProducts
.ExecuteUpdate(s => s.SetProperty(
p => p.Tag,
p => p.Price switch
{
> 100 => "premium",
> 50 => "standard",
_ => "budget"
}));

var products = ctx.Products.AsNoTracking().OrderBy(p => p.Id).ToList();
Assert.AreEqual("premium", products[0].Tag); // Price=150
Assert.AreEqual("standard", products[1].Tag); // Price=75
Assert.AreEqual("budget", products[2].Tag); // Price=30
}

/// <summary>
/// Proves new capability: null-coalescing operator inside SetProperty value lambda.
/// </summary>
[TestMethod]
public void ExecuteUpdate_NullCoalescing_TranslatesToSql()
{
using var ctx = CreateContext();
SeedProducts(ctx);

ctx.ExpressiveProducts
.ExecuteUpdate(s => s.SetProperty(
p => p.Tag,
p => p.Category ?? "UNKNOWN"));

var products = ctx.Products.AsNoTracking().OrderBy(p => p.Id).ToList();
Assert.AreEqual("A", products[0].Tag); // Category="A"
Assert.AreEqual("B", products[1].Tag); // Category="B"
Assert.AreEqual("UNKNOWN", products[2].Tag); // Category=null
}

/// <summary>
/// Proves that multiple SetProperty calls with modern C# syntax work together,
/// producing multiple SET clauses in a single SQL UPDATE statement.
/// </summary>
[TestMethod]
public void ExecuteUpdate_MultipleProperties_WithModernSyntax()
{
using var ctx = CreateContext();
SeedProducts(ctx);

ctx.ExpressiveProducts
.ExecuteUpdate(s => s
.SetProperty(p => p.Tag, p => p.Price switch
{
> 100 => "expensive",
_ => "moderate"
})
.SetProperty(p => p.Category, "updated"));

var products = ctx.Products.AsNoTracking().OrderBy(p => p.Id).ToList();
Assert.AreEqual("expensive", products[0].Tag); // Price=150
Assert.AreEqual("updated", products[0].Category);
Assert.AreEqual("moderate", products[1].Tag); // Price=75
Assert.AreEqual("updated", products[1].Category);
Assert.AreEqual("moderate", products[2].Tag); // Price=30
Assert.AreEqual("updated", products[2].Category);
}

/// <summary>
/// Proves async variant works end-to-end with modern C# syntax.
/// </summary>
[TestMethod]
public async Task ExecuteUpdateAsync_SwitchExpression_TranslatesToSql()
{
using var ctx = CreateContext();
SeedProducts(ctx);

await ctx.ExpressiveProducts
.ExecuteUpdateAsync(s => s.SetProperty(
p => p.Tag,
p => p.Price switch
{
> 100 => "premium",
> 50 => "standard",
_ => "budget"
}));

var products = await ctx.Products.AsNoTracking().OrderBy(p => p.Id).ToListAsync();
Assert.AreEqual("premium", products[0].Tag);
Assert.AreEqual("standard", products[1].Tag);
Assert.AreEqual("budget", products[2].Tag);
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;

namespace ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.Tests.Models;

public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string? Category { get; set; }
public string Tag { get; set; } = "";
public double Price { get; set; }
public int Quantity { get; set; }
}

public class ExecuteUpdateTestDbContext : DbContext
{
public DbSet<Product> Products => Set<Product>();
public ExpressiveDbSet<Product> ExpressiveProducts => this.ExpressiveSet<Product>();

public ExecuteUpdateTestDbContext(DbContextOptions<ExecuteUpdateTestDbContext> options) : base(options)
{
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.HasKey(e => e.Id);
});
}
}
Loading
Loading