diff --git a/.editorconfig b/.editorconfig index 30882e46..5affb9be 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,6 +8,7 @@ #use soft tabs (spaces) for indentation indent_style = space +indent_size = 4 #Formatting - new line options @@ -131,3 +132,7 @@ dotnet_naming_symbols.instance_fields.applicable_kinds = field dotnet_naming_style.instance_field_style.capitalization = camel_case dotnet_naming_style.instance_field_style.required_prefix = _ + +[*.{csproj,props,targets}] +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/.gitignore b/.gitignore index a5b1eb71..bc526d3f 100644 --- a/.gitignore +++ b/.gitignore @@ -370,3 +370,4 @@ FodyWeavers.xsd # Docs /docs/.vitepress/cache/ /docs/.vitepress/dist/ +/ReadmeSample.db diff --git a/Directory.Packages.props b/Directory.Packages.props index ff81c4d0..26fd55d1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,6 +21,7 @@ + diff --git a/samples/ReadmeSample/ApplicationDbContext.cs b/samples/ReadmeSample/ApplicationDbContext.cs index 56ee87bb..1d3b5229 100644 --- a/samples/ReadmeSample/ApplicationDbContext.cs +++ b/samples/ReadmeSample/ApplicationDbContext.cs @@ -1,31 +1,28 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography.X509Certificates; -using System.Text; -using System.Threading.Tasks; -using EntityFrameworkCore.Projectables.Extensions; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using ReadmeSample.Entities; -namespace ReadmeSample +namespace ReadmeSample; + +public class ApplicationDbContext : DbContext { - public class ApplicationDbContext : DbContext - { - public DbSet Users { get; set; } - public DbSet Products { get; set; } - public DbSet Orders { get; set; } + public DbSet Users => Set(); + public DbSet Products => Set(); + public DbSet Orders => Set(); + public DbSet Suppliers => Set(); - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseSqlServer("Server=(localdb)\\MSSQLLocalDB;Database=ReadmeSample;Trusted_Connection=True"); - optionsBuilder.UseProjectables(); - } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseSqlServer("Server=(localdb)\\MSSQLLocalDB;Database=ReadmeSample;Trusted_Connection=True"); - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity().HasKey(x => new { x.OrderId, x.ProductId }); - } + // Feature 10: Compatibility mode + // Full (default) — expands every query on each invocation; maximum compatibility. + // Limited — expands once, then caches; better performance for repeated queries. + // Switch with: optionsBuilder.UseProjectables(p => p.CompatibilityMode(CompatibilityMode.Limited)); + optionsBuilder.UseProjectables(); + } + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(x => new { x.OrderId, x.ProductId }); } } diff --git a/samples/ReadmeSample/ConsoleHelper.cs b/samples/ReadmeSample/ConsoleHelper.cs new file mode 100644 index 00000000..e57153bb --- /dev/null +++ b/samples/ReadmeSample/ConsoleHelper.cs @@ -0,0 +1,59 @@ +using System.Text.RegularExpressions; +using Spectre.Console; + +namespace ReadmeSample; + +/// Spectre.Console rendering helpers used by Program.cs. +static internal partial class ConsoleHelper +{ + /// + /// Applies Spectre.Console markup to a raw SQL string: + /// string literals → green, SQL keywords → bold cyan, ef_* helpers → dim grey. + /// + private static string SqlMarkup(string sql) + { + // Escape [ and ] so Spectre doesn't misinterpret them as markup tags. + var esc = Markup.Escape(sql); + + // One-pass regex — order of alternatives matters: + // group 1 → single-quoted string literals + // group 2 → multi-word keywords (INNER JOIN, LEFT JOIN, ORDER BY, GROUP BY) + // group 3 → single-word SQL keywords + // group 4 → SQLite-specific ef_* helper functions + return SqlHighlightRegex().Replace(esc, m => + { + if (m.Groups[1].Success) return $"[green]{m.Value}[/]"; + if (m.Groups[2].Success || m.Groups[3].Success) return $"[bold deepskyblue1]{m.Value}[/]"; + if (m.Groups[4].Success) return $"[grey50]{m.Value}[/]"; + return m.Value; + }); + } + + /// Renders a numbered feature section header using a yellow rule. + public static void Section(int n, string title) + { + AnsiConsole.WriteLine(); + AnsiConsole.Write( + new Rule($"[bold yellow]Feature {n}[/] — [white]{Markup.Escape(title)}[/]") + .LeftJustified() + .RuleStyle("dim yellow")); + AnsiConsole.WriteLine(); + } + + /// Renders a SQL string inside a rounded panel with syntax highlighting. + public static void ShowSql(string sql) + { + AnsiConsole.Write(new Panel(new Markup(SqlMarkup(sql))) + { + Header = new PanelHeader("[grey50] SQL [/]"), + Border = BoxBorder.Rounded, + BorderStyle = Style.Parse("grey"), + Padding = new Padding(1, 0, 1, 0), + }); + AnsiConsole.WriteLine(); + } + + [GeneratedRegex(@"('[^']*')|(\bINNER JOIN\b|\bLEFT JOIN\b|\bORDER BY\b|\bGROUP BY\b)|(\bSELECT\b|\bFROM\b|\bWHERE\b|\bCASE\b|\bWHEN\b|\bTHEN\b|\bELSE\b|\bEND\b|\bAND\b|\bOR\b|\bNOT\b|\bNULL\b|\bIS\b|\bIN\b|\bON\b|\bAS\b|\bLIMIT\b|\bCOALESCE\b|\bCAST\b)|(\bef_\w+\b)", RegexOptions.IgnoreCase, matchTimeoutMilliseconds: 500)] + private static partial Regex SqlHighlightRegex(); +} + diff --git a/samples/ReadmeSample/Dtos/OrderSummaryDto.cs b/samples/ReadmeSample/Dtos/OrderSummaryDto.cs new file mode 100644 index 00000000..8b720f79 --- /dev/null +++ b/samples/ReadmeSample/Dtos/OrderSummaryDto.cs @@ -0,0 +1,30 @@ +using EntityFrameworkCore.Projectables; +using ReadmeSample.Entities; + +namespace ReadmeSample.Dtos; + +/// +/// DTO with a [Projectable] constructor — the entire mapping is inlined into SQL by the source generator. +/// +public class OrderSummaryDto +{ + public int Id { get; set; } + public string? UserName { get; set; } + public decimal GrandTotal { get; set; } + public string? StatusName { get; set; } + public string? PriorityLabel { get; set; } + + /// Required parameterless constructor (EFP0008 ensures its presence). + public OrderSummaryDto() { } + + [Projectable] + public OrderSummaryDto(Order order) + { + Id = order.Id; + UserName = order.User.UserName; + GrandTotal = order.GrandTotal; + StatusName = order.StatusDisplayName; + PriorityLabel = order.PriorityLabel; + } +} + diff --git a/samples/ReadmeSample/Entities/Order.cs b/samples/ReadmeSample/Entities/Order.cs index 19c5f4ee..a8c7510e 100644 --- a/samples/ReadmeSample/Entities/Order.cs +++ b/samples/ReadmeSample/Entities/Order.cs @@ -1,26 +1,92 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using EntityFrameworkCore.Projectables; - -namespace ReadmeSample.Entities +using EntityFrameworkCore.Projectables; + +namespace ReadmeSample.Entities; + +public class Order { - public class Order - { - public int Id { get; set; } - public int UserId { get; set; } - public DateTime CreatedDate { get; set; } - public DateTime? FulfilledDate { get; set; } + public int Id { get; set; } + public int UserId { get; set; } + public DateTime CreatedDate { get; set; } + public DateTime? FulfilledDate { get; set; } + public decimal TaxRate { get; set; } + public OrderStatus Status { get; set; } + + public User User { get; set; } = null!; + public ICollection Items { get; set; } = []; + + // ── Feature 1: Projectable properties (compose each other recursively) ────── + + /// Sum of (unit price × quantity) for all items — inlined into SQL. + [Projectable] public decimal Subtotal => Items.Sum(item => item.Product.ListPrice * item.Quantity); + + /// Tax amount (Subtotal × TaxRate) — composed from another [Projectable]. + [Projectable] public decimal Tax => Subtotal * TaxRate; + + /// Total including tax — composed from two [Projectable] properties. + [Projectable] public decimal GrandTotal => Subtotal + Tax; + + /// True when the order has been fulfilled — usable in .Where() filters. + [Projectable] public bool IsFulfilled => FulfilledDate != null; + + // ── Feature 1 (method): Projectable method with a parameter ───────────────── - public decimal TaxRate { get; set; } + /// Grand total after applying a percentage discount — demonstrates a [Projectable] method. + [Projectable] + public decimal GetDiscountedTotal(decimal discountPct) => GrandTotal * (1 - discountPct); - public User User { get; set; } - public ICollection Items { get; set; } + // ── Feature 5: Pattern matching — switch expression ────────────────────────── - [Projectable] public decimal Subtotal => Items.Sum(item => item.Product.ListPrice * item.Quantity); - [Projectable] public decimal Tax => Subtotal * TaxRate; - [Projectable] public decimal GrandTotal => Subtotal * TaxRate; + /// + /// Priority label derived from GrandTotal using a switch expression. + /// The generator rewrites this into SQL CASE WHEN expressions. + /// + [Projectable] + public string PriorityLabel => GrandTotal switch + { + >= 100m => "High", + >= 30m => "Medium", + _ => "Low", + }; + + // ── Feature 6: Block-bodied member (experimental) ──────────────────────────── + + /// + /// Shipping category determined via an if/else block body. + /// AllowBlockBody = true acknowledges the experimental nature (suppresses EFP0001). + /// The block is converted to a ternary expression — identical SQL to the switch above. + /// + [Projectable(AllowBlockBody = true)] + public string GetShippingCategory() + { + if (GrandTotal >= 100m) + return "Express"; + else if (GrandTotal >= 30m) + return "Standard"; + else + return "Economy"; } + + // ── Feature 8: Enum method expansion ───────────────────────────────────────── + + /// + /// Human-readable status label. + /// ExpandEnumMethods = true makes the generator enumerate every OrderStatus value at + /// compile time and bake the results in as a SQL CASE expression — the GetDisplayName() + /// method itself never runs at runtime. + /// + [Projectable(ExpandEnumMethods = true)] + public string StatusDisplayName => Status.GetDisplayName(); + + // ── Feature 9: UseMemberBody ────────────────────────────────────────────────── + + // Private EF-compatible expression — the actual body EF Core will use. + private bool IsHighValueOrderImpl => GrandTotal >= 50m; + + /// + /// UseMemberBody delegates the expression source to IsHighValueOrderImpl. + /// The annotated member's own body is ignored by the generator; the target + /// member's body is used as the expression tree instead. + /// + [Projectable(UseMemberBody = nameof(IsHighValueOrderImpl))] + public bool IsHighValueOrder => IsHighValueOrderImpl; } diff --git a/samples/ReadmeSample/Entities/OrderItem.cs b/samples/ReadmeSample/Entities/OrderItem.cs index a74b9455..5bbf8f31 100644 --- a/samples/ReadmeSample/Entities/OrderItem.cs +++ b/samples/ReadmeSample/Entities/OrderItem.cs @@ -1,12 +1,11 @@ -namespace ReadmeSample.Entities +namespace ReadmeSample.Entities; + +public class OrderItem { - public class OrderItem - { - public int OrderId { get; set; } - public int ProductId { get; set; } - public int Quantity { get; set; } + public int OrderId { get; set; } + public int ProductId { get; set; } + public int Quantity { get; set; } - public Order Order { get; set; } - public Product Product { get; set; } - } + public Order Order { get; set; } = null!; + public Product Product { get; set; } = null!; } diff --git a/samples/ReadmeSample/Entities/OrderStatus.cs b/samples/ReadmeSample/Entities/OrderStatus.cs new file mode 100644 index 00000000..1dcebe81 --- /dev/null +++ b/samples/ReadmeSample/Entities/OrderStatus.cs @@ -0,0 +1,26 @@ +namespace ReadmeSample.Entities; + +public enum OrderStatus +{ + Pending, + Fulfilled, + Cancelled, +} + +public static class OrderStatusExtensions +{ + /// + /// Plain C# method — not [Projectable]. Used with ExpandEnumMethods = true. + /// The generator evaluates this at compile time for every enum value and bakes + /// the results into a CASE expression EF Core can translate to SQL. + /// + public static string GetDisplayName(this OrderStatus status) => + status switch + { + OrderStatus.Pending => "Pending Review", + OrderStatus.Fulfilled => "Fulfilled", + OrderStatus.Cancelled => "Cancelled", + _ => status.ToString(), + }; +} + diff --git a/samples/ReadmeSample/Entities/Product.cs b/samples/ReadmeSample/Entities/Product.cs index 488213b0..4d98f573 100644 --- a/samples/ReadmeSample/Entities/Product.cs +++ b/samples/ReadmeSample/Entities/Product.cs @@ -1,11 +1,21 @@ -namespace ReadmeSample.Entities +using EntityFrameworkCore.Projectables; + +namespace ReadmeSample.Entities; + +public class Product { - public class Product - { - public int Id { get; set; } + public int Id { get; set; } + public required string Name { get; set; } + public decimal ListPrice { get; set; } - public string Name { get; set; } + // Optional supplier — foreign key is nullable so the join is a LEFT JOIN in SQL. + public int? SupplierId { get; set; } + public Supplier? Supplier { get; set; } - public decimal ListPrice { get; set; } - } + /// + /// Null-conditional rewriting (NullConditionalRewriteSupport.Ignore): + /// the ?. operator is stripped and EF Core handles nullability via the LEFT JOIN. + /// + [Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] + public string? SupplierName => Supplier?.Name; } diff --git a/samples/ReadmeSample/Entities/Supplier.cs b/samples/ReadmeSample/Entities/Supplier.cs new file mode 100644 index 00000000..4b5a61d7 --- /dev/null +++ b/samples/ReadmeSample/Entities/Supplier.cs @@ -0,0 +1,11 @@ +namespace ReadmeSample.Entities; + +/// Optional supplier linked to a product — used to demonstrate null-conditional rewriting. +public class Supplier +{ + public int Id { get; set; } + public required string Name { get; set; } + + public ICollection Products { get; set; } = []; +} + diff --git a/samples/ReadmeSample/Entities/User.cs b/samples/ReadmeSample/Entities/User.cs index 596c161f..74e374f2 100644 --- a/samples/ReadmeSample/Entities/User.cs +++ b/samples/ReadmeSample/Entities/User.cs @@ -1,16 +1,10 @@ -using System.Collections; -using System.Collections.Generic; +namespace ReadmeSample.Entities; -namespace ReadmeSample.Entities +public class User { - public class User - { - public int Id { get; set; } + public int Id { get; set; } + public required string UserName { get; set; } + public required string EmailAddress { get; set; } - public string UserName { get; set; } - - public string EmailAddress { get; set; } - - public ICollection Orders { get; set; } - } + public ICollection Orders { get; set; } = []; } diff --git a/samples/ReadmeSample/Extensions/UserExtensions.cs b/samples/ReadmeSample/Extensions/UserExtensions.cs index 80437a51..56bad7da 100644 --- a/samples/ReadmeSample/Extensions/UserExtensions.cs +++ b/samples/ReadmeSample/Extensions/UserExtensions.cs @@ -1,20 +1,28 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using EntityFrameworkCore.Projectables; +using EntityFrameworkCore.Projectables; using ReadmeSample.Entities; -namespace ReadmeSample.Extensions +namespace ReadmeSample.Extensions; + +public static class UserExtensions { - public static class UserExtensions - { - [Projectable] - public static Order GetMostRecentOrderForUser(this User user, bool includeUnfulfilled) => - user.Orders - .Where(x => !includeUnfulfilled ? x .FulfilledDate != null : true) - .OrderByDescending(x => x.CreatedDate) - .FirstOrDefault(); - } + /// + /// Returns the most recent order for the user. + /// Matches the README example — inlined into SQL via [Projectable]. + /// + [Projectable] + public static Order? GetMostRecentOrder(this User user) => + user.Orders + .OrderByDescending(x => x.CreatedDate) + .FirstOrDefault(); + + /// + /// Returns the most recent order with an optional filter on fulfillment status. + /// Demonstrates method overloads: both variants are supported. + /// + [Projectable] + public static Order? GetMostRecentOrderForUser(this User user, bool includeUnfulfilled) => + user.Orders + .Where(x => includeUnfulfilled || x.FulfilledDate != null) + .OrderByDescending(x => x.CreatedDate) + .FirstOrDefault(); } diff --git a/samples/ReadmeSample/Program.cs b/samples/ReadmeSample/Program.cs index ca242705..46b2c447 100644 --- a/samples/ReadmeSample/Program.cs +++ b/samples/ReadmeSample/Program.cs @@ -1,35 +1,171 @@ -using ReadmeSample; -using ReadmeSample.Entities; +using Microsoft.EntityFrameworkCore; +using ReadmeSample; +using ReadmeSample.Dtos; using ReadmeSample.Extensions; +using Spectre.Console; +using static ReadmeSample.ConsoleHelper; -using var dbContext = new ApplicationDbContext(); - -// recreate database -dbContext.Database.EnsureDeleted(); -dbContext.Database.EnsureCreated(); - -// Populate with seed data -var sampleUser = new User { UserName = "Jon", EmailAddress = "jon@doe.com" }; -var sampleProduct = new Product { Name = "Blue Pen", ListPrice = 1.5m }; -var sampleOrder = new Order { - User = sampleUser, - TaxRate = .19m, - CreatedDate = DateTime.UtcNow.AddDays(-1), - FulfilledDate = DateTime.UtcNow, - Items = new List { - new OrderItem { Product = sampleProduct, Quantity = 5 } - } -}; - -dbContext.AddRange(sampleUser, sampleProduct, sampleOrder); -dbContext.SaveChanges(); - -var query = dbContext.Users - .Where(x => x.UserName == sampleUser.UserName) - .Select(x => new { - GrandTotal = x.GetMostRecentOrderForUser(/* includeUnfulfilled: */ false).GrandTotal - }); - -var result = query.First(); - -Console.WriteLine($"Jons latest order had a grant total of {result.GrandTotal}"); +// ───────────────────────────────────────────────────────────────────────────── +// Banner +// ───────────────────────────────────────────────────────────────────────────── +AnsiConsole.Write(new Panel( + "[bold yellow]EntityFrameworkCore.Projectables[/] — Feature Tour\n" + + "[dim]SQL Server · .NET 10 · All 11 features from the README demonstrated[/]") +{ + Border = BoxBorder.Double, + BorderStyle = Style.Parse("yellow dim"), + Padding = new Padding(2, 0, 2, 0), +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Bootstrap +// ───────────────────────────────────────────────────────────────────────────── +await using var dbContext = new ApplicationDbContext(); + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 1 — Properties & methods +// ───────────────────────────────────────────────────────────────────────────── +Section(1, "Properties & methods"); + +var totalsQuery = dbContext.Orders.Select(o => new +{ + o.Id, o.Subtotal, o.Tax, o.GrandTotal, + Discounted10Pct = o.GetDiscountedTotal(0.10m), +}); + +ShowSql(totalsQuery.ToQueryString()); + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 2 — Extension methods +// ───────────────────────────────────────────────────────────────────────────── +Section(2, "Extension methods"); + +var recentQuery = dbContext.Users + .Where(u => u.UserName == "Jon") + .Select(u => new { u.UserName, LatestOrderGrandTotal = u.GetMostRecentOrder()!.GrandTotal }); + +ShowSql(recentQuery.ToQueryString()); + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 3 — Constructor projections +// ───────────────────────────────────────────────────────────────────────────── +Section(3, "Constructor projections → new OrderSummaryDto(o)"); + +var dtoQuery = dbContext.Orders.Select(o => new OrderSummaryDto(o)); + +ShowSql(dtoQuery.ToQueryString()); + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 4 — Method overloads +// ───────────────────────────────────────────────────────────────────────────── +Section(4, "Method overloads"); + +var withPendingQuery = dbContext.Users + .Where(u => u.UserName == "Jon") + .Select(u => new { u.UserName, LatestAnyOrderTotal = u.GetMostRecentOrderForUser(true)!.GrandTotal }); + +ShowSql(withPendingQuery.ToQueryString()); + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 5 — Pattern matching +// ───────────────────────────────────────────────────────────────────────────── +Section(5, "Pattern matching → switch expression becomes SQL CASE WHEN"); + +var priorityQuery = dbContext.Orders.Select(o => new { o.Id, o.GrandTotal, o.PriorityLabel }); + +ShowSql(priorityQuery.ToQueryString()); + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 6 — Block-bodied members +// ───────────────────────────────────────────────────────────────────────────── +Section(6, "Block-bodied members → AllowBlockBody = true"); + +var shippingQuery = dbContext.Orders.Select(o => new { o.Id, ShippingCategory = o.GetShippingCategory() }); + +ShowSql(shippingQuery.ToQueryString()); + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 7 — Null-conditional rewriting +// ───────────────────────────────────────────────────────────────────────────── +Section(7, "Null-conditional rewriting → NullConditionalRewriteSupport.Ignore"); + +var supplierQuery = dbContext.Products.Select(p => new { p.Name, p.SupplierName }); + +ShowSql(supplierQuery.ToQueryString()); + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 8 — Enum method expansion +// ───────────────────────────────────────────────────────────────────────────── +Section(8, "Enum method expansion → ExpandEnumMethods = true"); + +var statusQuery = dbContext.Orders.Select(o => new { o.Id, o.Status, o.StatusDisplayName }); + +ShowSql(statusQuery.ToQueryString()); + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 9 — UseMemberBody +// ───────────────────────────────────────────────────────────────────────────── +Section(9, "UseMemberBody → expression sourced from a private member"); + +var highValueQuery = dbContext.Orders.Select(o => new { o.Id, o.GrandTotal, o.IsHighValueOrder }); + +ShowSql(highValueQuery.ToQueryString()); + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 10 — Compatibility mode +// ───────────────────────────────────────────────────────────────────────────── +Section(10, "Compatibility mode → UseProjectables(p => p.CompatibilityMode(...))"); + +var modeTable = new Table { Border = TableBorder.Rounded, BorderStyle = Style.Parse("grey") }; +modeTable.AddColumn(new TableColumn("[bold]Mode[/]")); +modeTable.AddColumn(new TableColumn("[bold]When expansion runs[/]")); +modeTable.AddColumn(new TableColumn("[bold]Query cache[/]")); +modeTable.AddColumn(new TableColumn("[bold]Performance[/]")); +modeTable.AddRow( + "[bold]Full[/] [dim](default)[/]", + "Every query invocation", + "Per query shape", + "Baseline"); +modeTable.AddRow( + "[bold]Limited[/]", + "First invocation, then cached", + "[bold green]Reused[/]", + "[bold chartreuse1]Often faster than vanilla EF[/]"); +AnsiConsole.Write(modeTable); +AnsiConsole.WriteLine(); +AnsiConsole.MarkupLine(" Configure in [cyan]ApplicationDbContext.OnConfiguring[/]:"); +AnsiConsole.MarkupLine(" [dim]// Full (default)[/]"); +AnsiConsole.MarkupLine(" [cyan]optionsBuilder.UseProjectables()[/]"); +AnsiConsole.MarkupLine(" [dim]// Limited[/]"); +AnsiConsole.MarkupLine(" [cyan]optionsBuilder.UseProjectables(p => p.CompatibilityMode(CompatibilityMode.Limited))[/]"); + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 11 — Roslyn analyzers & code fixes +// ───────────────────────────────────────────────────────────────────────────── +Section(11, "Roslyn analyzers & code fixes → EFP0001–EFP0012 (compile-time)"); + +var diagTable = new Table { Border = TableBorder.Rounded, BorderStyle = Style.Parse("grey") }; +diagTable.AddColumn(new TableColumn("[bold]Code[/]").Centered()); +diagTable.AddColumn(new TableColumn("[bold]Triggered when…[/]")); +diagTable.AddColumn(new TableColumn("[bold]IDE quick-fix[/]")); +diagTable.AddRow( + "[bold yellow]EFP0001[/]", + "Block body without [cyan]AllowBlockBody = true[/]", + "Add [cyan]AllowBlockBody = true[/]"); +diagTable.AddRow( + "[bold red1]EFP0002[/]", + "[cyan]?.[/] used without [cyan]NullConditionalRewriteSupport[/]", + "Choose [cyan]Ignore[/] or [cyan]Rewrite[/]"); +diagTable.AddRow( + "[bold red1]EFP0008[/]", + "DTO class missing parameterless constructor", + "Insert parameterless constructor"); +diagTable.AddRow( + "[bold blue]EFP0012[/]", + "Factory method can be converted to a [cyan][[Projectable]][/] constructor", + "Convert & update call sites"); +AnsiConsole.Write(diagTable); +AnsiConsole.WriteLine(); +AnsiConsole.MarkupLine( + " [dim]Diagnostics are reported at compile time in the IDE — see source comments for examples.[/]"); +AnsiConsole.WriteLine(); diff --git a/samples/ReadmeSample/README.md b/samples/ReadmeSample/README.md new file mode 100644 index 00000000..d5fe4176 --- /dev/null +++ b/samples/ReadmeSample/README.md @@ -0,0 +1,258 @@ +# ReadmeSample + +A runnable sample illustrating **every feature** from the [Features table](../../README.md) of EntityFrameworkCore.Projectables, using a **local SQLite database** created automatically on startup. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- No database server required — SQLite is embedded. + +## Running the sample + +```bash +dotnet run --project samples/ReadmeSample +``` + +The `ReadmeSample.db` file is recreated automatically on every run (`EnsureDeleted` / `EnsureCreated`). +Each section prints the generated SQL followed by the query results. + +--- + +## Project structure + +``` +ReadmeSample/ +├── Program.cs # Entry point — 11 numbered feature demos +├── ApplicationDbContext.cs # SQLite DbContext with UseProjectables() +├── Entities/ +│ ├── User.cs # User with an Orders collection +│ ├── Order.cs # Order entity — most features live here +│ ├── OrderItem.cs # Order line item (composite primary key) +│ ├── Product.cs # Product with optional Supplier navigation +│ ├── Supplier.cs # Optional supplier (for null-conditional demo) +│ └── OrderStatus.cs # Enum + GetDisplayName() (for enum expansion demo) +├── Dtos/ +│ └── OrderSummaryDto.cs # DTO with a [Projectable] constructor +└── Extensions/ + └── UserExtensions.cs # [Projectable] extension methods on User +``` + +--- + +## Features demonstrated + +All features from the [root README features table](../../README.md#features-v6x) are covered. + +### Feature 1 — Properties & methods + +**Properties** compose each other recursively — `GrandTotal` inlines `Subtotal` and `Tax`: + +```csharp +[Projectable] public decimal Subtotal => Items.Sum(item => item.Product.ListPrice * item.Quantity); +[Projectable] public decimal Tax => Subtotal * TaxRate; +[Projectable] public decimal GrandTotal => Subtotal + Tax; +``` + +**Methods** accept parameters and are equally inlined into SQL: + +```csharp +[Projectable] +public decimal GetDiscountedTotal(decimal discountPct) => GrandTotal * (1 - discountPct); +``` + +### Feature 2 — Extension methods + +The extension method body is inlined as a correlated subquery: + +```csharp +// Extensions/UserExtensions.cs +[Projectable] +public static Order? GetMostRecentOrder(this User user) => + user.Orders.OrderByDescending(x => x.CreatedDate).FirstOrDefault(); +``` + +### Feature 3 — Constructor projections + +Mark a constructor with `[Projectable]` to project a DTO entirely in SQL — no client-side mapping: + +```csharp +// Dtos/OrderSummaryDto.cs +public OrderSummaryDto() { } // required parameterless ctor (EFP0008 ensures its presence) + +[Projectable] +public OrderSummaryDto(Order order) +{ + Id = order.Id; + UserName = order.User.UserName; + GrandTotal = order.GrandTotal; // other [Projectable] members are recursively inlined + StatusName = order.StatusDisplayName; + PriorityLabel = order.PriorityLabel; +} + +// Usage +dbContext.Orders.Select(o => new OrderSummaryDto(o)); +``` + +### Feature 4 — Method overloads + +Both overloads of `GetMostRecentOrderForUser` are independently supported; each generates its own expression class: + +```csharp +[Projectable] +public static Order? GetMostRecentOrder(this User user) => …; + +[Projectable] +public static Order? GetMostRecentOrderForUser(this User user, bool includeUnfulfilled) => …; +``` + +### Feature 5 — Pattern matching (`switch`, `is`) + +Switch expressions are rewritten into SQL `CASE WHEN` expressions: + +```csharp +[Projectable] +public string PriorityLabel => GrandTotal switch +{ + >= 100m => "High", + >= 30m => "Medium", + _ => "Low", +}; +``` + +Generated SQL: +```sql +CASE WHEN GrandTotal >= 100 THEN 'High' + WHEN GrandTotal >= 30 THEN 'Medium' + ELSE 'Low' END +``` + +### Feature 6 — Block-bodied members (experimental) + +`if`/`else` block bodies are converted to ternary expressions, producing identical SQL to a switch expression. +`AllowBlockBody = true` acknowledges the experimental nature and suppresses warning **EFP0001**: + +```csharp +[Projectable(AllowBlockBody = true)] +public string GetShippingCategory() +{ + if (GrandTotal >= 100m) + return "Express"; + else if (GrandTotal >= 30m) + return "Standard"; + else + return "Economy"; +} +``` + +### Feature 7 — Null-conditional rewriting + +`Supplier?.Name` uses the null-conditional operator, which cannot be expressed in an `Expression` directly. +`NullConditionalRewriteSupport.Ignore` strips the `?.` — EF Core handles nullability via a `LEFT JOIN`: + +```csharp +// Entities/Product.cs +[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] +public string? SupplierName => Supplier?.Name; +``` + +Generated SQL: +```sql +SELECT p.Name, s.Name AS SupplierName +FROM Products p +LEFT JOIN Suppliers s ON p.SupplierId = s.Id +``` + +> Use `NullConditionalRewriteSupport.Rewrite` for explicit `CASE WHEN NULL` guards (safer for Cosmos DB). + +### Feature 8 — Enum method expansion + +`GetDisplayName()` is a plain C# method — not `[Projectable]`. With `ExpandEnumMethods = true`, the generator +evaluates it **at compile time** for every enum value and bakes the results into a SQL `CASE` expression. +The method never runs at query time: + +```csharp +// Entities/OrderStatus.cs +public static string GetDisplayName(this OrderStatus status) => status switch +{ + OrderStatus.Pending => "Pending Review", + OrderStatus.Fulfilled => "Fulfilled", + OrderStatus.Cancelled => "Cancelled", + _ => status.ToString(), +}; + +// Entities/Order.cs +[Projectable(ExpandEnumMethods = true)] +public string StatusDisplayName => Status.GetDisplayName(); +``` + +Generated SQL: +```sql +CASE WHEN Status = 0 THEN 'Pending Review' + WHEN Status = 1 THEN 'Fulfilled' + WHEN Status = 2 THEN 'Cancelled' END +``` + +### Feature 9 — `UseMemberBody` + +`UseMemberBody` replaces the annotated member's expression source with another member's body. +Useful when the public member has a different in-memory implementation but you want a clean SQL expression: + +```csharp +// Private EF-compatible expression +private bool IsHighValueOrderImpl => GrandTotal >= 50m; + +// The generator uses IsHighValueOrderImpl's body — the own body is ignored +[Projectable(UseMemberBody = nameof(IsHighValueOrderImpl))] +public bool IsHighValueOrder => IsHighValueOrderImpl; +``` + +### Feature 10 — Compatibility mode + +Configured in `ApplicationDbContext.OnConfiguring`: + +```csharp +// Full (default) — expands every query on each invocation; maximum compatibility +optionsBuilder.UseProjectables(); + +// Limited — expands once then caches; better performance for repeated queries +optionsBuilder.UseProjectables(p => p.CompatibilityMode(CompatibilityMode.Limited)); +``` + +| Mode | Expansion timing | Query cache | Performance | +|-----------|---------------------------|-------------|---------------------| +| `Full` | Every invocation | Per query | Baseline | +| `Limited` | First invocation, cached | Reused | ✅ Often faster than vanilla EF | + +### Feature 11 — Roslyn analyzers & code fixes (EFP0001–EFP0012) + +Compile-time only — not demonstrated at runtime. Diagnostics are reported directly in the IDE: + +| Code | When triggered | Fix available | +|------------|---------------------------------------------------------|----------------------------------| +| `EFP0001` | Block-bodied member without `AllowBlockBody = true` | Add `AllowBlockBody = true` | +| `EFP0002` | `?.` used without configuring `NullConditionalRewriteSupport` | Choose Ignore or Rewrite | +| `EFP0008` | DTO class missing parameterless constructor | Insert parameterless constructor | +| `EFP0012` | Factory method can be a constructor | Convert to `[Projectable]` ctor | + +See the [Diagnostics Reference](https://efnext.github.io/reference/diagnostics) for the full list. + +--- + +## How it works + +1. The **Roslyn Source Generator** (`EntityFrameworkCore.Projectables.Generator`) inspects every `[Projectable]`-annotated member at compile time and emits a companion `Expression` property. +2. The **runtime interceptor** (`UseProjectables()`) hooks into EF Core's query compilation pipeline and substitutes those expression trees in place of the annotated member calls before SQL translation. + +The final SQL contains each member's body **inlined directly** — no C# method calls at runtime, no client-side evaluation, no N+1. + +--- + +## Environment + +| Setting | Value | +|------------------|----------------------------------------------| +| .NET TFM | `net10.0` | +| C# language | 14.0 | +| Database | SQLite (`ReadmeSample.db`, local file) | +| EF Core provider | `Microsoft.EntityFrameworkCore.Sqlite` 10.x | +| Nullable | enabled | diff --git a/samples/ReadmeSample/ReadmeSample.csproj b/samples/ReadmeSample/ReadmeSample.csproj index 72664f6c..25df9fc2 100644 --- a/samples/ReadmeSample/ReadmeSample.csproj +++ b/samples/ReadmeSample/ReadmeSample.csproj @@ -1,14 +1,14 @@  - Exe - disable - false + Exe + net10.0; + false - - + +