diff --git a/README.md b/README.md index 6d113e0..583b927 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,11 @@ The aggregate package deliberately uses one canonical invocation vocabulary. Rep See the [migration guide](https://github.com/Raffinert/Raffinert.Expressions/blob/main/docs/migration.md) for details. +## Examples + +- [LINQKit comparison](examples/LinqKitComparison/README.md) — the LINQKit README scenarios implemented three + ways in a .NET 10 / EF Core 10 console app: plain EF Core lambdas, LINQKit, and Raffinert.Expressions. + ## Useful reading Expression composition and reusable query logic: diff --git a/Raffinert.Expressions.slnx b/Raffinert.Expressions.slnx index fe676ef..b3e8e80 100644 --- a/Raffinert.Expressions.slnx +++ b/Raffinert.Expressions.slnx @@ -1,4 +1,7 @@ + + + diff --git a/examples/LinqKitComparison/ComparisonRunner.cs b/examples/LinqKitComparison/ComparisonRunner.cs new file mode 100644 index 0000000..5959078 --- /dev/null +++ b/examples/LinqKitComparison/ComparisonRunner.cs @@ -0,0 +1,127 @@ +using Microsoft.EntityFrameworkCore; + +namespace LinqKitComparison; + +public static class ComparisonRunner +{ + public static async Task RunAsync(ExampleDbContext db, bool showSql) + { + const decimal minimumPrice = 1_000m; + var asOf = new DateTime(2026, 8, 30); + var recentSaleCutoff = asOf.AddDays(-30); + + await CompareAsync( + "Expression predicate inside a navigation collection", + PureDotNetExamples.CustomersWithQualifyingNavigationPurchase(db, minimumPrice), + LinqKitExamples.CustomersWithQualifyingNavigationPurchase(db, minimumPrice), + RaffinertExamples.CustomersWithQualifyingNavigationPurchase(db, minimumPrice), + showSql); + + await CompareAsync( + "Expression predicate inside an ad-hoc correlated subquery", + PureDotNetExamples.CustomersWithQualifyingAdHocPurchase(db, minimumPrice), + LinqKitExamples.CustomersWithQualifyingAdHocPurchase(db, minimumPrice), + RaffinertExamples.CustomersWithQualifyingAdHocPurchase(db, minimumPrice), + showSql); + + await CompareAsync( + "Combining reusable purchase criteria", + PureDotNetExamples.CombinedPurchaseCriteria(db, minimumPrice), + LinqKitExamples.CombinedPurchaseCriteria(db, minimumPrice), + RaffinertExamples.CombinedPurchaseCriteria(db, minimumPrice), + showSql); + + await CompareAsync( + "Dynamic keyword search: all keywords", + PureDotNetExamples.ProductsMatchingAllKeywords(db, "classic", "phone"), + LinqKitExamples.ProductsMatchingAllKeywords(db, "classic", "phone"), + RaffinertExamples.ProductsMatchingAllKeywords(db, "classic", "phone"), + showSql); + + await CompareAsync( + "Dynamic keyword search: any keyword", + PureDotNetExamples.ProductsMatchingAnyKeyword(db, "BlackBerry", "iPhone"), + LinqKitExamples.ProductsMatchingAnyKeyword(db, "BlackBerry", "iPhone"), + RaffinertExamples.ProductsMatchingAnyKeyword(db, "BlackBerry", "iPhone"), + showSql); + + await CompareAsync( + "Nested predicates", + PureDotNetExamples.NestedProductCriteria(db), + LinqKitExamples.NestedProductCriteria(db), + RaffinertExamples.NestedProductCriteria(db), + showSql); + + await CompareAsync( + "Reusable predicate library", + PureDotNetExamples.ProductsFromReusableRuleScenario(db, recentSaleCutoff), + LinqKitExamples.ProductsFromReusableRuleScenario(db, recentSaleCutoff), + RaffinertExamples.ProductsFromReusableRuleScenario(db, recentSaleCutoff), + showSql); + + await CompareAsync( + "Generic reusable validity predicate", + PureDotNetExamples.CurrentPriceListsStartingWith(db, asOf, "A"), + LinqKitExamples.CurrentPriceListsStartingWith(db, asOf, "A"), + RaffinertExamples.CurrentPriceListsStartingWith(db, asOf, "A"), + showSql); + + await CompareAsync( + "Reusable aggregate expression", + PureDotNetExamples.DailyOrderAverages(db), + LinqKitExamples.DailyOrderAverages(db), + RaffinertExamples.DailyOrderAverages(db), + showSql); + + Console.WriteLine(); + Console.WriteLine("All three implementations returned identical results."); + } + + private static async Task CompareAsync( + string title, + IQueryable pureDotNet, + IQueryable linqKit, + IQueryable raffinert, + bool showSql) + { + Console.WriteLine(); + Console.WriteLine($"=== {title} ==="); + + if (showSql) + { + WriteSql("Pure .NET", pureDotNet); + WriteSql("LINQKit", linqKit); + WriteSql("Raffinert.Expressions", raffinert); + } + + var pureResults = await pureDotNet.ToArrayAsync(); + var linqKitResults = await linqKit.ToArrayAsync(); + var raffinertResults = await raffinert.ToArrayAsync(); + + EnsureSame(title, pureResults, linqKitResults, raffinertResults); + + Console.WriteLine($"Pure .NET : {Format(pureResults)}"); + Console.WriteLine($"LINQKit : {Format(linqKitResults)}"); + Console.WriteLine($"Raffinert.Expressions : {Format(raffinertResults)}"); + } + + private static void EnsureSame( + string title, + IReadOnlyList pureDotNet, + IReadOnlyList linqKit, + IReadOnlyList raffinert) + { + if (!pureDotNet.SequenceEqual(linqKit) || !pureDotNet.SequenceEqual(raffinert)) + throw new InvalidOperationException($"The three implementations disagreed for '{title}'."); + } + + private static string Format(IEnumerable values) => + string.Join(", ", values.Select(value => value?.ToString() ?? "")); + + private static void WriteSql(string label, IQueryable query) + { + Console.WriteLine(); + Console.WriteLine($"-- {label}"); + Console.WriteLine(query.ToQueryString()); + } +} diff --git a/examples/LinqKitComparison/LinqKitComparison.csproj b/examples/LinqKitComparison/LinqKitComparison.csproj new file mode 100644 index 0000000..1fcc4db --- /dev/null +++ b/examples/LinqKitComparison/LinqKitComparison.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0 + + + + + + + + + diff --git a/examples/LinqKitComparison/LinqKitExamples.cs b/examples/LinqKitComparison/LinqKitExamples.cs new file mode 100644 index 0000000..2c1148e --- /dev/null +++ b/examples/LinqKitComparison/LinqKitExamples.cs @@ -0,0 +1,160 @@ +using System.Linq.Expressions; +using LinqKit; + +namespace LinqKitComparison; + +public static class LinqKitExamples +{ + public static IQueryable CustomersWithQualifyingNavigationPurchase( + ExampleDbContext db, + decimal minimumPrice) + { + Expression> purchaseCriteria = purchase => purchase.Price > minimumPrice; + + return db.Customers + .AsExpandable() + .Where(customer => customer.Purchases.Any(purchaseCriteria.Compile())) + .OrderBy(customer => customer.Name) + .Select(customer => customer.Name); + } + + public static IQueryable CustomersWithQualifyingAdHocPurchase( + ExampleDbContext db, + decimal minimumPrice) + { + Expression> purchaseCriteria = purchase => purchase.Price > minimumPrice; + + return + from customer in db.Customers.AsExpandable() + where db.Purchases + .Where(purchase => purchase.CustomerId == customer.Id) + .Any(purchaseCriteria) + orderby customer.Name + select customer.Name; + } + + public static IQueryable CombinedPurchaseCriteria(ExampleDbContext db, decimal minimumPrice) + { + Expression> expensive = purchase => purchase.Price > minimumPrice; + Expression> combined = purchase => + expensive.Invoke(purchase) || purchase.Description.Contains("service"); + + return db.Purchases + .Where(combined.Expand()) + .OrderBy(purchase => purchase.Description) + .Select(purchase => purchase.Description); + } + + public static IQueryable ProductsMatchingAllKeywords( + ExampleDbContext db, + params string[] keywords) + { + var predicate = PredicateBuilder.New(true); + + foreach (var keyword in keywords) + predicate = predicate.And(product => product.Description.Contains(keyword)); + + return db.Products + .AsExpandable() + .Where(predicate) + .OrderBy(product => product.Description) + .Select(product => product.Description); + } + + public static IQueryable ProductsMatchingAnyKeyword( + ExampleDbContext db, + params string[] keywords) + { + var predicate = PredicateBuilder.New(); + + foreach (var keyword in keywords) + predicate = predicate.Or(product => product.Description.Contains(keyword)); + + return db.Products + .AsExpandable() + .Where(predicate) + .OrderBy(product => product.Description) + .Select(product => product.Description); + } + + public static IQueryable NestedProductCriteria(ExampleDbContext db) + { + var descriptions = PredicateBuilder.New() + .Start(product => product.Description.Contains("foo")) + .Or(product => product.Description.Contains("far")); + + var predicate = PredicateBuilder.New() + .Start(product => product.Price > 100m) + .And(product => product.Price < 1_000m) + .And(descriptions); + + return db.Products + .AsExpandable() + .Where(predicate) + .OrderBy(product => product.Description) + .Select(product => product.Description); + } + + public static IQueryable ProductsFromReusableRuleScenario( + ExampleDbContext db, + DateTime recentSaleCutoff) + { + var newKids = ContainsInDescription("BlackBerry", "iPhone"); + var classics = ContainsInDescription("Nokia", "Ericsson") + .And(IsSelling(recentSaleCutoff)); + + return db.Products + .AsExpandable() + .Where(newKids.Or(classics)) + .OrderBy(product => product.Description) + .Select(product => product.Description); + } + + public static IQueryable CurrentPriceListsStartingWith( + ExampleDbContext db, + DateTime asOf, + string prefix) + { + var predicate = IsCurrent(asOf) + .And(priceList => priceList.Name.StartsWith(prefix)); + + return db.PriceLists + .AsExpandable() + .Where(predicate) + .OrderBy(priceList => priceList.Name) + .Select(priceList => priceList.Name); + } + + public static IQueryable DailyOrderAverages(ExampleDbContext db) + { + Expression, double?>> average = orders => + orders.Average(order => (double?)order.Amount); + + return + from order in db.Orders.AsExpandable() + group order by order.OrderDate into orders + orderby orders.Key + select new DailyAverage( + orders.Key, + average.Invoke(orders.AsQueryable())); + } + + private static Expression> IsCurrent(DateTime asOf) + where TEntity : IValidFromTo => + entity => + (entity.ValidFrom == null || entity.ValidFrom <= asOf) && + (entity.ValidTo == null || entity.ValidTo >= asOf); + + private static Expression> ContainsInDescription(params string[] keywords) + { + var predicate = PredicateBuilder.New(); + + foreach (var keyword in keywords) + predicate = predicate.Or(product => product.Description.Contains(keyword)); + + return predicate; + } + + private static Expression> IsSelling(DateTime recentSaleCutoff) => + product => !product.Discontinued && product.LastSale > recentSaleCutoff; +} diff --git a/examples/LinqKitComparison/Model.cs b/examples/LinqKitComparison/Model.cs new file mode 100644 index 0000000..b7cb84f --- /dev/null +++ b/examples/LinqKitComparison/Model.cs @@ -0,0 +1,182 @@ +using Microsoft.EntityFrameworkCore; + +namespace LinqKitComparison; + +public sealed class Customer +{ + public int Id { get; set; } + public required string Name { get; set; } + public ICollection Purchases { get; set; } = []; +} + +public sealed class Purchase +{ + public int Id { get; set; } + public int CustomerId { get; set; } + public Customer Customer { get; set; } = null!; + public decimal Price { get; set; } + public required string Description { get; set; } + public DateTime Date { get; set; } +} + +public sealed class Product : IValidFromTo +{ + public int Id { get; set; } + public required string Description { get; set; } + public decimal Price { get; set; } + public bool Discontinued { get; set; } + public DateTime LastSale { get; set; } + public DateTime? ValidFrom { get; set; } + public DateTime? ValidTo { get; set; } +} + +public sealed class PriceList : IValidFromTo +{ + public int Id { get; set; } + public required string Name { get; set; } + public DateTime? ValidFrom { get; set; } + public DateTime? ValidTo { get; set; } +} + +public interface IValidFromTo +{ + DateTime? ValidFrom { get; } + DateTime? ValidTo { get; } +} + +public sealed class Order +{ + public int Id { get; set; } + public int Amount { get; set; } + public DateTime OrderDate { get; set; } +} + +public sealed record DailyAverage(DateTime OrderDate, double? AverageAmount); + +public sealed class ExampleDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Customers => Set(); + public DbSet Purchases => Set(); + public DbSet Products => Set(); + public DbSet PriceLists => Set(); + public DbSet Orders => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasMany(customer => customer.Purchases) + .WithOne(purchase => purchase.Customer) + .HasForeignKey(purchase => purchase.CustomerId); + } +} + +public static class ExampleData +{ + public static async Task SeedAsync(ExampleDbContext db) + { + var alice = new Customer { Name = "Alice" }; + var bob = new Customer { Name = "Bob" }; + var clara = new Customer { Name = "Clara" }; + + db.AddRange( + new Purchase + { + Customer = alice, + Price = 1_500m, + Description = "Laptop", + Date = new DateTime(2026, 8, 10) + }, + new Purchase + { + Customer = alice, + Price = 25m, + Description = "Mouse", + Date = new DateTime(2026, 8, 11) + }, + new Purchase + { + Customer = bob, + Price = 700m, + Description = "Phone", + Date = new DateTime(2026, 7, 20) + }, + new Purchase + { + Customer = clara, + Price = 250m, + Description = "Premium service plan", + Date = new DateTime(2026, 8, 15) + }); + + db.Products.AddRange( + new Product + { + Description = "BlackBerry phone", + Price = 400m, + LastSale = new DateTime(2026, 8, 20), + ValidFrom = new DateTime(2025, 1, 1) + }, + new Product + { + Description = "iPhone handset", + Price = 900m, + LastSale = new DateTime(2026, 8, 25), + ValidFrom = new DateTime(2025, 1, 1) + }, + new Product + { + Description = "Nokia classic phone", + Price = 150m, + LastSale = new DateTime(2026, 8, 22), + ValidFrom = new DateTime(2025, 1, 1) + }, + new Product + { + Description = "Ericsson classic phone", + Price = 200m, + LastSale = new DateTime(2026, 1, 1), + Discontinued = true, + ValidFrom = new DateTime(2025, 1, 1), + ValidTo = new DateTime(2026, 6, 1) + }, + new Product + { + Description = "foo office chair", + Price = 300m, + LastSale = new DateTime(2026, 8, 28), + ValidFrom = new DateTime(2026, 1, 1) + }, + new Product + { + Description = "far away desk", + Price = 1_200m, + LastSale = new DateTime(2026, 8, 28), + ValidFrom = new DateTime(2026, 1, 1) + }); + + db.PriceLists.AddRange( + new PriceList + { + Name = "Active retail", + ValidFrom = new DateTime(2026, 1, 1), + ValidTo = new DateTime(2026, 12, 31) + }, + new PriceList + { + Name = "Archived retail", + ValidTo = new DateTime(2025, 12, 31) + }, + new PriceList + { + Name = "Business", + ValidFrom = new DateTime(2026, 1, 1) + }); + + db.Orders.AddRange( + new Order { Amount = 3, OrderDate = new DateTime(2026, 8, 1) }, + new Order { Amount = 5, OrderDate = new DateTime(2026, 8, 1) }, + new Order { Amount = 7, OrderDate = new DateTime(2026, 8, 2) }); + + await db.SaveChangesAsync(); + } +} diff --git a/examples/LinqKitComparison/Program.cs b/examples/LinqKitComparison/Program.cs new file mode 100644 index 0000000..ae8feff --- /dev/null +++ b/examples/LinqKitComparison/Program.cs @@ -0,0 +1,19 @@ +using LinqKitComparison; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +await using var connection = new SqliteConnection("Data Source=:memory:"); +await connection.OpenAsync(); + +var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .EnableSensitiveDataLogging() + .Options; + +await using var db = new ExampleDbContext(options); +await db.Database.EnsureCreatedAsync(); +await ExampleData.SeedAsync(db); + +var showSql = args.Contains("--sql", StringComparer.OrdinalIgnoreCase); +await ComparisonRunner.RunAsync(db, showSql); +await RaffinertSpecificExamples.RunAsync(db, showSql); diff --git a/examples/LinqKitComparison/PureDotNetExamples.cs b/examples/LinqKitComparison/PureDotNetExamples.cs new file mode 100644 index 0000000..9ebcff0 --- /dev/null +++ b/examples/LinqKitComparison/PureDotNetExamples.cs @@ -0,0 +1,123 @@ +namespace LinqKitComparison; + +/// +/// The no-library baseline. Reusable expression plumbing is deliberately avoided: logic is written inline, +/// and duplicated where an ordinary EF Core lambda has no composition mechanism. +/// +public static class PureDotNetExamples +{ + public static IQueryable CustomersWithQualifyingNavigationPurchase( + ExampleDbContext db, + decimal minimumPrice) => + db.Customers + // Limitation: the purchase rule must live inside this query and cannot be supplied as a reusable + // expression. LINQKit expands a supplied expression; Raffinert.Expressions expands a nested Condition. + .Where(customer => customer.Purchases.Any(purchase => purchase.Price > minimumPrice)) + .OrderBy(customer => customer.Name) + .Select(customer => customer.Name); + + public static IQueryable CustomersWithQualifyingAdHocPurchase( + ExampleDbContext db, + decimal minimumPrice) => + from customer in db.Customers + // Limitation: the price rule is duplicated inside the correlated subquery. LINQKit and Raffinert.Expressions + // are both designed to inject a separately declared predicate at this point. + where db.Purchases.Any(purchase => + purchase.CustomerId == customer.Id && purchase.Price > minimumPrice) + orderby customer.Name + select customer.Name; + + public static IQueryable CombinedPurchaseCriteria(ExampleDbContext db, decimal minimumPrice) => + db.Purchases + // Limitation: both branches are hardcoded into one lambda. There is no separately reusable + // "expensive purchase" predicate to combine, which Invoke/Expand and Condition.Invoke provide. + .Where(purchase => + purchase.Price > minimumPrice || + purchase.Description.Contains("service")) + .OrderBy(purchase => purchase.Description) + .Select(purchase => purchase.Description); + + public static IQueryable ProductsMatchingAllKeywords( + ExampleDbContext db, + params string[] keywords) + { + IQueryable query = db.Products; + + // Standard chained Where calls naturally mean "all keywords", but they do not produce one reusable + // predicate that can be nested elsewhere. PredicateBuilder and Condition.And do produce such a value. + foreach (var keyword in keywords) + query = query.Where(product => product.Description.Contains(keyword)); + + return query.OrderBy(product => product.Description).Select(product => product.Description); + } + + public static IQueryable ProductsMatchingAnyKeyword( + ExampleDbContext db, + params string[] keywords) + { + // Limitation: with no expression composition, the number of OR branches is hardcoded. This sample + // accepts exactly two keywords; LINQKit PredicateBuilder and Raffinert.Expressions Condition.Or accept any count. + if (keywords.Length != 2) + throw new ArgumentException("The pure .NET example intentionally spells out exactly two OR terms.", nameof(keywords)); + + var first = keywords[0]; + var second = keywords[1]; + + return db.Products + .Where(product => + product.Description.Contains(first) || + product.Description.Contains(second)) + .OrderBy(product => product.Description) + .Select(product => product.Description); + } + + public static IQueryable NestedProductCriteria(ExampleDbContext db) => + db.Products + // Limitation: the parenthesized description rule is hardcoded inside the outer price rule. + // LINQKit and Raffinert.Expressions can build the inner rule independently and compose it into the outer rule. + .Where(product => + product.Price > 100m && + product.Price < 1_000m && + (product.Description.Contains("foo") || product.Description.Contains("far"))) + .OrderBy(product => product.Description) + .Select(product => product.Description); + + public static IQueryable ProductsFromReusableRuleScenario( + ExampleDbContext db, + DateTime recentSaleCutoff) => + db.Products + // Limitation: the keyword groups and IsSelling rule are duplicated into one provider lambda. + // LINQKit expressions and Raffinert.Expressions Conditions keep those rules named, reusable, and composable. + .Where(product => + product.Description.Contains("BlackBerry") || + product.Description.Contains("iPhone") || + ((product.Description.Contains("Nokia") || product.Description.Contains("Ericsson")) && + !product.Discontinued && + product.LastSale > recentSaleCutoff)) + .OrderBy(product => product.Description) + .Select(product => product.Description); + + public static IQueryable CurrentPriceListsStartingWith( + ExampleDbContext db, + DateTime asOf, + string prefix) => + db.PriceLists + // Limitation: the ValidFrom/ValidTo rule is hardcoded for PriceList. LINQKit can compose a generic + // expression and Raffinert.Expressions a generic Condition with the entity-specific name rule. + .Where(priceList => + (priceList.ValidFrom == null || priceList.ValidFrom <= asOf) && + (priceList.ValidTo == null || priceList.ValidTo >= asOf) && + priceList.Name.StartsWith(prefix)) + .OrderBy(priceList => priceList.Name) + .Select(priceList => priceList.Name); + + public static IQueryable DailyOrderAverages(ExampleDbContext db) => + from order in db.Orders + group order by order.OrderDate into orders + orderby orders.Key + select new DailyAverage( + orders.Key, + // Limitation: Average is hardcoded in this projection. LINQKit can invoke a reusable aggregate + // expression here; Raffinert.Expressions can invoke a reusable Projection, double?>. + orders.Average(order => (double?)order.Amount)); +} diff --git a/examples/LinqKitComparison/README.md b/examples/LinqKitComparison/README.md new file mode 100644 index 0000000..e6d4c0c --- /dev/null +++ b/examples/LinqKitComparison/README.md @@ -0,0 +1,66 @@ +# LINQKit README examples: pure .NET, LINQKit, and Raffinert.Expressions + +This .NET 10 console app runs equivalent EF Core 10 SQLite queries three ways: + +1. **Pure .NET / EF Core** — ordinary lambdas with logic kept inline or duplicated. It intentionally contains no + custom expression visitor, parameter rebinder, or home-grown expansion helper. +2. **LINQKit** — `AsExpandable`, `Invoke`, `Expand`, and `PredicateBuilder`, following the patterns in the + [LINQKit README](https://github.com/scottksmith95/LINQKit/blob/master/README.md). +3. **Raffinert.Expressions** — `Condition`, `Projection`, direct composable LINQ overloads, + and `AsRaffinertQuery()` for query syntax. + +The runner executes every query against the same in-memory database and fails if the three result sets differ. + +## Run + +```shell +dotnet run --project examples/LinqKitComparison +``` + +Add `--sql` to print the SQL generated for all three implementations: + +```shell +dotnet run --project examples/LinqKitComparison -- --sql +``` + +## Scenario map + +| LINQKit README scenario | Pure .NET / EF Core | LINQKit | Raffinert.Expressions | +|---|---|---|---| +| Predicate in a navigation collection | Inline the purchase predicate | `AsExpandable()` + `Compile()` | Nested `Condition.Invoke` method group, expanded before `Where` | +| Expression variable in a correlated subquery | Inline the predicate | `AsExpandable()` + an expression passed to subquery `Any` | `AsRaffinertQuery()` + `Condition.Invoke` | +| Combining expressions | Write the combined lambda inline | `Invoke()` + `Expand()` | A condition containing another condition's `Invoke()` | +| Dynamic all-keyword predicate | Chain ordinary `Where` calls | `PredicateBuilder.And` | Fold conditions with `Condition.And` | +| Dynamic any-keyword predicate | Spell out the OR terms (two in this example) | `PredicateBuilder.Or` | Fold conditions with `Condition.Or` | +| Nested predicates | Write the parenthesized lambda inline | Nested `PredicateBuilder` instances | Compose inner and outer `Condition` instances | +| Reusable predicate library | Duplicate the complete rule inline | Reusable expressions composed with `And`/`Or` | Reusable conditions composed with `And`/`Or` | +| Generic validity predicate | Duplicate the validity clauses in the provider lambda | Generic expression + `And` | Generic `Condition` + `And` | +| Reusable aggregate | Put `Average` directly in the group projection | Invoked aggregate expression + `AsExpandable()` | Invoked `Projection, double?>` + `AsRaffinertQuery()` | + +The pure .NET implementations are deliberately the baseline, not an expression-composition library hidden inside +the example. They show that EF Core can solve every concrete query when the logic is placed directly in the +provider-facing lambda. LINQKit and Raffinert.Expressions become useful when that logic must remain independently reusable. + +The LINQKit README's optional expression-optimizer section has no direct Raffinert.Expressions equivalent. Raffinert.Expressions performs +composition/expansion only; it does not attempt general constant folding or query optimization. + +The original README writes the ad-hoc subquery with a `let` that temporarily projects an `IQueryable`. +EF Core 10 rejects that intermediate projection. This sample keeps the same correlated-subquery intent but writes +the `Where(...).Any(...)` operation directly in the outer predicate. + +## Raffinert.Expressions capabilities beyond LINQKit's API + +After the three-way comparisons, `RaffinertSpecificExamples` runs capabilities that Raffinert.Expressions exposes as supported +APIs and LINQKit does not expose directly: + +| Raffinert.Expressions API | What the runnable example does | LINQKit comparison | +|---|---|---| +| `AdaptSource` / `Adapt` | Reuses a condition and projection with structurally compatible source and result types | No structural source/result adaptation API | +| `Projection.Then` | Type-safely chains projections and then a condition | Possible with `Invoke`/`Expand`, but no typed forward-composition abstraction | +| `MergeBindings` | Combines two member-initializer projections, with defined conflict behavior | No projection-binding merge API | +| `InvokeOrDefault` | Inlines a nested projection with built-in null-to-default behavior | Requires an explicit conditional around a LINQKit invocation | +| `MapToExisting` | Compiles a projection into an updater and preserves an existing destination instance | Outside LINQKit's expression-expansion scope | + +“No LINQKit equivalent” here means LINQKit itself provides no corresponding operation. Since both libraries expose +ordinary expression trees, custom visitors or mapping code could reproduce most outcomes; that additional code is +not a LINQKit feature. diff --git a/examples/LinqKitComparison/RaffinertExamples.cs b/examples/LinqKitComparison/RaffinertExamples.cs new file mode 100644 index 0000000..a175051 --- /dev/null +++ b/examples/LinqKitComparison/RaffinertExamples.cs @@ -0,0 +1,146 @@ +using Raffinert.Expressions; + +namespace LinqKitComparison; + +public static class RaffinertExamples +{ + public static IQueryable CustomersWithQualifyingNavigationPurchase( + ExampleDbContext db, + decimal minimumPrice) + { + var purchaseCriteria = Condition.Create(purchase => purchase.Price > minimumPrice); + var customerCriteria = Condition.Create(customer => + customer.Purchases.Any(purchaseCriteria.Invoke)); + + return db.Customers + .Where(customerCriteria) + .OrderBy(customer => customer.Name) + .Select(customer => customer.Name); + } + + public static IQueryable CustomersWithQualifyingAdHocPurchase( + ExampleDbContext db, + decimal minimumPrice) + { + var purchaseCriteria = Condition.Create(purchase => purchase.Price > minimumPrice); + + return + from customer in db.Customers.AsRaffinertQuery() + where db.Purchases.Any(purchase => + purchase.CustomerId == customer.Id && purchaseCriteria.Invoke(purchase)) + orderby customer.Name + select customer.Name; + } + + public static IQueryable CombinedPurchaseCriteria(ExampleDbContext db, decimal minimumPrice) + { + var expensive = Condition.Create(purchase => purchase.Price > minimumPrice); + var combined = Condition.Create(purchase => + expensive.Invoke(purchase) || purchase.Description.Contains("service")); + + return db.Purchases + .Where(combined) + .OrderBy(purchase => purchase.Description) + .Select(purchase => purchase.Description); + } + + public static IQueryable ProductsMatchingAllKeywords( + ExampleDbContext db, + params string[] keywords) + { + var predicate = keywords + .Select(keyword => Condition.Create(product => product.Description.Contains(keyword))) + .Aggregate(Condition.True, (current, next) => current.And(next)); + + return db.Products + .Where(predicate) + .OrderBy(product => product.Description) + .Select(product => product.Description); + } + + public static IQueryable ProductsMatchingAnyKeyword( + ExampleDbContext db, + params string[] keywords) + { + var predicate = keywords + .Select(keyword => Condition.Create(product => product.Description.Contains(keyword))) + .Aggregate(Condition.False, (current, next) => current.Or(next)); + + return db.Products + .Where(predicate) + .OrderBy(product => product.Description) + .Select(product => product.Description); + } + + public static IQueryable NestedProductCriteria(ExampleDbContext db) + { + var descriptions = Condition.Create(product => product.Description.Contains("foo")) + .Or(product => product.Description.Contains("far")); + + var predicate = Condition.Create(product => product.Price > 100m) + .And(product => product.Price < 1_000m) + .And(descriptions); + + return db.Products + .Where(predicate) + .OrderBy(product => product.Description) + .Select(product => product.Description); + } + + public static IQueryable ProductsFromReusableRuleScenario( + ExampleDbContext db, + DateTime recentSaleCutoff) + { + var newKids = ContainsInDescription("BlackBerry", "iPhone"); + var classics = ContainsInDescription("Nokia", "Ericsson") + .And(IsSelling(recentSaleCutoff)); + + return db.Products + .Where(newKids.Or(classics)) + .OrderBy(product => product.Description) + .Select(product => product.Description); + } + + public static IQueryable CurrentPriceListsStartingWith( + ExampleDbContext db, + DateTime asOf, + string prefix) + { + var predicate = IsCurrent(asOf) + .And(priceList => priceList.Name.StartsWith(prefix)); + + return db.PriceLists + .Where(predicate) + .OrderBy(priceList => priceList.Name) + .Select(priceList => priceList.Name); + } + + public static IQueryable DailyOrderAverages(ExampleDbContext db) + { + var average = Projection, double?>.Create(orders => + orders.Average(order => (double?)order.Amount)); + + return + from order in db.Orders.AsRaffinertQuery() + group order by order.OrderDate into orders + orderby orders.Key + select new DailyAverage( + orders.Key, + average.Invoke(orders.AsQueryable())); + } + + private static Condition IsCurrent(DateTime asOf) + where TEntity : IValidFromTo => + Condition.Create(entity => + (entity.ValidFrom == null || entity.ValidFrom <= asOf) && + (entity.ValidTo == null || entity.ValidTo >= asOf)); + + private static Condition ContainsInDescription(params string[] keywords) => + keywords + .Select(keyword => Condition.Create(product => product.Description.Contains(keyword))) + .Aggregate(Condition.False, (current, next) => current.Or(next)); + + private static Condition IsSelling(DateTime recentSaleCutoff) => + Condition.Create(product => + !product.Discontinued && product.LastSale > recentSaleCutoff); +} diff --git a/examples/LinqKitComparison/RaffinertSpecificExamples.cs b/examples/LinqKitComparison/RaffinertSpecificExamples.cs new file mode 100644 index 0000000..d3d40a9 --- /dev/null +++ b/examples/LinqKitComparison/RaffinertSpecificExamples.cs @@ -0,0 +1,202 @@ +using Microsoft.EntityFrameworkCore; +using Raffinert.Expressions; + +namespace LinqKitComparison; + +/// +/// Demonstrates higher-level Raffinert APIs which LINQKit does not provide. Equivalent behavior would require +/// application-specific expression-tree or object-mapping code rather than another LINQKit call. +/// +public static class RaffinertSpecificExamples +{ + public static async Task RunAsync(ExampleDbContext db, bool showSql) + { + Console.WriteLine(); + Console.WriteLine("=== Raffinert-specific APIs (no direct LINQKit equivalent) ==="); + + await StructuralAdaptationAsync(db, showSql); + await TypedForwardCompositionAsync(db, showSql); + await MergeProjectionBindingsAsync(db, showSql); + NullSafeProjection(); + await MapToExistingAsync(db); + } + + private static async Task StructuralAdaptationAsync(ExampleDbContext db, bool showSql) + { + var templateCondition = Condition.Create(product => + product.Price >= 300m && product.Description.Contains("phone")); + var templateProjection = Projection.Create(product => + new ProductCardTemplate + { + Id = product.Id, + Description = product.Description, + Price = product.Price, + IsPremium = product.Price >= 800m + }); + + // Raffinert rebinds compatible public members by name, including construction of a different result type. + // LINQKit expands expression invocation, but has no source/result structural-adaptation API. + var condition = templateCondition.AdaptSource(); + var projection = templateProjection.Adapt(); + var query = db.Products + .Where(condition) + .OrderBy(product => product.Description) + .Select(projection); + + WriteSqlIfRequested("Structural adaptation", query, showSql); + var rows = await query.ToArrayAsync(); + + Console.WriteLine(); + Console.WriteLine("Structural adaptation:"); + Console.WriteLine($" {Format(rows)}"); + } + + private static async Task TypedForwardCompositionAsync(ExampleDbContext db, bool showSql) + { + var price = Projection.Create(product => product.Price); + var withTax = Projection.Create(value => value * 1.25m); + var premium = Condition.Create(value => value >= 1_000m); + + // Then preserves the intermediate types and returns a Condition. LINQKit can reproduce the + // final tree with Invoke/Expand, but does not provide a typed projection-to-projection/condition API. + var premiumAfterTax = price.Then(withTax).Then(premium); + var query = db.Products + .Where(premiumAfterTax) + .OrderBy(product => product.Description) + .Select(product => product.Description); + + WriteSqlIfRequested("Typed forward composition", query, showSql); + var descriptions = await query.ToArrayAsync(); + + Console.WriteLine(); + Console.WriteLine("Typed forward composition with Then:"); + Console.WriteLine($" {Format(descriptions)}"); + } + + private static async Task MergeProjectionBindingsAsync(ExampleDbContext db, bool showSql) + { + var identity = Projection.Create(product => new ProductCard + { + Id = product.Id, + Description = product.Description + }); + var commercial = Projection.Create(product => new ProductCard + { + Price = product.Price, + IsPremium = product.Price >= 800m + }); + + // MergeBindings combines member initializers and has explicit duplicate-member conflict policies. + // LINQKit has no projection-binding merge operation. + var merged = identity.MergeBindings(commercial); + var query = db.Products + .OrderBy(product => product.Description) + .Select(merged); + + WriteSqlIfRequested("Merged projection bindings", query, showSql); + var rows = await query.ToArrayAsync(); + + Console.WriteLine(); + Console.WriteLine("Merged projection bindings:"); + Console.WriteLine($" {Format(rows)}"); + } + + private static void NullSafeProjection() + { + var card = Projection.Create(product => new ProductCard + { + Id = product.Id, + Description = product.Description, + Price = product.Price, + IsPremium = product.Price >= 800m + }); + var optionalCard = Projection.Create(value => + card.InvokeOrDefault(value.Product)); + + // InvokeOrDefault expands to a conditional and returns default when the source is null. + // A LINQKit expression must spell out that null conditional itself. + var missing = optionalCard.Invoke(new OptionalProduct()); + var present = optionalCard.Invoke(new OptionalProduct + { + Product = new Product { Description = "Portable screen", Price = 850m } + }); + + Console.WriteLine(); + Console.WriteLine("Null-safe nested projection with InvokeOrDefault:"); + Console.WriteLine($" missing = {missing?.ToString() ?? ""}; present = {present}"); + } + + private static async Task MapToExistingAsync(ExampleDbContext db) + { + var map = Projection.Create(product => new ProductCard + { + Id = product.Id, + Description = product.Description, + Price = product.Price, + IsPremium = product.Price >= 800m + }); + var product = await db.Products + .AsNoTracking() + .OrderByDescending(value => value.Price) + .FirstAsync(); + ProductCard? destination = new() + { + Id = -1, + Description = "Existing instance", + Price = -1m + }; + var original = destination; + + // MapToExisting compiles an update action from the member initializer and preserves the root instance. + // LINQKit is an expression-expansion library and has no object-update/mapping API. + map.MapToExisting(product, ref destination); + + if (!ReferenceEquals(original, destination)) + throw new InvalidOperationException("MapToExisting unexpectedly replaced the destination instance."); + + Console.WriteLine(); + Console.WriteLine("Map projection to an existing object:"); + Console.WriteLine($" same instance = {ReferenceEquals(original, destination)}; value = {destination}"); + } + + private static void WriteSqlIfRequested(string title, IQueryable query, bool showSql) + { + if (!showSql) return; + + Console.WriteLine(); + Console.WriteLine($"-- Raffinert.Expressions: {title}"); + Console.WriteLine(query.ToQueryString()); + } + + private static string Format(IEnumerable values) => string.Join(", ", values); +} + +public sealed class ProductTemplate +{ + public int Id { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Price { get; set; } +} + +public sealed class ProductCardTemplate +{ + public int Id { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Price { get; set; } + public bool IsPremium { get; set; } +} + +public sealed class ProductCard +{ + public int Id { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Price { get; set; } + public bool IsPremium { get; set; } + + public override string ToString() => $"{Description} ({Price:C}, premium: {IsPremium})"; +} + +public sealed class OptionalProduct +{ + public Product? Product { get; set; } +} diff --git a/tests/Raffinert.Expressions.IntegrationTests/EfCoreExpressionTests.cs b/tests/Raffinert.Expressions.IntegrationTests/EfCoreExpressionTests.cs index 3f8c6b1..c44ed71 100644 --- a/tests/Raffinert.Expressions.IntegrationTests/EfCoreExpressionTests.cs +++ b/tests/Raffinert.Expressions.IntegrationTests/EfCoreExpressionTests.cs @@ -136,10 +136,11 @@ public async Task ThenAndDeepMixedCompositionTranslateProjectionAsConditionAndCo var composedCondition = composedScalar.Then(threshold); var mixed = Projection.Create(product => composedCondition.Invoke(product)); - var values = await _db.Products + var query = _db.Products .Where(mixed) - .Select(composedCondition) - .ToArrayAsync(); + .Select(composedCondition); + + var values = await query.ToArrayAsync(); Assert.Equal( "product => (product.PriceCents * 2) >= 3000",