diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0e42e7d..63d4873 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,13 +10,15 @@ src/ EntityFrameworkCore.Projectables.Abstractions/ # [Projectable] attribute, enums EntityFrameworkCore.Projectables.Generator/ # Roslyn IIncrementalGenerator + EntityFrameworkCore.Projectables.CodeFixes/ # Roslyn code fix providers (EFP0001/0002/0008/0012) EntityFrameworkCore.Projectables/ # Runtime library (EF Core integration) tests/ - EntityFrameworkCore.Projectables.Generator.Tests/ # Roslyn generator unit tests (Verify snapshots) - EntityFrameworkCore.Projectables.FunctionalTests/ # End-to-end EF Core tests (Verify snapshots) - EntityFrameworkCore.Projectables.Tests/ # Misc unit tests -benchmarks/ # BenchmarkDotNet benchmarks -samples/ # Readme sample project + EntityFrameworkCore.Projectables.Generator.Tests/ # Roslyn generator unit tests (Verify snapshots) + EntityFrameworkCore.Projectables.CodeFixes.Tests/ # Code fix unit tests (Verify snapshots) + EntityFrameworkCore.Projectables.FunctionalTests/ # End-to-end EF Core tests (Verify snapshots) + EntityFrameworkCore.Projectables.Tests/ # Misc unit tests +benchmarks/ # BenchmarkDotNet benchmarks +samples/ # Readme sample project ``` --- @@ -233,7 +235,7 @@ $env:VERIFY_AUTO_APPROVE = "true"; dotnet test | `ProjectableDescriptor.cs` | Pure data record describing a projectable member | | `ProjectableAttributeData.cs` | Serializable snapshot of `[Projectable]` attribute values (no live Roslyn objects) | | `ProjectionRegistryEmitter.cs` | Emits `ProjectionRegistry.g.cs` | -| `Diagnostics.cs` | All `DiagnosticDescriptor` constants (EFP0001–EFP0009) | +| `Diagnostics.cs` | All `DiagnosticDescriptor` constants (EFP0001–EFP0012) | ### Incremental generator rules - **Never capture live Roslyn objects** (`ISymbol`, `SemanticModel`, `Compilation`, `AttributeData`) in the incremental pipeline transforms — they break caching. Use `ProjectableAttributeData` (a plain struct) instead. @@ -243,15 +245,20 @@ $env:VERIFY_AUTO_APPROVE = "true"; dotnet test ## Diagnostics Reference -| ID | Severity | Title | -|---------|----------|----------------------------------------------------| -| EFP0001 | Warning | Block-bodied member support is experimental | -| EFP0002 | Error | Null-conditional expression unsupported | -| EFP0003 | Warning | Unsupported statement in block-bodied method | -| EFP0004 | Error | Statement with side effects in block-bodied method | -| EFP0005 | Warning | Potential side effect in block-bodied method | -| EFP0006 | Error | Method/property should expose a body definition | -| EFP0007 | Warning | Non-projectable method call in block body | +| ID | Severity | Title | Code Fix | +|---------|----------|----------------------------------------------------------------|-----------------------------------------------------------| +| EFP0001 | Warning | Block-bodied member support is experimental | Add `AllowBlockBody = true` to `[Projectable]` | +| EFP0002 | Error | Null-conditional expression not configured | Configure `NullConditionalRewriteSupport` | +| EFP0003 | Warning | Unsupported statement in block-bodied method | — | +| EFP0004 | Error | Statement with side effects in block-bodied method | — | +| EFP0005 | Warning | Potential side effect in block-bodied method | — | +| EFP0006 | Error | Method or property should expose a body definition | — | +| EFP0007 | Error | Unsupported pattern in projectable expression | — | +| EFP0008 | Error | Target class is missing a parameterless constructor | Add parameterless constructor to the class | +| EFP0009 | Error | Delegated constructor cannot be analyzed for projection | — | +| EFP0010 | Error | UseMemberBody target member not found | — | +| EFP0011 | Error | UseMemberBody target member is incompatible | — | +| EFP0012 | Info | [Projectable] factory method can be converted to a constructor | Convert to `[Projectable]` constructor (+ update callers) | --- @@ -280,6 +287,43 @@ $env:VERIFY_AUTO_APPROVE = "true"; dotnet test --- +## Documentation & README + +### When to update `README.md` + +- A user-facing feature is added, changed, or removed — keep the **feature table** current +- Supported EF Core / .NET versions change +- NuGet package names or the "Getting started" steps change + +### When to update `docs/` + +The docs site is a **VitePress** project (`docs/`). Run it locally with: + +```bash +cd docs +npm install # first time only +npm run dev +``` + +Update the relevant page(s) in `docs/` whenever: + +- A feature's behavior changes — edit the corresponding guide or reference page +- A new doc page is added or an existing one is removed — **also update the sidebar** in `docs/.vitepress/config.mts` +- Package installation or `UseProjectables()` API changes — update `docs/guide/quickstart.md` + +### Doc structure + +| Folder | Content | +|-------------------|------------------------------------------------------| +| `docs/guide/` | Getting-started guides (quickstart, core concepts) | +| `docs/reference/` | Attribute reference, diagnostics, compatibility mode | +| `docs/advanced/` | Internals, block-bodied members, limitations | +| `docs/recipes/` | End-to-end usage examples | + +The VitePress sidebar is declared in `docs/.vitepress/config.mts` — keep it in sync with actual files. + +--- + ## Package Management Central package version management is enabled (`ManagePackageVersionsCentrally = true`). diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..9340560 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,58 @@ +name: Deploy Docs to GitHub Pages + +on: + push: + branches: + - master +# TODO: Remove this when the docs website is ready to be merged into master : + - feature/docs-website + paths: + - 'docs/**' + - '.github/workflows/docs.yml' + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + name: Build and deploy VitePress site + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Needed for lastUpdated feature + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: docs/package-lock.json + + - name: Install dependencies + working-directory: docs + run: npm ci + + - name: Build VitePress site + working-directory: docs + run: npm run build + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }} + publish_dir: ./docs/.vitepress/dist + external_repository: EFNext/efnext.github.io + publish_branch: main + user_name: 'github-actions[bot]' + user_email: 'github-actions[bot]@users.noreply.github.com' + diff --git a/.gitignore b/.gitignore index 4e57e37..a5b1eb7 100644 --- a/.gitignore +++ b/.gitignore @@ -366,3 +366,7 @@ FodyWeavers.xsd *.received.* .idea + +# Docs +/docs/.vitepress/cache/ +/docs/.vitepress/dist/ diff --git a/EntityFrameworkCore.Projectables.sln b/EntityFrameworkCore.Projectables.sln index a5f8bfa..c30754a 100644 --- a/EntityFrameworkCore.Projectables.sln +++ b/EntityFrameworkCore.Projectables.sln @@ -51,6 +51,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ ProjectSection(SolutionItems) = preProject .github\workflows\build.yml = .github\workflows\build.yml .github\workflows\release.yml = .github\workflows\release.yml + .github\workflows\docs.yml = .github\workflows\docs.yml EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkCore.Projectables.CodeFixes", "src\EntityFrameworkCore.Projectables.CodeFixes\EntityFrameworkCore.Projectables.CodeFixes.csproj", "{1890C6AF-37A4-40B0-BD0C-7FB18357891A}" diff --git a/README.md b/README.md index 8cb0c4f..df34389 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Flexible projection magic for EF Core [![NuGet version (EntityFrameworkCore.Projectables)](https://img.shields.io/nuget/v/EntityFrameworkCore.Projectables.Abstractions.svg?style=flat-square)](https://www.nuget.org/packages/EntityFrameworkCore.Projectables.Abstractions/) -[![.NET](https://github.com/koenbeuk/EntityFrameworkCore.Projectables/actions/workflows/build.yml/badge.svg)](https://github.com/koenbeuk/EntityFrameworkCore.Projectables/actions/workflows/build.yml) +[![.NET](https://github.com/EFNext/EntityFrameworkCore.Projectables/actions/workflows/build.yml/badge.svg)](https://github.com/EFNext/EntityFrameworkCore.Projectables/actions/workflows/build.yml) ## NuGet packages - EntityFrameworkCore.Projectables.Abstractions [![NuGet version](https://img.shields.io/nuget/v/EntityFrameworkCore.Projectables.Abstractions.svg?style=flat-square)](https://www.nuget.org/packages/EntityFrameworkCore.Projectables.Abstractions/) [![NuGet](https://img.shields.io/nuget/dt/EntityFrameworkCore.Projectables.Abstractions.svg?style=flat-square)](https://www.nuget.org/packages/EntityFrameworkCore.Projectables.Abstractions/) @@ -10,26 +10,18 @@ Flexible projection magic for EF Core > Starting with V2 of this project we're binding against **EF Core 6**. If you're targeting **EF Core 5** or **EF Core 3.1** then you can use the latest v1 release. These are functionally equivalent. - ## Getting started 1. Install the package from [NuGet](https://www.nuget.org/packages/EntityFrameworkCore.Projectables/) 2. Enable Projectables in your DbContext by adding: `dbContextOptions.UseProjectables()` -3. Implement projectable properties and methods, marking them with the `[Projectable]` attribute. -4. Explore our [samples](https://github.com/koenbeuk/EntityFrameworkCore.Projectables/tree/master/samples) and checkout our [Blog Post](https://onthedrift.com/posts/efcore-projectables/) for further guidance. +3. Mark properties, methods, or constructors with `[Projectable]`. +4. Read the **[documentation](https://projectables.github.io)** for guides, reference, and recipes. ### Example -Assuming this sample: ```csharp class Order { - public int Id { get; set; } - public int UserId { get; set; } - public DateTime CreatedDate { get; set; } - public decimal TaxRate { get; set; } - - public User User { get; set; } public ICollection Items { get; set; } [Projectable] public decimal Subtotal => Items.Sum(item => item.Product.ListPrice * item.Quantity); @@ -40,585 +32,56 @@ class Order public static class UserExtensions { [Projectable] - public static Order GetMostRecentOrderForUser(this User user, DateTime? cutoffDate) => - user.Orders - .Where(x => cutoffDate == null || x.CreatedDate >= cutoffDate) - .OrderByDescending(x => x.CreatedDate) - .FirstOrDefault(); + public static Order GetMostRecentOrder(this User user) => + user.Orders.OrderByDescending(x => x.CreatedDate).FirstOrDefault(); } -var result = _dbContext.Users - .Where(x => x.UserName == "Jon") - .Select(x => new { - x.GetMostRecentOrderForUser(DateTime.UtcNow.AddDays(-30)).GrandTotal - }); +var result = dbContext.Users + .Where(u => u.UserName == "Jon") + .Select(u => new { u.GetMostRecentOrder().GrandTotal }) .FirstOrDefault(); ``` -The following query gets generated (assuming SQL Server as a database provider) -```sql -DECLARE @__sampleUser_UserName_0 nvarchar(4000) = N'Jon'; - -SELECT ( - SELECT COALESCE(SUM([p].[ListPrice] * CAST([o].[Quantity] AS decimal(18,2))), 0.0) - FROM [OrderItem] AS [o] - INNER JOIN [Products] AS [p] ON [o].[ProductId] = [p].[Id] - WHERE ( - SELECT TOP(1) [o0].[Id] - FROM [Orders] AS [o0] - WHERE [u].[Id] = [o0].[UserId] AND [o0].[FulfilledDate] IS NOT NULL - ORDER BY [o0].[CreatedDate] DESC) IS NOT NULL AND ( - SELECT TOP(1) [o1].[Id] - FROM [Orders] AS [o1] - WHERE [u].[Id] = [o1].[UserId] AND [o1].[FulfilledDate] IS NOT NULL - ORDER BY [o1].[CreatedDate] DESC) = [o].[OrderId]) * ( - SELECT TOP(1) [o2].[TaxRate] - FROM [Orders] AS [o2] - WHERE [u].[Id] = [o2].[UserId] AND [o2].[FulfilledDate] IS NOT NULL - ORDER BY [o2].[CreatedDate] DESC) AS [GrandTotal] -FROM [Users] AS [u] -WHERE [u].[UserName] = @__sampleUser_UserName_0 -``` - -Projectable properties and methods have been inlined! the generated SQL could be improved but this is what EF Core (v8) gives us. +The properties are **inlined into SQL** — no client-side evaluation, no N+1. ### How it works -Essentially, there are two components: We have a source generator that can write companion expressions for properties and methods marked with the Projectable attribute. Then, we have a runtime component that intercepts any query and translates any call to a property or method marked with the Projectable attribute, translating the query to use the generated expression instead. - -### FAQ - -#### Are there currently any known limitations? -Currently, there is no support for overloaded methods. Each method name needs to be unique within a given type. - -#### Is this specific to a database provider? -No, the runtime component injects itself into the EFCore query compilation pipeline, thus having no impact on the database provider used. Of course, you're still limited to whatever your database provider can do. - -#### Are there performance implications that I should be aware of? -There are two compatibility modes: Limited and Full (Default). Most of the time, limited compatibility mode is sufficient. However, if you are running into issues with failed query compilation, then you may want to stick with Full compatibility mode. With Full compatibility mode, each query will first be expanded (any calls to Projectable properties and methods will be replaced by their respective expression) before being handed off to EFCore. (This is similar to how LinqKit/LinqExpander/Expressionify works.) Because of this additional step, there is a small performance impact. Limited compatibility mode is smart about things and only expands the query after it has been accepted by EF. The expanded query will then be stored in the Query Cache. With Limited compatibility, you will likely see increased performance over EFCore without Projectables. - -#### Can I call additional properties and methods from my Projectable properties and methods? -Yes, you can! Any projectable property/method can call into other properties and methods as long as those properties/methods are native to EFCore or marked with a Projectable attribute. - -#### Can I use projectable extensions methods on non-entity types? -Yes you can. It's perfectly acceptable to have the following code: -```csharp -[Projectable] -public static int Squared(this int i) => i * i; -``` -Any call to squared given any int will perfectly translate to SQL. - -#### How do I deal with nullable properties -Expressions and Lambdas are different and not equal. Expressions can only express a subset of valid C# statements that are allowed in lambda's and arrow functions. One obvious limitation is the null-conditional operator. Consider the following example: -```csharp -[Projectable] -public static string? GetFullAddress(this User? user) => user?.Location?.AddressLine1 + " " + user?.Location.AddressLine2; -``` -This is a perfectly valid arrow function, but it can't be translated directly to an expression tree. This Project will generate an error by default and suggest 2 solutions: Either you rewrite the function to explicitly check for nullables or you let the generator do that for you! - -Starting from the official release of V2, we can now hint the generator in how to translate this arrow function to an expression tree. We can say: -```csharp -[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] -``` -which will simply generate an expression tree that ignores the null-conditional operator. This generates: -```csharp -user.Location.AddressLine1 + " " + user.Location.AddressLine2 -``` -This is perfect for a database like SQL Server where nullability is implicit and if any of the arguments were to be null, the resulting value will be null. If you are dealing with CosmosDB (which may result to client-side evaluation) or want to be explicit about things. You can configure your projectable as such: -```csharp -[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Rewrite)] -``` -This will rewrite your expression to explicitly check for nullables. In the former example, this will be rewritten to: -```csharp -(user != null ? user.Location != null ? user.Location?.AddressLine1 + (user != null ? user.Location != null ? user.Location.AddressLine2 : null) : null) -``` -Note that using rewrite (not ignore) may increase the actual SQL query complexity being generated with some database providers such as SQL Server - -#### Can I use Projectables in any part of my query? -Certainly, consider the following example: -```csharp -public class User -{ - public int Id { get; set; } - public string FirstName { get; set; } - public string LastName { get; set; } - - [Projectable] - public string FullName => FirstName + " " + LastName; -} - -var query = dbContext.Users - .Where(x => x.FullName.Contains("Jon")) - .GroupBy(x => x.FullName) - .OrderBy(x => x.Key) - .Select(x => x.Key); -``` -Which generates the following SQL (SQLite syntax) -```sql -SELECT (COALESCE("u"."FirstName", '') || ' ') || COALESCE("u"."LastName", '') -FROM "Users" AS "u" -WHERE ('Jon' = '') OR (instr((COALESCE("u"."FirstName", '') || ' ') || COALESCE("u"."LastName", ''), 'Jon') > 0) -GROUP BY (COALESCE("u"."FirstName", '') || ' ') || COALESCE("u"."LastName", '') -ORDER BY (COALESCE("u"."FirstName", '') || ' ') || COALESCE("u"."LastName", '') -``` - -#### Can I use block-bodied members instead of expression-bodied members? - -> [!NOTE] -> This feature is available starting from version 6.x and is considered experimental. - -Yes! you can now use traditional block-bodied members with `[Projectable]`. This makes code more readable when dealing with complex conditional logic: - -```csharp -// Expression-bodied (still supported) -[Projectable] -public string Level() => Value > 100 ? "High" : Value > 50 ? "Medium" : "Low"; - -// Block-bodied (now also supported!) -[Projectable(AllowBlockBody = true)] // Note: AllowBlockBody is required to remove the warning for experimental feature usage -public string Level() -{ - if (Value > 100) - return "High"; - else if (Value > 50) - return "Medium"; - else - return "Low"; -} -``` - -> This is an experimental feature and may have some limitations. Please refer to the documentation for details. - -Both generate identical SQL. Block-bodied members support: -- If-else statements (converted to ternary/CASE expressions) -- Switch statements -- Local variables (automatically inlined) -- Simple return statements - -The generator will also detect and report side effects (assignments, method calls to non-projectable members, etc.) with precise error messages. See [Block-Bodied Members Documentation](docs/BlockBodiedMembers.md) for complete details. - -#### Can I use `[Projectable]` on a constructor? - -> [!NOTE] -> This feature is available starting from version 6.x. - -Yes! constructors can now be marked with `[Projectable]`. The generator will produce a member-init expression (`new T() { Prop = value, … }`) that EF Core can translate to a SQL projection. - -**Requirements:** -- The class must expose an accessible **parameterless constructor** (public, internal, or protected-internal), because the generated code relies on `new T() { … }` syntax. -- If a parameterless constructor is missing, the generator reports **EFP0008**. - -```csharp -public class Customer -{ - public int Id { get; set; } - public string FirstName { get; set; } - public string LastName { get; set; } - public bool IsActive { get; set; } - public ICollection Orders { get; set; } -} - -public class Order -{ - public int Id { get; set; } - public decimal Amount { get; set; } -} - -public class CustomerDto -{ - public int Id { get; set; } - public string FullName { get; set; } - public bool IsActive { get; set; } - public int OrderCount { get; set; } - - public CustomerDto() { } // required parameterless ctor - - [Projectable] - public CustomerDto(Customer customer) - { - Id = customer.Id; - FullName = customer.FirstName + " " + customer.LastName; - IsActive = customer.IsActive; - OrderCount = customer.Orders.Count(); - } -} - -// Usage — the constructor call is translated directly to SQL -var customers = dbContext.Customers - .Select(c => new CustomerDto(c)) - .ToList(); -``` - -The generator produces an expression equivalent to: -```csharp -(Customer customer) => new CustomerDto() -{ - Id = customer.Id, - FullName = customer.FirstName + " " + customer.LastName, - IsActive = customer.IsActive, - OrderCount = customer.Orders.Count() -} -``` - -**Supported in constructor bodies:** -- Simple property assignments (`FullName = customer.FirstName + " " + customer.LastName;`) -- Local variable declarations (inlined at usage points) -- If/else and chained if/else-if statements (converted to ternary expressions) -- Switch expressions -- Base/this initializer chains – the generator recursively inlines the delegated constructor's assignments - -The base/this initializer chain is particularly useful when you have a DTO inheritance hierarchy: - -```csharp -public class PersonDto -{ - public string FullName { get; set; } - public string Email { get; set; } - - public PersonDto() { } - - [Projectable] - public PersonDto(Person person) - { - FullName = person.FirstName + " " + person.LastName; - Email = person.Email; - } -} - -public class EmployeeDto : PersonDto -{ - public string Department { get; set; } - public string Grade { get; set; } - - public EmployeeDto() { } - - [Projectable] - public EmployeeDto(Employee employee) : base(employee) // PersonDto assignments are inlined automatically - { - Department = employee.Department.Name; - Grade = employee.YearsOfService >= 10 ? "Senior" : "Junior"; - } -} - -// Usage -var employees = dbContext.Employees - .Select(e => new EmployeeDto(e)) - .ToList(); -``` - -The generated expression inlines both the base constructor and the derived constructor body: -```csharp -(Employee employee) => new EmployeeDto() -{ - FullName = employee.FirstName + " " + employee.LastName, - Email = employee.Email, - Department = employee.Department.Name, - Grade = employee.YearsOfService >= 10 ? "Senior" : "Junior" -} -``` - -Multiple `[Projectable]` constructors (overloads) per class are fully supported. - -> [!NOTE] -> If the delegated constructor's source is not available in the current compilation, the generator reports **EFP0009** and skips the projection. - -#### Can I redirect the expression body to a different member with `UseMemberBody`? - -Yes! The `UseMemberBody` property on `[Projectable]` lets you redirect the source of the generated expression to a *different* member on the same type. - -This is useful when you want to: - -- keep a regular C# implementation for in-memory use while maintaining a separate, cleaner expression for EF Core -- supply the body as a pre-built `Expression>` property for full control over the generated tree -##### Delegating to a method or property body +There are two components: a **Roslyn source generator** that emits companion `Expression` trees for each `[Projectable]` member at compile time, and a **runtime interceptor** that walks your LINQ queries and substitutes those expressions before EF Core translates them to SQL. -The simplest case — point `UseMemberBody` at another method or property that has the **same return type and parameter signature**. The generator uses the body of the target member instead: +## Features (v6.x+) -```csharp -public class Entity -{ - public int Id { get; set; } - - // EF-side: generates an expression from ComputedImpl - [Projectable(UseMemberBody = nameof(ComputedImpl))] - public int Computed => Id; // original body is ignored - - // In-memory implementation (or a different algorithm) - private int ComputedImpl => Id * 2; -} -``` - -The generated expression is `(@this) => @this.Id * 2`, so `Computed` projects as `Id * 2` in SQL even though the arrow body says `Id`. - -> [!NOTE] -> When delegating to a regular method or property body the target member must be declared in the **same source file** as the `[Projectable]` member so the generator can read its body. - -##### Using an `Expression>` property as the body - -For even more control you can supply the body as a typed `Expression>` property. This lets you write the expression once and reuse it from both the `[Projectable]` member and any runtime code that needs the expression tree directly: - -```csharp -public class Entity -{ - public int Id { get; set; } - - [Projectable(UseMemberBody = nameof(Computed4))] - public int Computed3 => Id; // body is replaced at compile time - - // The expression tree is picked up by the generator and by the runtime resolver - private static Expression> Computed4 => x => x.Id * 3; -} -``` - -Unlike regular method/property delegation, `Expression>` backing properties may be declared in a **different file** — for example in a separate part of a `partial class`: - -```csharp -// File: Entity.cs -public partial class Entity -{ - public int Id { get; set; } - - [Projectable(UseMemberBody = nameof(IdDoubledExpr))] - public int Computed => Id; -} - -// File: Entity.Expressions.cs -public partial class Entity -{ - private static Expression> IdDoubledExpr => @this => @this.Id * 2; -} -``` - -For **instance methods**, the generator automatically aligns lambda parameter names with the method's own parameter names, so you are free to choose any names in the lambda. Using `@this` for the receiver is conventional and avoids any renaming: - -```csharp -public class Entity -{ - public int Value { get; set; } - - [Projectable(UseMemberBody = nameof(IsPositiveExpr))] - public bool IsPositive() => Value > 0; - - // Any receiver name works; @this is conventional - private static Expression> IsPositiveExpr => @this => @this.Value > 0; -} -``` - -If the lambda parameter names differ from the method's parameter names the generator renames them automatically: - -```csharp -// Lambda uses (c, t) but method parameter is named threshold — generated code uses threshold -private static Expression> ExceedsThresholdExpr => - (c, t) => c.Value > t; -``` - -##### Static extension methods - -`UseMemberBody` works equally well on static extension methods. Name the lambda parameters to match the method's parameter names: - -```csharp -public static class FooExtensions -{ - [Projectable(UseMemberBody = nameof(NameEqualsExpr))] - public static bool NameEquals(this Foo a, Foo b) => a.Name == b.Name; - - private static Expression> NameEqualsExpr => - (a, b) => a.Name == b.Name; -} -``` - -The generated expression is `(Foo a, Foo b) => a.Name == b.Name` — the same lambda that EF Core receives at query time. The two implementations are kept in sync in one place. +| Feature | Docs | +|---|---| +| Properties & methods | [Guide →](https://projectables.github.io/guide/projectable-properties) | +| Extension methods | [Guide →](https://projectables.github.io/guide/extension-methods) | +| Constructor projections | [Guide →](https://projectables.github.io/guide/projectable-constructors) | +| Method overloads | Fully supported | +| Pattern matching (`switch`, `is`) | [Reference →](https://projectables.github.io/reference/pattern-matching) | +| Block-bodied members (experimental) | [Advanced →](https://projectables.github.io/advanced/block-bodied-members) | +| Null-conditional rewriting | [Reference →](https://projectables.github.io/reference/null-conditional-rewrite) | +| Enum method expansion | [Reference →](https://projectables.github.io/reference/expand-enum-methods) | +| `UseMemberBody` | [Reference →](https://projectables.github.io/reference/use-member-body) | +| Roslyn analyzers & code fixes (EFP0001–EFP0012) | [Reference →](https://projectables.github.io/reference/diagnostics) | +| Limited/Full compatibility mode | [Reference →](https://projectables.github.io/reference/compatibility-mode) | -##### Diagnostics +## FAQ -| Code | Severity | Cause | -|-------------|----------|------------------------------------------------------------------------------------------------------| -| **EFP0010** | Error | The name given to `UseMemberBody` does not match any member on the containing type | -| **EFP0011** | Error | A member with that name exists but its type or signature is incompatible with the projectable member | - -#### Can I use pattern matching in projectable members? - -> [!NOTE] -> This feature is available starting from version 6.x. - -Yes! the generator supports a rich set of C# pattern-matching constructs and rewrites them into expression-tree-compatible ternary/binary expressions that EF Core can translate to SQL CASE expressions. - -**Switch expressions** with the following arm patterns are supported: - -| Pattern | Example | -|-----------------------|--------------------------| -| Constant | `1 => "one"` | -| Discard / default | `_ => "other"` | -| Type | `GroupItem g => …` | -| Relational | `>= 90 => "A"` | -| `and` / `or` combined | `>= 80 and < 90 => "B"` | -| `when` guard | `4 when Prop == 12 => …` | - -```csharp -[Projectable] -public string GetGrade() => Score switch -{ - >= 90 => "A", - >= 80 => "B", - >= 70 => "C", - _ => "F", -}; -``` - -Generated expression (which EF Core translates to a SQL CASE): -```csharp -(@this) => @this.Score >= 90 ? "A" : @this.Score >= 80 ? "B" : @this.Score >= 70 ? "C" : "F" -``` - -**`is` patterns** in expression-bodied members are also supported: - -```csharp -// Range check using 'and' -[Projectable] -public bool IsInRange => Value is >= 1 and <= 100; - -// Alternative-value check using 'or' -[Projectable] -public bool IsOutOfRange => Value is 0 or > 100; - -// Null check using 'not' -[Projectable] -public bool HasName => Name is not null; - -// Property pattern -[Projectable] -public static bool IsActiveAndPositive(this Entity entity) => - entity is { IsActive: true, Value: > 0 }; -``` - -These are all rewritten into plain binary/unary expressions that expression trees support: -```csharp -// Value is >= 1 and <= 100 → Value >= 1 && Value <= 100 -// Name is not null → !(Name == null) -// entity is { IsActive: true, Value: > 0 } -// → entity != null && entity.IsActive == true && entity.Value > 0 -``` - -**Type patterns in switch arms** produce a cast + type-check: -```csharp -[Projectable] -public static ItemData ToData(this Item item) => - item switch - { - GroupItem g => new GroupData(g.Id, g.Name, g.Description), - DocumentItem d => new DocumentData(d.Id, d.Name, d.Priority), - _ => null! - }; -``` - -Unsupported patterns (e.g. positional/deconstruct patterns, variable designations outside switch arms) are reported as **EFP0007**. - -#### How do I expand enum extension methods? - -> [!NOTE] -> This feature is available starting from version 6.x. - -When you have an enum property and want to call an extension method on it (like getting a display name from a `[Display]` attribute), you can use the `ExpandEnumMethods` property on the `[Projectable]` attribute. This will expand the enum method call into a chain of ternary expressions for each enum value, allowing EF Core to translate it to SQL CASE expressions. - -```csharp -public enum OrderStatus -{ - [Display(Name = "Pending Review")] - Pending, - - [Display(Name = "Approved")] - Approved, - - [Display(Name = "Rejected")] - Rejected -} - -public static class EnumExtensions -{ - public static string GetDisplayName(this OrderStatus value) - { - // Your implementation here - return value.ToString(); - } - - public static bool IsApproved(this OrderStatus value) - { - return value == OrderStatus.Approved; - } - - public static int GetSortOrder(this OrderStatus value) - { - return (int)value; - } - - public static string Format(this OrderStatus value, string prefix) - { - return prefix + value.ToString(); - } -} - -public class Order -{ - public int Id { get; set; } - public OrderStatus Status { get; set; } - - [Projectable(ExpandEnumMethods = true)] - public string StatusName => Status.GetDisplayName(); - - [Projectable(ExpandEnumMethods = true)] - public bool IsStatusApproved => Status.IsApproved(); - - [Projectable(ExpandEnumMethods = true)] - public int StatusOrder => Status.GetSortOrder(); - - [Projectable(ExpandEnumMethods = true)] - public string FormattedStatus => Status.Format("Status: "); -} -``` - -This generates expression trees equivalent to: -```csharp -// For StatusName -@this.Status == OrderStatus.Pending ? GetDisplayName(OrderStatus.Pending) - : @this.Status == OrderStatus.Approved ? GetDisplayName(OrderStatus.Approved) - : @this.Status == OrderStatus.Rejected ? GetDisplayName(OrderStatus.Rejected) - : null - -// For IsStatusApproved (boolean) -@this.Status == OrderStatus.Pending ? false - : @this.Status == OrderStatus.Approved ? true - : @this.Status == OrderStatus.Rejected ? false - : default(bool) -``` - -Which EF Core translates to SQL CASE expressions: -```sql -SELECT CASE - WHEN [o].[Status] = 0 THEN N'Pending Review' - WHEN [o].[Status] = 1 THEN N'Approved' - WHEN [o].[Status] = 2 THEN N'Rejected' -END AS [StatusName] -FROM [Orders] AS [o] -``` +#### Is this specific to a database provider? +No. The interceptor hooks into EF Core's query compilation pipeline before any provider-specific translation, so it works with SQL Server, PostgreSQL, SQLite, Cosmos DB, and any other EF Core provider. -The `ExpandEnumMethods` feature supports: -- **String return types** - returns `null` as the default fallback -- **Boolean return types** - returns `default(bool)` (false) as the default fallback -- **Integer return types** - returns `default(int)` (0) as the default fallback -- **Other value types** - returns `default(T)` as the default fallback -- **Nullable enum types** - wraps the expansion in a null check -- **Methods with parameters** - parameters are passed through to each enum value call -- **Enum properties on navigation properties** - works with nested navigation +#### Are there performance implications? +Two compatibility modes are available: **Full** (default) expands every query before handing it to EF Core; **Limited** expands once and caches the result. Limited mode often outperforms plain EF Core on repeated queries. See the [Compatibility Mode docs](https://projectables.github.io/reference/compatibility-mode). +#### Can I compose projectables? +Yes — a `[Projectable]` member can call other `[Projectable]` members. They are recursively inlined into the final SQL. #### How does this relate to [Expressionify](https://github.com/ClaveConsulting/Expressionify)? -Expressionify is a project that was launched before this project. It has some overlapping features and uses similar approaches. When I first published this project, I was not aware of its existence, so shame on me. Currently, Expressionify targets a more focused scope of what this project is doing, and thereby it seems to be more limiting in its capabilities. Check them out though! - -#### How does this relate to LinqKit/LinqExpander/...? -There are a few projects like [LinqKit](https://github.com/scottksmith95/LINQKit) that were created before we had source generators in .NET. These are great options if you're stuck with classical EF or don't want to rely on code generation. Otherwise, I would suggest that EntityFrameworkCore.Projectables and Expressionify are superior approaches as they can rely on SourceGenerators to do most of the hard work. +Expressionify has overlapping features and a similar approach but a narrower scope. Projectables adds constructor projections, pattern matching, block-bodied members, enum expansion, and a richer diagnostics layer. -#### Is the available for EFCore 3.1, 5 and 6? -V1 is targeting EF Core 5 and 3.1. V2 and V3 are targeting EF Core 6 and are compatible with EF Core 7. You can upgrade/downgrade between these versions based on your EF Core version requirements. +#### How does this relate to LinqKit/LinqExpander? +[LinqKit](https://github.com/scottksmith95/LINQKit) and similar libraries predate source generators. Projectables (and Expressionify) are superior approaches for modern .NET because the source generator does the heavy lifting at compile time with no runtime reflection. -#### What is next for this project? -TBD... However, one thing I'd like to improve is our expression generation logic as it's currently making a few assumptions (have yet to experience it breaking). Community contributions are very welcome! +#### What .NET and EF Core versions are supported? +- v1.x → EF Core 3.1 / 5 +- v2.x–v3.x → EF Core 6 / 7 +- v6.x+ → EF Core 6+ (current; targets `net8.0` and `net10.0`) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts new file mode 100644 index 0000000..6ef7c81 --- /dev/null +++ b/docs/.vitepress/config.mts @@ -0,0 +1,109 @@ +import {defineConfig, type HeadConfig} from 'vitepress' + +const umamiScript: HeadConfig = ["script", { + defer: "true", + src: "https://cloud.umami.is/script.js", + "data-website-id": "ccd38f75-b037-4535-abe6-3794413f607c", +}] + +const baseHeaders: HeadConfig[] = [ + ['link', { rel: 'icon', type: 'image/svg+xml', href: '/logo.svg' }], + ['meta', { property: 'og:image', content: 'https://projectables.github.io/social.svg' }], + ['meta', { property: 'og:type', content: 'website' }], + ['meta', { name: 'twitter:card', content: 'summary_large_image' }], + ['meta', { name: 'twitter:image', content: 'https://projectables.github.io/social.svg' }], +]; + +const headers = process.env.GITHUB_PAGES === "true" ? + [...baseHeaders, umamiScript] : + baseHeaders; + +// https://vitepress.dev/reference/site-config +export default defineConfig({ + title: "EF Core Projectables", + description: "Flexible projection magic for EF Core — use properties and methods directly in your LINQ queries", + head: headers, + themeConfig: { + logo: '/logo.svg', + nav: [ + { text: 'Home', link: '/' }, + { text: 'Guide', link: '/guide/introduction' }, + { text: 'Reference', link: '/reference/projectable-attribute' }, + { text: 'Advanced', link: '/advanced/how-it-works' }, + { text: 'Recipes', link: '/recipes/computed-properties' }, + ], + + sidebar: { + '/guide/': [ + { + text: 'Getting Started', + items: [ + { text: 'Introduction', link: '/guide/introduction' }, + { text: 'Quick Start', link: '/guide/quickstart' }, + ] + }, + { + text: 'Core Concepts', + items: [ + { text: 'Projectable Properties', link: '/guide/projectable-properties' }, + { text: 'Projectable Methods', link: '/guide/projectable-methods' }, + { text: 'Extension Methods', link: '/guide/extension-methods' }, + { text: 'Constructor Projections', link: '/guide/projectable-constructors' }, + ] + } + ], + '/reference/': [ + { + text: 'Reference', + items: [ + { text: '[Projectable] Attribute', link: '/reference/projectable-attribute' }, + { text: 'Compatibility Mode', link: '/reference/compatibility-mode' }, + { text: 'Null-Conditional Rewrite', link: '/reference/null-conditional-rewrite' }, + { text: 'Pattern Matching', link: '/reference/pattern-matching' }, + { text: 'Expand Enum Methods', link: '/reference/expand-enum-methods' }, + { text: 'Use Member Body', link: '/reference/use-member-body' }, + { text: 'Diagnostics & Code Fixes', link: '/reference/diagnostics' }, + ] + } + ], + '/advanced/': [ + { + text: 'Advanced', + items: [ + { text: 'How It Works', link: '/advanced/how-it-works' }, + { text: 'Query Compiler Pipeline', link: '/advanced/query-compiler-pipeline' }, + { text: 'Block-Bodied Members', link: '/advanced/block-bodied-members' }, + { text: 'Limitations', link: '/advanced/limitations' }, + ] + } + ], + '/recipes/': [ + { + text: 'Recipes', + items: [ + { text: 'Computed Entity Properties', link: '/recipes/computed-properties' }, + { text: 'DTO Projections with Constructors', link: '/recipes/dto-projections' }, + { text: 'Scoring & Classification', link: '/recipes/scoring-classification' }, + { text: 'Collection Aggregates', link: '/recipes/collection-aggregates' }, + { text: 'Enum Display Names', link: '/recipes/enum-display-names' }, + { text: 'Nullable Navigation Properties', link: '/recipes/nullable-navigation' }, + { text: 'Reusable Query Filters', link: '/recipes/reusable-query-filters' }, + ] + } + ], + }, + + socialLinks: [ + { icon: 'github', link: 'https://github.com/EFNext/EntityFrameworkCore.Projectables' } + ], + + search: { + provider: 'local' + }, + + footer: { + message: 'Released under the MIT License.', + copyright: 'Copyright © EntityFrameworkCore.Projectables Contributors' + } + } +}) diff --git a/docs/BlockBodiedMembers.md b/docs/BlockBodiedMembers.md deleted file mode 100644 index 8db2346..0000000 --- a/docs/BlockBodiedMembers.md +++ /dev/null @@ -1,410 +0,0 @@ -# Block-Bodied Members Support - -EntityFrameworkCore.Projectables now supports "classic" block-bodied members (methods and properties) decorated with `[Projectable]`, in addition to expression-bodied members. - -## ⚠️ Experimental Feature - -Block-bodied members support is currently **experimental**. By default, using a block-bodied member with `[Projectable]` will emit a warning: - -``` -EFP0001: Block-bodied member 'MethodName' is using an experimental feature. Set AllowBlockBody = true on the Projectable attribute to suppress this warning. -``` - -To acknowledge that you're using an experimental feature and suppress the warning, set `AllowBlockBody = true`: - -```csharp -[Projectable(AllowBlockBody = true)] -public string GetCategory() -{ - if (Value > 100) - { - return "High"; - } - else - { - return "Low"; - } -} -``` - -This requirement will be removed in a future version once the feature is considered stable. - -## What's Supported - -Block-bodied members can now be transformed into expression trees when they contain: - -### 1. Simple Return Statements -```csharp -[Projectable] -public int GetConstant() -{ - return 42; -} -``` - -### 2. If-Else Statements (converted to ternary expressions) -```csharp -[Projectable] -public string GetCategory() -{ - if (Value > 100) - { - return "High"; - } - else - { - return "Low"; - } -} -``` - -### 3. Nested If-Else Statements -```csharp -[Projectable] -public string GetLevel() -{ - if (Value > 100) - { - return "High"; - } - else if (Value > 50) - { - return "Medium"; - } - else - { - return "Low"; - } -} -``` - -### 4. Local Variable Declarations (inlined into the expression) -```csharp -[Projectable] -public int CalculateDouble() -{ - var doubled = Value * 2; - return doubled + 5; -} - -// Transitive inlining is also supported: -[Projectable] -public int CalculateComplex() -{ - var a = Value * 2; - var b = a + 5; - return b + 10; // Fully expanded to: Value * 2 + 5 + 10 -} -``` - -**⚠️ Important Notes:** -- Local variables are inlined at each usage point, which duplicates the initializer expression -- If a local variable is used multiple times, its initializer expression is duplicated at each usage, which can change semantics if the initializer has side effects -- Local variables can only be declared at the method body level, not inside nested blocks (if/switch/etc.) -- Variables are fully expanded transitively (variables that reference other variables are fully inlined) - -### 5. Switch Statements (converted to nested ternary expressions) -```csharp -[Projectable] -public string GetValueLabel() -{ - switch (Value) - { - case 1: - return "One"; - case 2: - return "Two"; - case 3: - return "Three"; - default: - return "Many"; - } -} -``` - -### 6. If Statements Without Else (uses default value) -```csharp -// Pattern 1: Explicit null return -[Projectable] -public int? GetPremiumIfActive() -{ - if (IsActive) - { - return Value * 2; - } - return null; // Explicit return for all code paths -} - -// Pattern 2: Explicit fallback return -[Projectable] -public string GetStatus() -{ - if (IsActive) - { - return "Active"; - } - return "Inactive"; // Explicit fallback -} -``` - -### 7. Multiple Early Returns (converted to nested ternary expressions) -```csharp -[Projectable] -public string GetValueCategory() -{ - if (Value > 100) - { - return "Very High"; - } - - if (Value > 50) - { - return "High"; - } - - if (Value > 10) - { - return "Medium"; - } - - return "Low"; -} - -// Converted to: Value > 100 ? "Very High" : (Value > 50 ? "High" : (Value > 10 ? "Medium" : "Low")) -``` - -## Limitations and Warnings - -The source generator will produce **warning EFP0003** when it encounters unsupported statements in block-bodied methods: - -### Unsupported Statements: -- While, for, foreach loops -- Try-catch-finally blocks -- Throw statements -- New object instantiation in statement position - -### Example of Unsupported Pattern: -```csharp -[Projectable] -public int GetValue() -{ - for (int i = 0; i < 10; i++) // ❌ Loops not supported - { - // ... - } - return 0; -} -``` - -Supported patterns: -```csharp -[Projectable] -public int GetValue() -{ - if (IsActive) // ✅ If without else is now supported! - { - return Value; - } - else - { - return 0; - } -} -``` - -Additional supported patterns: -```csharp -// If without else using fallback return: -[Projectable] -public int GetValue() -{ - if (IsActive) - { - return Value; - } - return 0; // ✅ Fallback return -} - -// Switch statement: -[Projectable] -public string GetLabel() -{ - switch (Value) // ✅ Switch statements now supported! - { - case 1: - return "One"; - case 2: - return "Two"; - default: - return "Other"; - } -} -``` - -Or as expression-bodied: -```csharp -[Projectable] -public int GetValue() => IsActive ? Value : 0; // ✅ Expression-bodied -``` - -## How It Works - -The source generator: -1. Parses block-bodied methods -2. Converts if-else statements to conditional (ternary) expressions -3. Converts switch statements to nested conditional expressions -4. Inlines local variables into the return expression -5. Rewrites the resulting expression using the existing expression transformation pipeline -6. Generates the same output as expression-bodied methods - -## Benefits - -- **More readable code**: Complex logic with nested conditions and switch statements is often easier to read than nested ternary operators -- **Gradual migration**: Existing code with block bodies can now be marked as `[Projectable]` without rewriting -- **Intermediate variables**: Local variables can make complex calculations more understandable -- **Switch support**: Traditional switch statements now work alongside switch expressions - -## SQL Output Examples - -### Switch Statement with Multiple Cases -Given this code: -```csharp -switch (Value) -{ - case 1: - case 2: - return "Low"; - case 3: - case 4: - case 5: - return "Medium"; - default: - return "High"; -} -``` - -Generates optimized SQL: -```sql -SELECT CASE - WHEN [e].[Value] IN (1, 2) THEN N'Low' - WHEN [e].[Value] IN (3, 4, 5) THEN N'Medium' - ELSE N'High' -END -FROM [Entity] AS [e] -``` - -### If-Else Example Output - -Given this code: -```csharp -public record Entity -{ - public int Value { get; set; } - public bool IsActive { get; set; } - - [Projectable] - public int GetAdjustedValue() - { - if (IsActive && Value > 0) - { - return Value * 2; - } - else - { - return 0; - } - } -} -``` - -The generated SQL will be: -```sql -SELECT CASE - WHEN [e].[IsActive] = CAST(1 AS bit) AND [e].[Value] > 0 - THEN [e].[Value] * 2 - ELSE 0 -END -FROM [Entity] AS [e] -``` - -## Side Effect Detection - -The generator provides specific error reporting for side effects in block-bodied methods, helping you identify and fix issues quickly. - -### Detected Side Effects - -#### 1. Property Assignments (EFP0004 - Error) - -Property assignments modify state and are not allowed: - -```csharp -[Projectable] -public int Foo() -{ - Bar = 10; // ❌ Error: Assignment operation has side effects - return Bar; -} -``` - -#### 2. Compound Assignments (EFP0004 - Error) - -Compound assignment operators like `+=`, `-=`, `*=`, etc. are not allowed: - -```csharp -[Projectable] -public int Foo() -{ - Bar += 10; // ❌ Error: Compound assignment operator '+=' has side effects - return Bar; -} -``` - -#### 3. Increment/Decrement Operators (EFP0004 - Error) - -Pre and post increment/decrement operators are not allowed: - -```csharp -[Projectable] -public int Foo() -{ - var x = 5; - x++; // ❌ Error: Increment/decrement operator '++' has side effects - return x; -} -``` - -#### 4. Non-Projectable Method Calls (EFP0005 - Warning) - -Calls to methods not marked with `[Projectable]` may have side effects: - -```csharp -[Projectable] -public int Foo() -{ - Console.WriteLine("test"); // ⚠️ Warning: Method call 'WriteLine' may have side effects - return Bar; -} -``` - -### Diagnostic Codes - -- **EFP0003**: Unsupported statement in block-bodied method (Warning) -- **EFP0004**: Statement with side effects in block-bodied method (Error) -- **EFP0005**: Potential side effect in block-bodied method (Warning) - -### Error Message Improvements - -Instead of generic error messages, you now get precise, actionable feedback: - -**Before:** -``` -warning EFP0003: Method 'Foo' contains an unsupported statement: Expression statements are not supported -``` - -**After:** -``` -error EFP0004: Property assignment 'Bar' has side effects and cannot be used in projectable methods -``` - -The error message points to the exact line with the problematic code, making it much easier to identify and fix issues. - diff --git a/docs/advanced/block-bodied-members.md b/docs/advanced/block-bodied-members.md new file mode 100644 index 0000000..903d0ca --- /dev/null +++ b/docs/advanced/block-bodied-members.md @@ -0,0 +1,324 @@ +# Block-Bodied Members + +As of v6.x, EF Core Projectables supports **block-bodied** properties and methods decorated with `[Projectable]`, in addition to expression-bodied members (`=>`). + +::: warning Experimental Feature +Block-bodied member support is currently **experimental**. Set `AllowBlockBody = true` on the attribute to acknowledge this and suppress warning EFP0001. +::: + +## Why Block Bodies? + +Expression-bodied members are concise but can become hard to read with complex conditional logic: + +```csharp +// Hard to read as a nested ternary +[Projectable] +public string Level() => Value > 100 ? "High" : Value > 50 ? "Medium" : "Low"; + +// Much easier to read as a block body +[Projectable(AllowBlockBody = true)] +public string Level() +{ + if (Value > 100) + return "High"; + else if (Value > 50) + return "Medium"; + else + return "Low"; +} +``` + +Both generate **identical SQL** — the block body is converted to a ternary expression internally. + +## Enabling Block Bodies + +Add `AllowBlockBody = true` to suppress the experimental warning: + +```csharp +[Projectable(AllowBlockBody = true)] +public string GetCategory() +{ + if (Value > 100) + return "High"; + else + return "Low"; +} +``` + +## Supported Constructs + +### Simple Return Statements + +```csharp +[Projectable(AllowBlockBody = true)] +public int GetConstant() +{ + return 42; +} +``` + +--- + +### If-Else Statements + +If-else chains are converted to ternary (`? :`) expressions: + +```csharp +[Projectable(AllowBlockBody = true)] +public string GetCategory() +{ + if (Value > 100) + return "High"; + else if (Value > 50) + return "Medium"; + else + return "Low"; +} +// Converted to: Value > 100 ? "High" : Value > 50 ? "Medium" : "Low" +``` + +--- + +### If Without Else (Fallback Return) + +An `if` statement without an `else` is supported when followed by a fallback `return`: + +```csharp +// Pattern 1: explicit fallback return +[Projectable(AllowBlockBody = true)] +public string GetStatus() +{ + if (IsActive) + return "Active"; + return "Inactive"; // Fallback +} + +// Pattern 2: explicit null return +[Projectable(AllowBlockBody = true)] +public int? GetPremium() +{ + if (IsActive) + return Value * 2; + return null; +} +``` + +--- + +### Multiple Early Returns + +Multiple independent early-return `if` statements are converted to a nested ternary chain: + +```csharp +[Projectable(AllowBlockBody = true)] +public string GetValueCategory() +{ + if (Value > 100) return "Very High"; + if (Value > 50) return "High"; + if (Value > 10) return "Medium"; + return "Low"; +} +// → Value > 100 ? "Very High" : (Value > 50 ? "High" : (Value > 10 ? "Medium" : "Low")) +``` + +--- + +### Switch Statements + +Switch statements are converted to nested ternary expressions: + +```csharp +[Projectable(AllowBlockBody = true)] +public string GetValueLabel() +{ + switch (Value) + { + case 1: return "One"; + case 2: return "Two"; + case 3: return "Three"; + default: return "Many"; + } +} +``` + +Multiple cases mapping to the same result are collapsed: + +```csharp +switch (Value) +{ + case 1: + case 2: + return "Low"; + case 3: + case 4: + case 5: + return "Medium"; + default: + return "High"; +} +``` + +Generated SQL: +```sql +SELECT CASE + WHEN [e].[Value] IN (1, 2) THEN N'Low' + WHEN [e].[Value] IN (3, 4, 5) THEN N'Medium' + ELSE N'High' +END +FROM [Entity] AS [e] +``` + +--- + +### Local Variables + +Local variables declared at the method body level are **inlined** at each usage point: + +```csharp +[Projectable(AllowBlockBody = true)] +public int CalculateDouble() +{ + var doubled = Value * 2; + return doubled + 5; +} +// → (Value * 2) + 5 +``` + +Transitive inlining is supported: + +```csharp +[Projectable(AllowBlockBody = true)] +public int CalculateComplex() +{ + var a = Value * 2; + var b = a + 5; + return b + 10; +} +// → ((Value * 2) + 5) + 10 +``` + +::: warning Variable Duplication +If a local variable is referenced **multiple times**, its initializer is duplicated at each reference point. This can affect performance (and semantics if the initializer has side effects): + +```csharp +[Projectable(AllowBlockBody = true)] +public int Foo() +{ + var x = ExpensiveComputation(); // Inlined at each use + return x + x; // → ExpensiveComputation() + ExpensiveComputation() +} +``` +::: + +**Local variables are only supported at the method body level** — not inside nested blocks (inside `if`, `switch`, etc.). + +## SQL Output Examples + +### If-Else → CASE WHEN + +```csharp +public record Entity +{ + public int Value { get; set; } + public bool IsActive { get; set; } + + [Projectable(AllowBlockBody = true)] + public int GetAdjustedValue() + { + if (IsActive && Value > 0) + return Value * 2; + else + return 0; + } +} +``` + +Generated SQL: +```sql +SELECT CASE + WHEN [e].[IsActive] = CAST(1 AS bit) AND [e].[Value] > 0 + THEN [e].[Value] * 2 + ELSE 0 +END +FROM [Entity] AS [e] +``` + +### Switch → CASE WHEN IN + +```csharp +[Projectable(AllowBlockBody = true)] +public string Category +{ + get + { + switch (Status) + { + case 1: case 2: return "Low"; + case 3: case 4: case 5: return "Medium"; + default: return "High"; + } + } +} +``` + +Generated SQL: +```sql +SELECT CASE + WHEN [e].[Status] IN (1, 2) THEN N'Low' + WHEN [e].[Status] IN (3, 4, 5) THEN N'Medium' + ELSE N'High' +END +FROM [Entity] AS [e] +``` + +## Limitations and Unsupported Constructs + +The following statement types produce **warning EFP0003** and are not supported: + +| Construct | Reason | +|---------------------------------------|----------------------------------------------------| +| `while` / `for` / `foreach` loops | Cannot be represented as expression trees | +| `try` / `catch` / `finally` | Cannot be represented as expression trees | +| `throw` statements | Cannot be represented as expression trees | +| `new MyClass()` in statement position | Object instantiation not supported in this context | + +```csharp +// ❌ Warning EFP0003 — loops are not supported +[Projectable(AllowBlockBody = true)] +public int SumItems() +{ + int total = 0; + foreach (var item in Items) // EFP0003 + total += item.Price; + return total; +} + +// ✅ Use LINQ instead +[Projectable] +public int SumItems() => Items.Sum(i => i.Price); +``` + +## Side Effect Detection + +The generator actively detects statements with side effects and reports them as errors (EFP0004) or warnings (EFP0005). See [Diagnostics](/reference/diagnostics) for the full list. + +| Code | Diagnostic | +|---------------------------|------------------------------------------| +| `Bar = 10;` | ❌ EFP0004 — property assignment | +| `Bar += 10;` | ❌ EFP0004 — compound assignment | +| `Bar++;` | ❌ EFP0004 — increment/decrement | +| `Console.WriteLine("x");` | ⚠️ EFP0005 — non-projectable method call | + +## How the Conversion Works + +The `BlockStatementConverter` class in the source generator: + +1. Collects all local variable declarations at the method body level. +2. Identifies the `return` statements and their conditions. +3. Converts `if`/`else` chains into ternary expression syntax nodes. +4. Converts `switch` statements into nested ternary expressions (or `case IN (...)` optimized forms). +5. Substitutes local variable references with their initializer expressions (via `VariableReplacementRewriter`). +6. Passes the resulting expression syntax to the standard expression rewriter pipeline. + +The output is equivalent to what would have been produced by an expression-bodied member with the same logic. + diff --git a/docs/advanced/how-it-works.md b/docs/advanced/how-it-works.md new file mode 100644 index 0000000..25fc4a0 --- /dev/null +++ b/docs/advanced/how-it-works.md @@ -0,0 +1,176 @@ +# How It Works + +Understanding the internals of EF Core Projectables helps you use it effectively and debug issues when they arise. The library has two main components: a **build-time source generator** and a **runtime query interceptor**. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────┐ +│ BUILD TIME │ +│ │ +│ Your C# code with [Projectable] members │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────┐ │ +│ │ Roslyn Source Generator │ │ +│ │ (ProjectionExpressionGenerator) │ │ +│ │ - Scans for [Projectable] │ │ +│ │ - Parses member bodies │ │ +│ │ - Generates Expression<> │ │ +│ │ companion classes │ │ +│ └───────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Auto-generated *.g.cs files with Expression<> trees │ +└─────────────────────────────────────────────────────────┘ + +┌───────────────────────────────────────────────────────────┐ +│ RUNTIME │ +│ │ +│ LINQ query using projectable member │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ ProjectableExpressionReplacer (ExpressionVisitor) │ │ +│ │ - Walks the LINQ expression tree │ │ +│ │ - Detects calls to [Projectable] members │ │ +│ │ - Loads generated Expression<> via reflection │ │ +│ │ - Substitutes the call with the expression │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Expanded expression tree (no [Projectable] calls) │ +│ │ │ +│ ▼ │ +│ Standard EF Core SQL translation → SQL query │ +└───────────────────────────────────────────────────────────┘ +``` + +## Build Time: The Source Generator + +### `ProjectionExpressionGenerator` + +This is the entry point for the Roslyn incremental source generator. It implements `IIncrementalGenerator` for high-performance, incremental code generation. + +**Pipeline:** +1. **Filter** — Uses `ForAttributeWithMetadataName` to efficiently find all `MemberDeclarationSyntax` nodes decorated with `[ProjectableAttribute]`. +2. **Interpret** — Calls `ProjectableInterpreter.GetDescriptor()` to extract all the information needed to generate code. +3. **Generate** — Produces a static class with an `Expression>` factory method. + +### `ProjectableInterpreter` + +Reads the attribute arguments, resolves the member's type information (namespace, generic parameters, containing classes), and extracts the expression body. + +**Key tasks:** +- Resolves `NullConditionalRewriteSupport`, `UseMemberBody`, `ExpandEnumMethods`, and `AllowBlockBody` from the attribute. +- Determines the correct parameter list for the generated lambda (including the implicit `@this` parameter for instance members and extension methods). +- Dispatches to `BlockStatementConverter` for block-bodied members. + +### `BlockStatementConverter` + +Converts block-bodied method statements into expression-tree-compatible forms: + +| Statement | Converted to | +|--------------------------------------|---------------------------------| +| `if (cond) return A; else return B;` | `cond ? A : B` | +| `switch (x) { case 1: return "a"; }` | `x == 1 ? "a" : ...` | +| `var v = expr; return v + 1;` | Inline substitution: `expr + 1` | +| Multiple early `return` | Nested ternary chain | + +### Expression Rewriters + +After the body is extracted, several rewriters transform the expression syntax: + +| Rewriter | Purpose | +|-------------------------------|------------------------------------------------------------------| +| `ExpressionSyntaxRewriter` | Rewrites `?.` operators based on `NullConditionalRewriteSupport` | +| `DeclarationSyntaxRewriter` | Adjusts member declarations for the generated class | +| `VariableReplacementRewriter` | Inlines local variables into the return expression | + +### Generated Code + +For a property like: + +```csharp +public class Order +{ + [Projectable] + public decimal GrandTotal => Subtotal + Tax; +} +``` + +The generator produces something like: + +```csharp +// Auto-generated — not visible in IntelliSense +[EditorBrowsable(EditorBrowsableState.Never)] +internal static class Order__GrandTotal +{ + public static Expression> Expression() + => @this => @this.Subtotal + @this.Tax; +} +``` + +The class name is deterministic, based on namespace + class name + member name. + +### `ProjectionExpressionClassNameGenerator` + +Generates a stable, unique class name for each projectable member. Handles generics, overloads (via parameter type names), and nested classes. + +## Runtime: The Query Interceptor + +### How Queries Are Intercepted + +When `UseProjectables()` is called, the library registers custom implementations of EF Core's internal query infrastructure. Depending on the [Compatibility Mode](/reference/compatibility-mode): + +**Full mode** — registers a `CustomQueryCompiler` that wraps EF Core's default compiler. Before compiling any query, it calls `ProjectableExpressionReplacer.Replace()` on the raw LINQ expression. + +**Limited mode** — registers a `CustomQueryTranslationPreprocessor` (via `CustomQueryTranslationPreprocessorFactory`). This runs inside EF Core's own query pipeline after the query is accepted, so the expanded query benefits from EF Core's query cache. + +### `ProjectableExpressionReplacer` + +Inherits from `ExpressionVisitor`. Its `Visit` method walks the LINQ expression tree and looks for: + +- **Property accesses** that correspond to `[Projectable]` properties. +- **Method calls** that correspond to `[Projectable]` methods. + +For each hit, it: +1. Calls `ProjectionExpressionResolver.FindGeneratedExpression()` to locate the auto-generated expression class via reflection. +2. Uses `ExpressionArgumentReplacer` to substitute the lambda parameters with the actual arguments from the call site. +3. Replaces the original call node with the inlined expression body. + +The replacement is done recursively — if the inlined expression itself contains projectable calls, they are also expanded. + +### `ProjectionExpressionResolver` + +Discovers the auto-generated companion class by constructing the expected class name (using the same naming logic as the generator) and reflecting into the assembly. + +```csharp +// Roughly equivalent to: +var type = assembly.GetType("Order__GrandTotal"); +var method = type.GetMethod("Expression"); +var expression = (LambdaExpression)method.Invoke(null, null); +``` + +### `ExpressionArgumentReplacer` + +Replaces the `@this` parameter (and any method arguments) in the retrieved lambda with the actual expressions from the call site. This is standard expression tree parameter substitution. + +## Tracking Behavior Handling + +The replacer also manages EF Core's tracking behavior. When a projectable member is used in a `Select` projection, the replacer wraps the expanded query in a `AsNoTracking()` call if necessary, ensuring consistent behavior with and without projectables. + +## Summary + +| Phase | Component | Responsibility | +|---------|--------------------------------------|----------------------------------------------| +| Build | `ProjectionExpressionGenerator` | Source gen entry point, orchestration | +| Build | `ProjectableInterpreter` | Extract descriptor from attribute + syntax | +| Build | `BlockStatementConverter` | Block body → expression conversion | +| Build | `ExpressionSyntaxRewriter` | `?.` handling, null-conditional rewrite | +| Runtime | `CustomQueryCompiler` | Full mode: expand before EF Core | +| Runtime | `CustomQueryTranslationPreprocessor` | Limited mode: expand inside EF Core pipeline | +| Runtime | `ProjectableExpressionReplacer` | Walk and replace projectable calls | +| Runtime | `ProjectionExpressionResolver` | Locate generated expression via reflection | +| Runtime | `ExpressionArgumentReplacer` | Substitute parameters in lambda | + diff --git a/docs/advanced/limitations.md b/docs/advanced/limitations.md new file mode 100644 index 0000000..aaf5a25 --- /dev/null +++ b/docs/advanced/limitations.md @@ -0,0 +1,131 @@ +# Limitations & Known Issues + +This page documents the current limitations of EF Core Projectables and guidance on how to work around them. + + +## Members Must Have a Body + +A `[Projectable]` member must have an **expression body** or a **block body** (with `AllowBlockBody = true`). Abstract members, interface declarations, and auto-properties without accessors are not supported and produce error EFP0006. + +```csharp +// ❌ Error EFP0006 — no body +[Projectable] +public string FullName { get; set; } + +// ✅ Expression-bodied property +[Projectable] +public string FullName => FirstName + " " + LastName; +``` + +Use [`UseMemberBody`](/reference/use-member-body) to delegate to another member if the projectable itself can't have a body. + +## Null-Conditional Operators Require Configuration + +The null-conditional operator (`?.`) cannot be used in projectable members unless `NullConditionalRewriteSupport` is set. The default (`None`) produces error EFP0002. + +```csharp +// ❌ Error EFP0002 +[Projectable] +public string? City => Address?.City; + +// ✅ Configured +[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] +public string? City => Address?.City; +``` + +See [Null-Conditional Rewrite](/reference/null-conditional-rewrite). + +## Block Body Restrictions + +When using block-bodied members (experimental), the following constructs are **not supported**: + +- `while`, `for`, `foreach` loops (EFP0003) +- `try` / `catch` / `finally` blocks (EFP0003) +- `throw` statements (EFP0003) +- Local variables inside nested blocks (only top-level variable declarations are supported) + +```csharp +// ❌ Not supported +[Projectable(AllowBlockBody = true)] +public int Process() +{ + for (int i = 0; i < 10; i++) { ... } // EFP0003 + return result; +} + +// ✅ Use LINQ +[Projectable] +public int Process() => Items.Take(10).Sum(i => i.Value); +``` + +## Local Variables Are Inlined (No De-duplication) + +In block-bodied members, local variables are **inlined at every usage point**. If a variable is used multiple times, the initializer expression is duplicated. This can: + +- Increase SQL complexity. +- Change semantics if the initializer has observable side effects (though side effects are detected as EFP0004/EFP0005). + +```csharp +// The initializer "Value * 2" appears twice in the generated expression +[Projectable(AllowBlockBody = true)] +public int Foo() +{ + var doubled = Value * 2; + return doubled + doubled; // → (Value * 2) + (Value * 2) +} +``` + +## Expression Tree Restrictions Apply + +Since projectable members are ultimately compiled to expression trees, all standard expression tree limitations apply: + +- **No `dynamic` typing** — expression trees must be statically typed. +- **No `ref` or `out` parameters**. +- **No named/optional parameters in LINQ** — parameters must be passed positionally in query expressions. +- **No multi-statement lambdas** — expression-bodied members must be single expressions (block bodies go through the converter, but with the limitations above). +- **Only EF Core-translatable operations** — the generated expression will ultimately be translated to SQL by EF Core. Any operation that EF Core cannot translate (e.g., calling a .NET method that has no SQL equivalent) will cause a runtime query translation error. + +## EF Core Translatable Operations Only + +The body of a projectable member can only use: + +- Mapped entity properties and navigation properties. +- Other `[Projectable]` members (transitively expanded). +- EF Core built-in functions (e.g., `EF.Functions.Like(...)`, `DateTime.Now`, string methods EF Core knows). +- LINQ methods EF Core supports (`Where`, `Sum`, `Any`, `Select`, etc.). + +```csharp +// ❌ Path.Combine has no SQL equivalent — runtime error +[Projectable] +public string FilePath => Path.Combine(Directory, FileName); + +// ✅ String concatenation — translated by EF Core +[Projectable] +public string FilePath => Directory + "/" + FileName; +``` + +## Limited Compatibility Mode and Dynamic State + +[Limited mode](/reference/compatibility-mode) caches the expanded query after the first execution. If a projectable member's expansion depends on external state that changes between calls (not through standard EF Core query parameters), the cached expansion may be stale. + +## No Support for Generic Type Parameters on Methods + +Generic method parameters are not supported on projectable methods: + +```csharp +// ❌ Not supported +[Projectable] +public T GetValue() => ...; +``` + +Generic **class** type parameters (on the containing entity) are supported. + +## Performance: First-Execution Overhead + +Both compatibility modes have a one-time cost on first execution: + +- **Full mode:** Expression walking + expansion on every execution. +- **Limited mode:** Expression walking + expansion on first execution; subsequent calls use EF Core's query cache. + +For performance-critical code paths, consider Limited mode to amortize this cost. + diff --git a/docs/advanced/query-compiler-pipeline.md b/docs/advanced/query-compiler-pipeline.md new file mode 100644 index 0000000..d7f294d --- /dev/null +++ b/docs/advanced/query-compiler-pipeline.md @@ -0,0 +1,158 @@ +# Query Compiler Pipeline + +This page explains how EF Core Projectables integrates with EF Core's internal query compilation pipeline, and the differences between Full and Limited compatibility modes. + +## EF Core's Query Pipeline (Background) + +When you execute a LINQ query against a `DbContext`, EF Core runs it through a multi-stage pipeline: + +``` +LINQ Expression (IQueryable) + ↓ +QueryCompiler.Execute() + ↓ +Query Translation Preprocessor + ↓ +Query Translator (LINQ → SQL model) + ↓ +SQL Generator + ↓ +SQL + Parameters → Database +``` + +Projectables hooks into this pipeline at different points depending on the selected compatibility mode. + +## Full Compatibility Mode + +In Full mode, expansion happens **before** the query reaches EF Core's pipeline: + +``` +LINQ Expression + ↓ +CustomQueryCompiler.Execute() / CreateCompiledQuery() + ↓ ← [Projectables expansion happens HERE] +ProjectableExpressionReplacer.Replace() + ↓ +Expanded LINQ Expression + ↓ +(Delegated to the original EF Core QueryCompiler) + ↓ +Standard EF Core pipeline... + ↓ +SQL +``` + +### `CustomQueryCompiler` + +The `CustomQueryCompiler` class wraps EF Core's default `QueryCompiler`. It overrides all execution entry points: + +```csharp +public override TResult Execute(Expression query) + => _decoratedQueryCompiler.Execute(Expand(query)); + +public override TResult ExecuteAsync(Expression query, CancellationToken cancellationToken) + => _decoratedQueryCompiler.ExecuteAsync(Expand(query), cancellationToken); + +public override Func CreateCompiledQuery(Expression query) + => _decoratedQueryCompiler.CreateCompiledQuery(Expand(query)); +``` + +The `Expand()` method calls `ProjectableExpressionReplacer.Replace()` on the raw expression before passing it downstream. + +### Query Cache Implications + +Because expansion happens before EF Core sees the query, the expanded expression is what gets compiled and cached. This means: + +- EF Core's query cache works on the **expanded** expression. +- Two queries that differ only in which projectable member they call will produce **different cache keys**, even if the expanded SQL is the same. +- Each unique LINQ query shape goes through expansion on **every execution** — there is no caching of the expansion step itself. + +## Limited Compatibility Mode + +In Limited mode, expansion happens **inside** EF Core's query translation preprocessor: + +``` +LINQ Expression + ↓ +EF Core QueryCompiler (default) + ↓ +CustomQueryTranslationPreprocessor.Process() + ↓ ← [Projectables expansion happens HERE] +ProjectableExpressionReplacer (via ExpandProjectables() extension) + ↓ +Expanded expression (now stored in EF Core's query cache) + ↓ +Standard EF Core query translator... + ↓ +SQL +``` + +### `CustomQueryTranslationPreprocessor` + +This class wraps EF Core's default `QueryTranslationPreprocessor` and overrides the `Process()` method: + +```csharp +public override Expression Process(Expression query) + => _decoratedPreprocessor.Process(query.ExpandProjectables()); +``` + +`ExpandProjectables()` is an extension method on `Expression` that runs the `ProjectableExpressionReplacer` over the expression tree. + +### Query Cache Benefits + +Because the expansion happens **inside** EF Core's own preprocessing step, EF Core compiles the resulting expanded expression and stores it in its query cache. On subsequent executions with the same query shape: + +1. EF Core computes the cache key from the original (unexpanded) query. +2. It finds the cached compiled query. +3. It executes the cached query directly — **no expansion needed**. + +This is why Limited mode can outperform both Full mode and vanilla EF Core for repeated queries. + +### Dynamic Parameter Caveat + +The downside of Limited mode is that EF Core's query cache key is based on the **original** LINQ expression. If your projectable member captures external state (a closure variable that changes between calls), the cache may not distinguish between calls with different values. + +**Safe with Limited mode:** +```csharp +// The threshold is a query parameter — EF Core handles it correctly +dbContext.Orders.Where(o => o.ExceedsThreshold(threshold)) +``` + +**Potentially unsafe with Limited mode:** +```csharp +// If GetCurrentUserRegion() returns a different value per call +// and the result is baked into the expression tree at expansion time +// (not captured as a standard EF Core parameter), this may be stale. +dbContext.Orders.Where(o => o.Region == GetCurrentUserRegion()) +``` + +## How Expansion Works + +In both modes, the core expansion logic is in `ProjectableExpressionReplacer`: + +1. **Visit the expression tree** — The replacer inherits from `ExpressionVisitor` and recursively visits every node. +2. **Detect projectable calls** — For each `MemberExpression` (property access) or `MethodCallExpression`, it checks if the member has a `[ProjectableAttribute]`. +3. **Load the generated expression** — Uses `ProjectionExpressionResolver` to find the auto-generated companion class and invoke its `Expression()` factory method via reflection. +4. **Cache the resolved expression** — The resolved `LambdaExpression` is cached in a per-replacer dictionary to avoid redundant reflection calls within the same query expansion. +5. **Substitute arguments** — Uses `ExpressionArgumentReplacer` to replace the lambda's parameters with the actual arguments from the call site. +6. **Recurse** — The substituted expression body is itself visited, expanding any nested projectable calls. + +## Registering the Infrastructure + +Both modes use the same EF Core extension mechanism. `ProjectionOptionsExtension` implements `IDbContextOptionsExtension` and registers the appropriate services: + +```csharp +// Full mode — registers CustomQueryCompiler +services.AddScoped(); + +// Limited mode — registers CustomQueryTranslationPreprocessorFactory +services.AddScoped(); +``` + +The `CustomConventionSetPlugin` also registers the `ProjectablePropertiesNotMappedConvention`, which ensures EF Core's model builder ignores `[Projectable]` properties (they are computed — not mapped to database columns). + +## Query Filters + +The `ProjectablesExpandQueryFiltersConvention` handles the case where global query filters reference projectable members. It ensures that query filters are also expanded when Projectables is active. + diff --git a/docs/guide/extension-methods.md b/docs/guide/extension-methods.md new file mode 100644 index 0000000..d1bee9e --- /dev/null +++ b/docs/guide/extension-methods.md @@ -0,0 +1,124 @@ +# Extension Methods + +Projectable extension methods let you define query logic outside of your entity classes — useful for keeping entities clean, applying logic to types you don't own, or grouping related query helpers. + +## Defining a Projectable Extension Method + +Add `[Projectable]` to any extension method in a **static class**: + +```csharp +using EntityFrameworkCore.Projectables; + +public static class UserExtensions +{ + [Projectable] + public static Order GetMostRecentOrder(this User user, DateTime? cutoffDate) => + user.Orders + .Where(x => cutoffDate == null || x.CreatedDate >= cutoffDate) + .OrderByDescending(x => x.CreatedDate) + .FirstOrDefault(); +} +``` + +## Using Extension Methods in Queries + +```csharp +var result = dbContext.Users + .Where(u => u.UserName == "Jon") + .Select(u => new { + GrandTotal = u.GetMostRecentOrder(DateTime.UtcNow.AddDays(-30)).GrandTotal + }) + .FirstOrDefault(); +``` + +The extension method is fully inlined — including any nested projectable members like `GrandTotal`. + +## Extension Methods on Non-Entity Types + +You don't need to restrict projectable extension methods to entity types. They work on **any type** that EF Core can work with in queries: + +```csharp +// On int +public static class IntExtensions +{ + [Projectable] + public static int Squared(this int i) => i * i; +} + +// On string +public static class StringExtensions +{ + [Projectable] + public static bool ContainsIgnoreCase(this string source, string value) => + source.ToLower().Contains(value.ToLower()); +} +``` + +Usage in queries: + +```csharp +var squaredScores = dbContext.Players + .Select(p => new { p.Name, SquaredScore = p.Score.Squared() }) + .ToList(); + +var results = dbContext.Products + .Where(p => p.Name.ContainsIgnoreCase("widget")) + .ToList(); +``` + +## Extension Methods with Multiple Parameters + +```csharp +public static class OrderExtensions +{ + [Projectable] + public static bool IsHighValueOrder(this Order order, decimal threshold, bool includeTax = false) => + (includeTax ? order.GrandTotal : order.Subtotal) > threshold; +} + +var highValue = dbContext.Orders + .Where(o => o.IsHighValueOrder(500, includeTax: true)) + .ToList(); +``` + +## Chaining Extension Methods + +Extension methods can call other projectable members (properties, methods, or other extension methods): + +```csharp +public static class UserExtensions +{ + [Projectable] + public static decimal TotalSpentThisMonth(this User user) => + user.Orders + .Where(o => o.CreatedDate.Month == DateTime.UtcNow.Month) + .Sum(o => o.GrandTotal); // GrandTotal is [Projectable] on Order + + [Projectable] + public static bool IsVipCustomer(this User user) => + user.TotalSpentThisMonth() > 1000; // Calls another [Projectable] extension +} +``` + +## Extension Methods on Nullable Types + +Extension methods on nullable entity types work naturally: + +```csharp +public static class UserExtensions +{ + [Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] + public static string GetFullAddress(this User? user) => + user?.Location?.AddressLine1 + " " + user?.Location?.AddressLine2; +} +``` + +See [Null-Conditional Rewrite](/reference/null-conditional-rewrite) for details on handling nullable navigation. + +## Important Rules + +- Extension methods **must be in a static class**. +- The `this` parameter represents the entity instance in the generated expression. +- **Method overloading is not supported** — each method name must be unique within its declaring static class. +- Default parameter values are supported but the caller must explicitly provide all arguments in LINQ queries (EF Core does not support optional parameters in expression trees). + diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md new file mode 100644 index 0000000..0eaf97e --- /dev/null +++ b/docs/guide/introduction.md @@ -0,0 +1,102 @@ +# Introduction + +**EntityFrameworkCore.Projectables** is a library that lets you write C# properties and methods — decorated with a simple `[Projectable]` attribute — and use them directly inside any EF Core LINQ query. The library takes care of translating those members into the SQL query, keeping your codebase DRY and your queries efficient. + +## The Problem It Solves + +When using EF Core, you often need to express the same business logic in two places: + +1. **In-memory** — as a regular C# property or method on your entity. +2. **In queries** — duplicated inline as a LINQ expression so EF Core can translate it to SQL. + +```csharp +// ❌ Without Projectables — logic duplicated +class Order +{ + // C# property (in-memory use) + public decimal GrandTotal => Subtotal + Tax; + + // Must be duplicated inline in every LINQ query +} + +var totals = dbContext.Orders + .Select(o => new { + GrandTotal = o.Items.Sum(i => i.Price) + (o.Items.Sum(i => i.Price) * o.TaxRate) + }) + .ToList(); +``` + +With Projectables, you write the logic once: + +```csharp +// ✅ With Projectables — write once, use everywhere +class Order +{ + [Projectable] public decimal Subtotal => Items.Sum(i => i.Price); + [Projectable] public decimal Tax => Subtotal * TaxRate; + [Projectable] public decimal GrandTotal => Subtotal + Tax; +} + +var totals = dbContext.Orders + .Select(o => new { o.GrandTotal }) // Inlined into SQL automatically + .ToList(); +``` + +## How It Works + +Projectables has two components that work together: + +### 1. Source Generator (build time) + +When you compile your project, a Roslyn source generator scans for members decorated with `[Projectable]` and generates a **companion expression tree** for each one. For example, the `GrandTotal` property above generates something like: + +```csharp +// Auto-generated — hidden from IntelliSense +public static Expression> GrandTotal_Expression() + => @this => @this.Items.Sum(i => i.Price) + (@this.Items.Sum(i => i.Price) * @this.TaxRate); +``` + +### 2. Runtime Interceptor (query time) + +At query execution time, a custom EF Core query pre-processor walks your LINQ expression tree. Whenever it encounters a call to a `[Projectable]` member, it **replaces it with the generated expression tree**, substituting the actual parameters. The resulting expanded expression tree is then handed off to EF Core for normal SQL translation. + +``` +LINQ query + → [Projectables interceptor replaces member calls with expressions] + → Expanded expression tree + → EF Core SQL translation + → SQL query +``` + +## Comparison with Similar Libraries + +| Feature | Projectables | Expressionify | LinqKit | +|-------------------------------|------------------|---------------|---------| +| Source generator based | ✅ | ✅ | ❌ | +| Works with entity methods | ✅ | ✅ | Partial | +| Works with extension methods | ✅ | ✅ | ✅ | +| Composable projectables | ✅ | ❌ | Partial | +| Constructor projections | ✅ | ❌ | ❌ | +| Pattern matching support | ✅ | ❌ | ❌ | +| Method overloads support | ✅ | ❌ | ❌ | +| Block-bodied members | ✅ (experimental) | ❌ | ❌ | +| Enum method expansion | ✅ | ❌ | ❌ | +| Null-conditional rewriting | ✅ | ❌ | ❌ | +| Roslyn analyzers & code fixes | ✅ | ❌ | ❌ | +| Limited/cached mode | ✅ | ❌ | ❌ | + +## EF Core Version Compatibility + +| Library Version | EF Core Version | Notable Additions | +|-----------------|-----------------|------------------------------------------------------------------------------------------------------------------| +| v1.x | EF Core 3.1, 5 | Initial release | +| v2.x, v3.x | EF Core 6, 7 | Null-conditional rewriting, enum expansion | +| v6.x+ | EF Core 6+ | Block-bodied members, constructor projections, pattern matching, method overloads, Roslyn analyzers & code fixes | + +## Next Steps + +- [Quick Start →](/guide/quickstart) +- [Constructor Projections →](/guide/projectable-constructors) +- [Analyzers & Code Fixes →](/reference/diagnostics) +- [Learn how it works internally →](/advanced/how-it-works) + diff --git a/docs/guide/projectable-constructors.md b/docs/guide/projectable-constructors.md new file mode 100644 index 0000000..e57166a --- /dev/null +++ b/docs/guide/projectable-constructors.md @@ -0,0 +1,147 @@ +# Constructor Projections + +> [!NOTE] +> Constructor projections are available starting from version 6.x. + +You can mark a constructor with `[Projectable]` to project your DTOs directly inside LINQ queries. The generator emits a member-init expression (`new T() { Prop = value, … }`) that EF Core can translate to SQL. + +## Basic Example + +```csharp +public class Customer +{ + public int Id { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + public bool IsActive { get; set; } + public ICollection Orders { get; set; } +} + +public class CustomerDto +{ + public int Id { get; set; } + public string FullName { get; set; } + public bool IsActive { get; set; } + public int OrderCount { get; set; } + + public CustomerDto() { } // required parameterless ctor + + [Projectable] + public CustomerDto(Customer customer) + { + Id = customer.Id; + FullName = customer.FirstName + " " + customer.LastName; + IsActive = customer.IsActive; + OrderCount = customer.Orders.Count(); + } +} + +// The constructor call is translated directly to SQL +var customers = dbContext.Customers + .Select(c => new CustomerDto(c)) + .ToList(); +``` + +The generator produces an expression equivalent to: + +```csharp +(Customer customer) => new CustomerDto() +{ + Id = customer.Id, + FullName = customer.FirstName + " " + customer.LastName, + IsActive = customer.IsActive, + OrderCount = customer.Orders.Count() +} +``` + +## Requirements + +- The class must expose an accessible **parameterless constructor** (public, internal, or protected-internal), because the generated code relies on `new T() { … }` syntax. +- If a parameterless constructor is missing, the generator reports **EFP0008** — an IDE quick-fix can insert it automatically. + +## Supported Constructs in Constructor Bodies + +| Construct | Notes | +|------------------------------|-------------------------------------------------------------| +| Simple property assignments | `FullName = customer.FirstName + " " + customer.LastName;` | +| Local variable declarations | Inlined at each usage point | +| If/else chains | Converted to ternary expressions | +| Switch expressions | Translated to nested ternary / CASE | +| Base/this initializer chains | Recursively inlines the delegated constructor's assignments | + +## Inheritance — Base/This Initializer Chains + +The generator recursively inlines the delegated constructor's assignments, which is particularly useful with DTO inheritance hierarchies: + +```csharp +public class PersonDto +{ + public string FullName { get; set; } + public string Email { get; set; } + + public PersonDto() { } + + [Projectable] + public PersonDto(Person person) + { + FullName = person.FirstName + " " + person.LastName; + Email = person.Email; + } +} + +public class EmployeeDto : PersonDto +{ + public string Department { get; set; } + public string Grade { get; set; } + + public EmployeeDto() { } + + [Projectable] + public EmployeeDto(Employee employee) : base(employee) // PersonDto assignments inlined automatically + { + Department = employee.Department.Name; + Grade = employee.YearsOfService >= 10 ? "Senior" : "Junior"; + } +} + +var employees = dbContext.Employees + .Select(e => new EmployeeDto(e)) + .ToList(); +``` + +The generated expression inlines both the base constructor and the derived constructor body: + +```csharp +(Employee employee) => new EmployeeDto() +{ + FullName = employee.FirstName + " " + employee.LastName, + Email = employee.Email, + Department = employee.Department.Name, + Grade = employee.YearsOfService >= 10 ? "Senior" : "Junior" +} +``` + +> [!NOTE] +> If the delegated constructor's source is not available in the current compilation, the generator reports **EFP0009** and skips the projection. + +## Constructor Overloads + +Multiple `[Projectable]` constructors (overloads) per class are fully supported — each overload generates its own expression class distinguished by parameter types. + +## Converting a Factory Method to a Constructor + +If you have an existing `[Projectable]` factory method that returns `new T { … }`, the generator emits diagnostic **EFP0012** suggesting a conversion. The IDE provides a one-click refactoring that: + +1. Converts the factory method to a `[Projectable]` constructor. +2. Optionally updates all call sites throughout the solution to use `new T(…)` instead. + +See [Diagnostics](/reference/diagnostics) for details. + +## Diagnostics + +| Code | Severity | Cause | +|-------------|----------|--------------------------------------------------| +| **EFP0008** | ❌ Error | Class is missing a parameterless constructor | +| **EFP0009** | ❌ Error | Delegated constructor source not available | +| **EFP0012** | ℹ️ Info | Factory method can be converted to a constructor | + diff --git a/docs/guide/projectable-methods.md b/docs/guide/projectable-methods.md new file mode 100644 index 0000000..b818aaf --- /dev/null +++ b/docs/guide/projectable-methods.md @@ -0,0 +1,118 @@ +# Projectable Methods + +Projectable methods work like projectable properties but accept parameters, making them ideal for reusable query fragments that vary based on runtime values. + +## Defining a Projectable Method + +Add `[Projectable]` to any **expression-bodied method** on an entity: + +```csharp +public class Order +{ + public int Id { get; set; } + public DateTime CreatedDate { get; set; } + public bool IsFulfilled { get; set; } + public decimal TaxRate { get; set; } + public ICollection Items { get; set; } + + [Projectable] + public decimal Subtotal => Items.Sum(item => item.Product.ListPrice * item.Quantity); + + [Projectable] + public bool IsRecentOrder(int days) => + CreatedDate >= DateTime.UtcNow.AddDays(-days) && IsFulfilled; +} +``` + +## Using Projectable Methods in Queries + +```csharp +// Pass runtime values as arguments +var recentOrders = dbContext.Orders + .Where(o => o.IsRecentOrder(30)) + .ToList(); + +// Use in Select +var summary = dbContext.Orders + .Select(o => new { + o.Id, + IsRecent = o.IsRecentOrder(7), + o.Subtotal + }) + .ToList(); +``` + +The method argument (`30` or `7`) is captured and translated into the generated SQL expression. + +## Methods with Multiple Parameters + +```csharp +public class Product +{ + public decimal ListPrice { get; set; } + public decimal DiscountRate { get; set; } + + [Projectable] + public decimal DiscountedPrice(decimal additionalDiscount, int quantity) => + ListPrice * (1 - DiscountRate - additionalDiscount) * quantity; +} + +// Usage +var prices = dbContext.Products + .Select(p => new { + p.Id, + FinalPrice = p.DiscountedPrice(0.05m, 10) + }) + .ToList(); +``` + +## Composing Methods and Properties + +Projectable methods can call projectable properties and vice versa: + +```csharp +public class Order +{ + [Projectable] public decimal Subtotal => Items.Sum(i => i.Price); + [Projectable] public decimal Tax => Subtotal * TaxRate; + + // Method calling projectable properties + [Projectable] + public bool ExceedsThreshold(decimal threshold) => (Subtotal + Tax) > threshold; +} + +var highValue = dbContext.Orders + .Where(o => o.ExceedsThreshold(500)) + .ToList(); +``` + +## Block-Bodied Methods (Experimental) + +Methods can also use traditional block bodies when `AllowBlockBody = true`: + +```csharp +[Projectable(AllowBlockBody = true)] +public string GetStatus(decimal threshold) +{ + if (GrandTotal > threshold) + return "High Value"; + else if (GrandTotal > threshold / 2) + return "Medium Value"; + else + return "Standard"; +} +``` + +See [Block-Bodied Members](/advanced/block-bodied-members) for full details. + +## Important Rules + +- Methods must be **expression-bodied** (`=>`) unless `AllowBlockBody = true`. +- **Method overloading is not supported** — each method name must be unique within its type. +- Parameters are passed through to the generated expression as closures and resolved at query time. +- Parameter types must be supported by EF Core (primitive types, enums, and other EF-translatable types). + +## Difference from Extension Methods + +Instance methods are defined directly on the entity. For query logic that doesn't belong on the entity, or that applies to types you don't own, use [Extension Methods](/guide/extension-methods) instead. + diff --git a/docs/guide/projectable-properties.md b/docs/guide/projectable-properties.md new file mode 100644 index 0000000..166eb49 --- /dev/null +++ b/docs/guide/projectable-properties.md @@ -0,0 +1,134 @@ +# Projectable Properties + +Projectable properties let you define computed values on your entities using standard C# expression-bodied properties, and have those computations automatically translated into SQL when used in LINQ queries. + +## Defining a Projectable Property + +Add `[Projectable]` to any **expression-bodied property**: + +```csharp +using EntityFrameworkCore.Projectables; + +public class User +{ + public string FirstName { get; set; } + public string LastName { get; set; } + + [Projectable] + public string FullName => FirstName + " " + LastName; +} +``` + +## Using Projectable Properties in Queries + +Once defined, projectable properties can be used in **any part of a LINQ query**: + +### In `Select` + +```csharp +var names = dbContext.Users + .Select(u => u.FullName) + .ToList(); +``` + +Generated SQL (SQLite): +```sql +SELECT (COALESCE("u"."FirstName", '') || ' ') || COALESCE("u"."LastName", '') +FROM "Users" AS "u" +``` + +### In `Where` + +```csharp +var users = dbContext.Users + .Where(u => u.FullName.Contains("Jon")) + .ToList(); +``` + +### In `GroupBy` + +```csharp +var grouped = dbContext.Users + .GroupBy(u => u.FullName) + .Select(g => new { Name = g.Key, Count = g.Count() }) + .ToList(); +``` + +### In `OrderBy` + +```csharp +var sorted = dbContext.Users + .OrderBy(u => u.FullName) + .ToList(); +``` + +### In multiple clauses at once + +```csharp +var query = dbContext.Users + .Where(u => u.FullName.Contains("Jon")) + .GroupBy(u => u.FullName) + .OrderBy(u => u.Key) + .Select(u => u.Key); +``` + +Generated SQL (SQLite): +```sql +SELECT (COALESCE("u"."FirstName", '') || ' ') || COALESCE("u"."LastName", '') +FROM "Users" AS "u" +WHERE ('Jon' = '') OR (instr((COALESCE("u"."FirstName", '') || ' ') || COALESCE("u"."LastName", ''), 'Jon') > 0) +GROUP BY (COALESCE("u"."FirstName", '') || ' ') || COALESCE("u"."LastName", '') +ORDER BY (COALESCE("u"."FirstName", '') || ' ') || COALESCE("u"."LastName", '') +``` + +## Composing Projectable Properties + +Projectable properties can reference **other projectable properties**. The entire chain is expanded into the final SQL: + +```csharp +public class Order +{ + public decimal TaxRate { get; set; } + public ICollection Items { get; set; } + + [Projectable] public decimal Subtotal => Items.Sum(item => item.Product.ListPrice * item.Quantity); + [Projectable] public decimal Tax => Subtotal * TaxRate; // uses Subtotal + [Projectable] public decimal GrandTotal => Subtotal + Tax; // uses Subtotal + Tax +} +``` + +All three properties are inlined transitively in the generated SQL. + +## Block-Bodied Properties (Experimental) + +In addition to expression-bodied properties (`=>`), you can use **block-bodied properties** with `AllowBlockBody = true`: + +```csharp +[Projectable(AllowBlockBody = true)] +public string Category +{ + get + { + if (Score > 90) + return "Excellent"; + else if (Score > 70) + return "Good"; + else + return "Average"; + } +} +``` + +See [Block-Bodied Members](/advanced/block-bodied-members) for the full feature documentation. + +## Important Rules + +- The property **must be expression-bodied** (using `=>`) unless `AllowBlockBody = true` is set. +- The expression must be translatable by EF Core — it can only use members that EF Core understands (mapped columns, navigation properties, and other `[Projectable]` members). +- Properties **cannot be overloaded** — each property name must be unique within its type. +- The property body has access to `this` (the entity instance) and its navigation properties. + +## Nullable Properties + +If your expression uses the null-conditional operator (`?.`), you need to configure `NullConditionalRewriteSupport`. See [Null-Conditional Rewrite](/reference/null-conditional-rewrite) for details. + diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md new file mode 100644 index 0000000..1ce7b28 --- /dev/null +++ b/docs/guide/quickstart.md @@ -0,0 +1,210 @@ +# Quick Start + +This guide walks you through a complete end-to-end example — from installing the NuGet packages to seeing the generated SQL. + +## Prerequisites + +- .NET 8 to .NET 10 +- EF Core 6 or later (any provider) + +## Step 1 — Install the Packages + +Projectables is split into **two NuGet packages**: + +| Package | Purpose | +|-------------------------------------------------|------------------------------------------------------------------| +| `EntityFrameworkCore.Projectables.Abstractions` | `[Projectable]` attribute + Roslyn source generator + code fixes | +| `EntityFrameworkCore.Projectables` | EF Core runtime interceptor (`UseProjectables()`) | + +In most single-project setups both packages go in the same project. + +### .NET CLI + +```bash +dotnet add package EntityFrameworkCore.Projectables.Abstractions +dotnet add package EntityFrameworkCore.Projectables +``` + +### Package Manager Console + +```powershell +Install-Package EntityFrameworkCore.Projectables.Abstractions +Install-Package EntityFrameworkCore.Projectables +``` + +### PackageReference (`.csproj`) + +```xml + + + + +``` + +> **Tip:** Replace `*` with the [latest stable version](https://www.nuget.org/packages/EntityFrameworkCore.Projectables/). + +## Step 2 — Define Your Entities + +Add `[Projectable]` to any property or method whose body you want EF Core to translate to SQL: + +```csharp +using EntityFrameworkCore.Projectables; + +public class User +{ + public int Id { get; set; } + public string UserName { get; set; } + public ICollection Orders { get; set; } +} + +public class Order +{ + public int Id { get; set; } + public int UserId { get; set; } + public DateTime CreatedDate { get; set; } + public decimal TaxRate { get; set; } + + public User User { get; set; } + public ICollection Items { get; set; } + + // Mark computed properties with [Projectable] + [Projectable] public decimal Subtotal => Items.Sum(item => item.Product.ListPrice * item.Quantity); + [Projectable] public decimal Tax => Subtotal * TaxRate; + [Projectable] public decimal GrandTotal => Subtotal + Tax; +} + +public class OrderItem +{ + public int Id { get; set; } + public int OrderId { get; set; } + public int Quantity { get; set; } + public Product Product { get; set; } +} + +public class Product +{ + public int Id { get; set; } + public decimal ListPrice { get; set; } +} +``` + +The source generator runs at **compile time** and emits a companion `Expression` for each `[Projectable]` member — no runtime reflection. + +## Step 3 — Enable Projectables on Your DbContext + +Call `UseProjectables()` when configuring your `DbContextOptions`. The extension method is in the `Microsoft.EntityFrameworkCore` namespace and is included in the `EntityFrameworkCore.Projectables` package. + +### With DI (`AddDbContext`) + +```csharp +services.AddDbContext(options => + options.UseSqlServer(connectionString) + .UseProjectables()); +``` + +### With `OnConfiguring` + +```csharp +public class AppDbContext : DbContext +{ + public DbSet Users { get; set; } + public DbSet Orders { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .UseSqlServer("your-connection-string") + .UseProjectables(); + } +} +``` + +## Step 4 — Use Projectable Members in Queries + +Now you can use `GrandTotal`, `Subtotal`, and `Tax` **directly in any LINQ query**: + +```csharp +// In a Select projection +var orderSummaries = dbContext.Orders + .Select(o => new { + o.Id, + o.Subtotal, + o.Tax, + o.GrandTotal + }) + .ToList(); + +// In a Where clause +var highValueOrders = dbContext.Orders + .Where(o => o.GrandTotal > 1000) + .ToList(); + +// In an OrderBy +var sortedOrders = dbContext.Orders + .OrderByDescending(o => o.GrandTotal) + .ToList(); +``` + +## Step 5 — Check the Generated SQL + +Use `ToQueryString()` to inspect the SQL EF Core generates: + +```csharp +var query = dbContext.Orders + .Where(o => o.GrandTotal > 1000) + .OrderByDescending(o => o.GrandTotal); + +Console.WriteLine(query.ToQueryString()); +``` + +The `GrandTotal` property composes `Subtotal` (which is also `[Projectable]`) — both are fully inlined into SQL: + +```sql +SELECT [o].[Id], [o].[UserId], [o].[CreatedDate], [o].[TaxRate] +FROM [Orders] AS [o] +WHERE ( + COALESCE(SUM([p].[ListPrice] * CAST([oi].[Quantity] AS decimal(18,2))), 0.0) + + COALESCE(SUM([p].[ListPrice] * CAST([oi].[Quantity] AS decimal(18,2))), 0.0) * [o].[TaxRate] +) > 1000.0 +ORDER BY ( + COALESCE(SUM([p].[ListPrice] * CAST([oi].[Quantity] AS decimal(18,2))), 0.0) + + COALESCE(SUM([p].[ListPrice] * CAST([oi].[Quantity] AS decimal(18,2))), 0.0) * [o].[TaxRate] +) DESC +``` + +All computation happens in the database — no data is loaded into memory for filtering or sorting. + +## Adding Extension Methods + +You can also define projectable extension methods — useful for logic that doesn't belong on the entity itself: + +```csharp +public static class UserExtensions +{ + [Projectable] + public static Order GetMostRecentOrder(this User user, DateTime? cutoffDate = null) => + user.Orders + .Where(x => cutoffDate == null || x.CreatedDate >= cutoffDate) + .OrderByDescending(x => x.CreatedDate) + .FirstOrDefault(); +} +``` + +Use it in a query just like any regular method: + +```csharp +var result = dbContext.Users + .Where(u => u.UserName == "Jon") + .Select(u => new { + GrandTotal = u.GetMostRecentOrder(DateTime.UtcNow.AddDays(-30)).GrandTotal + }) + .FirstOrDefault(); +``` + +## Next Steps + +- [Projectable Properties in depth →](/guide/projectable-properties) +- [Projectable Methods →](/guide/projectable-methods) +- [Extension Methods →](/guide/extension-methods) +- [Full [Projectable] attribute reference →](/reference/projectable-attribute) + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..bc53215 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,112 @@ +--- +layout: home + +hero: + name: "EF Core Projectables" + text: "Flexible projection magic for EF Core" + tagline: Write properties and methods once — use them anywhere in your LINQ queries, translated to efficient SQL automatically. + actions: + - theme: brand + text: Introduction + link: /guide/introduction + - theme: alt + text: Quick Start + link: /guide/quickstart + - theme: alt + text: View on GitHub + link: https://github.com/EFNext/EntityFrameworkCore.Projectables + +features: + - icon: 🏷️ + title: Just Add [Projectable] + details: Decorate any property, method, or constructor with [Projectable] and the source generator does the rest — no boilerplate, no manual expression trees. + + - icon: 🔌 + title: Works with Any EF Core Provider + details: Provider-agnostic. SQL Server, PostgreSQL, SQLite, Cosmos DB — Projectables hooks into the EF Core query pipeline regardless of your database. + + - icon: ⚡ + title: Performance-First Design + details: Limited compatibility mode expands and caches queries after their first execution. Subsequent calls skip the expansion step entirely. + + - icon: 🔗 + title: Composable by Design + details: Projectable members can call other projectable members. Build a library of reusable query fragments and compose them freely in any query. + + - icon: 🏗️ + title: Constructor Projections + details: Mark a constructor with [Projectable] to project your DTOs directly in queries — new CustomerDto(c) translates to a full SQL projection with member-init syntax. + + - icon: 🔀 + title: Pattern Matching Support + details: Use switch expressions, is patterns, relational patterns, and and/or combinators directly in projectable members — all rewritten into SQL CASE expressions automatically. + + - icon: 🛡️ + title: Null-Conditional Rewriting + details: Working with nullable navigation properties? Configure NullConditionalRewriteSupport to automatically handle the ?. operator in generated expressions. + + - icon: 🔢 + title: Enum Method Expansion + details: Use ExpandEnumMethods to translate enum extension methods (like display names from [Display] attributes) into SQL CASE expressions automatically. + + - icon: 🩺 + title: Roslyn Analyzers & Code Fixes + details: Built-in Roslyn diagnostics (EFP0001–EFP0012) catch projection errors at compile time. Quick-fix actions let you resolve them with a single click in your IDE. +--- + +## At a Glance + +**Without Projectables** — the same sub-expression copy-pasted into every query: + +```csharp +// ❌ Repeated 4× in a single query — change the formula and hunt down every copy +var orders = dbContext.Orders + .Where(o => o.Lines.Sum(l => l.Quantity * l.UnitPrice) > 500) + .OrderByDescending(o => o.Lines.Sum(l => l.Quantity * l.UnitPrice) * (1 + o.TaxRate)) + .Select(o => new + { + Total = o.Lines.Sum(l => l.Quantity * l.UnitPrice) * (1 + o.TaxRate), + Tier = o.Lines.Sum(l => l.Quantity * l.UnitPrice) > 1000 ? "Premium" : "Standard" + }) + .ToList(); +``` + +**With Projectables** — define once on the entity, compose freely, use anywhere: + +```csharp +// ✅ Business logic lives on the entity — queries stay clean +class Order +{ + public decimal TaxRate { get; set; } + public ICollection Lines { get; set; } + + [Projectable] + public decimal Subtotal => Lines.Sum(l => l.Quantity * l.UnitPrice); + + [Projectable] + public decimal Total => Subtotal * (1 + TaxRate); // composes ↑ + + [Projectable] + public string Tier => Subtotal switch // pattern matching → SQL CASE + { + > 1000 => "Premium", + > 250 => "Standard", + _ => "Basic" + }; +} + +var orders = dbContext.Orders + .Where(o => o.Subtotal > 500) // → WHERE + .OrderByDescending(o => o.Total) // → ORDER BY + .Select(o => new { o.Total, o.Tier }) // → SELECT + .ToList(); +``` + +The properties are **inlined into SQL at query time** — no client-side evaluation, no N+1, no duplicate expressions. + +## NuGet Packages + +| Package | Description | +|----------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------| +| [`EntityFrameworkCore.Projectables.Abstractions`](https://www.nuget.org/packages/EntityFrameworkCore.Projectables.Abstractions/) | The `[Projectable]` attribute and source generator | +| [`EntityFrameworkCore.Projectables`](https://www.nuget.org/packages/EntityFrameworkCore.Projectables/) | The EF Core runtime extension | diff --git a/docs/package-lock.json b/docs/package-lock.json new file mode 100644 index 0000000..f340559 --- /dev/null +++ b/docs/package-lock.json @@ -0,0 +1,2313 @@ +{ + "name": "docs", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "vitepress": "^2.0.0-alpha.17" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@docsearch/css": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.6.0.tgz", + "integrity": "sha512-YlcAimkXclvqta47g47efzCM5CFxDwv2ClkDfEs/fC/Ak0OxPH2b3czwa4o8O1TRBf+ujFF2RiUwszz2fPVNJQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-4.6.0.tgz", + "integrity": "sha512-9/rbgkm/BgTq46cwxIohvSAz3koOFjnPpg0mwkJItAfzKbQIj+310PvwtgUY1YITDuGCag6yOL50GW2DBkaaBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/sidepanel-js": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@docsearch/sidepanel-js/-/sidepanel-js-4.6.0.tgz", + "integrity": "sha512-lFT5KLwlzUmpoGArCScNoK41l9a22JYsEPwBzMrz+/ILVR5Ax87UphCuiyDFQWEvEmbwzn/kJx5W/O5BUlN1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.71", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.71.tgz", + "integrity": "sha512-rNoDFbq1fAYiEexBvrw613/xiUOPEu5MKVV/X8lI64AgdTzLQUUemr9f9fplxUMPoxCBP2rWzlhOEeTHk/Sf0Q==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.1.tgz", + "integrity": "sha512-xB0b51TB7IfDEzAojXahmr+gfA00uYVInJGgNNkeQG6RPnCPGr7udsylFLTubuIUSRE6FkcI1NElyRt83PP5oQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.1.tgz", + "integrity": "sha512-XOjPId0qwSDKHaIsdzHJtKCxX0+nH8MhBwvrNsT7tVyKmdTx1jJ4XzN5RZXCdTzMpufLb+B8llTC0D8uCrLhcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.1.tgz", + "integrity": "sha512-vQuRd28p0gQpPrS6kppd8IrWmFo42U8Pz1XLRjSZXq5zCqyMDYFABT7/sywL11mO1EL10Qhh7MVPEwkG8GiBeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.1.tgz", + "integrity": "sha512-x6VG6U29+Ivlnajrg1IHdzXeAwSoEHBFVO+CtC9Brugx6de712CUJobRUxsIA0KYrQvCmzNrMPFTT1A4CCqNTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.1.tgz", + "integrity": "sha512-Sgi0Uo6t1YCHJMNO3Y8+bm+SvOanUGkoZKn/VJPwYUe2kp31X5KnXmzKd/NjW8iA3gFcfNZ64zh14uOGrIllCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.1.tgz", + "integrity": "sha512-AM4xnwEZwukdhk7laMWfzWu9JGSVnJd+Fowt6Fd7QW1nrf3h0Hp7Qx5881M4aqrUlKBCybOxz0jofvIIfl7C5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.1.tgz", + "integrity": "sha512-KUizqxpwaR2AZdAUsMWfL/C94pUu7TKpoPd88c8yFVixJ+l9hejkrwoK5Zj3wiNh65UeyryKnJyxL1b7yNqFQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.1.tgz", + "integrity": "sha512-MZoQ/am77ckJtZGFAtPucgUuJWiop3m2R3lw7tC0QCcbfl4DRhQUBUkHWCkcrT3pqy5Mzv5QQgY6Dmlba6iTWg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.1.tgz", + "integrity": "sha512-Sez95TP6xGjkWB1608EfhCX1gdGrO5wzyN99VqzRtC17x/1bhw5VU1V0GfKUwbW/Xr1J8mSasoFoJa6Y7aGGSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.1.tgz", + "integrity": "sha512-9Cs2Seq98LWNOJzR89EGTZoiP8EkZ9UbQhBlDgfAkM6asVna1xJ04W2CLYWDN/RpUgOjtQvcv8wQVi1t5oQazA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.1.tgz", + "integrity": "sha512-n9yqttftgFy7IrNEnHy1bOp6B4OSe8mJDiPkT7EqlM9FnKOwUMnCK62ixW0Kd9Clw0/wgvh8+SqaDXMFvw3KqQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.1.tgz", + "integrity": "sha512-SfpNXDzVTqs/riak4xXcLpq5gIQWsqGWMhN1AGRQKB4qGSs4r0sEs3ervXPcE1O9RsQ5bm8Muz6zmQpQnPss1g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.1.tgz", + "integrity": "sha512-LjaChED0wQnjKZU+tsmGbN+9nN1XhaWUkAlSbTdhpEseCS4a15f/Q8xC2BN4GDKRzhhLZpYtJBZr2NZhR0jvNw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.1.tgz", + "integrity": "sha512-ojW7iTJSIs4pwB2xV6QXGwNyDctvXOivYllttuPbXguuKDX5vwpqYJsHc6D2LZzjDGHML414Tuj3LvVPe1CT1A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.1.tgz", + "integrity": "sha512-FP+Q6WTcxxvsr0wQczhSE+tOZvFPV8A/mUE6mhZYFW9/eea/y/XqAgRoLLMuE9Cz0hfX5bi7p116IWoB+P237A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.1.tgz", + "integrity": "sha512-L1uD9b/Ig8Z+rn1KttCJjwhN1FgjRMBKsPaBsDKkfUl7GfFq71pU4vWCnpOsGljycFEbkHWARZLf4lMYg3WOLw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.1.tgz", + "integrity": "sha512-EZc9NGTk/oSUzzOD4nYY4gIjteo2M3CiozX6t1IXGCOdgxJTlVu/7EdPeiqeHPSIrxkLhavqpBAUCfvC6vBOug==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.1.tgz", + "integrity": "sha512-NQ9KyU1Anuy59L8+HHOKM++CoUxrQWrZWXRik4BJFm+7i5NP6q/SW43xIBr80zzt+PDBJ7LeNmloQGfa0JGk0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.1.tgz", + "integrity": "sha512-GZkLk2t6naywsveSFBsEb0PLU+JC9ggVjbndsbG20VPhar6D1gkMfCx4NfP9owpovBXTN+eRdqGSkDGIxPHhmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.1.tgz", + "integrity": "sha512-1hjG9Jpl2KDOetr64iQd8AZAEjkDUUK5RbDkYWsViYLC1op1oNzdjMJeFiofcGhqbNTaY2kfgqowE7DILifsrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.1.tgz", + "integrity": "sha512-ARoKfflk0SiiYm3r1fmF73K/yB+PThmOwfWCk1sr7x/k9dc3uGLWuEE9if+Pw21el8MSpp3TMnG5vLNsJ/MMGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.1.tgz", + "integrity": "sha512-oOST61G6VM45Mz2vdzWMr1s2slI7y9LqxEV5fCoWi2MDONmMvgsJVHSXxce/I2xOSZPTZ47nDPOl1tkwKWSHcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.1.tgz", + "integrity": "sha512-x5WgLi5dWpRz7WclKBGEF15LcWTh0ewrHM6Cq4A+WUbkysUMZNeqt05bwPonOQ3ihPS/WMhAZV5zB1DfnI4Sxg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.1.tgz", + "integrity": "sha512-wS+zHAJRVP5zOL0e+a3V3E/NTEwM2HEvvNKoDy5Xcfs0o8lljxn+EAFPkUsxihBdmDq1JWzXmmB9cbssCPdxxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.1.tgz", + "integrity": "sha512-rhHyrMeLpErT/C7BxcEsU4COHQUzHyrPYW5tOZUeUhziNtRuYxmDWvqQqzpuUt8xpOgmbKa1btGXfnA/ANVO+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.22.0.tgz", + "integrity": "sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.22.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.22.0.tgz", + "integrity": "sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.22.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.22.0.tgz", + "integrity": "sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.22.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.22.0.tgz", + "integrity": "sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.22.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.22.0.tgz", + "integrity": "sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.22.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.22.0.tgz", + "integrity": "sha512-E7eRV7mwDBjueLF6852n2oYeJYxBq3NSsDk+uyruYAXONv4U8holGmIrT+mPRJQ1J1SNOH6L8G19KRzmBawrFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.22.0", + "@shikijs/types": "3.22.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.22.0.tgz", + "integrity": "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz", + "integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.28.tgz", + "integrity": "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.28", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.28.tgz", + "integrity": "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz", + "integrity": "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.28", + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.28.tgz", + "integrity": "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.6.tgz", + "integrity": "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.0.6" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.6.tgz", + "integrity": "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.0.6", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^2.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.6.tgz", + "integrity": "sha512-Pp1JylTqlgMJvxW6MGyfTF8vGvlBSCAvMFaDCYa82Mgw7TT5eE5kkHgDvmOGHWeJE4zIDfCpCxHapsK2LtIAJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.28.tgz", + "integrity": "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.28.tgz", + "integrity": "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.28.tgz", + "integrity": "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.28", + "@vue/runtime-core": "3.5.28", + "@vue/shared": "3.5.28", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.28.tgz", + "integrity": "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28" + }, + "peerDependencies": { + "vue": "3.5.28" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.28.tgz", + "integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", + "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.2.1", + "@vueuse/shared": "14.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/integrations": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.2.1.tgz", + "integrity": "sha512-2LIUpBi/67PoXJGqSDQUF0pgQWpNHh7beiA+KG2AbybcNm+pTGWT6oPGlBgUoDWmYwfeQqM/uzOHqcILpKL7nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "14.2.1", + "@vueuse/shared": "14.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7 || ^8", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7 || ^8", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", + "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", + "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/focus-trap": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-8.0.0.tgz", + "integrity": "sha512-Aa84FOGHs99vVwufDMdq2qgOwXPC2e9U66GcqBhn1/jEHPDhJaP8PYhkIbqG9lhfL5Kddk/567lj46LLHYCRUw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", + "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.1.tgz", + "integrity": "sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.1", + "@rollup/rollup-android-arm64": "4.59.1", + "@rollup/rollup-darwin-arm64": "4.59.1", + "@rollup/rollup-darwin-x64": "4.59.1", + "@rollup/rollup-freebsd-arm64": "4.59.1", + "@rollup/rollup-freebsd-x64": "4.59.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.1", + "@rollup/rollup-linux-arm-musleabihf": "4.59.1", + "@rollup/rollup-linux-arm64-gnu": "4.59.1", + "@rollup/rollup-linux-arm64-musl": "4.59.1", + "@rollup/rollup-linux-loong64-gnu": "4.59.1", + "@rollup/rollup-linux-loong64-musl": "4.59.1", + "@rollup/rollup-linux-ppc64-gnu": "4.59.1", + "@rollup/rollup-linux-ppc64-musl": "4.59.1", + "@rollup/rollup-linux-riscv64-gnu": "4.59.1", + "@rollup/rollup-linux-riscv64-musl": "4.59.1", + "@rollup/rollup-linux-s390x-gnu": "4.59.1", + "@rollup/rollup-linux-x64-gnu": "4.59.1", + "@rollup/rollup-linux-x64-musl": "4.59.1", + "@rollup/rollup-openbsd-x64": "4.59.1", + "@rollup/rollup-openharmony-arm64": "4.59.1", + "@rollup/rollup-win32-arm64-msvc": "4.59.1", + "@rollup/rollup-win32-ia32-msvc": "4.59.1", + "@rollup/rollup-win32-x64-gnu": "4.59.1", + "@rollup/rollup-win32-x64-msvc": "4.59.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/shiki": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.22.0.tgz", + "integrity": "sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.22.0", + "@shikijs/engine-javascript": "3.22.0", + "@shikijs/engine-oniguruma": "3.22.0", + "@shikijs/langs": "3.22.0", + "@shikijs/themes": "3.22.0", + "@shikijs/types": "3.22.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "2.0.0-alpha.17", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-2.0.0-alpha.17.tgz", + "integrity": "sha512-Z3VPUpwk/bHYqt1uMVOOK1/4xFiWQov1GNc2FvMdz6kvje4JRXEOngVI9C+bi5jeedMSHiA4dwKkff1NCvbZ9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "^4.5.3", + "@docsearch/js": "^4.5.3", + "@docsearch/sidepanel-js": "^4.5.3", + "@iconify-json/simple-icons": "^1.2.69", + "@shikijs/core": "^3.22.0", + "@shikijs/transformers": "^3.22.0", + "@shikijs/types": "^3.22.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^6.0.4", + "@vue/devtools-api": "^8.0.5", + "@vue/shared": "^3.5.27", + "@vueuse/core": "^14.2.0", + "@vueuse/integrations": "^14.2.0", + "focus-trap": "^8.0.0", + "mark.js": "8.11.1", + "minisearch": "^7.2.0", + "shiki": "^3.22.0", + "vite": "^7.3.1", + "vue": "^3.5.27" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "oxc-minify": "*", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "oxc-minify": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", + "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-sfc": "3.5.28", + "@vue/runtime-dom": "3.5.28", + "@vue/server-renderer": "3.5.28", + "@vue/shared": "3.5.28" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..02d8c2d --- /dev/null +++ b/docs/package.json @@ -0,0 +1,10 @@ +{ + "scripts": { + "dev": "vitepress dev", + "build": "vitepress build", + "preview": "vitepress preview" + }, + "devDependencies": { + "vitepress": "^2.0.0-alpha.17" + } +} diff --git a/docs/public/logo.svg b/docs/public/logo.svg new file mode 100644 index 0000000..ece1394 --- /dev/null +++ b/docs/public/logo.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/public/social.svg b/docs/public/social.svg new file mode 100644 index 0000000..a11bf63 --- /dev/null +++ b/docs/public/social.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + EF Core + + + Projectables + + + + + Flexible projection magic for EF Core + + + + + + [Projectable] + + + + + EF Core 6+ + + + diff --git a/docs/recipes/collection-aggregates.md b/docs/recipes/collection-aggregates.md new file mode 100644 index 0000000..7f567f7 --- /dev/null +++ b/docs/recipes/collection-aggregates.md @@ -0,0 +1,210 @@ +# Collection Aggregates + +This recipe shows how to expose reusable aggregation computations — counts, sums, averages, and existence checks on navigation collections — as `[Projectable]` properties that translate to efficient SQL subqueries. + +## The Pattern + +Define aggregate properties directly on the entity. EF Core translates them to correlated subqueries or `COUNT`/`SUM` expressions inline in the outer query. + +## Counts + +```csharp +public class Customer +{ + public ICollection Orders { get; set; } + public ICollection Reviews { get; set; } + + [Projectable] + public int OrderCount => Orders.Count(); + + [Projectable] + public int CompletedOrderCount => Orders.Count(o => o.CompletedDate != null); + + [Projectable] + public int ReviewCount => Reviews.Count(); +} +``` + +```csharp +var summary = dbContext.Customers + .Select(c => new + { + c.Id, + c.OrderCount, + c.CompletedOrderCount, + c.ReviewCount + }) + .ToList(); +``` + +Generated SQL (simplified): +```sql +SELECT + [c].[Id], + (SELECT COUNT(*) FROM [Orders] WHERE [CustomerId] = [c].[Id]) AS [OrderCount], + (SELECT COUNT(*) FROM [Orders] WHERE [CustomerId] = [c].[Id] AND [CompletedDate] IS NOT NULL) AS [CompletedOrderCount], + (SELECT COUNT(*) FROM [Reviews] WHERE [CustomerId] = [c].[Id]) AS [ReviewCount] +FROM [Customers] AS [c] +``` + +## Sums and Totals + +```csharp +public class Order +{ + public ICollection Items { get; set; } + public decimal TaxRate { get; set; } + + [Projectable] + public decimal Subtotal => Items.Sum(i => i.UnitPrice * i.Quantity); + + [Projectable] + public decimal TaxAmount => Subtotal * TaxRate; + + [Projectable] + public decimal GrandTotal => Subtotal + TaxAmount; + + [Projectable] + public int TotalUnits => Items.Sum(i => i.Quantity); +} +``` + +Projectable aggregates compose naturally: + +```csharp +// Sort by computed total — no data fetched to memory +var topOrders = dbContext.Orders + .OrderByDescending(o => o.GrandTotal) + .Take(10) + .Select(o => new { o.Id, o.Subtotal, o.TaxAmount, o.GrandTotal }) + .ToList(); +``` + +## Existence Checks + +Boolean `Any()` checks are useful as filter predicates: + +```csharp +public class Customer +{ + public ICollection Orders { get; set; } + public ICollection SupportTickets { get; set; } + + [Projectable] + public bool HasOrders => Orders.Any(); + + [Projectable] + public bool HasOpenTickets => SupportTickets.Any(t => t.ResolvedDate == null); + + [Projectable] + public bool HasRecentOrder => + Orders.Any(o => o.CreatedDate >= DateTime.UtcNow.AddDays(-30)); +} +``` + +```csharp +// Customers who have ordered recently but also have open support tickets +var atRisk = dbContext.Customers + .Where(c => c.HasRecentOrder && c.HasOpenTickets) + .ToList(); +``` + +## Averages + +```csharp +public class Product +{ + public ICollection Reviews { get; set; } + + [Projectable] + public double? AverageRating => Reviews.Any() + ? Reviews.Average(r => (double)r.Rating) + : null; + + [Projectable] + public int ReviewCount => Reviews.Count(); +} +``` + +## Min / Max + +```csharp +public class Customer +{ + public ICollection Orders { get; set; } + + [Projectable] + public DateTime? FirstOrderDate => Orders.Any() + ? Orders.Min(o => o.CreatedDate) + : null; + + [Projectable] + public DateTime? LastOrderDate => Orders.Any() + ? Orders.Max(o => o.CreatedDate) + : null; + + [Projectable] + public decimal? LargestOrderTotal => Orders.Any() + ? Orders.Max(o => o.GrandTotal) + : null; +} +``` + +## Combining with Filters + +Aggregate properties work in `Where`, `OrderBy`, and `GroupBy`: + +```csharp +// High-value customers (> 5 orders AND lifetime spend > $1000) +var highValue = dbContext.Customers + .Where(c => c.OrderCount > 5 && c.LifetimeSpend > 1000) + .OrderByDescending(c => c.LifetimeSpend) + .ToList(); + +// Tier distribution report +var tiers = dbContext.Customers + .GroupBy(c => c.OrderCount switch + { + 0 => "No Orders", + 1 => "First Order", + <= 5 => "Occasional", + <= 20 => "Regular", + _ => "VIP" + }) + .Select(g => new { Tier = g.Key, Count = g.Count() }) + .ToList(); +``` + +## Conditional Aggregates + +Add `if`/`else` logic inside a block-bodied projectable to conditionally return aggregates: + +```csharp +public class Supplier +{ + public bool IsPreferred { get; set; } + public ICollection Products { get; set; } + + [Projectable(AllowBlockBody = true)] + public decimal TotalStockValue + { + get + { + if (IsPreferred) + return Products.Sum(p => p.StockQuantity * p.PreferredPrice); + else + return Products.Sum(p => p.StockQuantity * p.ListPrice); + } + } +} +``` + +## Tips + +- **Avoid N+1** — aggregate projectables work best when used directly in a `Select` or `Where` at the top-level query. Accessing them inside nested `Select` on a collection may generate additional round-trips. +- **Guard nullable aggregates** — wrap `Min`, `Max`, `Average` in an `Any()` check or use `?? default` to avoid null-reference issues in the generated SQL. +- **Compose freely** — `GrandTotal` depending on `Subtotal` is a first-class pattern. The generator inlines transitively. +- **Use Limited mode** — aggregate projections in repeated queries benefit from [Limited compatibility mode](/reference/compatibility-mode) caching. + +See also: [Computed Entity Properties](/recipes/computed-properties), [Reusable Query Filters](/recipes/reusable-query-filters). + diff --git a/docs/recipes/computed-properties.md b/docs/recipes/computed-properties.md new file mode 100644 index 0000000..695841e --- /dev/null +++ b/docs/recipes/computed-properties.md @@ -0,0 +1,157 @@ +# Computed Entity Properties + +This recipe shows how to define reusable computed properties on your entities and use them across multiple query operations — all translated to SQL without any duplication. + +## The Pattern + +Define computed values as `[Projectable]` properties directly on your entity. These properties can then be used in `Select`, `Where`, `GroupBy`, `OrderBy`, and any combination thereof. + +## Example: Order Totals + +```csharp +public class Order +{ + public int Id { get; set; } + public decimal TaxRate { get; set; } + public DateTime CreatedDate { get; set; } + public ICollection Items { get; set; } + + // Building blocks + [Projectable] + public decimal Subtotal => Items.Sum(item => item.Product.ListPrice * item.Quantity); + + [Projectable] + public decimal Tax => Subtotal * TaxRate; + + // Composed from other projectables + [Projectable] + public decimal GrandTotal => Subtotal + Tax; +} +``` + +### Use in Select + +```csharp +var summaries = dbContext.Orders + .Select(o => new OrderSummaryDto + { + Id = o.Id, + Subtotal = o.Subtotal, // ✅ Inlined into SQL + Tax = o.Tax, // ✅ Inlined into SQL + GrandTotal = o.GrandTotal // ✅ Inlined into SQL + }) + .ToList(); +``` + +### Use in Where + +```csharp +// Only load high-value orders +var highValue = dbContext.Orders + .Where(o => o.GrandTotal > 1000) + .ToList(); +``` + +### Use in OrderBy + +```csharp +// Sort by computed value +var ranked = dbContext.Orders + .OrderByDescending(o => o.GrandTotal) + .Take(10) + .ToList(); +``` + +### All Together + +```csharp +var report = dbContext.Orders + .Where(o => o.GrandTotal > 500) + .OrderByDescending(o => o.GrandTotal) + .GroupBy(o => o.CreatedDate.Year) + .Select(g => new + { + Year = g.Key, + Count = g.Count(), + TotalRevenue = g.Sum(o => o.GrandTotal) + }) + .ToList(); +``` + +All computed values are evaluated **in the database** — no data is fetched to memory for filtering or aggregation. + +## Example: User Profile + +```csharp +public class User +{ + public string FirstName { get; set; } + public string LastName { get; set; } + public DateTime BirthDate { get; set; } + public DateTime? LastLoginDate { get; set; } + + [Projectable] + public string FullName => FirstName + " " + LastName; + + [Projectable] + public int Age => DateTime.Today.Year - BirthDate.Year + - (DateTime.Today.DayOfYear < BirthDate.DayOfYear ? 1 : 0); + + [Projectable] + public bool IsActive => LastLoginDate != null + && LastLoginDate >= DateTime.UtcNow.AddDays(-30); +} +``` + +```csharp +// Find active adult users, sorted by name +var results = dbContext.Users + .Where(u => u.IsActive && u.Age >= 18) + .OrderBy(u => u.FullName) + .Select(u => new { u.FullName, u.Age }) + .ToList(); +``` + +## Example: Product Catalog + +```csharp +public class Product +{ + public decimal ListPrice { get; set; } + public decimal DiscountRate { get; set; } + public int StockQuantity { get; set; } + public int ReorderPoint { get; set; } + + [Projectable] + public decimal SalePrice => ListPrice * (1 - DiscountRate); + + [Projectable] + public decimal SavingsAmount => ListPrice - SalePrice; + + [Projectable] + public bool NeedsReorder => StockQuantity <= ReorderPoint; +} +``` + +```csharp +// Products on sale that need restocking +var reorder = dbContext.Products + .Where(p => p.NeedsReorder && p.SalePrice < 50) + .OrderBy(p => p.StockQuantity) + .Select(p => new + { + p.Id, + p.SalePrice, + p.SavingsAmount, + p.StockQuantity + }) + .ToList(); +``` + +## Tips + +- **Compose freely** — projectables can call other projectables. Build from simple to complex. +- **Use Limited mode** in production for repeated queries — computed properties are cached after the first execution. +- **Keep it pure** — projectable properties should be pure computations (no side effects). Everything must be translatable to SQL. +- **Avoid N+1** — if a projectable property references navigation properties, make sure to structure your queries so EF Core can generate a single efficient query. + diff --git a/docs/recipes/dto-projections.md b/docs/recipes/dto-projections.md new file mode 100644 index 0000000..11a837d --- /dev/null +++ b/docs/recipes/dto-projections.md @@ -0,0 +1,187 @@ +# DTO Projections with Constructors + +This recipe shows how to use `[Projectable]` constructors to project database rows directly into DTOs inside your LINQ queries — with no boilerplate `Select` expressions and full SQL translation. + +## The Problem + +Projecting entities into DTOs usually requires writing a `Select` expression that repeats the mapping logic: + +```csharp +// ❌ Repetitive — mapping duplicated in every query +var customers = dbContext.Customers + .Select(c => new CustomerDto + { + Id = c.Id, + FullName = c.FirstName + " " + c.LastName, + IsActive = c.IsActive, + OrderCount = c.Orders.Count() + }) + .ToList(); +``` + +If the mapping changes you must update every `Select` that uses it. + +## The Solution: `[Projectable]` Constructor + +Mark a constructor with `[Projectable]` and call `new CustomerDto(c)` directly in your query: + +```csharp +public class CustomerDto +{ + public int Id { get; set; } + public string FullName { get; set; } + public bool IsActive { get; set; } + public int OrderCount { get; set; } + + public CustomerDto() { } // required by the generator + + [Projectable] + public CustomerDto(Customer c) + { + Id = c.Id; + FullName = c.FirstName + " " + c.LastName; + IsActive = c.IsActive; + OrderCount = c.Orders.Count(); + } +} +``` + +```csharp +// ✅ Clean — mapping defined once, used everywhere +var customers = dbContext.Customers + .Where(c => c.IsActive) + .Select(c => new CustomerDto(c)) + .ToList(); +``` + +The constructor body is inlined as SQL — no data is fetched to memory for the projection. + +## Using Conditional Logic in the Constructor + +Constructor bodies support `if`/`else` chains and local variables: + +```csharp +public class ProductDto +{ + public int Id { get; set; } + public string Name { get; set; } + public decimal Price { get; set; } + public string PriceTier { get; set; } + + public ProductDto() { } + + [Projectable] + public ProductDto(Product p) + { + Id = p.Id; + Name = p.Name; + Price = p.SalePrice; + PriceTier = p.SalePrice switch + { + > 500 => "Premium", + > 100 => "Standard", + _ => "Budget" + }; + } +} +``` + +## Inheritance — Reusing Base Mappings + +When your DTOs form an inheritance hierarchy, use `: base(…)` to avoid duplicating base-class assignments: + +```csharp +public class PersonDto +{ + public string FullName { get; set; } + public string Email { get; set; } + + public PersonDto() { } + + [Projectable] + public PersonDto(Person p) + { + FullName = p.FirstName + " " + p.LastName; + Email = p.Email; + } +} + +public class EmployeeDto : PersonDto +{ + public string Department { get; set; } + public string Grade { get; set; } + + public EmployeeDto() { } + + [Projectable] + public EmployeeDto(Employee e) : base(e) // PersonDto assignments are inlined automatically + { + Department = e.Department.Name; + Grade = e.YearsOfService >= 10 ? "Senior" : "Junior"; + } +} +``` + +```csharp +var employees = dbContext.Employees + .Select(e => new EmployeeDto(e)) + .ToList(); +``` + +The generated SQL projects all fields in a single query — `FullName`, `Email`, `Department`, and `Grade` are all computed in the database. + +## Multiple Overloads + +If you need different projections from the same DTO, use constructor overloads — each gets its own generated expression: + +```csharp +public class OrderSummaryDto +{ + public int Id { get; set; } + public decimal Total { get; set; } + public string CustomerName { get; set; } + + public OrderSummaryDto() { } + + // Full projection (with customer name — requires navigation) + [Projectable] + public OrderSummaryDto(Order o) + { + Id = o.Id; + Total = o.GrandTotal; + CustomerName = o.Customer.FirstName + " " + o.Customer.LastName; + } + + // Lightweight projection (no navigation join needed) + [Projectable] + public OrderSummaryDto(Order o, bool lightweight) + { + Id = o.Id; + Total = o.GrandTotal; + CustomerName = null; + } +} +``` + +## Converting a Factory Method + +If you already have a `[Projectable]` static factory method, the IDE offers a **one-click refactoring** to convert it to a constructor: + +```csharp +// Before — factory method +[Projectable] +public static CustomerDto From(Customer c) => new CustomerDto { … }; + +// After — projectable constructor (via IDE refactoring or EFP0012 code fix) +[Projectable] +public CustomerDto(Customer c) { … } +``` + +## Tips + +- **Always add a parameterless constructor** — the generator emits `new T() { … }` syntax; if the parameterless constructor is missing you get **EFP0008** (IDE inserts it for you). +- **Keep mappings pure** — no side effects, no calls to non-projectable methods. +- **Prefer constructors over factory methods** — constructors are the idiomatic Projectables pattern; factory methods trigger the **EFP0012** suggestion. + +See also: [Constructor Projections guide](/guide/projectable-constructors), [Diagnostics](/reference/diagnostics#efp0008). + diff --git a/docs/recipes/enum-display-names.md b/docs/recipes/enum-display-names.md new file mode 100644 index 0000000..5cf2cf7 --- /dev/null +++ b/docs/recipes/enum-display-names.md @@ -0,0 +1,172 @@ +# Enum Display Names in Queries + +This recipe shows how to project human-readable labels from enum values — such as names from `[Display]` attributes — directly into SQL queries using `ExpandEnumMethods`. + +## The Problem + +You have an enum with display-friendly labels: + +```csharp +public enum OrderStatus +{ + [Display(Name = "Pending Review")] + Pending = 0, + + [Display(Name = "Approved & Processing")] + Approved = 1, + + [Display(Name = "Rejected")] + Rejected = 2, + + [Display(Name = "Shipped")] + Shipped = 3 +} +``` + +And a helper extension method: + +```csharp +public static class OrderStatusExtensions +{ + public static string GetDisplayName(this OrderStatus status) + { + var field = typeof(OrderStatus).GetField(status.ToString()); + var attr = field?.GetCustomAttribute(); + return attr?.Name ?? status.ToString(); + } +} +``` + +The problem: `GetDisplayName` uses reflection — EF Core cannot translate this to SQL. + +## The Solution with `ExpandEnumMethods` + +Use `ExpandEnumMethods = true` on the projectable member that calls `GetDisplayName`: + +```csharp +public class Order +{ + public int Id { get; set; } + public OrderStatus Status { get; set; } + + [Projectable(ExpandEnumMethods = true)] + public string StatusLabel => Status.GetDisplayName(); +} +``` + +The source generator evaluates `GetDisplayName` for each enum value at **compile time** and bakes the results into the expression tree as string constants: + +```csharp +// Generated expression equivalent: +Status == OrderStatus.Pending ? "Pending Review" : +Status == OrderStatus.Approved ? "Approved & Processing" : +Status == OrderStatus.Rejected ? "Rejected" : +Status == OrderStatus.Shipped ? "Shipped" : +null +``` + +Which translates to: + +```sql +SELECT CASE + WHEN [o].[Status] = 0 THEN N'Pending Review' + WHEN [o].[Status] = 1 THEN N'Approved & Processing' + WHEN [o].[Status] = 2 THEN N'Rejected' + WHEN [o].[Status] = 3 THEN N'Shipped' +END AS [StatusLabel] +FROM [Orders] AS [o] +``` + +## Using StatusLabel in Queries + +```csharp +// Project enum labels into a DTO +var orders = dbContext.Orders + .Select(o => new OrderDto + { + Id = o.Id, + StatusLabel = o.StatusLabel // Translated to CASE in SQL + }) + .ToList(); + +// Group by display name +var statusCounts = dbContext.Orders + .GroupBy(o => o.StatusLabel) + .Select(g => new { Status = g.Key, Count = g.Count() }) + .ToList(); + +// Filter on the computed label (less efficient — prefer filtering on the enum value directly) +var pending = dbContext.Orders + .Where(o => o.StatusLabel == "Pending Review") + .ToList(); +``` + +## Adding More Computed Properties + +```csharp +public class Order +{ + public OrderStatus Status { get; set; } + + [Projectable(ExpandEnumMethods = true)] + public string StatusLabel => Status.GetDisplayName(); + + [Projectable(ExpandEnumMethods = true)] + public bool IsProcessing => Status.IsInProgress(); // Custom bool extension + + [Projectable(ExpandEnumMethods = true)] + public int StatusSortOrder => Status.GetSortOrder(); +} + +public static class OrderStatusExtensions +{ + public static string GetDisplayName(this OrderStatus status) { /* ... */ } + + public static bool IsInProgress(this OrderStatus status) => + status is OrderStatus.Approved or OrderStatus.Shipped; + + public static int GetSortOrder(this OrderStatus status) => + status switch { + OrderStatus.Pending => 1, + OrderStatus.Approved => 2, + OrderStatus.Shipped => 3, + OrderStatus.Rejected => 99, + _ => 0 + }; +} +``` + +## Nullable Enum Properties + +If the enum property is nullable, wrap the call in a null-conditional and configure the rewrite: + +```csharp +public class Order +{ + public OrderStatus? OptionalStatus { get; set; } + + [Projectable( + ExpandEnumMethods = true, + NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] + public string? OptionalStatusLabel => OptionalStatus?.GetDisplayName(); +} +``` + +## Enum on Navigation Property + +```csharp +public class Order +{ + public Customer Customer { get; set; } + + [Projectable(ExpandEnumMethods = true)] + public string CustomerTierLabel => Customer.Tier.GetDisplayName(); +} +``` + +## Best Practices + +- **Filter on the enum value** (not the label) for best SQL performance: `Where(o => o.Status == OrderStatus.Pending)`. +- **Use labels only for projection** (`Select`) — translating `WHERE StatusLabel = 'Pending Review'` is less efficient than `WHERE Status = 0`. +- If your enum changes frequently, regenerate — the display name values are baked in at compile time. + diff --git a/docs/recipes/nullable-navigation.md b/docs/recipes/nullable-navigation.md new file mode 100644 index 0000000..75d9544 --- /dev/null +++ b/docs/recipes/nullable-navigation.md @@ -0,0 +1,159 @@ +# Nullable Navigation Properties + +This recipe covers how to work with optional (nullable) navigation properties in projectable members, using `NullConditionalRewriteSupport` to safely handle `?.` operators. + +## The Challenge + +Navigation properties can be nullable — either because the relationship is optional, or because the related entity isn't loaded. Using `?.` in a projectable body without configuration produces **error EFP0002**, because expression trees cannot represent the null-conditional operator directly. + +## Choosing a Strategy + +| Strategy | Best For | +|-------------------|--------------------------------------------------------------------------------------| +| `Ignore` | SQL Server / databases with implicit null propagation; navigation is usually present | +| `Rewrite` | Cosmos DB; client-side evaluation scenarios; maximum correctness | +| Manual null check | Complex multi-level nullable chains where you want full control | + +## Strategy 1: `Ignore` + +Strips the `?.` — `A?.B` becomes `A.B`. In SQL, NULL propagates implicitly in most expressions. + +```csharp +public class User +{ + public Address? Address { get; set; } + + [Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] + public string? CityName => Address?.City; +} +``` + +Generated expression: `Address.City` + +Generated SQL (SQL Server): +```sql +SELECT [a].[City] +FROM [Users] AS [u] +LEFT JOIN [Addresses] AS [a] ON [u].[AddressId] = [a].[Id] +``` + +If `Address` is `NULL`, SQL returns `NULL` for `City` — which matches the expected C# behavior. + +**Use when:** You're on SQL Server (or a database with implicit null propagation), and you're confident that `NULL` will propagate correctly for your use case. + +## Strategy 2: `Rewrite` + +Rewrites `A?.B` as `A != null ? A.B : null` — generates explicit null checks in the expression. + +```csharp +public class User +{ + public Address? Address { get; set; } + + [Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Rewrite)] + public string? CityName => Address?.City; +} +``` + +Generated expression: `Address != null ? Address.City : null` + +Generated SQL (SQL Server): +```sql +SELECT CASE WHEN [a].[Id] IS NOT NULL THEN [a].[City] END +FROM [Users] AS [u] +LEFT JOIN [Addresses] AS [a] ON [u].[AddressId] = [a].[Id] +``` + +**Use when:** You need explicit null handling, you're targeting Cosmos DB, or you want maximum semantic equivalence to C# code. + +## Multi-Level Nullable Chains + +For deeply nested nullable navigation: + +```csharp +public class User +{ + public Address? Address { get; set; } +} + +public class Address +{ + public City? City { get; set; } +} + +public class City +{ + public string? PostalCode { get; set; } +} + +// Ignore: strips all ?. +[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] +public string? PostalCode => Address?.City?.PostalCode; +// → Address.City.PostalCode + +// Rewrite: explicit null check at each level +[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Rewrite)] +public string? PostalCode => Address?.City?.PostalCode; +// → Address != null +// ? Address.City != null +// ? Address.City.PostalCode +// : null +// : null +``` + +## Strategy 3: Manual Null Checks + +For maximum control, write the null check explicitly — no `NullConditionalRewriteSupport` needed: + +```csharp +[Projectable] +public string? CityName => + Address != null ? Address.City : null; + +[Projectable] +public string? PostalCode => + Address != null && Address.City != null + ? Address.City.PostalCode + : null; +``` + +This approach is verbose but gives you precise control over the generated expression. + +## Extension Methods on Nullable Entity Parameters + +When an extension method's `this` parameter is nullable: + +```csharp +public static class UserExtensions +{ + [Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Rewrite)] + public static string? GetFullAddress(this User? user) => + user?.Address?.AddressLine1 + ", " + user?.Address?.City; +} +``` + +## Combining with Other Options + +Null-conditional rewrite is compatible with other `[Projectable]` options: + +```csharp +[Projectable( + NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore, + ExpandEnumMethods = true)] +public string? ShippingStatusLabel => + ShippingInfo?.Status.GetDisplayName(); +``` + +## Practical Recommendation + +``` +Is the property on SQL Server? + → Yes, and null propagation is acceptable: use Ignore (simpler SQL) + → Yes, but you need explicit null behavior: use Rewrite + → No (Cosmos DB, in-memory, or client-side eval): use Rewrite or manual check +``` + +::: tip +Start with `Ignore` for SQL Server projects. Switch to `Rewrite` if you observe unexpected nullability behavior in query results. +::: + diff --git a/docs/recipes/reusable-query-filters.md b/docs/recipes/reusable-query-filters.md new file mode 100644 index 0000000..cec5100 --- /dev/null +++ b/docs/recipes/reusable-query-filters.md @@ -0,0 +1,167 @@ +# Reusable Query Filters + +This recipe shows how to define reusable filtering logic as projectable extension methods or properties, and compose them across multiple queries without duplicating LINQ expressions. + +## The Pattern + +Define your filtering criteria as `[Projectable]` members that return `bool`. Use them in `Where()` clauses exactly as you would any other property. EF Core translates the expanded expression to a SQL `WHERE` clause. + +## Example: Active Entity Filter + +```csharp +public class User +{ + public bool IsDeleted { get; set; } + public DateTime? LastLoginDate { get; set; } + public DateTime? EmailVerifiedDate { get; set; } + + [Projectable] + public bool IsActive => + !IsDeleted + && EmailVerifiedDate != null + && LastLoginDate >= DateTime.UtcNow.AddDays(-90); +} +``` + +```csharp +// Reuse everywhere +var activeUsers = dbContext.Users.Where(u => u.IsActive).ToList(); +var activeAdmins = dbContext.Users.Where(u => u.IsActive && u.IsAdmin).ToList(); +var activeCount = dbContext.Users.Count(u => u.IsActive); +``` + +Generated SQL (simplified): +```sql +SELECT * FROM [Users] +WHERE [IsDeleted] = 0 + AND [EmailVerifiedDate] IS NOT NULL + AND [LastLoginDate] >= DATEADD(day, -90, GETUTCDATE()) +``` + +## Example: Parameterized Filter as Extension Method + +Extension methods are ideal for filters that accept parameters: + +```csharp +public static class OrderExtensions +{ + [Projectable] + public static bool IsWithinDateRange(this Order order, DateTime from, DateTime to) => + order.CreatedDate >= from && order.CreatedDate <= to; + + [Projectable] + public static bool IsHighValue(this Order order, decimal threshold) => + order.GrandTotal >= threshold; + + [Projectable] + public static bool BelongsToRegion(this Order order, string region) => + order.ShippingAddress != null && order.ShippingAddress.Region == region; +} +``` + +```csharp +var from = DateTime.UtcNow.AddMonths(-1); +var to = DateTime.UtcNow; + +var recentHighValueOrders = dbContext.Orders + .Where(o => o.IsWithinDateRange(from, to)) + .Where(o => o.IsHighValue(500m)) + .ToList(); +``` + +## Example: Composing Multiple Filters + +Build complex filters by composing simpler ones: + +```csharp +public class Order +{ + [Projectable] + public bool IsFulfilled => FulfilledDate != null; + + [Projectable] + public bool IsRecent => CreatedDate >= DateTime.UtcNow.AddDays(-30); + + // Composed from simpler projectables + [Projectable] + public bool IsRecentFulfilledOrder => IsFulfilled && IsRecent; +} + +public static class OrderExtensions +{ + [Projectable] + public static bool IsEligibleForReturn(this Order order) => + order.IsFulfilled + && order.FulfilledDate >= DateTime.UtcNow.AddDays(-30) + && !order.HasOpenReturnRequest; +} +``` + +## Example: Global Query Filters + +Projectable properties work in EF Core's global query filters (configured in `OnModelCreating`): + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + // Soft-delete global filter using a projectable property + modelBuilder.Entity() + .HasQueryFilter(o => !o.IsDeleted); + + // Tenant isolation filter + modelBuilder.Entity() + .HasQueryFilter(o => o.TenantId == _currentTenantId); +} +``` + +::: info +When using global query filters with Projectables, ensure that `UseProjectables()` is configured on your `DbContext`. The library includes a convention (`ProjectablesExpandQueryFiltersConvention`) that ensures global filters referencing projectable members are also expanded correctly. +::: + +## Example: Specification Pattern + +Projectables pair naturally with the Specification pattern: + +```csharp +public static class OrderSpecifications +{ + [Projectable] + public static bool IsActive(this Order order) => + !order.IsCancelled && !order.IsDeleted; + + [Projectable] + public static bool IsOverdue(this Order order) => + order.IsActive() + && order.DueDate < DateTime.UtcNow + && !order.IsFulfilled; + + [Projectable] + public static bool RequiresAttention(this Order order) => + order.IsOverdue() + || order.HasOpenDispute + || order.PaymentStatus == PaymentStatus.Failed; +} +``` + +```csharp +// Dashboard: count orders requiring attention +var attentionCount = await dbContext.Orders + .Where(o => o.RequiresAttention()) + .CountAsync(); + +// Alert users with overdue orders +var overdueUserIds = await dbContext.Orders + .Where(o => o.IsOverdue()) + .Select(o => o.UserId) + .Distinct() + .ToListAsync(); +``` + +## Tips + +- **Keep filters pure** — filter projectables should only read data, never modify it. +- **Compose at the projectable level** — compose filters inside projectable members rather than chaining multiple `.Where()` calls for more reusable building blocks. +- **Name clearly** — use names that express business intent (`IsEligibleForRefund`) rather than technical details (`HasRefundDateNullAndStatusIsComplete`). +- **Prefer entity-level properties for entity-specific filters**, and extension methods for cross-entity or parameterized filters. +- **Use Limited mode** — parameterized filter methods are a perfect use case for [Limited compatibility mode](/reference/compatibility-mode), which caches the expanded query after the first execution. + diff --git a/docs/recipes/scoring-classification.md b/docs/recipes/scoring-classification.md new file mode 100644 index 0000000..695d8de --- /dev/null +++ b/docs/recipes/scoring-classification.md @@ -0,0 +1,184 @@ +# Scoring and Classification with Pattern Matching + +This recipe shows how to use C# pattern matching — `switch` expressions and `is` patterns — inside `[Projectable]` members to compute scores, grades, tiers, and labels directly in SQL. + +## Grading with Relational Patterns + +Classic grading logic maps numeric ranges to labels. Pattern matching makes this readable and the generator translates it to a SQL `CASE` expression: + +```csharp +public class Student +{ + public int Score { get; set; } + + [Projectable] + public string Grade => Score switch + { + >= 90 => "A", + >= 80 => "B", + >= 70 => "C", + >= 60 => "D", + _ => "F" + }; + + [Projectable] + public bool IsPassing => Score >= 60; + + [Projectable] + public bool IsHonors => Score >= 90; +} +``` + +Generated SQL: +```sql +SELECT CASE + WHEN [s].[Score] >= 90 THEN N'A' + WHEN [s].[Score] >= 80 THEN N'B' + WHEN [s].[Score] >= 70 THEN N'C' + WHEN [s].[Score] >= 60 THEN N'D' + ELSE N'F' +END AS [Grade] +FROM [Students] AS [s] +``` + +## Customer Tiers with `and` Patterns + +Use `and` to express range bands cleanly: + +```csharp +public class Customer +{ + public int LifetimeOrderCount { get; set; } + public decimal LifetimeSpend { get; set; } + + [Projectable] + public string Tier => LifetimeSpend switch + { + >= 10_000 => "Platinum", + >= 5_000 and < 10_000 => "Gold", + >= 1_000 and < 5_000 => "Silver", + _ => "Bronze" + }; + + [Projectable] + public bool IsLoyalty => LifetimeOrderCount >= 10; +} +``` + +```csharp +// Segment customers for a marketing campaign +var segments = dbContext.Customers + .GroupBy(c => c.Tier) + .Select(g => new { Tier = g.Key, Count = g.Count() }) + .ToList(); +``` + +## Risk Scoring with Guards + +Use `when` guards for conditions that can't be expressed with a pattern alone: + +```csharp +public class Loan +{ + public int CreditScore { get; set; } + public decimal DebtToIncomeRatio { get; set; } + + [Projectable] + public string RiskCategory => CreditScore switch + { + >= 750 when DebtToIncomeRatio < 0.3m => "Low", + >= 700 => "Medium", + >= 600 => "High", + _ => "Very High" + }; +} +``` + +## `is` Patterns for Boolean Flags + +Use `is` patterns for concise boolean properties: + +```csharp +public class Product +{ + public int Stock { get; set; } + public decimal Price { get; set; } + public int ReorderPoint { get; set; } + + [Projectable] + public bool IsInStock => Stock is > 0; + + [Projectable] + public bool NeedsReorder => Stock is >= 0 and <= ReorderPoint; + + [Projectable] + public bool IsBudget => Price is > 0 and < 25; + + [Projectable] + public bool HasNoStock => Stock is 0; +} +``` + +## Combining Classification with Aggregation + +Compose projectable properties to build richer query results: + +```csharp +public class Order +{ + public decimal GrandTotal { get; set; } + public DateTime CreatedDate { get; set; } + + [Projectable] + public string ValueBand => GrandTotal switch + { + >= 1000 => "High", + >= 250 => "Medium", + _ => "Low" + }; + + [Projectable] + public bool IsRecent => CreatedDate >= DateTime.UtcNow.AddDays(-30); +} +``` + +```csharp +// Breakdown of recent orders by value band +var breakdown = dbContext.Orders + .Where(o => o.IsRecent) + .GroupBy(o => o.ValueBand) + .Select(g => new + { + Band = g.Key, + Count = g.Count(), + Total = g.Sum(o => o.GrandTotal) + }) + .OrderBy(x => x.Band) + .ToList(); +``` + +## Property Patterns for Multi-Field Classification + +Use property patterns to match on multiple fields simultaneously: + +```csharp +public class Employee +{ + public int YearsOfService { get; set; } + public string Department { get; set; } + + [Projectable] + public static bool IsEligibleForBonus(this Employee e) => + e is { YearsOfService: >= 2, Department: "Sales" or "Engineering" }; +} +``` + +## Tips + +- **Use `_` as the default arm** — always include a discard arm to avoid generating a ternary chain with no final fallback. +- **Keep arms ordered from most to least specific** — the generator emits a ternary chain in arm order; put the most restrictive cases first. +- **Avoid positional patterns** — deconstruct patterns (`(x, y) =>`) are not supported (EFP0007). Use property patterns (`{ X: x, Y: y }`) instead. +- **Compose with filters** — classification properties work perfectly in `Where`, `GroupBy`, and `OrderBy` just like any other projectable. + +See also: [Pattern Matching reference](/reference/pattern-matching), [Diagnostics EFP0007](/reference/diagnostics#efp0007). + diff --git a/docs/reference/compatibility-mode.md b/docs/reference/compatibility-mode.md new file mode 100644 index 0000000..f675a3b --- /dev/null +++ b/docs/reference/compatibility-mode.md @@ -0,0 +1,111 @@ +# Compatibility Mode + +Compatibility mode controls **when** and **how** EF Core Projectables expands your projectable members during query execution. The choice affects both performance and query caching behavior. + +## Configuration + +Set the compatibility mode when registering Projectables: + +```csharp +options.UseProjectables(projectables => + projectables.CompatibilityMode(CompatibilityMode.Limited)); +``` + +## Modes + +### `Full` (Default) + +```csharp +options.UseProjectables(); // Full is the default + +// Or explicitly: +options.UseProjectables(p => p.CompatibilityMode(CompatibilityMode.Full)); +``` + +In Full mode, the expression tree is **expanded on every individual query invocation**, before being passed to EF Core. This is similar to how libraries like LinqKit work. + +**Flow:** +``` +LINQ query + → [Projectables expands all member calls] + → Expanded query sent to EF Core compiler + → SQL generated and executed +``` + +**Characteristics:** +- ✅ Works with **dynamic parameters** — captures fresh parameter values on each execution. +- ✅ Maximum compatibility — works in all EF Core scenarios. +- ⚠️ Slight overhead per query invocation (expression tree walking + expansion). +- ⚠️ EF Core's query cache key changes with expanded expressions, so the compiled query cache may be less effective. + +**When to use Full:** +- When you're running into query compilation errors with Limited mode. +- When your projectable members depend on dynamic expressions that change between calls. +- As a safe default while getting started. + +--- + +### `Limited` + +```csharp +options.UseProjectables(p => p.CompatibilityMode(CompatibilityMode.Limited)); +``` + +In Limited mode, expansion happens inside **EF Core's query translation preprocessor** — after EF Core accepts the query and before it compiles it. The expanded query is then stored in EF Core's query cache. Subsequent executions with the same query shape skip the expansion step entirely. + +**Flow:** +``` +LINQ query + → EF Core query preprocessor + → [Projectables expands member calls here] + → Expanded query compiled and stored in query cache + → SQL generated and executed + +Second execution with same query shape: + → EF Core query cache hit + → Compiled query reused directly (no expansion needed) +``` + +**Characteristics:** +- ✅ **Better performance** — after the first execution, cached queries bypass expansion entirely. +- ✅ Often **outperforms vanilla EF Core** for repeated queries. +- ⚠️ Dynamic parameters captured as closures may not work correctly — the expanded query is cached with the parameter values from the first execution. +- ⚠️ If a projectable member uses external runtime state (not EF Core query parameters), the cached expansion may be stale. + +**When to use Limited:** +- When all your projectable members' logic is deterministic given the query parameters. +- In production environments where query performance is critical. +- When queries are executed many times with the same shape. + +## Performance Comparison + +| Scenario | Full | Limited | Vanilla EF Core | +|--------------------------------|-----------------------------|--------------------------------------|-----------------| +| First query execution | Slower (expansion overhead) | Slower (expansion + compile) | Baseline | +| Subsequent executions | Slower (expansion overhead) | **Faster** (cache hit, no expansion) | Baseline | +| Dynamic projectable parameters | ✅ Correct | ⚠️ May be stale | N/A | + +## Choosing a Mode + +``` +Start with Full (default) + ↓ +Is performance critical? + → No: Stay on Full + → Yes: Try Limited + ↓ + Do your queries produce correct results with Limited? + → Yes: Use Limited + → No: Stay on Full +``` + +## Troubleshooting + +### Queries returning wrong results in Limited mode + +If you're using projectable members that depend on values computed at runtime (outside of EF Core's parameter system), Limited mode may cache the wrong expansion. Switch to Full mode. + +### Query compilation errors in Full mode + +If Full mode causes compilation errors related to expression tree translation, check that your projectable members only use EF Core-translatable expressions. Refer to [Limitations](/advanced/limitations). + diff --git a/docs/reference/diagnostics.md b/docs/reference/diagnostics.md new file mode 100644 index 0000000..7c91b88 --- /dev/null +++ b/docs/reference/diagnostics.md @@ -0,0 +1,527 @@ +# Diagnostics & Code Fixes + +The Projectables source generator emits diagnostics (warnings and errors) during compilation to help you identify and fix issues with your projectable members. Many diagnostics also have **IDE code fixes** that resolve them automatically. + +## Overview + +| ID | Severity | Title | Code Fix | +|---|---|---|---| +| [EFP0001](#efp0001) | ⚠️ Warning | Block-bodied member support is experimental | [Add `AllowBlockBody = true`](#efp0001-fix) | +| [EFP0002](#efp0002) | ❌ Error | Null-conditional expression not configured | [Set `NullConditionalRewriteSupport`](#efp0002-fix) | +| [EFP0003](#efp0003) | ⚠️ Warning | Unsupported statement in block-bodied method | — | +| [EFP0004](#efp0004) | ❌ Error | Statement with side effects in block-bodied method | — | +| [EFP0005](#efp0005) | ⚠️ Warning | Potential side effect in block-bodied method | — | +| [EFP0006](#efp0006) | ❌ Error | Method or property requires a body definition | — | +| [EFP0007](#efp0007) | ❌ Error | Unsupported pattern in projectable expression | — | +| [EFP0008](#efp0008) | ❌ Error | Target class is missing a parameterless constructor | [Add parameterless constructor](#efp0008-fix) | +| [EFP0009](#efp0009) | ❌ Error | Delegated constructor cannot be analyzed for projection | — | +| [EFP0010](#efp0010) | ❌ Error | UseMemberBody target member not found | — | +| [EFP0011](#efp0011) | ❌ Error | UseMemberBody target member is incompatible | — | +| [EFP0012](#efp0012) | ℹ️ Info | [Projectable] factory method can be converted to a constructor | [Convert to constructor](#efp0012-fix) | + +--- + +## EFP0001 — Block-bodied member support is experimental {#efp0001} + +**Severity:** Warning +**Category:** Design + +### Message + +``` +Block-bodied member '{0}' is using an experimental feature. +Set AllowBlockBody = true on the Projectable attribute to suppress this warning. +``` + +### Cause + +A `[Projectable]` member uses a block body (`{ ... }`) instead of an expression body (`=>`), which is an experimental feature. + +### Fix {#efp0001-fix} + +The IDE offers a quick-fix to add `AllowBlockBody = true` automatically. You can also apply it manually: + +```csharp +// Before (warning) +[Projectable] +public string GetCategory() +{ + if (Value > 100) return "High"; + return "Low"; +} + +// After (warning suppressed) +[Projectable(AllowBlockBody = true)] +public string GetCategory() +{ + if (Value > 100) return "High"; + return "Low"; +} +``` + +Or convert to an expression-bodied member: + +```csharp +[Projectable] +public string GetCategory() => Value > 100 ? "High" : "Low"; +``` + +--- + +## EFP0002 — Null-conditional expression not configured {#efp0002} + +**Severity:** Error +**Category:** Design + +### Message + +``` +'{0}' has a null-conditional expression exposed but is not configured to rewrite this +(Consider configuring a strategy using the NullConditionalRewriteSupport property +on the Projectable attribute) +``` + +### Cause + +The projectable member's body contains a null-conditional operator (`?.`) but `NullConditionalRewriteSupport` is not configured (defaults to `None`). + +### Fix {#efp0002-fix} + +The IDE offers two code-fix options — **Ignore** and **Rewrite** — which set the attribute property automatically: + +```csharp +// ❌ Error +[Projectable] +public string? FullAddress => Location?.AddressLine1 + " " + Location?.City; + +// ✅ Option 1: Ignore (strips the ?. — safe for SQL Server) +[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] +public string? FullAddress => Location?.AddressLine1 + " " + Location?.City; + +// ✅ Option 2: Rewrite (explicit null checks) +[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Rewrite)] +public string? FullAddress => Location?.AddressLine1 + " " + Location?.City; + +// ✅ Option 3: Rewrite the expression manually +[Projectable] +public string? FullAddress => + Location != null ? Location.AddressLine1 + " " + Location.City : null; +``` + +See [Null-Conditional Rewrite](/reference/null-conditional-rewrite) for details. + +--- + +## EFP0003 — Unsupported statement in block-bodied method {#efp0003} + +**Severity:** Warning +**Category:** Design + +### Message + +``` +Method '{0}' contains an unsupported statement: {1} +``` + +### Cause + +A block-bodied `[Projectable]` member contains a statement type that cannot be converted to an expression tree (e.g., loops, try-catch, throw, new object instantiation in statement position). + +### Unsupported Statements + +- `while`, `for`, `foreach` loops +- `try`/`catch`/`finally` blocks +- `throw` statements +- Object instantiation as a statement (not in a `return`) + +### Fix + +Refactor to use only supported constructs (`if`/`else`, `switch`, local variables, `return`), or convert to an expression-bodied member: + +```csharp +// ❌ Warning: loops are not supported +[Projectable(AllowBlockBody = true)] +public int SumItems() +{ + int total = 0; + foreach (var item in Items) // EFP0003 + total += item.Price; + return total; +} + +// ✅ Use LINQ instead +[Projectable] +public int SumItems() => Items.Sum(i => i.Price); +``` + +--- + +## EFP0004 — Statement with side effects in block-bodied method {#efp0004} + +**Severity:** Error +**Category:** Design + +### Message + +Context-specific — one of: + +- `Property assignment '{0}' has side effects and cannot be used in projectable methods` +- `Compound assignment operator '{0}' has side effects` +- `Increment/decrement operator '{0}' has side effects` + +### Cause + +A block-bodied projectable member modifies state. Expression trees cannot represent side effects. + +### Triggers + +```csharp +// ❌ Property assignment +Bar = 10; + +// ❌ Compound assignment +Bar += 10; + +// ❌ Increment / Decrement +Bar++; +--Count; +``` + +### Fix + +Remove the side-effecting statement. Projectable members must be **pure functions** — they can only read data and return a value. + +```csharp +// ❌ Error: has side effects +[Projectable(AllowBlockBody = true)] +public int Foo() +{ + Bar = 10; // EFP0004 + return Bar; +} + +// ✅ Read-only computation +[Projectable] +public int Foo() => Bar + 10; +``` + +--- + +## EFP0005 — Potential side effect in block-bodied method {#efp0005} + +**Severity:** Warning +**Category:** Design + +### Message + +``` +Method call '{0}' may have side effects and cannot be guaranteed to be safe in projectable methods +``` + +### Cause + +A block-bodied projectable member calls a method that is **not** itself marked with `[Projectable]`. Such calls may have side effects that cannot be represented in an expression tree. + +### Example + +```csharp +[Projectable(AllowBlockBody = true)] +public int Foo() +{ + Console.WriteLine("test"); // ⚠️ EFP0005 — may have side effects + return Bar; +} +``` + +### Fix + +- Remove the method call if it is not needed in a query context. +- If the method is safe to use in queries, mark it with `[Projectable]`. + +--- + +## EFP0006 — Method or property requires a body definition {#efp0006} + +**Severity:** Error +**Category:** Design + +### Message + +``` +Method or property '{0}' should expose a body definition (e.g. an expression-bodied member +or a block-bodied method) to be used as the source for the generated expression tree. +``` + +### Cause + +A `[Projectable]` member has no body — it is abstract, an interface declaration, or an auto-property without an expression. + +### Fix + +Provide a body, or use [`UseMemberBody`](/reference/use-member-body) to delegate to another member: + +```csharp +// ❌ Error: no body +[Projectable] +public string FullName { get; set; } + +// ✅ Expression-bodied property +[Projectable] +public string FullName => FirstName + " " + LastName; + +// ✅ Delegate to another member +[Projectable(UseMemberBody = nameof(ComputeFullName))] +public string FullName => ComputeFullName(); +private string ComputeFullName() => FirstName + " " + LastName; +``` + +--- + +## EFP0007 — Unsupported pattern in projectable expression {#efp0007} + +**Severity:** Error +**Category:** Design + +### Message + +``` +The pattern '{0}' cannot be rewritten into an expression tree. +Simplify the pattern or restructure the projectable member body. +``` + +### Cause + +A pattern used inside a `[Projectable]` member (e.g. in a `switch` expression or an `is` expression) cannot be translated into an expression tree. Unsupported patterns include positional/deconstruct patterns and variable designations outside switch arms. + +### Supported Patterns + +| Pattern | Example | +|---|---| +| Constant | `1 => "one"` | +| Discard / default | `_ => "other"` | +| Type | `GroupItem g => …` | +| Relational | `>= 90 => "A"` | +| `and` / `or` combined | `>= 80 and < 90 => "B"` | +| `when` guard | `4 when Prop == 12 => …` | +| Property | `entity is { IsActive: true }` | +| `not null` / `not` | `Name is not null` | + +### Fix + +Rewrite using a supported pattern or convert to an explicit conditional expression: + +```csharp +// ❌ Error — positional pattern not supported +[Projectable] +public bool IsOrigin(Point p) => p is (0, 0); + +// ✅ Use a property pattern instead +[Projectable] +public bool IsOrigin(Point p) => p is { X: 0, Y: 0 }; +``` + +See also: [Pattern Matching](/reference/pattern-matching). + +--- + +## EFP0008 — Target class is missing a parameterless constructor {#efp0008} + +**Severity:** Error +**Category:** Design + +### Message + +``` +Class '{0}' must have a parameterless constructor to be used with a [Projectable] constructor. +The generated projection uses 'new {0}() { ... }' (object-initializer syntax), +which requires an accessible parameterless constructor. +``` + +### Cause + +A constructor is marked `[Projectable]`, but the class does not expose a public, internal, or protected-internal parameterless constructor. The generator emits `new T() { … }` syntax which requires one. + +### Fix {#efp0008-fix} + +The IDE inserts the parameterless constructor automatically. You can also add it manually: + +```csharp +public class CustomerDto +{ + public CustomerDto() { } // ← inserted by code fix + + [Projectable] + public CustomerDto(Customer customer) { … } +} +``` + +See also: [Constructor Projections](/guide/projectable-constructors). + +--- + +## EFP0009 — Delegated constructor cannot be analyzed for projection {#efp0009} + +**Severity:** Error +**Category:** Design + +### Message + +``` +The delegated constructor '{0}' in type '{1}' has no source available and cannot be analyzed. +Base/this initializer in member '{2}' will not be projected. +``` + +### Cause + +A `[Projectable]` constructor delegates to another constructor via `: base(…)` or `: this(…)`, but the target constructor's source code is not available in the current compilation (e.g. it lives in a referenced binary). + +### Fix + +Ensure the delegated constructor's source is available (e.g. move it to the same project), or restructure to avoid the delegation. + +--- + +## EFP0010 — UseMemberBody target member not found {#efp0010} + +**Severity:** Error +**Category:** Design + +### Message + +``` +Member '{1}' referenced by UseMemberBody on '{0}' was not found on type '{2}' +``` + +### Cause + +The name passed to `UseMemberBody` does not match any member on the containing type. + +### Fix + +Verify the member name — use `nameof(...)` to avoid typos: + +```csharp +// ❌ Error — "ComputeFullname" (lowercase n) doesn't exist +[Projectable(UseMemberBody = "ComputeFullname")] +public string FullName => …; + +// ✅ Use nameof to catch mistakes at compile time +[Projectable(UseMemberBody = nameof(ComputeFullName))] +public string FullName => …; +private string ComputeFullName => FirstName + " " + LastName; +``` + +See also: [Use Member Body](/reference/use-member-body). + +--- + +## EFP0011 — UseMemberBody target member is incompatible {#efp0011} + +**Severity:** Error +**Category:** Design + +### Message + +``` +Member '{1}' referenced by UseMemberBody on '{0}' has an incompatible type or signature +``` + +### Cause + +A member with the given name exists on the type but its return type or parameter list is incompatible with the `[Projectable]` member. + +### Fix + +Align the target member's signature with the projectable member. For `Expression>` properties the lambda parameter types must correspond to the projectable's parameters: + +```csharp +// ❌ Error — return type mismatch (string vs int) +[Projectable(UseMemberBody = nameof(ComputeExpr))] +public int Computed => …; +private static Expression> ComputeExpr => @this => @this.Name; + +// ✅ Matching return type +private static Expression> ComputeExpr => @this => @this.Id * 2; +``` + +See also: [Use Member Body](/reference/use-member-body). + +--- + +## EFP0012 — [Projectable] factory method can be converted to a constructor {#efp0012} + +**Severity:** Info +**Category:** Design + +### Message + +``` +Factory method '{0}' creates and returns an instance of the containing class via object initializer. +Consider converting it to a [Projectable] constructor. +``` + +### Cause + +A `[Projectable]` static or instance method returns a `new T { … }` object initializer where `T` is the containing class. This is equivalent to a `[Projectable]` constructor and the IDE can convert it automatically. + +### Fix {#efp0012-fix} + +Two IDE actions are available — as a quick-fix on the diagnostic, or as a refactoring (always available even when the diagnostic is suppressed via the IDE's refactoring menu): + +| Action | Scope | +|---|---| +| Convert to constructor | Current document only | +| Convert to constructor (and update callers) | Entire solution | + +```csharp +// Before — factory method +public class CustomerDto +{ + [Projectable] + public static CustomerDto FromCustomer(Customer c) => new CustomerDto + { + Id = c.Id, + Name = c.FirstName + " " + c.LastName + }; +} + +// After — projectable constructor (converted by IDE) +public class CustomerDto +{ + public CustomerDto() { } + + [Projectable] + public CustomerDto(Customer c) + { + Id = c.Id; + Name = c.FirstName + " " + c.LastName; + } +} +``` + +See also: [Constructor Projections](/guide/projectable-constructors). + +--- + +## Suppressing Diagnostics + +Individual warnings can be suppressed with standard C# pragma directives: + +```csharp +#pragma warning disable EFP0001 +[Projectable] +public string GetValue() +{ + if (IsActive) return "Active"; + return "Inactive"; +} +#pragma warning restore EFP0001 +``` + +Or via `.editorconfig` / `Directory.Build.props`: + +```xml + + $(NoWarn);EFP0001;EFP0003 + +``` \ No newline at end of file diff --git a/docs/reference/expand-enum-methods.md b/docs/reference/expand-enum-methods.md new file mode 100644 index 0000000..f2ffb29 --- /dev/null +++ b/docs/reference/expand-enum-methods.md @@ -0,0 +1,169 @@ +# Expand Enum Methods + +The `ExpandEnumMethods` option allows you to call ordinary C# methods on enum values inside a projectable member and have those calls translated to SQL `CASE` expressions. Without this option, calling a non-projectable method on an enum value would fail SQL translation. + +## The Problem + +You have an enum with a helper method: + +```csharp +public enum OrderStatus { Pending, Approved, Rejected } + +public static class OrderStatusExtensions +{ + public static string GetDisplayName(this OrderStatus status) => + status switch { + OrderStatus.Pending => "Pending Review", + OrderStatus.Approved => "Approved", + OrderStatus.Rejected => "Rejected", + _ => status.ToString() + }; +} +``` + +If you try to use `GetDisplayName()` inside a projectable member without `ExpandEnumMethods`, the generator cannot produce a valid expression tree because `GetDisplayName` is not a database function. + +## The Solution + +Set `ExpandEnumMethods = true` on the projectable member that calls the enum method: + +```csharp +public class Order +{ + public OrderStatus Status { get; set; } + + [Projectable(ExpandEnumMethods = true)] + public string StatusName => Status.GetDisplayName(); +} +``` + +The generator enumerates all values of `OrderStatus` and produces a chain of ternary expressions: + +```csharp +// Generated expression equivalent +Status == OrderStatus.Pending ? GetDisplayName(OrderStatus.Pending) : +Status == OrderStatus.Approved ? GetDisplayName(OrderStatus.Approved) : +Status == OrderStatus.Rejected ? GetDisplayName(OrderStatus.Rejected) : +null +``` + +EF Core then translates this to a SQL `CASE` expression: + +```sql +SELECT CASE + WHEN [o].[Status] = 0 THEN N'Pending Review' + WHEN [o].[Status] = 1 THEN N'Approved' + WHEN [o].[Status] = 2 THEN N'Rejected' +END AS [StatusName] +FROM [Orders] AS [o] +``` + +## Supported Return Types + +| Return type | Default fallback value | +|-------------------|---------------------------| +| `string` | `null` | +| `bool` | `default(bool)` → `false` | +| `int` | `default(int)` → `0` | +| Other value types | `default(T)` | +| Nullable types | `null` | + +## Examples + +### Boolean Return + +```csharp +public static bool IsApproved(this OrderStatus status) => + status == OrderStatus.Approved; + +[Projectable(ExpandEnumMethods = true)] +public bool IsStatusApproved => Status.IsApproved(); +``` + +Generated SQL: +```sql +SELECT CASE + WHEN [o].[Status] = 0 THEN CAST(0 AS bit) + WHEN [o].[Status] = 1 THEN CAST(1 AS bit) + WHEN [o].[Status] = 2 THEN CAST(0 AS bit) + ELSE CAST(0 AS bit) +END AS [IsStatusApproved] +FROM [Orders] AS [o] +``` + +### Integer Return + +```csharp +public static int GetSortOrder(this OrderStatus status) => (int)status; + +[Projectable(ExpandEnumMethods = true)] +public int StatusSortOrder => Status.GetSortOrder(); +``` + +Generated SQL: +```sql +SELECT CASE + WHEN [o].[Status] = 0 THEN 0 + WHEN [o].[Status] = 1 THEN 1 + WHEN [o].[Status] = 2 THEN 2 + ELSE 0 +END AS [StatusSortOrder] +FROM [Orders] AS [o] +``` + +### Methods with Additional Parameters + +Additional parameters are passed through to each branch of the expanded ternary: + +```csharp +public static string Format(this OrderStatus status, string prefix) => + prefix + status.ToString(); + +[Projectable(ExpandEnumMethods = true)] +public string FormattedStatus => Status.Format("Status: "); +``` + +### Nullable Enum Types + +If the enum property is nullable, the expansion is wrapped in a null check: + +```csharp +public class Order +{ + public OrderStatus? OptionalStatus { get; set; } + + [Projectable(ExpandEnumMethods = true)] + public string? OptionalStatusName => OptionalStatus?.GetDisplayName(); +} +``` + +### Enum on Navigation Properties + +```csharp +public class Order +{ + public Customer Customer { get; set; } + + [Projectable(ExpandEnumMethods = true)] + public string CustomerTierName => Customer.Tier.GetDisplayName(); +} +``` + +## Limitations + +- The method being expanded **must be deterministic** — it will be evaluated at code-generation time for each enum value. +- All enum values must produce valid SQL-translatable results. +- The enum type must be known at compile time (no dynamic enum types). +- Only the outermost enum method call on the enum property is expanded; nested calls may require multiple projectable members. + +## Comparison with `[Projectable]` on the Extension Method + +You might wonder: why not just put `[Projectable]` on `GetDisplayName` itself? + +| Approach | When to use | +|-----------------------------------------|-------------------------------------------------------------------------------------------------------------------------| +| `[Projectable]` on the extension method | The method body is a simple expression EF Core can translate (e.g., `== OrderStatus.Approved`). | +| `ExpandEnumMethods = true` | The method body is complex or references non-EF-translatable code (e.g., reads a `[Display]` attribute via reflection). | + +`ExpandEnumMethods` evaluates the method at **compile time** for each enum value and bakes the results into the expression tree, so the method body doesn't need to be translatable at all. + diff --git a/docs/reference/null-conditional-rewrite.md b/docs/reference/null-conditional-rewrite.md new file mode 100644 index 0000000..949070e --- /dev/null +++ b/docs/reference/null-conditional-rewrite.md @@ -0,0 +1,152 @@ +# Null-Conditional Rewrite + +Expression trees — the representation EF Core uses internally — cannot directly express the null-conditional operator (`?.`). If your projectable member contains `?.`, the source generator needs to know how to handle it. + +## The Problem + +Consider this projectable property: + +```csharp +[Projectable] +public string? FullAddress => + Location?.AddressLine1 + " " + Location?.City; +``` + +This is valid C# code, but it **cannot be converted to an expression tree as-is**. The null-conditional operator is syntactic sugar that cannot be represented directly in an `Expression>`. + +By default (with `NullConditionalRewriteSupport.None`), the generator will report **error EFP0002** and refuse to generate code. + +## The `NullConditionalRewriteSupport` Options + +Configure the strategy on the `[Projectable]` attribute: + +```csharp +[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] +``` + +--- + +### `None` (Default) + +```csharp +[Projectable] // NullConditionalRewriteSupport.None is the default +public string? FullAddress => Location?.AddressLine1 + " " + Location?.City; +``` + +The generator **rejects** any use of `?.`. This produces error EFP0002: + +``` +error EFP0002: 'FullAddress' has a null-conditional expression exposed but is not configured +to rewrite this (Consider configuring a strategy using the NullConditionalRewriteSupport +property on the Projectable attribute) +``` + +**Use when:** Your projectable members never use `?.` — this is the safest default that prevents accidental misuse. + +--- + +### `Ignore` + +```csharp +[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] +public string? FullAddress => Location?.AddressLine1 + " " + Location?.City; +``` + +The null-conditional operators are **stripped** — `A?.B` becomes `A.B`. + +Generated expression is equivalent to: +```csharp +Location.AddressLine1 + " " + Location.City +``` + +**Behavior in SQL:** The result is `NULL` if any operand is `NULL`, because SQL's null propagation works implicitly in most expressions. This is consistent with how most SQL databases handle null values. + +**Use when:** +- You're using SQL Server or another database where null propagation in expressions works as expected. +- You know the navigation property will not be null in practice (or null is an acceptable result when it is). +- You want simpler, shorter SQL output. + +**Generated SQL example (SQL Server):** +```sql +SELECT ([u].[AddressLine1] + N' ') + [u].[City] +FROM [Users] AS [u] +``` + +--- + +### `Rewrite` + +```csharp +[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Rewrite)] +public string? FullAddress => Location?.AddressLine1 + " " + Location?.City; +``` + +The null-conditional operators are **rewritten as explicit null checks** — `A?.B` becomes `A != null ? A.B : null`. + +Generated expression is equivalent to: +```csharp +(Location != null ? Location.AddressLine1 : null) ++ " " + +(Location != null ? Location.City : null) +``` + +**Use when:** +- You need **explicit null handling** in the generated expression. +- You're targeting Cosmos DB or another provider that evaluates expressions client-side. +- You want the expression to behave identically to the original C# code. + +**Trade-off:** The generated SQL can become significantly more complex, especially with deeply nested null-conditional chains. + +**Generated SQL example (SQL Server):** +```sql +SELECT + CASE WHEN [u].[LocationId] IS NOT NULL THEN [l].[AddressLine1] ELSE NULL END + + N' ' + + CASE WHEN [u].[LocationId] IS NOT NULL THEN [l].[City] ELSE NULL END +FROM [Users] AS [u] +LEFT JOIN [Locations] AS [l] ON [u].[LocationId] = [l].[Id] +``` + +## Comparison Table + +| Option | `?.` allowed | Expression generated | SQL complexity | +|-----------|-------------------|--------------------------|----------------| +| `None` | ❌ (error EFP0002) | — | — | +| `Ignore` | ✅ | `A.B` | Simple | +| `Rewrite` | ✅ | `A != null ? A.B : null` | Higher | + +## Practical Recommendation + +- **SQL Server + navigations you control:** Use `Ignore` — SQL Server's null semantics match C#'s null-conditional in most cases. +- **Cosmos DB or client-side evaluation:** Use `Rewrite` — you need explicit null checks. +- **Unsure:** Start with `Rewrite` for correctness, optimize to `Ignore` if SQL complexity is an issue. + +## Example: Navigation Property Chain + +```csharp +public class User +{ + public Address? Location { get; set; } +} + +public class Address +{ + public string? AddressLine1 { get; set; } + public string? City { get; set; } +} + +// Option 1: Ignore (simpler SQL) +[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] +public static string? GetFullAddress(this User? user) => + user?.Location?.AddressLine1 + " " + user?.Location?.City; +// → user.Location.AddressLine1 + " " + user.Location.City + +// Option 2: Rewrite (explicit null checks, safer) +[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Rewrite)] +public static string? GetFullAddress(this User? user) => + user?.Location?.AddressLine1 + " " + user?.Location?.City; +// → (user != null ? (user.Location != null ? user.Location.AddressLine1 : null) : null) +// + " " + +// (user != null ? (user.Location != null ? user.Location.City : null) : null) +``` + diff --git a/docs/reference/pattern-matching.md b/docs/reference/pattern-matching.md new file mode 100644 index 0000000..418f0ea --- /dev/null +++ b/docs/reference/pattern-matching.md @@ -0,0 +1,157 @@ +# Pattern Matching + +> [!NOTE] +> Pattern matching support is available starting from version 6.x. + +The source generator rewrites C# pattern-matching constructs into expression-tree-compatible ternary and binary expressions that EF Core can translate to SQL `CASE` expressions. + +## Switch Expressions + +### Relational and Constant Patterns + +```csharp +[Projectable] +public string GetGrade() => Score switch +{ + >= 90 => "A", + >= 80 => "B", + >= 70 => "C", + _ => "F", +}; +``` + +Generated expression: +```csharp +(@this) => @this.Score >= 90 ? "A" + : @this.Score >= 80 ? "B" + : @this.Score >= 70 ? "C" + : "F" +``` + +Which EF Core translates to: +```sql +SELECT CASE + WHEN [e].[Score] >= 90 THEN N'A' + WHEN [e].[Score] >= 80 THEN N'B' + WHEN [e].[Score] >= 70 THEN N'C' + ELSE N'F' +END +``` + +### `and` / `or` Combined Patterns + +```csharp +[Projectable] +public string GetBand() => Score switch +{ + >= 90 and <= 100 => "Excellent", + >= 70 and < 90 => "Good", + _ => "Poor", +}; +``` + +### `when` Guards + +```csharp +[Projectable] +public string Classify() => Value switch +{ + 4 when IsSpecial => "Special Four", + 4 => "Regular Four", + _ => "Other", +}; +``` + +### Type Patterns + +Type patterns in switch arms produce a cast and type-check expression: + +```csharp +[Projectable] +public static ItemData ToData(this Item item) => + item switch + { + GroupItem g => new GroupData(g.Id, g.Name, g.Description), + DocumentItem d => new DocumentData(d.Id, d.Name, d.Priority), + _ => null! + }; +``` + +### Discard / Default + +The discard pattern (`_`) maps to the final `else` branch of the generated ternary chain. + +--- + +## `is` Patterns in Expression Bodies + +### Relational `and` / `or` + +```csharp +// Range check +[Projectable] +public bool IsInRange => Value is >= 1 and <= 100; +// → Value >= 1 && Value <= 100 + +// Alternative values +[Projectable] +public bool IsEdge => Value is 0 or 100; +// → Value == 0 || Value == 100 +``` + +### `not null` / `not` + +```csharp +[Projectable] +public bool HasName => Name is not null; +// → !(Name == null) +``` + +### Property Patterns + +```csharp +[Projectable] +public static bool IsActiveAndPositive(this Entity entity) => + entity is { IsActive: true, Value: > 0 }; +// → entity != null && entity.IsActive == true && entity.Value > 0 +``` + +--- + +## Supported Pattern Summary + +| Pattern | Context | Example | +|-------------------|------------------|----------------------------------------------| +| Constant | switch arm, `is` | `1 => "one"`, `Value is 42` | +| Discard / default | switch arm | `_ => "other"` | +| Relational | switch arm, `is` | `>= 90 => "A"`, `Value is > 0` | +| `and` combined | switch arm, `is` | `>= 80 and < 90`, `Value is >= 1 and <= 100` | +| `or` combined | switch arm, `is` | `1 or 2 => "low"`, `Value is 0 or > 100` | +| `not` | `is` | `Name is not null` | +| `when` guard | switch arm | `4 when Prop == 12 => …` | +| Type | switch arm | `GroupItem g => …` | +| Property | `is` | `entity is { IsActive: true }` | + +--- + +## Unsupported Patterns + +The following patterns **cannot** be translated into expression trees and produce diagnostic **EFP0007**: + +- Positional / deconstruct patterns: `(0, 0) => …` +- Variable designations outside switch arms: `item is GroupItem g` in an `if` condition +- List patterns: `[1, 2, ..]` +- `var` patterns + +```csharp +// ❌ EFP0007 — positional pattern not supported +[Projectable] +public bool IsOrigin(Point p) => p is (0, 0); + +// ✅ Use a property pattern instead +[Projectable] +public bool IsOrigin(Point p) => p is { X: 0, Y: 0 }; +``` + +See [Diagnostics Reference](/reference/diagnostics#efp0007) for the full EFP0007 description. + diff --git a/docs/reference/projectable-attribute.md b/docs/reference/projectable-attribute.md new file mode 100644 index 0000000..308d77d --- /dev/null +++ b/docs/reference/projectable-attribute.md @@ -0,0 +1,149 @@ +# `[Projectable]` Attribute + +The `ProjectableAttribute` is the entry point for this library. Place it on any property or method to tell the source generator to produce a companion expression tree for it. + +## Namespace + +```csharp +using EntityFrameworkCore.Projectables; +``` + +## Target + +| Target | Supported | +|-------------------|-----------| +| Properties | ✅ | +| Methods | ✅ | +| Extension methods | ✅ | +| Constructors | ✅ (v6.x+) | +| Indexers | ❌ | + +The attribute can be inherited by derived types (`Inherited = true`). + +## Properties + +### `NullConditionalRewriteSupport` + +**Type:** `NullConditionalRewriteSupport` +**Default:** `NullConditionalRewriteSupport.None` + +Controls how null-conditional operators (`?.`) in the member body are handled by the source generator. + +```csharp +[Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Rewrite)] +public string? FullAddress => Location?.AddressLine1 + " " + Location?.City; +``` + +| Value | Behavior | +|------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| `None` (default) | Null-conditional operators are **not allowed** — the generator raises error EFP0002. | +| `Ignore` | Null-conditional operators are **stripped** — `A?.B` becomes `A.B`. Safe for databases where null propagates implicitly (SQL Server). | +| `Rewrite` | Null-conditional operators are **rewritten** as explicit null checks — `A?.B` becomes `A != null ? A.B : null`. Safer but may increase SQL complexity. | + +See [Null-Conditional Rewrite](/reference/null-conditional-rewrite) for full details. + +--- + +### `UseMemberBody` + +**Type:** `string?` +**Default:** `null` + +Tells the generator to use a **different member's body** as the source for the generated expression tree. Useful when the projectable member's body is not directly available (e.g., interface implementation, abstract member). + +```csharp +public class Order +{ + // The actual computation is defined here + private decimal ComputeGrandTotal() => Subtotal + Tax; + + // The projectable member delegates to it + [Projectable(UseMemberBody = nameof(ComputeGrandTotal))] + public decimal GrandTotal => ComputeGrandTotal(); +} +``` + +See [Use Member Body](/reference/use-member-body) for full details. + +--- + +### `ExpandEnumMethods` + +**Type:** `bool` +**Default:** `false` + +When set to `true`, method calls on enum values inside this projectable member are expanded into a **chain of ternary expressions** — one branch per enum value. This allows enum helper methods (like display name lookups) to be translated to SQL `CASE` expressions. + +```csharp +[Projectable(ExpandEnumMethods = true)] +public string StatusName => Status.GetDisplayName(); +``` + +See [Expand Enum Methods](/reference/expand-enum-methods) for full details. + +--- + +### `AllowBlockBody` + +**Type:** `bool` +**Default:** `false` + +Enables **block-bodied member** support (experimental). Without this flag, using a block body with `[Projectable]` produces warning EFP0001. Setting this to `true` suppresses the warning. + +```csharp +[Projectable(AllowBlockBody = true)] +public string Category +{ + get + { + if (Score > 100) return "High"; + else if (Score > 50) return "Medium"; + else return "Low"; + } +} +``` + +See [Block-Bodied Members](/advanced/block-bodied-members) for full details. + +--- + +## Complete Example + +```csharp +public class Order +{ + public OrderStatus Status { get; set; } + public decimal TaxRate { get; set; } + public Address? ShippingAddress { get; set; } + public ICollection Items { get; set; } + + // Simple computed property + [Projectable] + public decimal Subtotal => Items.Sum(i => i.Price * i.Quantity); + + // Composing projectables + [Projectable] + public decimal GrandTotal => Subtotal * (1 + TaxRate); + + // Handling nullable navigation + [Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Rewrite)] + public string? ShippingLine => ShippingAddress?.AddressLine1 + ", " + ShippingAddress?.City; + + // Enum expansion + [Projectable(ExpandEnumMethods = true)] + public string StatusLabel => Status.GetDisplayName(); + + // Block-bodied (experimental) + [Projectable(AllowBlockBody = true)] + public string Priority + { + get + { + if (GrandTotal > 1000) return "High"; + if (GrandTotal > 500) return "Medium"; + return "Normal"; + } + } +} +``` + diff --git a/docs/reference/use-member-body.md b/docs/reference/use-member-body.md new file mode 100644 index 0000000..e4b2349 --- /dev/null +++ b/docs/reference/use-member-body.md @@ -0,0 +1,149 @@ +# Use Member Body + +The `UseMemberBody` option on `[Projectable]` tells the source generator to use a **different member's body** as the source expression, instead of the annotated member's own body. This lets you maintain a separate in-memory implementation while supplying a clean expression for EF Core. + +## Delegating to a Method or Property Body + +Point `UseMemberBody` at another method or property that has the **same return type and parameter signature**. The generator uses the target member's body instead: + +```csharp +public class Entity +{ + public int Id { get; set; } + + // EF-side: generates an expression from ComputedImpl + [Projectable(UseMemberBody = nameof(ComputedImpl))] + public int Computed => Id; // original body is ignored + + // In-memory implementation (different algorithm) + private int ComputedImpl => Id * 2; +} +``` + +The generated expression is `(@this) => @this.Id * 2`, so `Computed` projects as `Id * 2` in SQL. + +> [!NOTE] +> When delegating to a regular method or property body the target member must be declared in the **same source file** as the `[Projectable]` member so the generator can read its body. + +## Using an `Expression>` Property as the Body + +For even more control you can supply the body as a typed `Expression>` property. This lets you write the expression once and reuse it from both the `[Projectable]` member and any runtime code that needs the expression tree directly: + +```csharp +public class Entity +{ + public int Id { get; set; } + + [Projectable(UseMemberBody = nameof(Computed4))] + public int Computed3 => Id; // body is replaced at compile time + + // The expression tree is picked up by the generator and by the runtime resolver + private static Expression> Computed4 => x => x.Id * 3; +} +``` + +Unlike regular method/property delegation, `Expression>` backing properties may be declared in a **different file** — for example in a separate part of a `partial class`: + +```csharp +// File: Entity.cs +public partial class Entity +{ + public int Id { get; set; } + + [Projectable(UseMemberBody = nameof(IdDoubledExpr))] + public int Computed => Id; +} + +// File: Entity.Expressions.cs +public partial class Entity +{ + private static Expression> IdDoubledExpr => @this => @this.Id * 2; +} +``` + +## Instance Methods and Parameter Alignment + +For instance methods the generator automatically aligns lambda parameter names with the method's own parameter names, so you are free to choose any names in the lambda. Using `@this` for the receiver is conventional: + +```csharp +public class Entity +{ + public int Value { get; set; } + + [Projectable(UseMemberBody = nameof(IsPositiveExpr))] + public bool IsPositive() => Value > 0; + + // Any receiver name works; @this is conventional + private static Expression> IsPositiveExpr => @this => @this.Value > 0; +} +``` + +If the lambda parameter names differ from the method's parameter names the generator renames them automatically: + +```csharp +// Lambda uses (c, t) but method parameter is named threshold — generated code uses threshold +private static Expression> ExceedsThresholdExpr => + (c, t) => c.Value > t; +``` + +## Static Extension Methods + +`UseMemberBody` works equally well on static extension methods. Name the lambda parameters to match the method's parameter names: + +```csharp +public static class FooExtensions +{ + [Projectable(UseMemberBody = nameof(NameEqualsExpr))] + public static bool NameEquals(this Foo a, Foo b) => a.Name == b.Name; + + private static Expression> NameEqualsExpr => + (a, b) => a.Name == b.Name; +} +``` + +The generated expression is `(Foo a, Foo b) => a.Name == b.Name`. + +## Use Cases + +### Interface Members + +Interface members cannot have bodies. Use `UseMemberBody` to delegate to a default implementation or a helper: + +```csharp +public class Order +{ + public decimal TaxRate { get; set; } + public ICollection Items { get; set; } + + private decimal ComputeGrandTotal() => + Items.Sum(i => i.Price * i.Quantity) * (1 + TaxRate); + + [Projectable(UseMemberBody = nameof(ComputeGrandTotal))] + public decimal GrandTotal => ComputeGrandTotal(); +} +``` + +### Reusing Bodies Across Multiple Members + +```csharp +public class Order +{ + private bool IsEligibleForDiscount() => + Items.Count > 5 && TotalValue > 100; + + [Projectable(UseMemberBody = nameof(IsEligibleForDiscount))] + public bool CanApplyDiscount => IsEligibleForDiscount(); + + [Projectable(UseMemberBody = nameof(IsEligibleForDiscount))] + public bool ShowDiscountBadge => IsEligibleForDiscount(); +} +``` + +## Diagnostics + +| Code | Severity | Cause | +|-------------|----------|------------------------------------------------------------------------------------| +| **EFP0010** | ❌ Error | The name given to `UseMemberBody` does not match any member on the containing type | +| **EFP0011** | ❌ Error | A member with that name exists but its type or signature is incompatible | + +See the full [Diagnostics Reference](/reference/diagnostics) for fix guidance.