Skip to content
Merged
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
14 changes: 10 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,21 @@ CI targets both .NET 8.0 and .NET 10.0 SDKs.

1. **Compile-time (source generation):** Two incremental generators in `ExpressiveSharp.Generator` (targets netstandard2.0):
- `ExpressiveGenerator` — finds `[Expressive]` members, validates them via `ExpressiveInterpreter`, emits expression trees via `ExpressionTreeEmitter`, and builds a runtime registry via `ExpressionRegistryEmitter`
- `PolyfillInterceptorGenerator` — uses C# 13 `[InterceptsLocation]` to rewrite `ExpressionPolyfill.Create()` and `IRewritableQueryable<T>` LINQ call sites from delegate form to expression tree form
- `PolyfillInterceptorGenerator` — uses C# 13 `[InterceptsLocation]` to rewrite `ExpressionPolyfill.Create()` and `IRewritableQueryable<T>` LINQ call sites from delegate form to expression tree form. Supports all standard `Queryable` methods, multi-lambda methods (Join, GroupJoin, GroupBy overloads), non-lambda-first methods (Zip, ExceptBy, etc.), and custom target types via `[PolyfillTarget]` (e.g., EF Core's `EntityFrameworkQueryableExtensions` for async methods)

2. **Runtime:** `ExpressiveResolver` looks up generated expressions by (DeclaringType, MemberName, ParameterTypes). `ExpressiveReplacer` is an `ExpressionVisitor` that substitutes `[Expressive]` member accesses with the generated expression trees. Transformers (in `Transformers/`) post-process trees for provider compatibility.

### Key Source Files

- `src/ExpressiveSharp.Generator/ExpressiveGenerator.cs` — main generator entry point
- `src/ExpressiveSharp.Generator/Emitter/ExpressionTreeEmitter.cs` — maps IOperation nodes to `Expression.*` factory calls (the heart of code generation)
- `src/ExpressiveSharp.Generator/Emitter/ExpressionTreeEmitter.cs` — maps IOperation nodes to `Expression.*` factory calls (the heart of code generation). Uses `varPrefix` to ensure unique local variable names across multi-lambda emitters
- `src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.cs` — validates and prepares `[Expressive]` members
- `src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs` — interceptor generation
- `src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs` — interceptor generation. Dedicated emitters for complex methods (Join, GroupJoin, GroupBy multi-lambda), enhanced generic fallback (`EmitGenericSingleLambda`) for single-lambda methods with non-lambda arg forwarding, `[PolyfillTarget]` support for custom target types
- `src/ExpressiveSharp/Services/ExpressiveResolver.cs` — runtime expression registry lookup
- `src/ExpressiveSharp/PolyfillTargetAttribute.cs` — specifies a non-`Queryable` target type for interceptor forwarding (e.g., `EntityFrameworkQueryableExtensions`)
- `src/ExpressiveSharp/Extensions/RewritableQueryableLinqExtensions.cs` — delegate-based LINQ stubs on `IRewritableQueryable<T>` (~85 intercepted + ~15 passthrough methods)
- `src/ExpressiveSharp.EntityFrameworkCore/Extensions/RewritableQueryableEfCoreExtensions.cs` — EF Core-specific stubs: chain-continuity (AsNoTracking, TagWith, etc.), Include/ThenInclude, and async lambda methods (AnyAsync, SumAsync, etc.)
- `src/ExpressiveSharp.EntityFrameworkCore/IIncludableRewritableQueryable.cs` — hybrid interface bridging `IIncludableQueryable` and `IRewritableQueryable` for Include/ThenInclude chain continuity

### Project Dependencies

Expand All @@ -66,7 +70,9 @@ ExpressiveSharp.Generator (source generator, netstandard2.0)

ExpressiveSharp.EntityFrameworkCore (net8.0;net10.0)
├── ExpressiveSharp
└── EF Core 8.0.25 / 10.0.0
├── EF Core 8.0.25 / 10.0.0
└── Provides: ExpressiveDbSet<T>, IIncludableRewritableQueryable<T,P>,
chain-continuity stubs, async lambda stubs with [PolyfillTarget]

ExpressiveSharp.EntityFrameworkCore.CodeFixers (Roslyn analyzer, netstandard2.0)
└── Microsoft.CodeAnalysis.CSharp.Workspaces 4.12.0
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ var results = queryable

The source generator intercepts these calls at compile time and rewrites them to use proper expression trees — no runtime overhead.

Available LINQ methods: `Where`, `Select`, `SelectMany`, `OrderBy`, `OrderByDescending`, `ThenBy`, `ThenByDescending`, `GroupBy`.
All standard `Queryable` methods are supported — filtering (`Where`, `Any`, `All`), projection (`Select`, `SelectMany`), ordering (`OrderBy`, `ThenBy`), grouping (`GroupBy`), joins (`Join`, `GroupJoin`, `Zip`), aggregation (`Sum`, `Average`, `Min`, `Max`, `Count`), element access (`First`, `Single`, `Last` and their `OrDefault` variants), set operations (`ExceptBy`, `IntersectBy`, `UnionBy`, `DistinctBy`), and more. Non-lambda operators like `Take`, `Skip`, `Distinct`, and `Reverse` preserve the `IRewritableQueryable<T>` chain. Comparer overloads (`IEqualityComparer<T>`, `IComparer<T>`) are also supported.

### `ExpressionPolyfill.Create`

Expand Down Expand Up @@ -213,6 +213,17 @@ public class MyDbContext : DbContext
ctx.Orders.Where(o => o.Customer?.Name == "Alice");
```

`ExpressiveDbSet<T>` preserves chain continuity across EF Core operations — `Include`/`ThenInclude`, `AsNoTracking`, `IgnoreQueryFilters`, `TagWith`, and all async lambda methods (`AnyAsync`, `FirstAsync`, `SumAsync`, etc.) work seamlessly:

```csharp
var result = await ctx.Orders
.Include(o => o.Customer)
.ThenInclude(c => c.Address)
.AsNoTracking()
.Where(o => o.Customer?.Name == "Alice")
.FirstOrDefaultAsync(o => o.Total > 100);
```

## Supported C# Features

### Expression-Level
Expand Down
48 changes: 2 additions & 46 deletions samples/EFCoreSample/Models.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
using System.ComponentModel;
using ExpressiveSharp;

// ── Enums ────────────────────────────────────────────────────────────────────

public enum OrderStatus
{
[Description("Awaiting processing")]
Expand All @@ -27,8 +25,6 @@ public static class OrderStatusExtensions
};
}

// ── Domain models ────────────────────────────────────────────────────────────

public class Customer
{
public int Id { get; set; }
Expand All @@ -47,59 +43,19 @@ public class Order
public int CustomerId { get; set; }
public Customer Customer { get; set; } = null!;

/// Price * Quantity — becomes a SQL expression, not a client-side property.
[Expressive]
public double Total => Price * Quantity;

/// Null-conditional navigation — UseExpressives() strips the null check
/// automatically so EF Core can translate it to a JOIN.
[Expressive]
public string? CustomerEmail => Total >= 0 ? Customer?.Email : "";

/// Enum method expansion — each enum value is evaluated at compile time,
/// producing a CASE WHEN chain that translates directly to SQL.
[Expressive]
public string StatusDescription => Status.GetDescription();

/// Switch expression with relational patterns — becomes SQL CASE WHEN.
[Expressive]
public string GetGrade() => Price switch
public string Grade => Price switch
{
>= 100 => "Premium",
>= 50 => "Standard",
_ => "Budget",
};

/// Block body with local variable + if/else — requires AllowBlockBody opt-in.
/// The FlattenBlockExpressions transformer inlines locals for SQL translation.
[Expressive(AllowBlockBody = true)]
public string GetCategory()
{
var threshold = Quantity * 10;
if (threshold > 100)
return "Bulk";
else
return "Regular";
}
}

// ── DTO for constructor projection ───────────────────────────────────────────

public class OrderSummaryDto
{
public int Id { get; set; }
public string Description { get; set; } = "";
public double Total { get; set; }

public OrderSummaryDto() { }

/// Expands to MemberInit so EF Core translates this projection to a clean
/// SQL SELECT instead of loading entire Order entities.
[Expressive]
public OrderSummaryDto(int id, string description, double total)
{
Id = id;
Description = description;
Total = total;
}
}
public record OrderSummaryDto(int OrderId, string CustomerName, double Total, string Grade, string Status);
121 changes: 42 additions & 79 deletions samples/EFCoreSample/Program.cs
Original file line number Diff line number Diff line change
@@ -1,100 +1,63 @@
// A small order-management tool that queries an SQLite database.
// ExpressiveSharp lets us write natural C# (null-conditional ?., switch expressions,
// computed properties) and have it all translate to SQL instead of evaluating client-side.

using ExpressiveSharp.Extensions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

// ── Setup ────────────────────────────────────────────────────────────────────

var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();

var options = new DbContextOptionsBuilder<SampleDbContext>()
.UseSqlite(connection)
.UseExpressives() // ← this one line enables everything below
.UseExpressives()
.Options;

using var db = new SampleDbContext(options);
db.Database.EnsureCreated();
SeedData(db);

// ── 1. Computed properties in SQL ────────────────────────────────────────────
// Total => Price * Quantity becomes a SQL expression, not a client-side eval.
Section("Computed Properties in SQL");

var totalsQuery = db.Orders.AsQueryable().Select(o => new { o.Id, o.Total });
PrintSql(totalsQuery);
foreach (var r in totalsQuery) Console.WriteLine($" Order #{r.Id}: Total = {r.Total}");

// ── 2. Modern syntax in queries ──────────────────────────────────────────────
// ExpressiveDbSet lets you use ?. directly in delegate lambdas.
// The source generator rewrites them to expression trees at compile time.
Section("Modern Syntax in Queries");

// Where with null-conditional ?.
var emailFilter = db.Orders.Where(o => o.Customer?.Email != null);
PrintSql(emailFilter);
foreach (var o in emailFilter) Console.WriteLine($" Order #{o.Id}: {o.Customer.Name} <{o.Customer.Email}>");

// Switch expression in Select — becomes CASE WHEN in SQL
var gradesQuery = db.Orders.AsQueryable().Select(o => new { o.Id, o.Price, Grade = o.GetGrade() });
PrintSql(gradesQuery);
foreach (var r in gradesQuery) Console.WriteLine($" Order #{r.Id} (${r.Price}): {r.Grade}");

// GroupBy with ?.
var groupQuery = db.Orders.GroupBy(o => o.Customer?.Email);
PrintSql(groupQuery);
foreach (var g in groupQuery)
Console.WriteLine($" Email={g.Key ?? "(null)"}: [{string.Join(", ", g.Select(o => $"#{o.Id}"))}]");

// ── 3. Block bodies and enum expansion ───────────────────────────────────────
// Block-bodied methods and enum method calls are flattened into SQL-translatable
// expressions automatically.
Section("Block Bodies and Enum Expansion");

// GetCategory() — block body with local variable + if/else → CASE WHEN
var catsQuery = db.Orders.AsQueryable().Select(o => new { o.Id, o.Quantity, Category = o.GetCategory() });
PrintSql(catsQuery);
foreach (var r in catsQuery) Console.WriteLine($" Order #{r.Id} (qty={r.Quantity}): {r.Category}");

// StatusDescription — enum method expansion → ternary chain in SQL
var statusQuery = db.Orders.AsQueryable().Select(o => new { o.Id, o.Status, Desc = o.StatusDescription });
PrintSql(statusQuery);
foreach (var r in statusQuery) Console.WriteLine($" Order #{r.Id} ({r.Status}): \"{r.Desc}\"");

// ── 4. Constructor projection ────────────────────────────────────────────────
// [Expressive] constructors expand to MemberInit, so EF Core translates the
// projection to a clean SQL SELECT instead of loading entire entities.
Section("Constructor Projection");

var dtoQuery = db.Orders.AsQueryable().Select(o => new OrderSummaryDto(o.Id, o.Tag ?? "N/A", o.Total));
PrintSql(dtoQuery);
foreach (var dto in dtoQuery) Console.WriteLine($" {{ Id={dto.Id}, Desc=\"{dto.Description}\", Total={dto.Total} }}");

// ── 5. Combining everything ──────────────────────────────────────────────────
// One query: ?. filter + [Expressive] property + switch expression.
Section("Combining Everything");

// ExpressiveDbSet handles the ?. in Where; UseExpressives() expands
// [Expressive] members when they appear elsewhere in the query pipeline.
var combined = db.Orders.Where(o => o.Customer?.Name == "Alice");
PrintSql(combined);
foreach (var o in combined) Console.WriteLine($" Order #{o.Id}: Total={o.Total}, Grade={o.GetGrade()}");

// ── Helpers ──────────────────────────────────────────────────────────────────
Console.WriteLine();

connection.Close();
// Find all orders where the customer has an email on file.
// The ?. operator works directly in the lambda — the source generator
// rewrites it to an expression tree that EF Core translates to SQL.
var contactableOrders = db.Orders
.Where(o => o.Customer?.Email != null)
.Select(o => new OrderSummaryDto(o.Id, o.Customer.Name, o.Total, o.Grade, o.StatusDescription))
.ToList();

static void Section(string title)
{
Console.WriteLine();
Console.WriteLine($"--- {title} ---");
Console.WriteLine();
}
Console.WriteLine("Orders with customer email on file:");
foreach (var dto in contactableOrders)
Console.WriteLine($" #{dto.OrderId} {dto.CustomerName} — ${dto.Total:N2} ({dto.Grade}, {dto.Status})");

static void PrintSql<T>(IQueryable<T> query)
{
Console.WriteLine($" SQL: {query.ToQueryString()}");
Console.WriteLine();
}
Console.WriteLine();

// Premium approved orders — filter on [Expressive] computed properties.
var premiumApproved = db.Orders
.Where(o => o.Total >= 200 && o.Status == OrderStatus.Approved)
.Select(o => new { o.Id, o.Total, o.Grade })
.ToList();

Console.WriteLine("Premium approved orders (total >= $200):");
foreach (var o in premiumApproved)
Console.WriteLine($" #{o.Id} — ${o.Total:N2} ({o.Grade})");

Console.WriteLine();

// Revenue by status — the [Expressive] StatusDescription property expands to a
// CASE WHEN chain in SQL via UseExpressives(), no special queryable needed.
var revenueByStatus = db.Orders.AsQueryable()
.GroupBy(o => o.StatusDescription)
.Select(g => new { Status = g.Key, Revenue = g.Sum(o => o.Total) })
.ToList();

Console.WriteLine("Revenue by status:");
foreach (var row in revenueByStatus)
Console.WriteLine($" {row.Status}: ${row.Revenue:N2}");

connection.Close();

static void SeedData(SampleDbContext db)
{
Expand Down
8 changes: 8 additions & 0 deletions samples/EFCoreSample/SampleDbContext.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using ExpressiveSharp.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

public class SampleDbContext : DbContext
{
Expand All @@ -8,6 +9,13 @@ public class SampleDbContext : DbContext

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

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging();
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>(e => e.HasKey(c => c.Id));
Expand Down
Loading
Loading