From 1fe03b44c0285d3c861e4d91e136b7bbded879cb Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sat, 4 Apr 2026 00:05:15 +0200 Subject: [PATCH 1/5] Update ReadmeSample project --- .gitignore | 1 + samples/ReadmeSample/ApplicationDbContext.cs | 43 ++- samples/ReadmeSample/Dtos/OrderSummaryDto.cs | 30 ++ samples/ReadmeSample/Entities/Order.cs | 97 ++++++- samples/ReadmeSample/Entities/OrderItem.cs | 17 +- samples/ReadmeSample/Entities/OrderStatus.cs | 26 ++ samples/ReadmeSample/Entities/Product.cs | 24 +- samples/ReadmeSample/Entities/Supplier.cs | 11 + samples/ReadmeSample/Entities/User.cs | 18 +- .../ReadmeSample/Extensions/UserExtensions.cs | 40 +-- samples/ReadmeSample/Program.cs | 244 +++++++++++++++-- samples/ReadmeSample/README.md | 258 ++++++++++++++++++ samples/ReadmeSample/ReadmeSample.csproj | 9 +- 13 files changed, 713 insertions(+), 105 deletions(-) create mode 100644 samples/ReadmeSample/Dtos/OrderSummaryDto.cs create mode 100644 samples/ReadmeSample/Entities/OrderStatus.cs create mode 100644 samples/ReadmeSample/Entities/Supplier.cs create mode 100644 samples/ReadmeSample/README.md 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/samples/ReadmeSample/ApplicationDbContext.cs b/samples/ReadmeSample/ApplicationDbContext.cs index 56ee87bb..77314e0c 100644 --- a/samples/ReadmeSample/ApplicationDbContext.cs +++ b/samples/ReadmeSample/ApplicationDbContext.cs @@ -1,31 +1,30 @@ -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 EntityFrameworkCore.Projectables.Extensions; +using EntityFrameworkCore.Projectables.Infrastructure; 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.UseSqlite("Data Source=ReadmeSample.db"); - 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/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..006c4bb4 100644 --- a/samples/ReadmeSample/Entities/Order.cs +++ b/samples/ReadmeSample/Entities/Order.cs @@ -5,22 +5,93 @@ using System.Threading.Tasks; using EntityFrameworkCore.Projectables; -namespace ReadmeSample.Entities +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..5b5c7235 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 fulfilled 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..47c14b35 100644 --- a/samples/ReadmeSample/Program.cs +++ b/samples/ReadmeSample/Program.cs @@ -1,35 +1,237 @@ -using ReadmeSample; +using Microsoft.EntityFrameworkCore; +using ReadmeSample; +using ReadmeSample.Dtos; using ReadmeSample.Entities; using ReadmeSample.Extensions; -using var dbContext = new ApplicationDbContext(); +// ───────────────────────────────────────────────────────────────────────────── +// Bootstrap — create (or recreate) the SQLite database automatically +// ───────────────────────────────────────────────────────────────────────────── +await 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 } - } +// ───────────────────────────────────────────────────────────────────────────── +// Seed data +// ───────────────────────────────────────────────────────────────────────────── +var user = new User { UserName = "Jon", EmailAddress = "jon@doe.com" }; + +var supplier = new Supplier { Name = "Acme Stationery" }; // linked to pen, not to book + +var pen = new Product { Name = "Blue Pen", ListPrice = 1.50m, Supplier = supplier }; +var book = new Product { Name = "C# in Depth", ListPrice = 35.99m }; // no supplier → null-conditional demo + +var fulfilledOrder = new Order +{ + User = user, + TaxRate = .19m, + Status = OrderStatus.Fulfilled, + CreatedDate = DateTime.UtcNow.AddDays(-2), + FulfilledDate = DateTime.UtcNow.AddDays(-1), + Items = + [ + new OrderItem { Product = pen, Quantity = 5 }, + new OrderItem { Product = book, Quantity = 1 }, + ], +}; + +var pendingOrder = new Order +{ + User = user, + TaxRate = .19m, + Status = OrderStatus.Pending, + CreatedDate = DateTime.UtcNow, + FulfilledDate = null, + Items = + [ + new OrderItem { Product = pen, Quantity = 2 }, + ], }; -dbContext.AddRange(sampleUser, sampleProduct, sampleOrder); +dbContext.AddRange(user, supplier, pen, book, fulfilledOrder, pendingOrder); dbContext.SaveChanges(); -var query = dbContext.Users - .Where(x => x.UserName == sampleUser.UserName) - .Select(x => new { - GrandTotal = x.GetMostRecentOrderForUser(/* includeUnfulfilled: */ false).GrandTotal +static void Section(string title) +{ + Console.WriteLine(); + Console.WriteLine(new string('─', 72)); + Console.WriteLine($" {title}"); + Console.WriteLine(new string('─', 72)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 1 — Properties & methods +// [Projectable] properties compose each other recursively. +// [Projectable] methods accept parameters. +// ───────────────────────────────────────────────────────────────────────────── +Section("Feature 1: Properties & methods"); + +var totalsQuery = dbContext.Orders + .Select(o => new + { + o.Id, + o.Subtotal, + o.Tax, + o.GrandTotal, + Discounted10Pct = o.GetDiscountedTotal(0.10m), // [Projectable] method + }); + +Console.WriteLine(totalsQuery.ToQueryString()); +foreach (var row in totalsQuery) +{ + Console.WriteLine($" Order #{row.Id}: subtotal={row.Subtotal:C} tax={row.Tax:C}" + + $" grand total={row.GrandTotal:C} −10%={row.Discounted10Pct:C}"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 2 — Extension methods +// [Projectable] extension methods are inlined as correlated subqueries. +// ───────────────────────────────────────────────────────────────────────────── +Section("Feature 2: Extension methods"); + +var recentQuery = dbContext.Users + .Where(u => u.UserName == "Jon") + .Select(u => new + { + u.UserName, + LatestOrderGrandTotal = u.GetMostRecentOrder()!.GrandTotal, + }); + +Console.WriteLine(recentQuery.ToQueryString()); +var recent = recentQuery.First(); +Console.WriteLine($" {recent.UserName}'s most recent order: {recent.LatestOrderGrandTotal:C}"); + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 3 — Constructor projections +// [Projectable] constructor maps an entity to a DTO entirely in SQL. +// No client-side evaluation — the full SELECT is generated from the constructor body. +// ───────────────────────────────────────────────────────────────────────────── +Section("Feature 3: Constructor projections"); + +var dtoQuery = dbContext.Orders + .Select(o => new OrderSummaryDto(o)); + +Console.WriteLine(dtoQuery.ToQueryString()); +foreach (var dto in dtoQuery) +{ + Console.WriteLine($" [{dto.Id}] {dto.UserName} — {dto.GrandTotal:C} ({dto.StatusName}) priority={dto.PriorityLabel}"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 4 — Method overloads +// Both overloads of GetMostRecentOrderForUser are independently supported. +// ───────────────────────────────────────────────────────────────────────────── +Section("Feature 4: Method overloads"); + +var withPendingQuery = dbContext.Users + .Where(u => u.UserName == "Jon") + .Select(u => new + { + u.UserName, + LatestAnyOrderTotal = u.GetMostRecentOrderForUser(true)!.GrandTotal, }); -var result = query.First(); +Console.WriteLine(withPendingQuery.ToQueryString()); +var withPending = withPendingQuery.First(); +Console.WriteLine($" {withPending.UserName}'s most recent order (incl. pending): {withPending.LatestAnyOrderTotal:C}"); + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 5 — Pattern matching (switch expression) +// The switch expression is rewritten into SQL CASE WHEN expressions. +// ───────────────────────────────────────────────────────────────────────────── +Section("Feature 5: Pattern matching — switch expression → SQL CASE WHEN"); + +var priorityQuery = dbContext.Orders + .Select(o => new { o.Id, o.GrandTotal, o.PriorityLabel }); + +Console.WriteLine(priorityQuery.ToQueryString()); +foreach (var row in priorityQuery) +{ + Console.WriteLine($" Order #{row.Id}: {row.GrandTotal:C} → priority={row.PriorityLabel}"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 6 — Block-bodied members (experimental) +// if/else block bodies are converted to ternary expressions → SQL CASE WHEN. +// AllowBlockBody = true on the attribute suppresses warning EFP0001. +// ───────────────────────────────────────────────────────────────────────────── +Section("Feature 6: Block-bodied members (AllowBlockBody = true)"); + +var shippingQuery = dbContext.Orders + .Select(o => new { o.Id, ShippingCategory = o.GetShippingCategory() }); + +Console.WriteLine(shippingQuery.ToQueryString()); +foreach (var row in shippingQuery) +{ + Console.WriteLine($" Order #{row.Id}: shipping={row.ShippingCategory}"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 7 — Null-conditional rewriting +// Supplier?.Name — the ?. is stripped (Ignore mode) and EF Core uses a LEFT JOIN. +// Result is NULL when the product has no linked supplier. +// ───────────────────────────────────────────────────────────────────────────── +Section("Feature 7: Null-conditional rewriting (NullConditionalRewriteSupport.Ignore)"); + +var supplierQuery = dbContext.Products + .Select(p => new { p.Name, p.SupplierName }); + +Console.WriteLine(supplierQuery.ToQueryString()); +foreach (var row in supplierQuery) +{ + Console.WriteLine($" {row.Name}: supplier={row.SupplierName ?? "(none)"}"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 8 — Enum method expansion +// GetDisplayName() is evaluated at compile time for every enum value. +// The results are baked into a SQL CASE expression — no C# runs at query time. +// ───────────────────────────────────────────────────────────────────────────── +Section("Feature 8: Enum method expansion (ExpandEnumMethods = true)"); + +var statusQuery = dbContext.Orders + .Select(o => new { o.Id, o.Status, o.StatusDisplayName }); + +Console.WriteLine(statusQuery.ToQueryString()); +foreach (var row in statusQuery) +{ + Console.WriteLine($" Order #{row.Id}: status={row.Status} → \"{row.StatusDisplayName}\""); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 9 — UseMemberBody +// IsHighValueOrder's body is replaced at compile time by IsHighValueOrderImpl's body. +// Useful when the public member has a different in-memory implementation. +// ───────────────────────────────────────────────────────────────────────────── +Section("Feature 9: UseMemberBody — expression sourced from a private member"); + +var highValueQuery = dbContext.Orders + .Select(o => new { o.Id, o.GrandTotal, o.IsHighValueOrder }); + +Console.WriteLine(highValueQuery.ToQueryString()); +foreach (var row in highValueQuery) +{ + Console.WriteLine($" Order #{row.Id}: {row.GrandTotal:C} → high-value={row.IsHighValueOrder}"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Feature 10 — Compatibility mode (configured in ApplicationDbContext.cs) +// Full (default) : expands on every query invocation — maximum compatibility. +// Limited : expands once, caches — better performance for repeated queries. +// See ApplicationDbContext.OnConfiguring for how to switch. +// ───────────────────────────────────────────────────────────────────────────── +Section("Feature 10: Compatibility mode"); +Console.WriteLine(" See ApplicationDbContext.OnConfiguring for configuration."); +Console.WriteLine(" Full (default): UseProjectables()"); +Console.WriteLine(" Limited: UseProjectables(p => p.CompatibilityMode(CompatibilityMode.Limited))"); -Console.WriteLine($"Jons latest order had a grant total of {result.GrandTotal}"); +// ───────────────────────────────────────────────────────────────────────────── +// Feature 11 — Roslyn analyzers & code fixes (EFP0001–EFP0012) +// Compile-time only — not demonstrated at runtime. +// Examples: EFP0001 warns on block bodies without AllowBlockBody = true, +// EFP0008 reports missing parameterless constructor on DTO classes. +// ───────────────────────────────────────────────────────────────────────────── +Section("Feature 11: Roslyn analyzers & code fixes"); +Console.WriteLine(" Compile-time feature — see source comments on [Projectable] members."); +Console.WriteLine(" Diagnostics EFP0001–EFP0012 are reported in the IDE and provide quick-fix actions."); 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..61a12e9d 100644 --- a/samples/ReadmeSample/ReadmeSample.csproj +++ b/samples/ReadmeSample/ReadmeSample.csproj @@ -1,14 +1,13 @@  - Exe - disable - false + Exe + net10.0 + false - - + From bd5df17b0b6277a36b679429b85bf5ad6ef52ed6 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sat, 4 Apr 2026 00:16:00 +0200 Subject: [PATCH 2/5] Better console output --- Directory.Packages.props | 1 + samples/ReadmeSample/ConsoleHelper.cs | 89 ++++++++ samples/ReadmeSample/Program.cs | 279 ++++++++++++----------- samples/ReadmeSample/ReadmeSample.csproj | 1 + 4 files changed, 236 insertions(+), 134 deletions(-) create mode 100644 samples/ReadmeSample/ConsoleHelper.cs 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/ConsoleHelper.cs b/samples/ReadmeSample/ConsoleHelper.cs new file mode 100644 index 00000000..16de2d7e --- /dev/null +++ b/samples/ReadmeSample/ConsoleHelper.cs @@ -0,0 +1,89 @@ +using System.Text.RegularExpressions; +using Spectre.Console; + +namespace ReadmeSample; + +/// Spectre.Console rendering helpers used by Program.cs. +internal static class ConsoleHelper +{ + /// + /// Applies Spectre.Console markup to a raw SQL string: + /// string literals → green, SQL keywords → bold cyan, ef_* helpers → dim grey. + /// + public 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 Regex.Replace( + esc, + @"('[^']*')" + + @"|(\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)", + 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; + }, + RegexOptions.IgnoreCase); + } + + /// 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(); + } + + /// Formats a currency amount in bold spring-green. + public static string Money(decimal v) => + $"[bold springgreen2]{Markup.Escape(v.ToString("C"))}[/]"; + + /// Formats a boolean as coloured true/false. + public static string BoolMark(bool v) => + v ? "[bold chartreuse1]true[/]" : "[bold red1]false[/]"; + + /// Formats a priority label with traffic-light colouring. + public static string PriorityMark(string? p) => p switch + { + "High" => "[bold red1]High[/]", + "Medium" => "[bold yellow]Medium[/]", + _ => "[bold green]Low[/]", + }; + + /// Formats an order status string with semantic colouring. + public static string StatusMark(string? s) => s switch + { + "Fulfilled" => $"[bold green]{Markup.Escape(s)}[/]", + "Cancelled" => $"[bold red1]{Markup.Escape(s)}[/]", + _ => $"[bold yellow]{Markup.Escape(s ?? "?")}[/]", + }; +} + diff --git a/samples/ReadmeSample/Program.cs b/samples/ReadmeSample/Program.cs index 47c14b35..156a34f2 100644 --- a/samples/ReadmeSample/Program.cs +++ b/samples/ReadmeSample/Program.cs @@ -3,235 +3,246 @@ using ReadmeSample.Dtos; using ReadmeSample.Entities; using ReadmeSample.Extensions; +using Spectre.Console; +using static ReadmeSample.ConsoleHelper; // ───────────────────────────────────────────────────────────────────────────── -// Bootstrap — create (or recreate) the SQLite database automatically +// Banner // ───────────────────────────────────────────────────────────────────────────── -await using var dbContext = new ApplicationDbContext(); +AnsiConsole.Write(new Panel( + "[bold yellow]EntityFrameworkCore.Projectables[/] — Feature Tour\n" + + "[dim]SQLite · .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(); dbContext.Database.EnsureDeleted(); dbContext.Database.EnsureCreated(); // ───────────────────────────────────────────────────────────────────────────── // Seed data // ───────────────────────────────────────────────────────────────────────────── -var user = new User { UserName = "Jon", EmailAddress = "jon@doe.com" }; - -var supplier = new Supplier { Name = "Acme Stationery" }; // linked to pen, not to book - -var pen = new Product { Name = "Blue Pen", ListPrice = 1.50m, Supplier = supplier }; -var book = new Product { Name = "C# in Depth", ListPrice = 35.99m }; // no supplier → null-conditional demo +var user = new User { UserName = "Jon", EmailAddress = "jon@doe.com" }; +var supplier = new Supplier { Name = "Acme Stationery" }; +var pen = new Product { Name = "Blue Pen", ListPrice = 1.50m, Supplier = supplier }; +var book = new Product { Name = "C# in Depth", ListPrice = 35.99m }; // no supplier → null-conditional demo var fulfilledOrder = new Order { - User = user, - TaxRate = .19m, - Status = OrderStatus.Fulfilled, - CreatedDate = DateTime.UtcNow.AddDays(-2), - FulfilledDate = DateTime.UtcNow.AddDays(-1), - Items = - [ - new OrderItem { Product = pen, Quantity = 5 }, - new OrderItem { Product = book, Quantity = 1 }, - ], + User = user, TaxRate = .19m, Status = OrderStatus.Fulfilled, + CreatedDate = DateTime.UtcNow.AddDays(-2), FulfilledDate = DateTime.UtcNow.AddDays(-1), + Items = [new OrderItem { Product = pen, Quantity = 5 }, new OrderItem { Product = book, Quantity = 1 }], }; - var pendingOrder = new Order { - User = user, - TaxRate = .19m, - Status = OrderStatus.Pending, - CreatedDate = DateTime.UtcNow, - FulfilledDate = null, - Items = - [ - new OrderItem { Product = pen, Quantity = 2 }, - ], + User = user, TaxRate = .19m, Status = OrderStatus.Pending, + CreatedDate = DateTime.UtcNow, FulfilledDate = null, + Items = [new OrderItem { Product = pen, Quantity = 2 }], }; dbContext.AddRange(user, supplier, pen, book, fulfilledOrder, pendingOrder); dbContext.SaveChanges(); -static void Section(string title) -{ - Console.WriteLine(); - Console.WriteLine(new string('─', 72)); - Console.WriteLine($" {title}"); - Console.WriteLine(new string('─', 72)); -} - // ───────────────────────────────────────────────────────────────────────────── // Feature 1 — Properties & methods -// [Projectable] properties compose each other recursively. -// [Projectable] methods accept parameters. -// ───────────────────────────────────────────────────────────────────────────── -Section("Feature 1: Properties & methods"); - -var totalsQuery = dbContext.Orders - .Select(o => new - { - o.Id, - o.Subtotal, - o.Tax, - o.GrandTotal, - Discounted10Pct = o.GetDiscountedTotal(0.10m), // [Projectable] method - }); - -Console.WriteLine(totalsQuery.ToQueryString()); +// ───────────────────────────────────────────────────────────────────────────── +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()); foreach (var row in totalsQuery) { - Console.WriteLine($" Order #{row.Id}: subtotal={row.Subtotal:C} tax={row.Tax:C}" - + $" grand total={row.GrandTotal:C} −10%={row.Discounted10Pct:C}"); + AnsiConsole.MarkupLine( + $" [dim]Order #{row.Id}[/]" + + $" subtotal={Money(row.Subtotal)}" + + $" tax={Money(row.Tax)}" + + $" grand total={Money(row.GrandTotal)}" + + $" [dim]−10%[/]={Money(row.Discounted10Pct)}"); } // ───────────────────────────────────────────────────────────────────────────── // Feature 2 — Extension methods -// [Projectable] extension methods are inlined as correlated subqueries. // ───────────────────────────────────────────────────────────────────────────── -Section("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, - }); + .Select(u => new { u.UserName, LatestOrderGrandTotal = u.GetMostRecentOrder()!.GrandTotal }); -Console.WriteLine(recentQuery.ToQueryString()); +ShowSql(recentQuery.ToQueryString()); var recent = recentQuery.First(); -Console.WriteLine($" {recent.UserName}'s most recent order: {recent.LatestOrderGrandTotal:C}"); +AnsiConsole.MarkupLine( + $" [white]{Markup.Escape(recent.UserName)}[/]'s most recent order: {Money(recent.LatestOrderGrandTotal)}"); // ───────────────────────────────────────────────────────────────────────────── // Feature 3 — Constructor projections -// [Projectable] constructor maps an entity to a DTO entirely in SQL. -// No client-side evaluation — the full SELECT is generated from the constructor body. // ───────────────────────────────────────────────────────────────────────────── -Section("Feature 3: Constructor projections"); +Section(3, "Constructor projections → new OrderSummaryDto(o)"); -var dtoQuery = dbContext.Orders - .Select(o => new OrderSummaryDto(o)); +var dtoQuery = dbContext.Orders.Select(o => new OrderSummaryDto(o)); -Console.WriteLine(dtoQuery.ToQueryString()); +ShowSql(dtoQuery.ToQueryString()); foreach (var dto in dtoQuery) { - Console.WriteLine($" [{dto.Id}] {dto.UserName} — {dto.GrandTotal:C} ({dto.StatusName}) priority={dto.PriorityLabel}"); + AnsiConsole.MarkupLine( + $" [dim]#{dto.Id}[/] [white]{Markup.Escape(dto.UserName ?? "")}[/]" + + $" — {Money(dto.GrandTotal)}" + + $" status={StatusMark(dto.StatusName)}" + + $" priority={PriorityMark(dto.PriorityLabel)}"); } // ───────────────────────────────────────────────────────────────────────────── // Feature 4 — Method overloads -// Both overloads of GetMostRecentOrderForUser are independently supported. // ───────────────────────────────────────────────────────────────────────────── -Section("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, - }); + .Select(u => new { u.UserName, LatestAnyOrderTotal = u.GetMostRecentOrderForUser(true)!.GrandTotal }); -Console.WriteLine(withPendingQuery.ToQueryString()); +ShowSql(withPendingQuery.ToQueryString()); var withPending = withPendingQuery.First(); -Console.WriteLine($" {withPending.UserName}'s most recent order (incl. pending): {withPending.LatestAnyOrderTotal:C}"); +AnsiConsole.MarkupLine( + $" [white]{Markup.Escape(withPending.UserName)}[/]'s most recent order (incl. pending): {Money(withPending.LatestAnyOrderTotal)}"); // ───────────────────────────────────────────────────────────────────────────── -// Feature 5 — Pattern matching (switch expression) -// The switch expression is rewritten into SQL CASE WHEN expressions. +// Feature 5 — Pattern matching // ───────────────────────────────────────────────────────────────────────────── -Section("Feature 5: Pattern matching — switch expression → SQL CASE WHEN"); +Section(5, "Pattern matching → switch expression becomes SQL CASE WHEN"); -var priorityQuery = dbContext.Orders - .Select(o => new { o.Id, o.GrandTotal, o.PriorityLabel }); +var priorityQuery = dbContext.Orders.Select(o => new { o.Id, o.GrandTotal, o.PriorityLabel }); -Console.WriteLine(priorityQuery.ToQueryString()); +ShowSql(priorityQuery.ToQueryString()); foreach (var row in priorityQuery) { - Console.WriteLine($" Order #{row.Id}: {row.GrandTotal:C} → priority={row.PriorityLabel}"); + AnsiConsole.MarkupLine( + $" [dim]Order #{row.Id}[/] {Money(row.GrandTotal)} → priority={PriorityMark(row.PriorityLabel)}"); } // ───────────────────────────────────────────────────────────────────────────── -// Feature 6 — Block-bodied members (experimental) -// if/else block bodies are converted to ternary expressions → SQL CASE WHEN. -// AllowBlockBody = true on the attribute suppresses warning EFP0001. +// Feature 6 — Block-bodied members // ───────────────────────────────────────────────────────────────────────────── -Section("Feature 6: Block-bodied members (AllowBlockBody = true)"); +Section(6, "Block-bodied members → AllowBlockBody = true"); -var shippingQuery = dbContext.Orders - .Select(o => new { o.Id, ShippingCategory = o.GetShippingCategory() }); +var shippingQuery = dbContext.Orders.Select(o => new { o.Id, ShippingCategory = o.GetShippingCategory() }); -Console.WriteLine(shippingQuery.ToQueryString()); +ShowSql(shippingQuery.ToQueryString()); foreach (var row in shippingQuery) { - Console.WriteLine($" Order #{row.Id}: shipping={row.ShippingCategory}"); + AnsiConsole.MarkupLine( + $" [dim]Order #{row.Id}[/] shipping=[bold]{Markup.Escape(row.ShippingCategory)}[/]"); } // ───────────────────────────────────────────────────────────────────────────── // Feature 7 — Null-conditional rewriting -// Supplier?.Name — the ?. is stripped (Ignore mode) and EF Core uses a LEFT JOIN. -// Result is NULL when the product has no linked supplier. // ───────────────────────────────────────────────────────────────────────────── -Section("Feature 7: Null-conditional rewriting (NullConditionalRewriteSupport.Ignore)"); +Section(7, "Null-conditional rewriting → NullConditionalRewriteSupport.Ignore"); -var supplierQuery = dbContext.Products - .Select(p => new { p.Name, p.SupplierName }); +var supplierQuery = dbContext.Products.Select(p => new { p.Name, p.SupplierName }); -Console.WriteLine(supplierQuery.ToQueryString()); +ShowSql(supplierQuery.ToQueryString()); foreach (var row in supplierQuery) { - Console.WriteLine($" {row.Name}: supplier={row.SupplierName ?? "(none)"}"); + var sup = row.SupplierName is null + ? "[dim](none)[/]" + : $"[bold green]{Markup.Escape(row.SupplierName)}[/]"; + AnsiConsole.MarkupLine($" [white]{Markup.Escape(row.Name)}[/] supplier={sup}"); } // ───────────────────────────────────────────────────────────────────────────── // Feature 8 — Enum method expansion -// GetDisplayName() is evaluated at compile time for every enum value. -// The results are baked into a SQL CASE expression — no C# runs at query time. // ───────────────────────────────────────────────────────────────────────────── -Section("Feature 8: Enum method expansion (ExpandEnumMethods = true)"); +Section(8, "Enum method expansion → ExpandEnumMethods = true"); -var statusQuery = dbContext.Orders - .Select(o => new { o.Id, o.Status, o.StatusDisplayName }); +var statusQuery = dbContext.Orders.Select(o => new { o.Id, o.Status, o.StatusDisplayName }); -Console.WriteLine(statusQuery.ToQueryString()); +ShowSql(statusQuery.ToQueryString()); foreach (var row in statusQuery) { - Console.WriteLine($" Order #{row.Id}: status={row.Status} → \"{row.StatusDisplayName}\""); + AnsiConsole.MarkupLine( + $" [dim]Order #{row.Id}[/] [dim]{row.Status}[/] → {StatusMark(row.StatusDisplayName)}"); } // ───────────────────────────────────────────────────────────────────────────── // Feature 9 — UseMemberBody -// IsHighValueOrder's body is replaced at compile time by IsHighValueOrderImpl's body. -// Useful when the public member has a different in-memory implementation. // ───────────────────────────────────────────────────────────────────────────── -Section("Feature 9: UseMemberBody — expression sourced from a private member"); +Section(9, "UseMemberBody → expression sourced from a private member"); -var highValueQuery = dbContext.Orders - .Select(o => new { o.Id, o.GrandTotal, o.IsHighValueOrder }); +var highValueQuery = dbContext.Orders.Select(o => new { o.Id, o.GrandTotal, o.IsHighValueOrder }); -Console.WriteLine(highValueQuery.ToQueryString()); +ShowSql(highValueQuery.ToQueryString()); foreach (var row in highValueQuery) { - Console.WriteLine($" Order #{row.Id}: {row.GrandTotal:C} → high-value={row.IsHighValueOrder}"); + AnsiConsole.MarkupLine( + $" [dim]Order #{row.Id}[/] {Money(row.GrandTotal)} → high-value={BoolMark(row.IsHighValueOrder)}"); } // ───────────────────────────────────────────────────────────────────────────── -// Feature 10 — Compatibility mode (configured in ApplicationDbContext.cs) -// Full (default) : expands on every query invocation — maximum compatibility. -// Limited : expands once, caches — better performance for repeated queries. -// See ApplicationDbContext.OnConfiguring for how to switch. -// ───────────────────────────────────────────────────────────────────────────── -Section("Feature 10: Compatibility mode"); -Console.WriteLine(" See ApplicationDbContext.OnConfiguring for configuration."); -Console.WriteLine(" Full (default): UseProjectables()"); -Console.WriteLine(" Limited: UseProjectables(p => p.CompatibilityMode(CompatibilityMode.Limited))"); - -// ───────────────────────────────────────────────────────────────────────────── -// Feature 11 — Roslyn analyzers & code fixes (EFP0001–EFP0012) -// Compile-time only — not demonstrated at runtime. -// Examples: EFP0001 warns on block bodies without AllowBlockBody = true, -// EFP0008 reports missing parameterless constructor on DTO classes. -// ───────────────────────────────────────────────────────────────────────────── -Section("Feature 11: Roslyn analyzers & code fixes"); -Console.WriteLine(" Compile-time feature — see source comments on [Projectable] members."); -Console.WriteLine(" Diagnostics EFP0001–EFP0012 are reported in the IDE and provide quick-fix actions."); +// 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/ReadmeSample.csproj b/samples/ReadmeSample/ReadmeSample.csproj index 61a12e9d..e66666f2 100644 --- a/samples/ReadmeSample/ReadmeSample.csproj +++ b/samples/ReadmeSample/ReadmeSample.csproj @@ -8,6 +8,7 @@ + From d0f7dc0259ea9f4451c1343c6ea408793aa095fc Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 12 Apr 2026 12:00:53 +0200 Subject: [PATCH 3/5] Apply code review suggestions and switch back to SQL server SQL, without query execution, only SQL display --- .editorconfig | 5 ++ samples/ReadmeSample/ApplicationDbContext.cs | 6 +- samples/ReadmeSample/ConsoleHelper.cs | 44 ++--------- samples/ReadmeSample/Entities/Order.cs | 7 +- .../ReadmeSample/Extensions/UserExtensions.cs | 2 +- samples/ReadmeSample/Program.cs | 77 ------------------- samples/ReadmeSample/ReadmeSample.csproj | 4 +- 7 files changed, 18 insertions(+), 127 deletions(-) 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/samples/ReadmeSample/ApplicationDbContext.cs b/samples/ReadmeSample/ApplicationDbContext.cs index 77314e0c..1d3b5229 100644 --- a/samples/ReadmeSample/ApplicationDbContext.cs +++ b/samples/ReadmeSample/ApplicationDbContext.cs @@ -1,6 +1,4 @@ -using EntityFrameworkCore.Projectables.Extensions; -using EntityFrameworkCore.Projectables.Infrastructure; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using ReadmeSample.Entities; namespace ReadmeSample; @@ -14,7 +12,7 @@ public class ApplicationDbContext : DbContext protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - optionsBuilder.UseSqlite("Data Source=ReadmeSample.db"); + optionsBuilder.UseSqlServer("Server=(localdb)\\MSSQLLocalDB;Database=ReadmeSample;Trusted_Connection=True"); // Feature 10: Compatibility mode // Full (default) — expands every query on each invocation; maximum compatibility. diff --git a/samples/ReadmeSample/ConsoleHelper.cs b/samples/ReadmeSample/ConsoleHelper.cs index 16de2d7e..e57153bb 100644 --- a/samples/ReadmeSample/ConsoleHelper.cs +++ b/samples/ReadmeSample/ConsoleHelper.cs @@ -4,13 +4,13 @@ namespace ReadmeSample; /// Spectre.Console rendering helpers used by Program.cs. -internal static class ConsoleHelper +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. /// - public static string SqlMarkup(string sql) + private static string SqlMarkup(string sql) { // Escape [ and ] so Spectre doesn't misinterpret them as markup tags. var esc = Markup.Escape(sql); @@ -20,22 +20,13 @@ public static string SqlMarkup(string sql) // 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 Regex.Replace( - esc, - @"('[^']*')" - + @"|(\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)", - m => + 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; - }, - RegexOptions.IgnoreCase); + }); } /// Renders a numbered feature section header using a yellow rule. @@ -61,29 +52,8 @@ public static void ShowSql(string sql) }); AnsiConsole.WriteLine(); } - - /// Formats a currency amount in bold spring-green. - public static string Money(decimal v) => - $"[bold springgreen2]{Markup.Escape(v.ToString("C"))}[/]"; - - /// Formats a boolean as coloured true/false. - public static string BoolMark(bool v) => - v ? "[bold chartreuse1]true[/]" : "[bold red1]false[/]"; - - /// Formats a priority label with traffic-light colouring. - public static string PriorityMark(string? p) => p switch - { - "High" => "[bold red1]High[/]", - "Medium" => "[bold yellow]Medium[/]", - _ => "[bold green]Low[/]", - }; - - /// Formats an order status string with semantic colouring. - public static string StatusMark(string? s) => s switch - { - "Fulfilled" => $"[bold green]{Markup.Escape(s)}[/]", - "Cancelled" => $"[bold red1]{Markup.Escape(s)}[/]", - _ => $"[bold yellow]{Markup.Escape(s ?? "?")}[/]", - }; + + [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/Entities/Order.cs b/samples/ReadmeSample/Entities/Order.cs index 006c4bb4..a8c7510e 100644 --- a/samples/ReadmeSample/Entities/Order.cs +++ b/samples/ReadmeSample/Entities/Order.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using EntityFrameworkCore.Projectables; +using EntityFrameworkCore.Projectables; namespace ReadmeSample.Entities; diff --git a/samples/ReadmeSample/Extensions/UserExtensions.cs b/samples/ReadmeSample/Extensions/UserExtensions.cs index 5b5c7235..56bad7da 100644 --- a/samples/ReadmeSample/Extensions/UserExtensions.cs +++ b/samples/ReadmeSample/Extensions/UserExtensions.cs @@ -6,7 +6,7 @@ namespace ReadmeSample.Extensions; public static class UserExtensions { /// - /// Returns the most recent fulfilled order for the user. + /// Returns the most recent order for the user. /// Matches the README example — inlined into SQL via [Projectable]. /// [Projectable] diff --git a/samples/ReadmeSample/Program.cs b/samples/ReadmeSample/Program.cs index 156a34f2..34a0bdde 100644 --- a/samples/ReadmeSample/Program.cs +++ b/samples/ReadmeSample/Program.cs @@ -1,7 +1,6 @@ using Microsoft.EntityFrameworkCore; using ReadmeSample; using ReadmeSample.Dtos; -using ReadmeSample.Entities; using ReadmeSample.Extensions; using Spectre.Console; using static ReadmeSample.ConsoleHelper; @@ -22,32 +21,6 @@ // Bootstrap // ───────────────────────────────────────────────────────────────────────────── await using var dbContext = new ApplicationDbContext(); -dbContext.Database.EnsureDeleted(); -dbContext.Database.EnsureCreated(); - -// ───────────────────────────────────────────────────────────────────────────── -// Seed data -// ───────────────────────────────────────────────────────────────────────────── -var user = new User { UserName = "Jon", EmailAddress = "jon@doe.com" }; -var supplier = new Supplier { Name = "Acme Stationery" }; -var pen = new Product { Name = "Blue Pen", ListPrice = 1.50m, Supplier = supplier }; -var book = new Product { Name = "C# in Depth", ListPrice = 35.99m }; // no supplier → null-conditional demo - -var fulfilledOrder = new Order -{ - User = user, TaxRate = .19m, Status = OrderStatus.Fulfilled, - CreatedDate = DateTime.UtcNow.AddDays(-2), FulfilledDate = DateTime.UtcNow.AddDays(-1), - Items = [new OrderItem { Product = pen, Quantity = 5 }, new OrderItem { Product = book, Quantity = 1 }], -}; -var pendingOrder = new Order -{ - User = user, TaxRate = .19m, Status = OrderStatus.Pending, - CreatedDate = DateTime.UtcNow, FulfilledDate = null, - Items = [new OrderItem { Product = pen, Quantity = 2 }], -}; - -dbContext.AddRange(user, supplier, pen, book, fulfilledOrder, pendingOrder); -dbContext.SaveChanges(); // ───────────────────────────────────────────────────────────────────────────── // Feature 1 — Properties & methods @@ -61,15 +34,6 @@ }); ShowSql(totalsQuery.ToQueryString()); -foreach (var row in totalsQuery) -{ - AnsiConsole.MarkupLine( - $" [dim]Order #{row.Id}[/]" - + $" subtotal={Money(row.Subtotal)}" - + $" tax={Money(row.Tax)}" - + $" grand total={Money(row.GrandTotal)}" - + $" [dim]−10%[/]={Money(row.Discounted10Pct)}"); -} // ───────────────────────────────────────────────────────────────────────────── // Feature 2 — Extension methods @@ -81,9 +45,6 @@ .Select(u => new { u.UserName, LatestOrderGrandTotal = u.GetMostRecentOrder()!.GrandTotal }); ShowSql(recentQuery.ToQueryString()); -var recent = recentQuery.First(); -AnsiConsole.MarkupLine( - $" [white]{Markup.Escape(recent.UserName)}[/]'s most recent order: {Money(recent.LatestOrderGrandTotal)}"); // ───────────────────────────────────────────────────────────────────────────── // Feature 3 — Constructor projections @@ -93,14 +54,6 @@ var dtoQuery = dbContext.Orders.Select(o => new OrderSummaryDto(o)); ShowSql(dtoQuery.ToQueryString()); -foreach (var dto in dtoQuery) -{ - AnsiConsole.MarkupLine( - $" [dim]#{dto.Id}[/] [white]{Markup.Escape(dto.UserName ?? "")}[/]" - + $" — {Money(dto.GrandTotal)}" - + $" status={StatusMark(dto.StatusName)}" - + $" priority={PriorityMark(dto.PriorityLabel)}"); -} // ───────────────────────────────────────────────────────────────────────────── // Feature 4 — Method overloads @@ -112,9 +65,6 @@ .Select(u => new { u.UserName, LatestAnyOrderTotal = u.GetMostRecentOrderForUser(true)!.GrandTotal }); ShowSql(withPendingQuery.ToQueryString()); -var withPending = withPendingQuery.First(); -AnsiConsole.MarkupLine( - $" [white]{Markup.Escape(withPending.UserName)}[/]'s most recent order (incl. pending): {Money(withPending.LatestAnyOrderTotal)}"); // ───────────────────────────────────────────────────────────────────────────── // Feature 5 — Pattern matching @@ -124,11 +74,6 @@ var priorityQuery = dbContext.Orders.Select(o => new { o.Id, o.GrandTotal, o.PriorityLabel }); ShowSql(priorityQuery.ToQueryString()); -foreach (var row in priorityQuery) -{ - AnsiConsole.MarkupLine( - $" [dim]Order #{row.Id}[/] {Money(row.GrandTotal)} → priority={PriorityMark(row.PriorityLabel)}"); -} // ───────────────────────────────────────────────────────────────────────────── // Feature 6 — Block-bodied members @@ -138,11 +83,6 @@ var shippingQuery = dbContext.Orders.Select(o => new { o.Id, ShippingCategory = o.GetShippingCategory() }); ShowSql(shippingQuery.ToQueryString()); -foreach (var row in shippingQuery) -{ - AnsiConsole.MarkupLine( - $" [dim]Order #{row.Id}[/] shipping=[bold]{Markup.Escape(row.ShippingCategory)}[/]"); -} // ───────────────────────────────────────────────────────────────────────────── // Feature 7 — Null-conditional rewriting @@ -152,13 +92,6 @@ var supplierQuery = dbContext.Products.Select(p => new { p.Name, p.SupplierName }); ShowSql(supplierQuery.ToQueryString()); -foreach (var row in supplierQuery) -{ - var sup = row.SupplierName is null - ? "[dim](none)[/]" - : $"[bold green]{Markup.Escape(row.SupplierName)}[/]"; - AnsiConsole.MarkupLine($" [white]{Markup.Escape(row.Name)}[/] supplier={sup}"); -} // ───────────────────────────────────────────────────────────────────────────── // Feature 8 — Enum method expansion @@ -168,11 +101,6 @@ var statusQuery = dbContext.Orders.Select(o => new { o.Id, o.Status, o.StatusDisplayName }); ShowSql(statusQuery.ToQueryString()); -foreach (var row in statusQuery) -{ - AnsiConsole.MarkupLine( - $" [dim]Order #{row.Id}[/] [dim]{row.Status}[/] → {StatusMark(row.StatusDisplayName)}"); -} // ───────────────────────────────────────────────────────────────────────────── // Feature 9 — UseMemberBody @@ -182,11 +110,6 @@ var highValueQuery = dbContext.Orders.Select(o => new { o.Id, o.GrandTotal, o.IsHighValueOrder }); ShowSql(highValueQuery.ToQueryString()); -foreach (var row in highValueQuery) -{ - AnsiConsole.MarkupLine( - $" [dim]Order #{row.Id}[/] {Money(row.GrandTotal)} → high-value={BoolMark(row.IsHighValueOrder)}"); -} // ───────────────────────────────────────────────────────────────────────────── // Feature 10 — Compatibility mode diff --git a/samples/ReadmeSample/ReadmeSample.csproj b/samples/ReadmeSample/ReadmeSample.csproj index e66666f2..25df9fc2 100644 --- a/samples/ReadmeSample/ReadmeSample.csproj +++ b/samples/ReadmeSample/ReadmeSample.csproj @@ -2,12 +2,12 @@ Exe - net10.0 + net10.0; false - + From 5f2837980e4a5eaed1222b690b962b93e44f6f4e Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 12 Apr 2026 12:03:58 +0200 Subject: [PATCH 4/5] Fix header --- samples/ReadmeSample/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/ReadmeSample/Program.cs b/samples/ReadmeSample/Program.cs index 34a0bdde..ae551d5f 100644 --- a/samples/ReadmeSample/Program.cs +++ b/samples/ReadmeSample/Program.cs @@ -10,7 +10,7 @@ // ───────────────────────────────────────────────────────────────────────────── AnsiConsole.Write(new Panel( "[bold yellow]EntityFrameworkCore.Projectables[/] — Feature Tour\n" - + "[dim]SQLite · .NET 10 · All 11 features from the README demonstrated[/]") + + "[dim]SQ Server · .NET 10 · All 11 features from the README demonstrated[/]") { Border = BoxBorder.Double, BorderStyle = Style.Parse("yellow dim"), From 2336262a4b1d88d5877ea2a53599a606ead0bbc2 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 12 Apr 2026 12:04:32 +0200 Subject: [PATCH 5/5] Fix header --- samples/ReadmeSample/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/ReadmeSample/Program.cs b/samples/ReadmeSample/Program.cs index ae551d5f..46b2c447 100644 --- a/samples/ReadmeSample/Program.cs +++ b/samples/ReadmeSample/Program.cs @@ -10,7 +10,7 @@ // ───────────────────────────────────────────────────────────────────────────── AnsiConsole.Write(new Panel( "[bold yellow]EntityFrameworkCore.Projectables[/] — Feature Tour\n" - + "[dim]SQ Server · .NET 10 · All 11 features from the README demonstrated[/]") + + "[dim]SQL Server · .NET 10 · All 11 features from the README demonstrated[/]") { Border = BoxBorder.Double, BorderStyle = Style.Parse("yellow dim"),