diff --git a/.github/workflows/format.yaml b/.github/workflows/format.yaml index 44e773e9..58f083c1 100644 --- a/.github/workflows/format.yaml +++ b/.github/workflows/format.yaml @@ -36,7 +36,6 @@ jobs: run: csharpier format . - name: Commit & Push - uses: stefanzweifel/git-auto-commit-action@v4 + uses: stefanzweifel/git-auto-commit-action@v7 with: - commit_message: "chore: format code with csharpier" - file_pattern: "**/*.cs, **/*.csproj, Directory.Build.props" \ No newline at end of file + commit_message: "[skip-ci] chore: format code with csharpier" diff --git a/README.md b/README.md index d066a8fd..f89cd624 100644 --- a/README.md +++ b/README.md @@ -158,56 +158,27 @@ Interested? Try it out in the [Playground](https://arika0093.github.io/Linqraft/ ![](./assets/replace-codefix-sample.gif) -## Usage -### Prerequisites -This library requirements **C# 12.0 or later** because it uses the [interceptor](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-12#interceptors) feature. - - -
-.NET 7 or below setup - -Set the `LangVersion` property to `12.0` or later and use [Polysharp](https://github.com/Sergio0694/PolySharp/) to enable C# latest features. - -```xml - - - 12.0 - - - - - -``` +## Quick Start +For detailed installation instructions, see the [Installation Guide](./docs/library/installation.md). -
- -Also, due to the constraints of `Microsoft.CodeAnalysis.CSharp`, One of the following environment is [required](https://andrewlock.net/supporting-multiple-sdk-versions-in-analyzers-and-source-generators/): - -* .NET 8.0.400 or later **SDK** -* Visual Studio 2022 version 17.11 or later +### Prerequisites +This library requires following environment: -> [!NOTE] -> This is only a constraint on the SDK side, so the runtime(target framework) can be older versions. +* C# 12 or later (for interceptor feature) +* One of the following versions (or later): + * .NET 8.0.400 + * Visual Studio 2022 version 17.11 ### Installation -Install `Linqraft` from NuGet. +Install `Linqraft` from NuGet: ```bash dotnet add package Linqraft ``` -When you open your `.csproj` file, you should see the package added like below. -The `PrivateAssets` attribute might look unfamiliar, but it indicates that this is a development-only dependency (the library will not be included in the production environment). - -```xml - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - -``` +## Basic Usage -## Examples ### Anonymous pattern Use `SelectExpr` without generics to get an anonymous-type projection: @@ -224,7 +195,8 @@ var orders = await dbContext.Orders ``` ### Explicit DTO pattern -If you want to change the result to a DTO class, simply specify the generics as follows. + +Specify the generics to generate a DTO class: ```csharp var orders = await dbContext.Orders @@ -239,26 +211,9 @@ var orders = await dbContext.Orders .ToListAsync(); ``` -Similarly, you can use only the auto-generation feature by specifying `IEnumerable` types. - -```csharp -var orders = MySampleList - .SelectExpr(o => new - { - Id = o.Id, - CustomerName = o.Customer?.Name, - // ... - }) - .ToList(); -``` - -> [!TIP] -> If you want to use the auto-generated type information, you can navigate to the generated code (for example via F12 in your editor) by placing the cursor on the `OrderDto` class. -> and then you can copy it or use it as you like. - - ### Pre-existing DTO pattern -If you already have DTO classes and want to use them directly, call `SelectExpr` without generics and construct your DTO type in the selector: + +Use your existing DTO classes: ```csharp var orders = await dbContext.Orders @@ -274,440 +229,42 @@ var orders = await dbContext.Orders public class OrderDto { /* ... */ } ``` -## Customize Auto-Generated Code -### Pass Local-Variables -local variables cannot be used directly inside `SelectExpr` because it is "translated" into another method. To use local variables, use capture arguments. - -```csharp -var val = 10; -var multiplier = 2; -var suffix = " units"; -var converted = dbContext.Entities - .SelectExpr( - x => new { - x.Id, - // cannot use local variable 'val' directly - NewValue = x.Value + val, - DoubledValue = x.Value * multiplier, - Description = x.Name + suffix, - }, - // you need to pass local variables as an object. - capture: new { val, multiplier, suffix } - ); -``` - -
-Generated code example - -```csharp -// code snippet -public static IQueryable SelectExpr_223F344D_DD65E389( - this IQueryable query, Func selector, object captureParam) -{ - var matchedQuery = query as object as IQueryable; - dynamic captureObj = captureParam; - int val = captureObj.val; - int multiplier = captureObj.multiplier; - string suffix = captureObj.suffix; - var converted = matchedQuery.Select(x => new global::EntityDto - { - Id = x.Id, - NewValue = x.Value + val, - DoubledValue = x.Value * multiplier, - Description = x.Name + suffix, - }); - return converted as object as IQueryable; -} -``` - -
- -An analyzer is also provided to automatically detect and apply this transformation. -It is detected as an error, so just apply the code fix. - -![](./assets/local-variable-capture-err.png) - -### Removing nullability from arrays -Generally, DTO class types should be generated as specified. However, for array types, it is convenient for null tolerance to be automatically removed during generation. -This is because with array types, there is rarely a need to distinguish between `null` and `[]`. - -Linqraft automatically removes nullability from array-type properties according to the following rules. -* Expression should NOT contain a ternary operator -* The type must be IEnumerable or derived (List, Array, etc.) -* The expression uses null-conditional access (?.) -* The expression contains a Select or SelectMany call - -For example, the following transformation is performed. - -```csharp -query.SelectExpr(e => new -{ - // in general, this property is generated as List? - // but Linqraft removes nullability for array types. - // so the generated type is List - ChildNames = e.Child?.Select(c => c.Name).ToList(), - - // This also applies to auto-generated child classes. - // so the generated type is IEnumerable - ChildDtos = e.Child?.Select(c => new { c.Name, c.Description }), - - // When explicitly comparing with a ternary operator, it is generated as a nullable type as usual. - // therefore, in this case, it is generated as List? - ExplicitNullableNames = e.Child != null ? e.Child.Select(c => c.Name).ToList() : null, -}); -``` - -
-Generated code example - -```csharp -// code snippet -var converted = matchedQuery.Select(d => new global::EntityDto -{ - ChildNames = d.Child != null ? d.Child.Select(c => c.Name).ToList() : new List(), - ChildDtos = d.Child != null ? d.Child - .Select(c => new global::LinqraftGenerated_HASH1234.ChildDto - { - Name = c.Name, - Description = c.Description - }) : Enumerable.Empty(), - ExplicitNullableNames = d.Child != null ? d.Child.Select(c => c.Name).ToList() : null, -}); - -// generated DTO class -public partial class EntityDto -{ - public required List ChildNames { get; set; } - public required IEnumerable ChildDtos { get; set; } - public required List? ExplicitNullableNames { get; set; } -} -``` - -
- -This change helps avoid unnecessary null checks like `dto.ChildNames ?? []`, keeping the code simple. - -If you don't like this behavior, you can disable it by setting the `LinqraftArrayNullabilityRemoval` property to `false`. - -### Partial Classes -You can extend the generated DTO classes as needed since they are output as `partial` classes. - -```csharp -// extend generated DTO class if needed -public partial class OrderDto -{ - public string GetDisplayName() => $"{Id}: {CustomerName}"; -} -``` - -### Property Accessibility Control -If you want to make specific properties of the auto-generated DTO class `internal`, you can do so by predefining the properties as partial classes. - -```csharp -public partial class ParentDto -{ - // This property will not be generated, so you can control its accessibility - internal string InternalData { get; set; } -} - -var orders = await dbContext.Orders - .SelectExpr(o => new - { - Id = o.Id, - PublicComment = o.Comment, - InternalData = o.InternalField, - }) - .ToListAsync(); - -// Generated code will look like this: -public partial class ParentDto -{ - public required int Id { get; set; } - public required string PublicComment { get; set; } -} -``` - -### Remove Hash from Generated Class Names -By default, nested DTO classes are generated with a namespace containing a hash suffix (e.g., `LinqraftGenerated_HASH1234.ItemsDto`) to avoid class name conflicts. -You can change this behavior to use class names with a hash-suffixed (e.g., `ItemsDto_HASH1234`). -This setting can be controlled via the `LinqraftNestedDtoUseHashNamespace` property. - -
-Generated code example - -**LinqraftNestedDtoUseHashNamespace = true (default)** - -```csharp -namespace Tutorial -{ - public partial class OrderDto - { - public required System.Collections.Generic.List Items { get; set; } - } -} -namespace Tutorial.LinqraftGenerated_DE33EA40 -{ - public partial class ItemsDto - { - public required string? ProductName { get; set; } - } -} -``` - -**LinqraftNestedDtoUseHashNamespace = false** - -```csharp -namespace Tutorial -{ - public partial class OrderDto - { - public required System.Collections.Generic.List Items { get; set; } - } - - public partial class OrderItemDto_DE33EA40 - { - public required string? ProductName { get; set; } - } -} -``` - -
- -### Auto generated comments -Linqraft attempts to retrieve comments attached to the original properties as much as possible and attach them as XML documentation comments to the properties of the DTO class. -In addition, reference information indicating what kind of query the DTO class was generated from is also attached. -This feature can be controlled using the `LinqraftCommentOutput` property. - -
-Generated code example with comments +For more usage patterns and examples, see the [Usage Patterns Guide](./docs/library/usage-patterns.md). -```csharp -// based entity class with comments -public class Entity -{ - /// - /// XML summary comment - /// - [Key] - public int Id { get; set; } +## Documentation - [Comment("EFCore Comment")] - public int Item1 { get; set; } +### Getting Started - [Display(Name = "Display Comment")] - public int Item2 { get; set; } +* **[Installation](./docs/library/installation.md)** - Prerequisites, installation, and setup +* **[Usage Patterns](./docs/library/usage-patterns.md)** - Anonymous, Explicit DTO, and Pre-existing DTO patterns - public List Childs { get; set; } -} -public class ChildEntity -{ - // single-line comment are also supported - public int ChildId { get; set; } -} +### Customization -// and use SelectExpr -query.SelectExpr(e => new -{ - Id = e.Id, - Item1Value = e.Item1, - Item2Value = e.Item2, - ChildIds = e.Childs.Select(c => c.ChildId).ToList(), -}); -``` +* **[Local Variable Capture](./docs/library/local-variable-capture.md)** - Using local variables in SelectExpr +* **[Nested SelectExpr](./docs/library/nested-selectexpr.md) (beta)** - Using nested SelectExpr for reusable DTOs +* **[Array Nullability Removal](./docs/library/array-nullability.md)** - Automatic null handling for collections +* **[Partial Classes](./docs/library/partial-classes.md)** - Extending generated DTOs +* **[Nested DTO Naming](./docs/library/nested-dto-naming.md)** - Customizing nested DTO names +* **[Auto-Generated Comments](./docs/library/auto-generated-comments.md)** - XML documentation generation +* **[Global Properties](./docs/library/global-properties.md)** - MSBuild configuration options -generates the following DTO class: +### Performance & Comparison -```csharp -/// -/// based entity class with comments -/// -/// -/// From: Entity -/// -public partial class EntityDto -{ - /// - /// XML summary comment - /// - /// - /// From: Entity.Id - /// Attributes: [Key] - /// - public required int Id { get; set; } - - /// - /// EFCore Comment - /// - /// - /// From: Entity.Item1 - /// - public required int Item1Value { get; set; } - - /// - /// Display Comment - /// - /// - /// From: Entity.Item2 - /// - public required int Item2Value { get; set; } - - /// - /// single-line comment are also supported - /// - /// - /// From: Entity.Childs.Select(...).ToList() - /// - public required System.Collections.Generic.List ChildIds { get; set; } -} -``` -
+* **[Performance](./docs/library/performance.md)** - Benchmarks +* **[Library Comparison](./docs/library/library-comparison.md)** - Comparisons with AutoMapper, Mapster, Mapperly, and Facet +### Help -### Global Properties -Linqraft supports several MSBuild properties to customize the generated code: +* **[FAQ](./docs/library/faq.md)** - Common questions +* **[Analyzers](./docs/analyzers/README.md)** - Code analyzers and fixes -
-Available Properties - -```xml - - - - - - - false - - - Default - - true - - - All - - true - - - true - - -``` - -
- -## Performance - -
-Benchmark Results - -``` -BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7171/25H2/2025Update/HudsonValley2) -Intel Core i7-14700F 2.10GHz, 1 CPU, 28 logical and 20 physical cores -.NET SDK 10.0.100 - [Host] : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 - DefaultJob : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 - - -| Method | Mean | Error | StdDev | Ratio | RatioSD | Rank | Gen0 | Gen1 | Allocated | Alloc Ratio | -|------------------------------ |-----------:|---------:|---------:|------:|--------:|-----:|--------:|-------:|----------:|------------:| -| 'Mapperly Projection' | 877.9 us | 6.96 us | 6.51 us | 0.98 | 0.01 | 1 | 13.6719 | 1.9531 | 244.69 KB | 1.00 | -| 'Mapster ProjectToType' | 881.6 us | 6.13 us | 5.73 us | 0.98 | 0.01 | 1 | 13.6719 | 1.9531 | 236.59 KB | 0.96 | -| 'AutoMapper ProjectTo' | 887.2 us | 5.97 us | 5.59 us | 0.99 | 0.01 | 1 | 13.6719 | 1.9531 | 237.38 KB | 0.97 | -| 'Linqraft Manual DTO' | 893.5 us | 3.05 us | 2.70 us | 0.99 | 0.01 | 1 | 13.6719 | 1.9531 | 245.97 KB | 1.00 | -| 'Traditional Manual DTO' | 898.2 us | 5.92 us | 5.24 us | 1.00 | 0.01 | 1 | 13.6719 | 1.9531 | 245.63 KB | 1.00 | -| 'Linqraft Auto-Generated DTO' | 900.2 us | 7.49 us | 7.01 us | 1.00 | 0.01 | 1 | 13.6719 | 1.9531 | 245.78 KB | 1.00 | -| 'Linqraft Anonymous' | 971.7 us | 19.31 us | 20.67 us | 1.08 | 0.02 | 2 | 13.6719 | 1.9531 | 245.36 KB | 1.00 | -| 'Traditional Anonymous' | 984.4 us | 16.56 us | 19.08 us | 1.09 | 0.02 | 2 | 13.6719 | 1.9531 | 247.29 KB | 1.01 | -| 'Facet ToFacetsAsync' | 2,086.8 us | 9.59 us | 8.50 us | 2.32 | 0.02 | 3 | 31.2500 | 3.9063 | 541.53 KB | 2.20 | -``` - -
- -Compared to the manual approach, the performance is nearly identical. -for more details, see [Linqraft.Benchmark](./examples/Linqraft.Benchmark) for details. - -## Comparison with Other Libraries -Mapping is a common task, and many libraries exist. -Here, instead of comparing performance and pros and cons in detail, we will explain the main differences. - -
-Compared Libraries - -### [AutoMapper](https://automapper.io/) -* You need to predefine the destination DTO. -* Mapping rules are set up in advance using `MapperConfiguration`. - * Highly configurable, but not type-safe. -* For Select queries, use the `ProjectTo` method. -* **Paid license required** for commercial use from version 15 onward. - -### [Mapster](https://github.com/MapsterMapper/Mapster) -* You need to predefine the destination DTO. - * You can generate DTOs using `Mapster.Tool`, but it must be run as a separate process. -* For Select queries, use the `ProjectToType` method. -* Customizing the structure requires manual configuration one by one using `NewConfig.Map(...)`. - -### [Mapperly](https://mapperly.riok.app/) -* You need to predefine the destination DTO. -* The conversion process is auto-generated by a source generator and is easy to read. -* Customizing the conversion is possible, but you need to define your own methods, which adds some extra steps. - * Although it can also be specified with attributes, you need to [explicitly specify the properties](https://mapperly.riok.app/docs/configuration/flattening/) before and after conversion. - -### [Facet](https://github.com/Tim-Maes/Facet) -* Automatically generates DTOs from existing types. - * You can prepare multiple DTOs as needed. - * However, you must explicitly control what gets generated using `Include`/`Exclude` attributes. -* Nested objects can be retrieved without `.Include()` queries. - * However, according to the documentation, you need to specify them explicitly with `NestedFacets`. -* With EFCore extensions, update queries and more are also auto-generated. -* Overall, it's feature-rich but the configuration can be somewhat complex. - -### [Linqraft](https://arika0093.github.io/Linqraft/) -* Automatically generates DTOs based on query definitions. - * In contrast, Traditional generators generate queries from class definitions. - * This allows you to flexibly generate DTO structures that do not depend on the original class structure. - * With traditional solutions, the original class structure is the base, so complex customizations or computed fields require extra effort. -* Zero-dependency because it uses source generators and interceptors. - * However, it requires a relatively recent environment (C# 12.0 or later). -* On the other hand, since it's query-based, it's not suitable for generating shared DTOs referenced from multiple projects. - * For example, you can mitigate this by using Linqraft in the API layer and generating separate classes for shared components from the API's OpenAPI Schema. -* Reverse conversion from DTO to the original entity is not supported. - * This is an intentional trade-off for the flexibility mentioned above: reverse conversion of computed fields would be ambiguous. +## Examples -
+Example projects are available in the [examples](./examples) folder: -In summary (admittedly subjective!), it looks like this: - -| Library | DTO Definition | Generation | Customization | Reverse | License | -| ---------- | -------------- | ---------- | ------------- | ------- | ---------- | -| AutoMapper | Manual | From class | Config-based | Yes | Paid (15+) | -| Mapster | Manual | From class | Config-based | Yes | MIT | -| Mapperly | Manual | From class | Code/Attr | Yes | Apache 2.0 | -| Facet | Semi-auto | From class | Attributes | Yes | MIT | -| Linqraft | Auto | From query | Inline | No | Apache 2.0 | - -## Frequently Asked Questions -### Only works with Entity Framework? -No. It can be used with any LINQ provider that supports `IEnumerable` and/or `IQueryable`. - -### Can the generated DTOs be used elsewhere? -Yes. You can use the generated DTOs anywhere, such as API result models, Swagger documentation, function return types, arguments, variables, and more. -For example, see [here](./examples/Linqraft.ApiSample) for an example of using it in an API. - -### Can I access the generated code? -Yes. `Go to Definition (F12)` on the generated DTO class name will take you to the generated code. -Alternatively, you can also output the generated code to files by adding the following settings to your project. - -```xml - - - - true - - Generated - - -``` +* [Linqraft.Sample](./examples/Linqraft.Sample) - Basic usage examples (with EFCore) +* [Linqraft.MinimumSample](./examples/Linqraft.MinimumSample) - Minimal working example +* [Linqraft.ApiSample](./examples/Linqraft.ApiSample) - API integration example ## License This project is licensed under the Apache License 2.0. diff --git a/docs/library/array-nullability.md b/docs/library/array-nullability.md new file mode 100644 index 00000000..890e1d40 --- /dev/null +++ b/docs/library/array-nullability.md @@ -0,0 +1,78 @@ +# Array Nullability Removal + +For convenience, Linqraft automatically removes nullability from array-type properties in generated DTOs. + +## Why? + +With array types, there's rarely a need to distinguish between `null` and `[]` (empty array). Removing nullability simplifies your code by eliminating unnecessary null checks like `dto.Items ?? []`. + +## Rules + +Nullability is removed when **all** of the following conditions are met: + +1. The expression does **not** contain a ternary operator (`? :`) +2. The type is `IEnumerable` or derived (List, Array, etc.) +3. The expression uses null-conditional access (`?.`) +4. The expression contains a `Select` or `SelectMany` call + +## Example + +```csharp +query.SelectExpr(e => new +{ + // Nullability removed: List? → List + ChildNames = e.Child?.Select(c => c.Name).ToList(), + + // Nullability removed: IEnumerable? → IEnumerable + ChildDtos = e.Child?.Select(c => new { c.Name, c.Description }), + + // Nullability preserved (explicit ternary): List? + ExplicitNullableNames = e.Child != null + ? e.Child.Select(c => c.Name).ToList() + : null, +}); +``` + +## Generated Code + +```csharp +public partial class EntityDto +{ + // Non-nullable array property + public required List ChildNames { get; set; } + + // Non-nullable collection property + public required IEnumerable ChildDtos { get; set; } + + // Nullable (because of explicit ternary) + public required List? ExplicitNullableNames { get; set; } +} + +// Conversion with default empty collections +var converted = matchedQuery.Select(d => new EntityDto +{ + ChildNames = d.Child != null + ? d.Child.Select(c => c.Name).ToList() + : new List(), + + ChildDtos = d.Child != null + ? d.Child.Select(c => new ChildDto { Name = c.Name, Description = c.Description }) + : Enumerable.Empty(), + + ExplicitNullableNames = d.Child != null + ? d.Child.Select(c => c.Name).ToList() + : null, +}); +``` + +## Disabling This Behavior + +If you prefer to keep nullability on array types, set the global property to `false`: + +```xml + + false + +``` + +This change helps avoid unnecessary null checks like `dto.ChildNames ?? []`, keeping the code simple. diff --git a/docs/library/auto-generated-comments.md b/docs/library/auto-generated-comments.md new file mode 100644 index 00000000..44a1e510 --- /dev/null +++ b/docs/library/auto-generated-comments.md @@ -0,0 +1,113 @@ +# Auto-Generated Comments + +Linqraft can automatically generate XML documentation comments on generated DTOs by extracting comments from source properties. + +## Supported Comment Types + +Linqraft extracts comments from: + +1. **XML summary comments** (`/// `) +2. **EF Core Comment attributes** (`[Comment("...")]`) +3. **Display attributes** (`[Display(Name = "...")]`) +4. **Single-line comments** (`// ...`) + +## Example + +```csharp +// Source entity with comments +public class Order +{ + /// + /// Unique identifier for the order + /// + [Key] + public int Id { get; set; } + + [Comment("Customer who placed the order")] + public string CustomerName { get; set; } + + [Display(Name = "Total order amount")] + public decimal TotalAmount { get; set; } + + // Collection of items in this order + public List Items { get; set; } +} + +public class OrderItem +{ + // Product associated with this item + public Product Product { get; set; } +} + +// Use SelectExpr +query.SelectExpr(o => new +{ + o.Id, + o.CustomerName, + Amount = o.TotalAmount, + ProductNames = o.Items.Select(i => i.Product?.Name).ToList(), +}); +``` + +## Generated Code with Comments + +```csharp +/// +/// Source entity with comments +/// +/// +/// From: Order +/// +public partial class OrderDto +{ + /// + /// Unique identifier for the order + /// + /// + /// From: Order.Id + /// Attributes: [Key] + /// + public required int Id { get; set; } + + /// + /// Customer who placed the order + /// + /// + /// From: Order.CustomerName + /// + public required string CustomerName { get; set; } + + /// + /// Total order amount + /// + /// + /// From: Order.TotalAmount + /// + public required decimal Amount { get; set; } + + /// + /// Product associated with this item + /// + /// + /// From: Order.Items.Select(...).ToList() + /// + public required List ProductNames { get; set; } +} +``` + +## Configuration + +Control comment generation with the `LinqraftCommentOutput` property: + +```xml + + + All + + + SummaryOnly + + + None + +``` diff --git a/docs/library/faq.md b/docs/library/faq.md new file mode 100644 index 00000000..b281ab32 --- /dev/null +++ b/docs/library/faq.md @@ -0,0 +1,163 @@ +# Frequently Asked Questions + +Common questions and answers about Linqraft. + +## General Questions + +### Does Linqraft only work with Entity Framework? + +No. Linqraft works with any LINQ provider that supports `IEnumerable` and/or `IQueryable`. + +**Works with:** +* Entity Framework Core +* Entity Framework 6 +* LINQ to SQL +* In-memory collections (`List`, `Array`, etc.) +* Any custom LINQ provider + +**Examples:** + +```csharp +// Entity Framework Core +var orders = await dbContext.Orders + .SelectExpr(o => new { o.Id, o.CustomerName }) + .ToListAsync(); + +// In-memory collection +var orders = myList + .SelectExpr(o => new { o.Id, o.CustomerName }) + .ToList(); + +// LINQ to Objects +var filtered = items + .Where(x => x.IsActive) + .SelectExpr(x => new { x.Id, x.Name }) + .ToArray(); +``` + +### Can the generated DTOs be used elsewhere? + +**Yes**, generated DTOs are regular C# classes and can be used anywhere: + +* API response models +* Swagger/OpenAPI documentation +* Function return types +* Method parameters +* Variables +* Serialization/deserialization +* Unit tests + +**Example:** + +```csharp +// API controller +[HttpGet] +public async Task>> GetOrders() +{ + var orders = await dbContext.Orders + .SelectExpr(o => new { o.Id, o.CustomerName }) + .ToListAsync(); + + return Ok(orders); // OrderDto serialized to JSON +} + +// Service method +public class OrderService +{ + public List GetRecentOrders() + { + return dbContext.Orders + .Where(o => o.CreatedAt > DateTime.Now.AddDays(-7)) + .SelectExpr(o => new { /* ... */ }) + .ToList(); + } +} + +// Unit test +[Fact] +public void OrderDto_Should_Have_Correct_Properties() +{ + var dto = new OrderDto + { + Id = 1, + CustomerName = "Test Customer" + }; + + Assert.Equal(1, dto.Id); + Assert.Equal("Test Customer", dto.CustomerName); +} +``` + +For a complete API example, see [Linqraft.ApiSample](../../examples/Linqraft.ApiSample). + +### Can I access the generated code? + +**Yes**, there are two ways to view generated code: + +#### Method 1: Go to Definition (F12) + +Place your cursor on the generated DTO class name and press F12 (or "Go to Definition" in your IDE). + +```csharp +var orders = dbContext.Orders + .SelectExpr(o => new { /* ... */ }) + // ^^^^^^^^ + // F12 here + .ToList(); +``` + +Your IDE will navigate to the generated source code. + +#### Method 2: Output to Files + +Add these settings to your `.csproj` file: + +```xml + + + + true + + + Generated + + +``` + +After building, generated files will be in the `Generated/` folder: + +``` +YourProject/ +├── Generated/ +│ ├── Linqraft.SourceGenerator/ +│ │ ├── SelectExpr_HASH1.g.cs +│ │ ├── SelectExpr_HASH2.g.cs +│ │ └── ... +├── Program.cs +└── YourProject.csproj +``` + +## Getting Help + +### Where can I report bugs or request features? + +Please use the [GitHub issue tracker](https://github.com/arika0093/Linqraft/issues). + +Include: +* Your `SelectExpr` usage code +* Expected behavior +* Actual behavior +* Generated code (if relevant) +### Where can I find examples? + +* [Linqraft.Sample](../../examples/Linqraft.Sample) - Basic usage examples +* [Linqraft.ApiSample](../../examples/Linqraft.ApiSample) - API integration example +* [Linqraft.Benchmark](../../examples/Linqraft.Benchmark) - Performance benchmarks +* [Online Playground](https://arika0093.github.io/Linqraft/playground/) - Try it in your browser + +## Next Steps + +* [Installation](./installation.md) - Get started with Linqraft +* [Usage Patterns](./usage-patterns.md) - Learn different usage patterns +* [Customization](./customization.md) - Customize generated code +* [Performance](./performance.md) - Performance benchmarks and comparisons diff --git a/docs/library/global-properties.md b/docs/library/global-properties.md new file mode 100644 index 00000000..da95103e --- /dev/null +++ b/docs/library/global-properties.md @@ -0,0 +1,171 @@ +# Global Properties + +Linqraft supports several MSBuild properties to customize code generation globally. + +## Available Properties + +```xml + + + + + + + false + + + + Default + + + true + + + + All + + + true + + + + true + + + + + false + + +``` + +## Property Details + +### LinqraftGlobalNamespace + +Controls the namespace for DTOs when the source entity is in the global namespace. + +```xml + + + + +MyProject.Dtos +``` + +### LinqraftRecordGenerate + +Generate records instead of classes: + +```xml +true +``` + +```csharp +// Generated as record +public partial record OrderDto +{ + public required int Id { get; init; } + public required string CustomerName { get; init; } + public required decimal TotalAmount { get; init; } +} +``` + +### LinqraftPropertyAccessor + +Control property accessor patterns: + +* `Default`: `get; set;` for classes, `get; init;` for records +* `GetAndSet`: `get; set;` +* `GetAndInit`: `get; init;` +* `GetAndInternalSet`: `get; internal set;` + +```xml +GetAndInit +``` + +```csharp +public partial class OrderDto +{ + public required int Id { get; init; } + public required string CustomerName { get; init; } +} +``` + +### LinqraftHasRequired + +Control the `required` keyword on properties: + +```xml +false +``` + +```csharp +public partial class OrderDto +{ + public int Id { get; set; } // No 'required' keyword + public string CustomerName { get; set; } +} +``` + +### LinqraftCommentOutput + +See [Auto-Generated Comments](./auto-generated-comments.md) for details. + +### LinqraftArrayNullabilityRemoval + +See [Array Nullability Removal](./array-nullability.md) for details. + +### LinqraftNestedDtoUseHashNamespace + +See [Nested DTO Naming](./nested-dto-naming.md) for details. + +### LinqraftUsePrebuildExpression + +Pre-build and cache expression trees for IQueryable operations to improve performance: + +```xml +true +``` + +When enabled, expression trees are constructed at compile-time and cached as static fields, avoiding the overhead of repeated lambda-to-expression-tree conversion at runtime. + +**Important Notes:** +- Only applies to IQueryable operations (not IEnumerable) +- Only works with named, predefined, or explicit DTO types (not anonymous types) +- Not applied when there are capture variables in the lambda expression + +**Example:** + +```csharp +// Without pre-built expressions (default): +// Expression tree is created on every call +var results = dbContext.Orders + .AsQueryable() + .SelectExpr(o => new OrderDto { Id = o.Id, Name = o.Name }) + .ToList(); + +// With LinqraftUsePrebuildExpression=true: +// Expression tree is created once and cached +// Subsequent calls reuse the same expression tree +``` + +**Performance Benefit:** + +Eliminates the runtime overhead of converting lambda expressions to expression trees for IQueryable operations. The expression tree is built once at the field declaration and reused for all invocations. + +## Viewing Generated Code + +To inspect the generated code: + +1. **F12 (Go to Definition)** on the DTO class name +2. **Output to files** by adding these settings: + +```xml + + true + Generated + +``` + +Generated files will be written to the `Generated/` folder in your project. diff --git a/docs/library/installation.md b/docs/library/installation.md new file mode 100644 index 00000000..bcd5b9c7 --- /dev/null +++ b/docs/library/installation.md @@ -0,0 +1,61 @@ +# Installation + +This guide covers the installation and setup requirements for Linqraft. + +## Prerequisites + +Linqraft requires **C# 12.0 or later** because it uses the [interceptor](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-12#interceptors) feature. + +### .NET 7 or below setup + +If you're using .NET 7 or below, you'll need to enable C# 12 features manually: + +1. Set the `LangVersion` property to `12.0` or later +2. Use [PolySharp](https://github.com/Sergio0694/PolySharp/) to enable C# latest features + +```xml + + + 12.0 + + + + + +``` + +### SDK Requirements + +Due to the constraints of `Microsoft.CodeAnalysis.CSharp`, one of the following environment is [required](https://andrewlock.net/supporting-multiple-sdk-versions-in-analyzers-and-source-generators/): + +* .NET 8.0.400 or later **SDK** +* Visual Studio 2022 version 17.11 or later + +> [!NOTE] +> This is only a constraint on the SDK side, so the runtime (target framework) can be older versions. + +## Installing Linqraft + +Install `Linqraft` from NuGet using the following command: + +```bash +dotnet add package Linqraft +``` + +When you open your `.csproj` file, you should see the package added like below: + +```xml + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + +``` + +The `PrivateAssets` attribute might look unfamiliar, but it indicates that this is a development-only dependency. This means: +* The library will not be included in the production environment +* It only affects compile-time code generation +* Your deployed application has zero runtime dependencies from Linqraft + +## Next Steps + +* [Usage Patterns](./usage-patterns.md) - Learn how to use SelectExpr with different patterns diff --git a/docs/library/library-comparison.md b/docs/library/library-comparison.md new file mode 100644 index 00000000..40b466ca --- /dev/null +++ b/docs/library/library-comparison.md @@ -0,0 +1,195 @@ +# Library Comparison + +Mapping and projection are common tasks in .NET development. Here's how Linqraft compares to other popular libraries. + +## Summary Table + +| Library | DTO Definition | Generation | Customization | Reverse | License | GitHub | +| ---------- | -------------- | ----------- | ------------- | ------- | ---------- | ------- | +| AutoMapper | Manual | From class | Config-based | Yes | Paid (15+) | ![GitHub Repo stars](https://img.shields.io/github/stars/LuckyPennySoftware/AutoMapper?style=flat-square) | +| Mapster | Manual | From class | Config-based | Yes | MIT | ![GitHub Repo stars](https://img.shields.io/github/stars/MapsterMapper/Mapster?style=flat-square) | +| Mapperly | Manual | From class | Code/Attr | Yes | Apache 2.0 | ![GitHub Repo stars](https://img.shields.io/github/stars/riok/mapperly?style=flat-square) | +| Facet | Semi-auto | From class | Attributes | Yes | MIT | ![GitHub Repo stars](https://img.shields.io/github/stars/Tim-Maes/Facet?style=flat-square) | +| Linqraft | Auto | From query | Inline | No | Apache 2.0 | ![GitHub Repo stars](https://img.shields.io/github/stars/arika0093/Linqraft?style=flat-square) | + +## AutoMapper + +[AutoMapper](https://automapper.io/) is one of the most popular mapping libraries in the .NET ecosystem. + +**How it works:** +* Define DTOs manually +* Configure mapping rules using `MapperConfiguration` +* Use `ProjectTo()` for projections + +**Example:** +```csharp +// Define DTO manually +public class OrderDto +{ + public int Id { get; set; } + public string CustomerName { get; set; } +} + +// Configure mapping +var config = new MapperConfiguration(cfg => +{ + cfg.CreateMap() + .ForMember(dest => dest.CustomerName, opt => opt.MapFrom(src => src.Customer.Name)); +}); + +// Use ProjectTo +var orders = dbContext.Orders + .ProjectTo(config) + .ToList(); +``` + +**Pros:** +* Highly configurable +* Large community and ecosystem +* Supports complex mapping scenarios + +**Cons:** +* Configuration is not type-safe +* DTO must be pre-defined +* **Paid license required for commercial use from version 15 onward** +* Runtime dependency + +## Mapster + +[Mapster](https://github.com/MapsterMapper/Mapster) is a fast and flexible object mapping library. + +**How it works:** +* Define DTOs manually (or generate with `Mapster.Tool`) +* Configure mappings if needed +* Use `ProjectToType()` for projections + +**Example:** +```csharp +// Define DTO manually +public class OrderDto +{ + public int Id { get; set; } + public string CustomerName { get; set; } +} + +// Optional: Configure custom mapping +TypeAdapterConfig + .NewConfig() + .Map(dest => dest.CustomerName, src => src.Customer.Name); + +// Use ProjectToType +var orders = dbContext.Orders + .ProjectToType() + .ToList(); +``` + +**Pros:** +* Fast performance +* Can generate DTOs with `Mapster.Tool` (separate process) +* Flexible configuration + +**Cons:** +* DTO must be pre-defined (or generated separately) +* Manual configuration for complex structures +* Runtime dependency + +## Mapperly + +[Mapperly](https://mapperly.riok.app/) is a modern source generator-based mapping library. + +**How it works:** +* Define DTOs manually +* Create a mapper interface with `[Mapper]` attribute +* Mapperly generates the implementation at compile-time + +**Example:** +```csharp +// Define DTO manually +public class OrderDto +{ + public int Id { get; set; } + public string CustomerName { get; set; } +} + +// Define mapper interface +[Mapper] +public partial class OrderMapper +{ + // Must explicitly specify property mappings for flattening + [MapProperty(nameof(Order.Customer.Name), nameof(OrderDto.CustomerName))] + public partial IQueryable MapToDto(IQueryable orders); +} + +// Use mapper +var mapper = new OrderMapper(); +var orders = mapper.MapToDto(dbContext.Orders).ToList(); +``` + +**Pros:** +* Source generator-based (no runtime overhead) +* Generated code is easy to read +* Type-safe + +**Cons:** +* DTO must be pre-defined +* Customization requires defining methods or attributes +* [Property mappings must be explicitly specified](https://mapperly.riok.app/docs/configuration/flattening/) for flattening + +## Facet + +[Facet](https://github.com/Tim-Maes/Facet) is a feature-rich DTO generation library. + +**How it works:** +* Automatically generates DTOs from existing types +* Control generation with `Include`/`Exclude` attributes +* Provides EF Core extensions for CRUD operations + +**Example:** +```csharp +[Facet(typeof(OrderChild), exclude = "OrderChildId")] +public partial record OrderChildFacet; + +[Facet(typeof(Order), NestedFacets = [typeof(OrderChildDto)] +public partial record OrderFacet; + +// Use ToFacetsAsync +var orders = await dbContext.Orders.ToFacetsAsync(); +``` + +**Pros:** +* Multiple DTOs per entity +* EF Core extensions for CRUD +* Feature-rich + +**Cons:** +* Configuration can be complex +* Must explicitly control generation with attributes +* Nested objects require explicit `NestedFacets` attribute + +## Linqraft + +Linqraft takes a fundamentally different approach: + +**Query-based generation** instead of class-based: +```csharp +// Traditional approach: Define DTO, configuration, then query +public class OrderDto { /* properties */ } +var config = new MapperConfiguration( /* mapping rules */ ); +var orders = dbContext.Orders.ProjectTo(); + +// Linqraft approach: Define query first, DTO is generated +var orders = dbContext.Orders + .SelectExpr(o => new { /* define structure here */ }); +``` + +**Benefits:** +1. **Flexible structures**: Not constrained by entity structure +2. **Computed fields**: Easy to add calculated properties +3. **Inline customization**: No separate configuration needed +4. **Zero runtime dependencies**: All code generated at compile-time + +**Trade-offs:** +1. **No reverse mapping**: Can't convert DTO back to entity (by design) +2. **Not for shared DTOs**: Query-based generation isn't suitable for DTOs shared across projects + * Workaround: Use Linqraft in API layer, generate shared types from OpenAPI schema +3. **Requires C# 12+**: Due to interceptor feature diff --git a/docs/library/local-variable-capture.md b/docs/library/local-variable-capture.md new file mode 100644 index 00000000..ab2cce4b --- /dev/null +++ b/docs/library/local-variable-capture.md @@ -0,0 +1,66 @@ +# Local Variable Capture + +Local variables cannot be used directly inside `SelectExpr` because the selector is translated into a separate method. To use local variables, you must explicitly capture them. + +## Problem + +```csharp +var threshold = 100; +var converted = dbContext.Entities + .SelectExpr(x => new { + x.Id, + IsExpensive = x.Price > threshold, // ERROR: Cannot access local variable + }); +``` + +## Solution: Use Capture Parameter + +Pass local variables as an anonymous object through the `capture` parameter: + +```csharp +var threshold = 100; +var multiplier = 2; +var suffix = " units"; + +var converted = dbContext.Entities + .SelectExpr( + x => new { + x.Id, + IsExpensive = x.Price > threshold, + DoubledValue = x.Value * multiplier, + Description = x.Name + suffix, + }, + capture: new { threshold, multiplier, suffix } + ); +``` + +## Generated Code + +```csharp +public static IQueryable SelectExpr_HASH( + this IQueryable query, Func selector, object captureParam) +{ + var matchedQuery = query as object as IQueryable; + dynamic captureObj = captureParam; + int threshold = captureObj.threshold; + int multiplier = captureObj.multiplier; + string suffix = captureObj.suffix; + + var converted = matchedQuery.Select(x => new EntityDto + { + Id = x.Id, + IsExpensive = x.Price > threshold, + DoubledValue = x.Value * multiplier, + Description = x.Name + suffix, + }); + return converted as object as IQueryable; +} +``` + +## Analyzer Support + +Linqraft provides an analyzer that automatically detects uncaptured local variables and suggests a code fix: + +![Local variable capture error](../../assets/local-variable-capture-err.png) + +Simply apply the suggested fix to add the capture parameter automatically. diff --git a/docs/library/nested-dto-naming.md b/docs/library/nested-dto-naming.md new file mode 100644 index 00000000..df19f621 --- /dev/null +++ b/docs/library/nested-dto-naming.md @@ -0,0 +1,93 @@ +# Nested DTO Naming + +Nested DTOs (generated from anonymous types inside the selector) can have their naming strategy customized. + +## Hash Namespace (Default) + +By default, nested DTOs are placed in a hash-suffixed namespace to avoid naming conflicts: + +```csharp +namespace MyProject +{ + public partial class OrderDto + { + public required List Items { get; set; } + } +} + +namespace MyProject.LinqraftGenerated_DE33EA40 +{ + public partial class ItemsDto + { + public required string? ProductName { get; set; } + } +} +``` + +**Pros:** +* Clean class names +* Avoids namespace pollution +* Easy to find in namespace explorer + +**Cons:** +* Longer fully-qualified names +* More namespaces + +## Hash Class Name + +Set `LinqraftNestedDtoUseHashNamespace` to `false` to use hash-suffixed class names instead: + +```xml + + false + +``` + +Generated code: + +```csharp +namespace MyProject +{ + public partial class OrderDto + { + public required List Items { get; set; } + } + + public partial class ItemsDto_DE33EA40 + { + public required string? ProductName { get; set; } + } +} +``` + +**Pros:** +* All DTOs in the same namespace +* Shorter fully-qualified names + +**Cons:** +* Hash-suffixed class names +* More classes in the same namespace + +## How Nested DTO Names Are Generated + +Nested DTOs are named based on: + +1. The property name in the parent DTO +2. A hash suffix to avoid conflicts + +**Example:** +```csharp +.SelectExpr(o => new +{ + Items = o.OrderItems.Select(oi => new { oi.ProductName }) +}) + +// Generated: +// - LinqraftGenerated_HASH.ItemsDto +// or +// - ItemsDto_HASH (if LinqraftNestedDtoUseHashNamespace = false) +``` + +## Explicit Class Name + +See [Nested SelectExpr](./nested-selectexpr.md). diff --git a/docs/library/nested-selectexpr.md b/docs/library/nested-selectexpr.md new file mode 100644 index 00000000..6de4eda8 --- /dev/null +++ b/docs/library/nested-selectexpr.md @@ -0,0 +1,164 @@ +# Nested SelectExpr (Beta) + +You can use `SelectExpr` inside another `SelectExpr` to explicitly control the DTO class generation for nested collections. +This allows you to create reusable DTOs for nested entities instead of auto-generated DTOs in hash namespaces. + +## Important Notes + +### Beta Feature Warning + +This feature is currently in **beta** (available since v0.6.2). +If you encounter any issues, please report them on [GitHub Issues](https://github.com/arika0093/Linqraft/issues). + +### Empty Partial Class Declarations Required + +To ensure DTOs are generated in the correct location, you **must** declare empty partial class definitions for all explicit DTO types: + +```csharp +public class MyService +{ + public void GetOrders(IQueryable query) + { + var result = query + .SelectExpr(o => new + { + o.Id, + Items = o.OrderItems.SelectExpr(i => new + { + i.ProductName, + }), + }); + } + + // Empty partial class definitions - REQUIRED + internal partial class OrderDto; + internal partial class OrderItemDto; +} +``` + +**Why is this necessary?** + +This is necessary because when evaluating the internal expressions using the Roslyn API, if the target class is not pre-declared, it cannot correctly identify the generated type. +For example, if `OrderItemDto` is not pre-defined, the part that should be recognized as `IEnumerable` will be treated as `?(UnknownType)`, causing DTO generation to fail. +Additionally, if `OrderDto` is not defined, the generation location of `OrderItemDto` becomes unclear, leading to it being generated in the wrong place. +Therefore, it is essential to declare empty partial class definitions for **all** required DTOs. + +## Basic Usage + +```csharp +var result = query + .SelectExpr(o => new + { + o.Id, + o.CustomerName, + // Using SelectExpr - ItemDto is generated in your namespace + Items = o.OrderItems.SelectExpr(i => new + { + i.ProductName, + i.Quantity, + }), + }); + +// Required partial class declarations +internal partial class OrderDto; +internal partial class OrderItemDto; +``` + +**Generated DTOs:** +```csharp +namespace MyProject +{ + public partial class OrderDto + { + public required int Id { get; set; } + public required string CustomerName { get; set; } + public required IEnumerable Items { get; set; } + } + + // This DTO is directly accessible and reusable + public partial class OrderItemDto + { + public required string ProductName { get; set; } + public required int Quantity { get; set; } + } +} +``` + +## Multiple Nesting Levels + +You can nest `SelectExpr` calls multiple levels deep: + +```csharp +var result = query + .SelectExpr(x => new + { + x.Id, + x.Name, + Items = x.Items.SelectExpr(i => new + { + i.Id, + i.Title, + SubItems = i.SubItems.SelectExpr(si => new + { + si.Id, + si.Value, + }), + }), + }) + .ToList(); + +// Declare all DTO types +internal partial class EntityDto; +internal partial class ItemDto; +internal partial class SubItemDto; +``` + +## Mixing Select and SelectExpr + +You can mix regular `Select` and `SelectExpr` within the same query: + +```csharp +var result = query + .SelectExpr(x => new + { + x.Id, + // Reusable DTO - generated in your namespace + Items = x.Items.SelectExpr(i => new + { + i.Id, + // Auto-generated DTO in hash namespace + SubItems = i.SubItems.Select(si => new { si.Value }), + }), + }); + +internal partial class EntityDto; +internal partial class ItemDto; +// No need to declare SubItemDto - it's auto-generated +``` + +## When to Use Nested SelectExpr + +**Use nested `SelectExpr` when:** +* You need to reuse nested DTOs across multiple queries +* You want full control over nested DTO naming +* You need to extend nested DTOs with partial classes +* You want to reference nested DTOs in your API documentation + +**Use regular `Select` when:** +* The nested DTO is used only once +* You don't need to reference the nested DTO type +* You prefer simpler, less verbose code + +## Comparison + +| Feature | Regular Select | Nested SelectExpr | +|---------|---------------|-------------------| +| DTO Location | `LinqraftGenerated_HASH` namespace | Anywhere you want | +| Reusability | No | Yes | +| Declaration Required | No | Yes (empty partial class) | + +## See Also + +* [Usage Patterns](usage-patterns.md) - Overview of all SelectExpr patterns +* [Nested DTO Naming](nested-dto-naming.md) - Configure nested DTO naming strategy +* [Partial Classes](partial-classes.md) - Extend generated DTOs diff --git a/docs/library/partial-classes.md b/docs/library/partial-classes.md new file mode 100644 index 00000000..d2f5580e --- /dev/null +++ b/docs/library/partial-classes.md @@ -0,0 +1,97 @@ +# Partial Classes + +All generated DTOs are `partial` classes, allowing you to extend them as needed. + +## Adding Methods + +```csharp +// Generated by Linqraft +public partial class OrderDto +{ + public required int Id { get; set; } + public required string CustomerName { get; set; } + public required decimal TotalAmount { get; set; } + public required IEnumerable Items { get; set; } +} + +// Your extension +public partial class OrderDto +{ + public string GetDisplayName() => $"Order #{Id} - {CustomerName}"; + + public decimal GetAverageItemPrice() => + Items.Any() ? TotalAmount / Items.Count() : 0; + + public bool IsLargeOrder() => TotalAmount > 1000; +} +``` + +## Adding Interfaces + +```csharp +public partial class OrderDto : IValidatableObject +{ + public IEnumerable Validate(ValidationContext validationContext) + { + if (TotalAmount < 0) + yield return new ValidationResult("Total amount cannot be negative"); + + if (string.IsNullOrWhiteSpace(CustomerName)) + yield return new ValidationResult("Customer name is required"); + } +} +``` + +## Adding Attributes + +```csharp +[Serializable] +[JsonConverter(typeof(CustomOrderDtoConverter))] +public partial class OrderDto +{ + // Additional custom logic +} +``` + +## Property Accessibility Control + +You can control the accessibility of specific properties by pre-defining them in a partial class. + +### Example + +```csharp +// Pre-define properties you want to control +public partial class OrderDto +{ + // This property will not be generated + // You control its accessibility and implementation + internal string InternalData { get; set; } +} + +var orders = await dbContext.Orders + .SelectExpr(o => new + { + o.Id, + PublicComment = o.Comment, + InternalData = o.InternalField, // Will use the pre-defined property + }) + .ToListAsync(); +``` + +### Generated Code + +```csharp +// Generated by Linqraft +public partial class OrderDto +{ + public required int Id { get; set; } + public required string PublicComment { get; set; } + // InternalData is NOT generated here + // because it was pre-defined in your partial class +} +``` + +This technique is useful when: +* You need internal properties for testing +* You want to hide certain properties from public API +* You need custom setters or getters diff --git a/docs/library/performance.md b/docs/library/performance.md new file mode 100644 index 00000000..3e8f1e57 --- /dev/null +++ b/docs/library/performance.md @@ -0,0 +1,44 @@ +# Performance + +This guide covers Linqraft's performance characteristics. + +## Performance Benchmarks + +Linqraft's performance is nearly identical to manually-written projections. This is because: + +1. **No runtime overhead**: Code is generated at compile-time using Source Generators +2. **Native Expression Trees**: Generated code uses standard LINQ `Select` expressions +3. **Zero dependencies**: No mapping framework is loaded or executed at runtime + +### Benchmark Results + +The following benchmark compares Linqraft with popular mapping libraries and manual approaches: + +``` +BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7171/25H2/2025Update/HudsonValley2) +Intel Core i7-14700F 2.10GHz, 1 CPU, 28 logical and 20 physical cores +.NET SDK 10.0.100 + [Host] : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 + DefaultJob : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 +``` + +| Method | Mean | Error | StdDev | Ratio | RatioSD | Rank | Gen0 | Gen1 | Allocated | Alloc Ratio | +|------------------------------ |-----------:|---------:|---------:|------:|--------:|-----:|--------:|-------:|----------:|------------:| +| 'Linqraft Manual DTO' | 858.5 us | 5.51 us | 5.16 us | 0.99 | 0.01 | 1 | 13.6719 | 1.9531 | 232.23 KB | 1.00 | +| 'Linqraft Auto-Generated DTO' | 865.0 us | 5.77 us | 5.40 us | 1.00 | 0.01 | 1 | 13.6719 | 1.9531 | 232.38 KB | 1.00 | +| 'Mapperly Projection' | 869.1 us | 5.65 us | 5.28 us | 1.00 | 0.01 | 1 | 13.6719 | 1.9531 | 244.44 KB | 1.05 | +| 'Mapster ProjectToType' | 873.6 us | 4.78 us | 4.47 us | 1.01 | 0.01 | 1 | 13.6719 | 1.9531 | 236.59 KB | 1.02 | +| 'AutoMapper ProjectTo' | 884.0 us | 2.50 us | 2.34 us | 1.02 | 0.01 | 1 | 13.6719 | 1.9531 | 237.44 KB | 1.02 | +| 'Traditional Manual DTO' | 885.2 us | 6.69 us | 6.26 us | 1.02 | 0.01 | 1 | 13.6719 | 1.9531 | 245.61 KB | 1.06 | +| 'Traditional Anonymous' | 952.6 us | 7.22 us | 6.75 us | 1.10 | 0.01 | 2 | 13.6719 | 1.9531 | 247 KB | 1.06 | +| 'Linqraft Anonymous' | 955.3 us | 6.22 us | 5.82 us | 1.10 | 0.01 | 2 | 13.6719 | 1.9531 | 245.25 KB | 1.06 | +| 'Facet ToFacetsAsync' | 2,062.3 us | 12.25 us | 11.46 us | 2.38 | 0.02 | 3 | 31.2500 | 3.9063 | 541.55 KB | 2.33 | + +For detailed benchmark code, see [Linqraft.Benchmark](../../examples/Linqraft.Benchmark). +For comparisons with other mapping libraries, see the [Library Comparison](./library-comparison.md) guide. + +## Next Steps + +* [Installation](./installation.md) - Get started with Linqraft +* [Usage Patterns](./usage-patterns.md) - Learn different usage patterns +* [Library Comparison](./library-comparison.md) - Compare with other mapping libraries diff --git a/docs/library/usage-patterns.md b/docs/library/usage-patterns.md new file mode 100644 index 00000000..fb6f06d5 --- /dev/null +++ b/docs/library/usage-patterns.md @@ -0,0 +1,197 @@ +# Usage Patterns + +Linqraft provides three main usage patterns for different scenarios. This guide explains when and how to use each pattern. + +## Overview + +| Pattern | Return Type | Use Case | +|---------|-------------|----------| +| [Anonymous](#anonymous-pattern) | Anonymous type | Quick queries, one-off projections | +| [Explicit DTO](#explicit-dto-pattern) | Auto-generated DTO | Reusable DTOs, API responses, type-safe code | +| [Pre-existing DTO](#pre-existing-dto-pattern) | Your DTO class | Using existing DTOs, shared types | + +## Anonymous Pattern + +Use `SelectExpr` without generic type parameters to get an anonymous-type projection. + +### When to Use +* Quick data exploration +* One-off queries +* When you don't need a named type + +### Example + +```csharp +var orders = await dbContext.Orders + .SelectExpr(o => new + { + o.Id, + CustomerName = o.Customer?.Name, + TotalAmount = o.OrderItems.Sum(oi => oi.Quantity * oi.UnitPrice), + }) + .ToListAsync(); + +// Result type: IEnumerable +``` + +### Pros & Cons + +**Pros:** +* Simple and quick to write +* No type declaration needed +* Good for prototyping + +**Cons:** +* Cannot use as return type from methods +* Limited IntelliSense support +* Not suitable for API responses + +## Explicit DTO Pattern + +Specify the input entity type and output DTO type as generic parameters. Linqraft automatically generates the DTO class based on your selector. + +### When to Use +* API endpoints +* Shared data transfer objects +* When you need type-safe code +* When you need to reference the DTO type elsewhere + +### Example + +```csharp +var orders = await dbContext.Orders + // Order: input entity type + // OrderDto: output DTO type (auto-generated) + .SelectExpr(o => new + { + o.Id, + CustomerName = o.Customer?.Name, + CustomerCountry = o.Customer?.Address?.Country?.Name, + Items = o.OrderItems.Select(oi => new + { + ProductName = oi.Product?.Name, + oi.Quantity + }), + }) + .ToListAsync(); + +// Result type: List +``` + +### Generated Code + +Linqraft generates a partial class for `OrderDto`: + +```csharp +public partial class OrderDto +{ + public required int Id { get; set; } + public required string? CustomerName { get; set; } + public required string? CustomerCountry { get; set; } + public required IEnumerable Items { get; set; } +} + +// Nested DTOs are also generated automatically +namespace LinqraftGenerated_HASH +{ + public partial class ItemsDto + { + public required string? ProductName { get; set; } + public required int Quantity { get; set; } + } +} +``` + +### Working with IEnumerable + +You can also use `SelectExpr` with `IEnumerable` for in-memory collections: + +```csharp +var orders = myList + .SelectExpr(o => new + { + o.Id, + CustomerName = o.Customer?.Name, + }) + .ToList(); + +// Works with any IEnumerable, not just IQueryable +``` + +### Extending Generated DTOs + +Since generated DTOs are `partial` classes, you can extend them: + +```csharp +// Add custom methods to the generated DTO +public partial class OrderDto +{ + public string GetDisplayName() => $"{Id}: {CustomerName}"; + + public decimal GetAverageItemPrice() => + Items.Any() ? TotalAmount / Items.Count() : 0; +} +``` + +### Pros & Cons + +**Pros:** +* Type-safe and reusable +* Full IntelliSense support +* Perfect for API responses +* Can extend with partial classes +* Nested DTOs generated automatically + +**Cons:** +* Slightly more verbose than anonymous +* DTO names must be unique in the namespace + +## Pre-existing DTO Pattern + +Use your own pre-defined DTO classes with `SelectExpr`. This pattern doesn't generate DTOs but still provides null-propagation support. + +### When to Use +* When you already have DTO classes +* When DTOs are shared across projects +* When you need specific attributes or interfaces on DTOs +* When DTO structure is defined by external requirements (e.g., API contracts) + +### Example + +```csharp +// Your existing DTO class +public class OrderDto +{ + [JsonPropertyName("order_id")] + public int Id { get; set; } + + [Required] + public string CustomerName { get; set; } + + public decimal TotalAmount { get; set; } +} + +// Use SelectExpr with your DTO +var orders = await dbContext.Orders + .SelectExpr(o => new OrderDto + { + Id = o.Id, + CustomerName = o.Customer?.Name, // null-propagation still works + TotalAmount = o.OrderItems.Sum(oi => oi.Quantity * oi.UnitPrice), + }) + .ToListAsync(); + +// Result type: List +``` + +### Pros & Cons + +**Pros:** +* Full control over DTO structure +* Can add attributes and interfaces +* Can share DTOs across projects +* Still benefits from null-propagation + +**Cons:** +* Must manually maintain DTO classes +* Changes to query require updating the DTO diff --git a/examples/Directory.Build.props b/examples/Directory.Build.props index e50e694a..9ce092d1 100644 --- a/examples/Directory.Build.props +++ b/examples/Directory.Build.props @@ -27,5 +27,6 @@ + diff --git a/examples/Linqraft.Benchmark/AutoMapperConfig.cs b/examples/Linqraft.Benchmark/AutoMapperConfig.cs index b61c7d88..cd470c5c 100644 --- a/examples/Linqraft.Benchmark/AutoMapperConfig.cs +++ b/examples/Linqraft.Benchmark/AutoMapperConfig.cs @@ -13,17 +13,45 @@ public AutoMapperProfile() { // Map SampleClass to ManualSampleClassDto CreateMap() - .ForMember(dest => dest.Child2Id, opt => opt.MapFrom(src => src.Child2 != null ? src.Child2.Id : (int?)null)) - .ForMember(dest => dest.Child2Quux, opt => opt.MapFrom(src => src.Child2 != null ? src.Child2.Quux : null)) + .ForMember( + dest => dest.Child2Id, + opt => opt.MapFrom(src => src.Child2 != null ? src.Child2.Id : (int?)null) + ) + .ForMember( + dest => dest.Child2Quux, + opt => opt.MapFrom(src => src.Child2 != null ? src.Child2.Quux : null) + ) .ForMember(dest => dest.Child3Id, opt => opt.MapFrom(src => src.Child3.Id)) .ForMember(dest => dest.Child3Corge, opt => opt.MapFrom(src => src.Child3.Corge)) - .ForMember(dest => dest.Child3ChildId, opt => opt.MapFrom(src => src.Child3 != null && src.Child3.Child != null ? src.Child3.Child.Id : (int?)null)) - .ForMember(dest => dest.Child3ChildGrault, opt => opt.MapFrom(src => src.Child3 != null && src.Child3.Child != null ? src.Child3.Child.Grault : null)); + .ForMember( + dest => dest.Child3ChildId, + opt => + opt.MapFrom(src => + src.Child3 != null && src.Child3.Child != null + ? src.Child3.Child.Id + : (int?)null + ) + ) + .ForMember( + dest => dest.Child3ChildGrault, + opt => + opt.MapFrom(src => + src.Child3 != null && src.Child3.Child != null + ? src.Child3.Child.Grault + : null + ) + ); // Map SampleChildClass to ManualSampleChildDto CreateMap() - .ForMember(dest => dest.ChildId, opt => opt.MapFrom(src => src.Child != null ? src.Child.Id : (int?)null)) - .ForMember(dest => dest.ChildQux, opt => opt.MapFrom(src => src.Child != null ? src.Child.Qux : null)); + .ForMember( + dest => dest.ChildId, + opt => opt.MapFrom(src => src.Child != null ? src.Child.Id : (int?)null) + ) + .ForMember( + dest => dest.ChildQux, + opt => opt.MapFrom(src => src.Child != null ? src.Child.Qux : null) + ); } } diff --git a/examples/Linqraft.Benchmark/FacetDtos.cs b/examples/Linqraft.Benchmark/FacetDtos.cs index 98c8337e..6dce12fb 100644 --- a/examples/Linqraft.Benchmark/FacetDtos.cs +++ b/examples/Linqraft.Benchmark/FacetDtos.cs @@ -5,39 +5,40 @@ namespace Linqraft.Benchmark; /// /// Facet-generated DTO for SampleChildChildClass (grandchild). /// -[Facet(typeof(SampleChildChildClass), - exclude: ["SampleChildClassId", "SampleChildClass"])] +[Facet(typeof(SampleChildChildClass), exclude: ["SampleChildClassId", "SampleChildClass"])] public partial record FacetSampleChildChildDto; /// /// Facet-generated DTO for SampleChildClass. /// Maps the child entity with nested grandchild. /// -[Facet(typeof(SampleChildClass), +[Facet( + typeof(SampleChildClass), exclude: ["SampleClassId", "SampleClass"], - NestedFacets = [typeof(FacetSampleChildChildDto)])] + NestedFacets = [typeof(FacetSampleChildChildDto)] +)] public partial record FacetSampleChildDto; /// /// Facet-generated DTO for SampleChildClass2 (optional second child). /// -[Facet(typeof(SampleChildClass2), - exclude: ["SampleClassId", "SampleClass"])] +[Facet(typeof(SampleChildClass2), exclude: ["SampleClassId", "SampleClass"])] public partial record FacetSampleChildClass2Dto; /// /// Facet-generated DTO for SampleChildChildClass2 (grandchild of Child3). /// -[Facet(typeof(SampleChildChildClass2), - exclude: ["SampleChildClass3Id", "SampleChildClass3"])] +[Facet(typeof(SampleChildChildClass2), exclude: ["SampleChildClass3Id", "SampleChildClass3"])] public partial record FacetSampleChildChildClass2Dto; /// /// Facet-generated DTO for SampleChildClass3 (third child). /// -[Facet(typeof(SampleChildClass3), +[Facet( + typeof(SampleChildClass3), exclude: ["SampleClassId", "SampleClass"], - NestedFacets = [typeof(FacetSampleChildChildClass2Dto)])] + NestedFacets = [typeof(FacetSampleChildChildClass2Dto)] +)] public partial record FacetSampleChildClass3Dto; /// @@ -45,10 +46,12 @@ public partial record FacetSampleChildClass3Dto; /// The Facet source generator creates the mapping logic at compile time. /// Uses standard Facet NestedFacets for nested object mapping. /// -[Facet(typeof(SampleClass), +[Facet( + typeof(SampleClass), NestedFacets = [ typeof(FacetSampleChildDto), typeof(FacetSampleChildClass2Dto), - typeof(FacetSampleChildClass3Dto) - ])] + typeof(FacetSampleChildClass3Dto), + ] +)] public partial record FacetSampleClassDto; diff --git a/examples/Linqraft.Benchmark/Linqraft.Benchmark.csproj b/examples/Linqraft.Benchmark/Linqraft.Benchmark.csproj index 4528a927..a16fe126 100644 --- a/examples/Linqraft.Benchmark/Linqraft.Benchmark.csproj +++ b/examples/Linqraft.Benchmark/Linqraft.Benchmark.csproj @@ -4,13 +4,16 @@ enable enable $(NoWarn);NU1608 + true + true + .generated - + diff --git a/examples/Linqraft.Benchmark/MapperlyMapper.cs b/examples/Linqraft.Benchmark/MapperlyMapper.cs index 0be54917..9db830a6 100644 --- a/examples/Linqraft.Benchmark/MapperlyMapper.cs +++ b/examples/Linqraft.Benchmark/MapperlyMapper.cs @@ -13,18 +13,46 @@ public static partial class MapperlyMapper /// Projects IQueryable of SampleClass to ManualSampleClassDto. /// Mapperly generates an expression tree for efficient database projection. /// - public static partial IQueryable ProjectToDto(this IQueryable query); + public static partial IQueryable ProjectToDto( + this IQueryable query + ); /// /// Maps a single SampleClass to ManualSampleClassDto. /// Used internally by the IQueryable projection. /// - [MapProperty(nameof(SampleClass.Child2) + "." + nameof(SampleChildClass2.Id), nameof(ManualSampleClassDto.Child2Id))] - [MapProperty(nameof(SampleClass.Child2) + "." + nameof(SampleChildClass2.Quux), nameof(ManualSampleClassDto.Child2Quux))] - [MapProperty(nameof(SampleClass.Child3) + "." + nameof(SampleChildClass3.Id), nameof(ManualSampleClassDto.Child3Id))] - [MapProperty(nameof(SampleClass.Child3) + "." + nameof(SampleChildClass3.Corge), nameof(ManualSampleClassDto.Child3Corge))] - [MapProperty(nameof(SampleClass.Child3) + "." + nameof(SampleChildClass3.Child) + "." + nameof(SampleChildChildClass2.Id), nameof(ManualSampleClassDto.Child3ChildId))] - [MapProperty(nameof(SampleClass.Child3) + "." + nameof(SampleChildClass3.Child) + "." + nameof(SampleChildChildClass2.Grault), nameof(ManualSampleClassDto.Child3ChildGrault))] + [MapProperty( + nameof(SampleClass.Child2) + "." + nameof(SampleChildClass2.Id), + nameof(ManualSampleClassDto.Child2Id) + )] + [MapProperty( + nameof(SampleClass.Child2) + "." + nameof(SampleChildClass2.Quux), + nameof(ManualSampleClassDto.Child2Quux) + )] + [MapProperty( + nameof(SampleClass.Child3) + "." + nameof(SampleChildClass3.Id), + nameof(ManualSampleClassDto.Child3Id) + )] + [MapProperty( + nameof(SampleClass.Child3) + "." + nameof(SampleChildClass3.Corge), + nameof(ManualSampleClassDto.Child3Corge) + )] + [MapProperty( + nameof(SampleClass.Child3) + + "." + + nameof(SampleChildClass3.Child) + + "." + + nameof(SampleChildChildClass2.Id), + nameof(ManualSampleClassDto.Child3ChildId) + )] + [MapProperty( + nameof(SampleClass.Child3) + + "." + + nameof(SampleChildClass3.Child) + + "." + + nameof(SampleChildChildClass2.Grault), + nameof(ManualSampleClassDto.Child3ChildGrault) + )] private static partial ManualSampleClassDto MapSampleClass(SampleClass source); /// @@ -32,7 +60,13 @@ public static partial class MapperlyMapper /// [MapperIgnoreSource(nameof(SampleChildClass.SampleClassId))] [MapperIgnoreSource(nameof(SampleChildClass.SampleClass))] - [MapProperty(nameof(SampleChildClass.Child) + "." + nameof(SampleChildChildClass.Id), nameof(ManualSampleChildDto.ChildId))] - [MapProperty(nameof(SampleChildClass.Child) + "." + nameof(SampleChildChildClass.Qux), nameof(ManualSampleChildDto.ChildQux))] + [MapProperty( + nameof(SampleChildClass.Child) + "." + nameof(SampleChildChildClass.Id), + nameof(ManualSampleChildDto.ChildId) + )] + [MapProperty( + nameof(SampleChildClass.Child) + "." + nameof(SampleChildChildClass.Qux), + nameof(ManualSampleChildDto.ChildQux) + )] private static partial ManualSampleChildDto MapSampleChild(SampleChildClass source); } diff --git a/examples/Linqraft.Benchmark/MapsterConfig.cs b/examples/Linqraft.Benchmark/MapsterConfig.cs index 93c6f81c..7ce0c49a 100644 --- a/examples/Linqraft.Benchmark/MapsterConfig.cs +++ b/examples/Linqraft.Benchmark/MapsterConfig.cs @@ -16,19 +16,32 @@ public static class MapsterConfig /// public static void Configure() { - if (_configured) return; + if (_configured) + return; // Configure SampleClass to ManualSampleClassDto mapping - TypeAdapterConfig.NewConfig() + TypeAdapterConfig + .NewConfig() .Map(dest => dest.Child2Id, src => src.Child2 != null ? src.Child2.Id : (int?)null) .Map(dest => dest.Child2Quux, src => src.Child2 != null ? src.Child2.Quux : null) .Map(dest => dest.Child3Id, src => src.Child3.Id) .Map(dest => dest.Child3Corge, src => src.Child3.Corge) - .Map(dest => dest.Child3ChildId, src => src.Child3 != null && src.Child3.Child != null ? src.Child3.Child.Id : (int?)null) - .Map(dest => dest.Child3ChildGrault, src => src.Child3 != null && src.Child3.Child != null ? src.Child3.Child.Grault : null); + .Map( + dest => dest.Child3ChildId, + src => + src.Child3 != null && src.Child3.Child != null + ? src.Child3.Child.Id + : (int?)null + ) + .Map( + dest => dest.Child3ChildGrault, + src => + src.Child3 != null && src.Child3.Child != null ? src.Child3.Child.Grault : null + ); // Configure SampleChildClass to ManualSampleChildDto mapping - TypeAdapterConfig.NewConfig() + TypeAdapterConfig + .NewConfig() .Map(dest => dest.ChildId, src => src.Child != null ? src.Child.Id : (int?)null) .Map(dest => dest.ChildQux, src => src.Child != null ? src.Child.Qux : null); diff --git a/examples/Linqraft.Benchmark/SelectBenchmark.cs b/examples/Linqraft.Benchmark/SelectBenchmark.cs index 378e96a3..4c734c95 100644 --- a/examples/Linqraft.Benchmark/SelectBenchmark.cs +++ b/examples/Linqraft.Benchmark/SelectBenchmark.cs @@ -253,9 +253,7 @@ public async Task AutoMapper_ProjectTo() [Benchmark(Description = "Mapperly Projection")] public async Task Mapperly_Projection() { - var results = await _dbContext - .SampleClasses.ProjectToDto() - .ToListAsync(); + var results = await _dbContext.SampleClasses.ProjectToDto().ToListAsync(); return results.Count; } @@ -279,8 +277,10 @@ public async Task Mapster_ProjectToType() [Benchmark(Description = "Facet ToFacetsAsync")] public async Task Facet_ToFacetsAsync() { - var results = await _dbContext - .SampleClasses.ToFacetsAsync(); + var results = await _dbContext.SampleClasses.ToFacetsAsync< + SampleClass, + FacetSampleClassDto + >(); return results.Count; } } diff --git a/playground/Components/Home/CodeBlock.razor b/playground/Components/Home/CodeBlock.razor index 66d51a6e..7a8217e7 100644 --- a/playground/Components/Home/CodeBlock.razor +++ b/playground/Components/Home/CodeBlock.razor @@ -7,7 +7,7 @@ ● @Title } -
+
@((MarkupString)HighlightedCode)
diff --git a/playground/Components/Home/IntroductionSection.razor b/playground/Components/Home/IntroductionSection.razor new file mode 100644 index 00000000..ce644c33 --- /dev/null +++ b/playground/Components/Home/IntroductionSection.razor @@ -0,0 +1,505 @@ +@implements IAsyncDisposable + +
+
+ Query-Based DTO Generation +

+ Unlike traditional generators that create queries from class definitions,
+ Linqraft generates DTOs from your queries. Watch how it works: +

+ + +
+
+ @for (int i = 1; i < steps.Length; i++) + { + var stepIndex = i; + // Tab is green if: step is completed (stepIndex <= currentStep) + var isCompleted = stepIndex <= currentStep; + // Tab is cyan if: this is the target step AND we're currently typing + var isCurrent = stepIndex == targetStep && isTyping; + // Tab is gray if: step is not completed and not current + var isPending = stepIndex > currentStep && stepIndex != targetStep; + +
+ + + @if (isCurrent && isTyping) + { + +
+
+
+ } +
+ } +
+
+ +
+ +
+
+

Your Query

+
+
+ + @if (isTyping) + { +
+ +
+ } +
+
+ + +
+
+
+ +

Generated Code

+
+ +
+ + +
+
+
+ +
+
+
+
+
+ +@code { + private int currentStep = 0; + private int targetStep = 0; + private int activeTab = 0; + private bool isTyping = false; + private bool isGeneratedUpdating = false; + private string displayedCode = ""; + private string previousCode = ""; + private string currentBlockText = ""; + private int currentCharIndex = 0; + private string highlightedSection = ""; + private System.Timers.Timer? typewriterTimer; + private CancellationTokenSource? _cts; + + // Step definitions based on README comments + private readonly CodeStep[] steps = [ + new CodeStep + { + Description = "Initial setup - empty structure", + CodeToAdd = "", + BaseCode = @"var orders = await dbContext.Orders + .SelectExpr(o => new + { + }) + .ToListAsync();", + DtoCode = @"// DTO class will be auto-generated", + SelectCode = @"// Select expression will be auto-generated" + }, + new CodeStep + { + Description = "can use inferred member names", + CodeToAdd = @" o.Id,", + BaseCode = "", + DtoCode = @"public partial class OrderDto +{ + public required int Id { get; set; } +}", + SelectCode = @".Select(o => new OrderDto +{ + Id = o.Id, +})" + }, + new CodeStep + { + Description = "null-propagation supported", + CodeToAdd = @" CustomerName = o.Customer?.Name,", + BaseCode = "", + DtoCode = @"public partial class OrderDto +{ + public required int Id { get; set; } + public required string? CustomerName { get; set; } +}", + SelectCode = @".Select(o => new OrderDto +{ + Id = o.Id, + CustomerName = o.Customer != null + ? o.Customer.Name : null, +})" + }, + new CodeStep + { + Description = "also works for nested objects", + CodeToAdd = @" CustomerCountry = o.Customer?.Address?.Country?.Name, + CustomerCity = o.Customer?.Address?.City?.Name,", + BaseCode = "", + DtoCode = @"public partial class OrderDto +{ + public required int Id { get; set; } + public required string? CustomerName { get; set; } + public required string? CustomerCountry { get; set; } + public required string? CustomerCity { get; set; } +}", + SelectCode = @".Select(o => new OrderDto +{ + Id = o.Id, + CustomerName = o.Customer != null + ? o.Customer.Name : null, + CustomerCountry = o.Customer != null + && o.Customer.Address != null + && o.Customer.Address.Country != null + ? o.Customer.Address.Country.Name : null, + CustomerCity = o.Customer != null + && o.Customer.Address != null + && o.Customer.Address.City != null + ? o.Customer.Address.City.Name : null, +})" + }, + new CodeStep + { + Description = "you can use anonymous types inside", + CodeToAdd = @" CustomerInfo = new + { + Email = o.Customer?.EmailAddress, + Phone = o.Customer?.PhoneNumber, + },", + BaseCode = "", + DtoCode = @"public partial class OrderDto +{ + public required int Id { get; set; } + public required string? CustomerName { get; set; } + public required string? CustomerCountry { get; set; } + public required string? CustomerCity { get; set; } + public required CustomerInfoDto? CustomerInfo { get; set; } +} + +public partial class CustomerInfoDto +{ + public required string? Email { get; set; } + public required string? Phone { get; set; } +}", + SelectCode = @".Select(o => new OrderDto +{ + Id = o.Id, + CustomerName = o.Customer != null + ? o.Customer.Name : null, + CustomerCountry = o.Customer != null + && o.Customer.Address != null + && o.Customer.Address.Country != null + ? o.Customer.Address.Country.Name : null, + CustomerCity = o.Customer != null + && o.Customer.Address != null + && o.Customer.Address.City != null + ? o.Customer.Address.City.Name : null, + CustomerInfo = new CustomerInfoDto + { + Email = o.Customer != null + ? o.Customer.EmailAddress : null, + Phone = o.Customer != null + ? o.Customer.PhoneNumber : null, + } +})" + }, + new CodeStep + { + Description = "calculated fields? no problem!", + CodeToAdd = @" LatestOrderDate = o.OrderItems.Max(oi => oi.OrderDate), + TotalAmount = o.OrderItems.Sum(oi => oi.Quantity * oi.UnitPrice),", + BaseCode = "", + DtoCode = @"public partial class OrderDto +{ + public required int Id { get; set; } + public required string? CustomerName { get; set; } + public required string? CustomerCountry { get; set; } + public required string? CustomerCity { get; set; } + public required CustomerInfoDto? CustomerInfo { get; set; } + public required DateTime LatestOrderDate { get; set; } + public required decimal TotalAmount { get; set; } +} + +public partial class CustomerInfoDto +{ + public required string? Email { get; set; } + public required string? Phone { get; set; } +}", + SelectCode = @".Select(o => new OrderDto +{ + Id = o.Id, + CustomerName = o.Customer != null + ? o.Customer.Name : null, + CustomerCountry = o.Customer != null + && o.Customer.Address != null + && o.Customer.Address.Country != null + ? o.Customer.Address.Country.Name : null, + CustomerCity = o.Customer != null + && o.Customer.Address != null + && o.Customer.Address.City != null + ? o.Customer.Address.City.Name : null, + CustomerInfo = new CustomerInfoDto + { + Email = o.Customer != null + ? o.Customer.EmailAddress : null, + Phone = o.Customer != null + ? o.Customer.PhoneNumber : null, + }, + LatestOrderDate = o.OrderItems.Max(oi => oi.OrderDate), + TotalAmount = o.OrderItems.Sum(oi => oi.Quantity * oi.UnitPrice), +})" + }, + new CodeStep + { + Description = "collections available", + CodeToAdd = @" Items = o.OrderItems.Select(oi => new + { + ProductName = oi.Product?.Name, + oi.Quantity + }),", + BaseCode = "", + DtoCode = @"public partial class OrderDto +{ + public required int Id { get; set; } + public required string? CustomerName { get; set; } + public required string? CustomerCountry { get; set; } + public required string? CustomerCity { get; set; } + public required CustomerInfoDto? CustomerInfo { get; set; } + public required DateTime LatestOrderDate { get; set; } + public required decimal TotalAmount { get; set; } + public required IEnumerable Items { get; set; } +} + +public partial class CustomerInfoDto +{ + public required string? Email { get; set; } + public required string? Phone { get; set; } +} + +public partial class ItemsDto +{ + public required string? ProductName { get; set; } + public required int Quantity { get; set; } +}", + SelectCode = @".Select(o => new OrderDto +{ + Id = o.Id, + CustomerName = o.Customer != null + ? o.Customer.Name : null, + CustomerCountry = o.Customer != null + && o.Customer.Address != null + && o.Customer.Address.Country != null + ? o.Customer.Address.Country.Name : null, + CustomerCity = o.Customer != null + && o.Customer.Address != null + && o.Customer.Address.City != null + ? o.Customer.Address.City.Name : null, + CustomerInfo = new CustomerInfoDto + { + Email = o.Customer != null + ? o.Customer.EmailAddress : null, + Phone = o.Customer != null + ? o.Customer.PhoneNumber : null, + }, + LatestOrderDate = o.OrderItems.Max(oi => oi.OrderDate), + TotalAmount = o.OrderItems.Sum(oi => oi.Quantity * oi.UnitPrice), + Items = o.OrderItems.Select(oi => new ItemsDto + { + ProductName = oi.Product != null + ? oi.Product.Name : null, + Quantity = oi.Quantity + }) +})" + } + ]; + + protected override async Task OnInitializedAsync() + { + _cts = new CancellationTokenSource(); + + // Initialize with Step 1 (showing o.Id) + currentStep = 1; + targetStep = 1; + BuildCodeUpToStep(1); + } + + private void BuildCodeUpToStep(int targetStep) + { + displayedCode = steps[0].BaseCode; + for (int i = 1; i <= targetStep && i < steps.Length; i++) + { + string codeToAdd = steps[i].CodeToAdd; + int insertPosition = displayedCode.LastIndexOf(" })"); + if (insertPosition > 0) + { + displayedCode = displayedCode.Insert(insertPosition, codeToAdd + "\n"); + } + } + } + + private async Task StartTypewriterForStep(int newTargetStep) + { + if (_cts?.Token.IsCancellationRequested == true) return; + + // Stop any existing animation + typewriterTimer?.Stop(); + + // Set target step immediately (this makes the tab turn cyan) + targetStep = newTargetStep; + + // Save previous code for highlighting + previousCode = displayedCode; + + // Determine what needs to be added + if (newTargetStep > currentStep) + { + // Adding new code blocks + isTyping = true; + currentCharIndex = 0; + + // Build the text to add (all blocks between currentStep and targetStep) + currentBlockText = ""; + for (int i = currentStep + 1; i <= newTargetStep && i < steps.Length; i++) + { + currentBlockText += steps[i].CodeToAdd + "\n"; + } + + highlightedSection = currentBlockText; + await InvokeAsync(StateHasChanged); + + // Start typewriter animation + typewriterTimer = new System.Timers.Timer(8); // 8ms per character (faster) + typewriterTimer.Elapsed += async (sender, e) => + { + try + { + await TypeNextCharacter(); + } + catch (ObjectDisposedException) { } + }; + typewriterTimer.AutoReset = true; + typewriterTimer.Start(); + } + else if (newTargetStep < currentStep) + { + // Removing code blocks - instant update + BuildCodeUpToStep(newTargetStep); + currentStep = newTargetStep; + targetStep = newTargetStep; + highlightedSection = ""; + await InvokeAsync(StateHasChanged); + } + } + + private async Task TypeNextCharacter() + { + if (_cts?.Token.IsCancellationRequested == true) return; + + if (currentCharIndex >= currentBlockText.Length) + { + // Typing complete + typewriterTimer?.Stop(); + isTyping = false; + + // Update currentStep immediately (this makes tabs turn green and updates right side) + currentStep = targetStep; + await InvokeAsync(StateHasChanged); + + // Wait a moment before clearing highlight + await Task.Delay(1000); + highlightedSection = ""; + await InvokeAsync(StateHasChanged); + return; + } + + // Add next character + char nextChar = currentBlockText[currentCharIndex]; + currentCharIndex++; + + // Insert the character before the closing braces + int insertPosition = displayedCode.LastIndexOf(" })"); + if (insertPosition > 0) + { + displayedCode = displayedCode.Insert(insertPosition, nextChar.ToString()); + } + + await InvokeAsync(StateHasChanged); + } + + private double GetProgressPercentage() + { + if (string.IsNullOrEmpty(currentBlockText) || currentBlockText.Length == 0) + return 0; + + return Math.Min(100, (double)currentCharIndex / currentBlockText.Length * 100); + } + + private async void JumpToStep(int newTargetStep) + { + if (newTargetStep == currentStep && !isTyping) return; + + // Start typewriter animation for the new step + await StartTypewriterForStep(newTargetStep); + } + + private void SetActiveTab(int tab) + { + activeTab = tab; + StateHasChanged(); + } + + private string GetCurrentTabTitle() + { + return activeTab == 0 ? "Auto-Generated DTO" : "Generated Select Expression"; + } + + private string GetCurrentGeneratedCode() + { + if (currentStep >= steps.Length) return steps[^1].GetCodeForTab(activeTab); + return steps[currentStep].GetCodeForTab(activeTab); + } + + public async ValueTask DisposeAsync() + { + _cts?.Cancel(); + _cts?.Dispose(); + _cts = null; + + if (typewriterTimer != null) + { + typewriterTimer.Stop(); + typewriterTimer.Dispose(); + typewriterTimer = null; + } + } + + private class CodeStep + { + public required string Description { get; set; } + public required string CodeToAdd { get; set; } + public required string BaseCode { get; set; } + public required string DtoCode { get; set; } + public required string SelectCode { get; set; } + + public string GetCodeForTab(int tab) + { + return tab == 0 ? DtoCode : SelectCode; + } + } +} diff --git a/playground/Components/Home/SectionTitle.razor b/playground/Components/Home/SectionTitle.razor index 97dc0234..34c4bc73 100644 --- a/playground/Components/Home/SectionTitle.razor +++ b/playground/Components/Home/SectionTitle.razor @@ -1,4 +1,4 @@ -

@ChildContent

+

@ChildContent

@code { [Parameter] diff --git a/playground/Components/Home/WhyLinqraftSection.razor b/playground/Components/Home/WhyLinqraftSection.razor index 1400423b..d7316f2f 100644 --- a/playground/Components/Home/WhyLinqraftSection.razor +++ b/playground/Components/Home/WhyLinqraftSection.razor @@ -82,7 +82,7 @@
  • - No manual DTO definitions - auto-generated from anonymous types + No manual DTO definitions - auto-generated from query shape
  • @@ -93,15 +93,6 @@ Zero dependencies - only a source generator, no runtime library needed
  • - -
    -

    See the comparison section below to see what it looks like in practice!

    -
    - - - -
    -
    diff --git a/playground/Components/Playground/EditorPane.razor b/playground/Components/Playground/EditorPane.razor index 0d537f25..1b847788 100644 --- a/playground/Components/Playground/EditorPane.razor +++ b/playground/Components/Playground/EditorPane.razor @@ -51,7 +51,8 @@ FontSize = 14, FontFamily = "'JetBrains Mono', monospace", Minimap = new EditorMinimapOptions { Enabled = false }, - ScrollBeyondLastLine = false, + StickyScroll = new EditorStickyScrollOptions { Enabled = false }, + ScrollBeyondLastLine = true, RenderLineHighlight = "all", TabSize = 4, BracketPairColorization = new BracketPairColorizationOptions { Enabled = true }, diff --git a/playground/Components/Playground/PreviewPane.razor b/playground/Components/Playground/PreviewPane.razor index 6d08dbc9..80ea0b3e 100644 --- a/playground/Components/Playground/PreviewPane.razor +++ b/playground/Components/Playground/PreviewPane.razor @@ -50,7 +50,8 @@ FontFamily = "'JetBrains Mono', monospace", ReadOnly = true, Minimap = new EditorMinimapOptions { Enabled = false }, - ScrollBeyondLastLine = false, + StickyScroll = new EditorStickyScrollOptions { Enabled = false }, + ScrollBeyondLastLine = true, RenderLineHighlight = "none", TabSize = 4, BracketPairColorization = new BracketPairColorizationOptions { Enabled = true }, diff --git a/playground/Components/Playground/Sidebar.razor b/playground/Components/Playground/Sidebar.razor index 78724d22..44eff668 100644 --- a/playground/Components/Playground/Sidebar.razor +++ b/playground/Components/Playground/Sidebar.razor @@ -154,6 +154,16 @@ @onchange="@(e => UpdateConfig(c => c with { NestedDtoUseHashNamespace = (bool)(e.Value ?? false) }))" /> + + +
    + + +
    } @@ -179,7 +189,7 @@ [Parameter] public EventCallback OnCopyShareUrlRequest { get; set; } [Parameter] public EventCallback OnCreateGitHubIssueRequest { get; set; } - private bool settingsExpanded = true; + private bool settingsExpanded = false; private bool shareExpanded = true; private string copyButtonText = "Copy Share URL"; diff --git a/playground/Layout/MainLayout.razor b/playground/Layout/MainLayout.razor index 2d1acae3..f474c175 100644 --- a/playground/Layout/MainLayout.razor +++ b/playground/Layout/MainLayout.razor @@ -7,7 +7,7 @@
    diff --git a/playground/Pages/Home.razor b/playground/Pages/Home.razor index b59faef4..106da63b 100644 --- a/playground/Pages/Home.razor +++ b/playground/Pages/Home.razor @@ -4,11 +4,12 @@
    + - +