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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

#use soft tabs (spaces) for indentation
indent_style = space
indent_size = 4

#Formatting - new line options

Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,4 @@ FodyWeavers.xsd
# Docs
/docs/.vitepress/cache/
/docs/.vitepress/dist/
/ReadmeSample.db
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<PackageVersion Include="Basic.Reference.Assemblies.Net100" Version="1.8.4" />
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Spectre.Console" Version="0.49.1" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="coverlet.collector" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
Expand Down
43 changes: 20 additions & 23 deletions samples/ReadmeSample/ApplicationDbContext.cs
Original file line number Diff line number Diff line change
@@ -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<User> Users { get; set; }
public DbSet<Product> Products { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<User> Users => Set<User>();
public DbSet<Product> Products => Set<Product>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<Supplier> Suppliers => Set<Supplier>();

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<OrderItem>().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<OrderItem>().HasKey(x => new { x.OrderId, x.ProductId });
}
}
59 changes: 59 additions & 0 deletions samples/ReadmeSample/ConsoleHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.Text.RegularExpressions;
using Spectre.Console;

namespace ReadmeSample;

/// <summary>Spectre.Console rendering helpers used by Program.cs.</summary>
static internal partial class ConsoleHelper
{
/// <summary>
/// Applies Spectre.Console markup to a raw SQL string:
/// string literals → green, SQL keywords → bold cyan, ef_* helpers → dim grey.
/// </summary>
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;
});
}

/// <summary>Renders a numbered feature section header using a yellow rule.</summary>
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();
}

/// <summary>Renders a SQL string inside a rounded panel with syntax highlighting.</summary>
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();
}

30 changes: 30 additions & 0 deletions samples/ReadmeSample/Dtos/OrderSummaryDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using EntityFrameworkCore.Projectables;
using ReadmeSample.Entities;

namespace ReadmeSample.Dtos;

/// <summary>
/// DTO with a [Projectable] constructor — the entire mapping is inlined into SQL by the source generator.
/// </summary>
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; }

/// <summary>Required parameterless constructor (EFP0008 ensures its presence).</summary>
public OrderSummaryDto() { }

[Projectable]
public OrderSummaryDto(Order order)
{
Id = order.Id;
UserName = order.User.UserName;
GrandTotal = order.GrandTotal;
StatusName = order.StatusDisplayName;
PriorityLabel = order.PriorityLabel;
}
}

106 changes: 86 additions & 20 deletions samples/ReadmeSample/Entities/Order.cs
Original file line number Diff line number Diff line change
@@ -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<OrderItem> Items { get; set; } = [];

// ── Feature 1: Projectable properties (compose each other recursively) ──────

/// <summary>Sum of (unit price × quantity) for all items — inlined into SQL.</summary>
[Projectable] public decimal Subtotal => Items.Sum(item => item.Product.ListPrice * item.Quantity);

/// <summary>Tax amount (Subtotal × TaxRate) — composed from another [Projectable].</summary>
[Projectable] public decimal Tax => Subtotal * TaxRate;

/// <summary>Total including tax — composed from two [Projectable] properties.</summary>
[Projectable] public decimal GrandTotal => Subtotal + Tax;

/// <summary>True when the order has been fulfilled — usable in .Where() filters.</summary>
[Projectable] public bool IsFulfilled => FulfilledDate != null;

// ── Feature 1 (method): Projectable method with a parameter ─────────────────

public decimal TaxRate { get; set; }
/// <summary>Grand total after applying a percentage discount — demonstrates a [Projectable] method.</summary>
[Projectable]
public decimal GetDiscountedTotal(decimal discountPct) => GrandTotal * (1 - discountPct);

public User User { get; set; }
public ICollection<OrderItem> 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;
/// <summary>
/// Priority label derived from GrandTotal using a switch expression.
/// The generator rewrites this into SQL CASE WHEN expressions.
/// </summary>
[Projectable]
public string PriorityLabel => GrandTotal switch
{
>= 100m => "High",
>= 30m => "Medium",
_ => "Low",
};

// ── Feature 6: Block-bodied member (experimental) ────────────────────────────

/// <summary>
/// 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.
/// </summary>
[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 ─────────────────────────────────────────

/// <summary>
/// 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.
/// </summary>
[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;

/// <summary>
/// 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.
/// </summary>
[Projectable(UseMemberBody = nameof(IsHighValueOrderImpl))]
public bool IsHighValueOrder => IsHighValueOrderImpl;
}
17 changes: 8 additions & 9 deletions samples/ReadmeSample/Entities/OrderItem.cs
Original file line number Diff line number Diff line change
@@ -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!;
}
26 changes: 26 additions & 0 deletions samples/ReadmeSample/Entities/OrderStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace ReadmeSample.Entities;

public enum OrderStatus
{
Pending,
Fulfilled,
Cancelled,
}

public static class OrderStatusExtensions
{
/// <summary>
/// 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.
/// </summary>
public static string GetDisplayName(this OrderStatus status) =>
status switch
{
OrderStatus.Pending => "Pending Review",
OrderStatus.Fulfilled => "Fulfilled",
OrderStatus.Cancelled => "Cancelled",
_ => status.ToString(),
};
}

24 changes: 17 additions & 7 deletions samples/ReadmeSample/Entities/Product.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
/// <summary>
/// Null-conditional rewriting (NullConditionalRewriteSupport.Ignore):
/// the ?. operator is stripped and EF Core handles nullability via the LEFT JOIN.
/// </summary>
[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)]
public string? SupplierName => Supplier?.Name;
}
11 changes: 11 additions & 0 deletions samples/ReadmeSample/Entities/Supplier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace ReadmeSample.Entities;

/// <summary>Optional supplier linked to a product — used to demonstrate null-conditional rewriting.</summary>
public class Supplier
{
public int Id { get; set; }
public required string Name { get; set; }

public ICollection<Product> Products { get; set; } = [];
}

18 changes: 6 additions & 12 deletions samples/ReadmeSample/Entities/User.cs
Original file line number Diff line number Diff line change
@@ -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<Order> Orders { get; set; }
}
public ICollection<Order> Orders { get; set; } = [];
}
Loading
Loading