diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml deleted file mode 100644 index a9518888..00000000 --- a/.github/workflows/changelog.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: Generate changelog -on: - workflow_dispatch: - release: - types: [created, edited] - -jobs: - generate-changelog: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Generate a changelog - uses: orhun/git-cliff-action@v4 - with: - config: .github/git-cliff.toml - args: --verbose - env: - OUTPUT: CHANGELOG.md - GITHUB_REPO: ${{ github.repository }} - - - name: Commit and Push changelog - uses: stefanzweifel/git-auto-commit-action@v4 - with: - commit_message: "chore: update changelog for release ${{ github.event.release.tag_name }}" - file_pattern: CHANGELOG.md \ No newline at end of file diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 45b60842..1e734282 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -98,3 +98,26 @@ jobs: - name: Publish Nuget Package run: dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{steps.login.outputs.NUGET_API_KEY}} + + generate-changelog: + runs-on: ubuntu-latest + needs: package-build + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate a changelog + uses: orhun/git-cliff-action@v4 + with: + config: .github/git-cliff.toml + args: --verbose + env: + OUTPUT: CHANGELOG.md + GITHUB_REPO: ${{ github.repository }} + + - name: Commit and Push changelog + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: "chore: update changelog for release ${{ needs.package-build.outputs.version }}" + file_pattern: CHANGELOG.md \ No newline at end of file diff --git a/.github/workflows/test-strict.yaml b/.github/workflows/test-strict.yaml new file mode 100644 index 00000000..1ba04662 --- /dev/null +++ b/.github/workflows/test-strict.yaml @@ -0,0 +1,46 @@ +ο»Ώname: .NET Build and Test (More Platforms) + +on: + workflow_dispatch: + push: + branches: + - 'release-pr/*' + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Set Target Frameworks (Unix) + run: echo 'TFMS=net10.0;net8.0' >> $GITHUB_ENV + if: matrix.os != 'windows-latest' + + - name: Set Target Frameworks (Windows, including .NET Framework) + shell: pwsh + run: echo 'TFMS=net10.0;net8.0;net48' >> $env:GITHUB_ENV + if: matrix.os == 'windows-latest' + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 10.0.x + 8.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' + + - name: Restore dependencies + run: dotnet restore --locked-mode + + - name: Build + run: dotnet build --no-restore + + - name: Run tests + run: dotnet test --no-build --verbosity normal --logger GitHubActions \ No newline at end of file diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f65108df..16f5ae45 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -15,13 +15,8 @@ env: DOTNET_VERSION: '10.0.x' jobs: - build: - strategy: - matrix: - # drop windows because setup-dotnet on windows has too slow performance... - # os: [ubuntu-latest, windows-latest] - os: [ubuntu-latest] - runs-on: ${{ matrix.os }} + test: + runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index c12a7566..a3c224bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.db* .generated/ +playground/wwwroot/css/tailwind.css ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. diff --git a/CHANGELOG.md b/CHANGELOG.md index 397bd5a5..4378f17a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +## [0.6.0] - 2025-12-03 + +### πŸš€ Features + +- Detect and warn when using auto-generated DTO classes (#202) +- [**breaking**] Change NestedDtoUseHashNamespace behavior (default is true) (#203) +- [**breaking**] Update generated DTO namespaces to use Linqraft prefix instead of hash suffix (#205) +- Add transparent background to scrollbar corner in tailwind.css +- Enhance TAILWIND_CDN_FRAGMENT with additional styles and fonts in DevTailwindUtil.razor + +### πŸ› Bug Fixes + +- Simplify name conversion and improve consistency in GroupBy usage (#204) +- Update BenchmarkDotNet version and refine benchmark results in README.md + +### 🚜 Refactor + +- Simplify CodeGenerationService constructor and improve internal attribute filtering + +### πŸ“š Documentation + +- Add known issues section for GroupBy and SelectExpr functionality ## [0.5.0] - 2025-12-02 ### πŸš€ Features diff --git a/README.md b/README.md index 2ed5fa78..d066a8fd 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,23 @@ [![NuGet Version](https://img.shields.io/nuget/v/Linqraft?style=flat-square&logo=NuGet&color=0080CC)](https://www.nuget.org/packages/Linqraft/) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/arika0093/Linqraft/test.yaml?branch=main&label=Test&style=flat-square) [![DeepWiki](https://img.shields.io/badge/DeepWiki-Linqraft-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/arika0093/Linqraft) -Write Select queries easily with on-demand DTO generation and null-propagation operators. No depedendencies. +Write Select queries easily with on-demand DTO generation and null-propagation operators. No dependencies. [Web Page](https://arika0093.github.io/Linqraft/) | [Online Playground](https://arika0093.github.io/Linqraft/playground/) ## Features ### Overview -Linqraft is a Roslyn Source Generator for easily writing `IQueryable` projections with null-propagation and automatic DTO generation. +Linqraft is a Roslyn Source Generator for easily writing `IQueryable` projections. + +* **Query-based** automatic DTO generation + * You can freely define DTO structures in the query without predefining them. + * Based on anonymous types, "what you see is what you get" declarations. + * Supports nested DTOs, collections, and calculated fields. +* Null-propagation operator support (`?.`) in Expression Trees + * No more need to write `o.Customer != null ? o.Customer.Name : null`. +* Zero-dependency + * No runtime dependencies are required since it uses Source Generators and Interceptors. + With Linqraft, you can write queries like this: ```csharp @@ -21,16 +31,20 @@ var orders = await dbContext.Orders // can use inferred member names o.Id, // null-propagation supported + // you can create flattened structures easily CustomerName = o.Customer?.Name, // also works for nested objects CustomerCountry = o.Customer?.Address?.Country?.Name, CustomerCity = o.Customer?.Address?.City?.Name, - // you can use anonymous types inside + // you can use anonymous types inside. great for grouping CustomerInfo = new { Email = o.Customer?.EmailAddress, Phone = o.Customer?.PhoneNumber, }, + // calculated fields? no problem! + LatestOrderDate = o.OrderItems.Max(oi => oi.OrderDate), + TotalAmount = o.OrderItems.Sum(oi => oi.Quantity * oi.UnitPrice), // collections available Items = o.OrderItems.Select(oi => new { @@ -43,8 +57,7 @@ var orders = await dbContext.Orders ``` By specifying `OrderDto` as the generic parameter for `SelectExpr`, DTO types are generated **automatically** from the anonymous-type selector. -That means **you don't need to manually declare** `OrderDto` or `OrderItemDto`. -Better yet, since these features are provided as a source generator, no additional dependencies are introduced. +That means **you don't need to manually declare** `OrderDto` or `OrderItemDto`. for example, the generated code looks like this: @@ -53,7 +66,7 @@ for example, the generated code looks like this: ```csharp // -// This file is auto-generated by Linqraft. +// This file is auto-generated by Linqraft // #nullable enable #pragma warning disable IDE0060 @@ -62,17 +75,15 @@ for example, the generated code looks like this: #pragma warning disable CS8603 #pragma warning disable CS8604 #pragma warning disable CS8618 - using System; using System.Linq; using System.Collections.Generic; - namespace Linqraft { file static partial class GeneratedExpression { - [global::System.Runtime.CompilerServices.InterceptsLocationAttribute(1, "1rTP47TjaPKlTizGJTAHaXsBAABUdXRvcmlhbENhc2VUZXN0LmNz")] - public static IQueryable SelectExpr_54EA5DDB_8D42F5FB( + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute(1, "HWIj1D9ydZTCzRj7o0y/oYkBAABUdXRvcmlhbENhc2VUZXN0LmNz")] + public static IQueryable SelectExpr_CE7A5A7D_5A34E201( this IQueryable query, Func selector) { var matchedQuery = query as object as IQueryable; @@ -87,6 +98,8 @@ namespace Linqraft Email = o.Customer != null ? (string?)o.Customer.EmailAddress : null, Phone = o.Customer != null ? (string?)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 global::Tutorial.LinqraftGenerated_DE33EA40.ItemsDto { @@ -96,9 +109,9 @@ namespace Linqraft }); return converted as object as IQueryable; } + } } - namespace Tutorial { public partial class OrderDto @@ -108,9 +121,10 @@ namespace Tutorial public required string? CustomerCountry { get; set; } public required string? CustomerCity { get; set; } public required global::Tutorial.LinqraftGenerated_F1A64BF4.CustomerInfoDto? CustomerInfo { get; set; } + public required global::System.DateTime LatestOrderDate { get; set; } + public required decimal TotalAmount { get; set; } public required global::System.Collections.Generic.IEnumerable Items { get; set; } } - } namespace Tutorial.LinqraftGenerated_DE33EA40 { @@ -121,7 +135,6 @@ namespace Tutorial.LinqraftGenerated_DE33EA40 public required string? ProductName { get; set; } public required int Quantity { get; set; } } - } namespace Tutorial.LinqraftGenerated_F1A64BF4 { @@ -137,123 +150,14 @@ namespace Tutorial.LinqraftGenerated_F1A64BF4 +Interested? Try it out in the [Playground](https://arika0093.github.io/Linqraft/playground/)! + ### Drop-in Replacement Analyzers [Analyzers](./docs/analyzers/README.md) are provided to replace existing Select code with Linqraft. The replacement is completed in an instant. ![](./assets/replace-codefix-sample.gif) -## Why Linqraft? - -Consider a case where you need to fetch data from a database with many related tables. -Writing it naively would involve heavy use of `Include` / `ThenInclude`, resulting in code that is hard to read and maintain. - -- The Include-based style becomes verbose and hard to follow. -- Forgetting an `Include` can lead to runtime `NullReferenceException`s that are hard to detect at compile time. -- Fetching entire object graphs is often wasteful and hurts performance. - -```csharp -// ⚠️ unreadable, inefficient, and error-prone -var orders = await dbContext.Orders - .Include(o => o.Customer).ThenInclude(c => c.Address).ThenInclude(a => a.Country) - .Include(o => o.Customer).ThenInclude(c => c.Address).ThenInclude(a => a.City) - .Include(o => o.OrderItems).ThenInclude(oi => oi.Product) - .ToListAsync(); -``` - -A better approach is to project into DTOs and select only the fields you need: - -```csharp -// βœ…οΈ readable and efficient -var orders = await dbContext.Orders - .Select(o => new OrderDto - { - Id = o.Id, - CustomerName = o.Customer.Name, - CustomerCountry = o.Customer.Address.Country.Name, - CustomerCity = o.Customer.Address.City.Name, - Items = o.OrderItems.Select(oi => new OrderItemDto - { - ProductName = oi.Product.Name, - Quantity = oi.Quantity - }) - }) - .ToListAsync(); -``` - -This yields better performance because only the required data is fetched. But this style has drawbacks: - -- If you want to pass the result to other methods or return it from APIs, you usually must define DTO classes manually. - - When there are multiple child classes of a DTO, things get even more complicated. -- The expression APIs don't support the `?.` operator directly, forcing verbose null checks using ternary operators. - - The deeper the child elements become, the more complex null checks are required. - -```csharp -// πŸ€” too ugly code with lots of null checks -var orders = await dbContext.Orders - .Select(o => new OrderDto - { - Id = o.Id, - // in expression trees, ?. is not supported - CustomerName = o.Customer != null ? o.Customer.Name : null, - // nested null checks get worse - 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, - Items = o.OrderItems.Select(oi => new OrderItemDto - { - // more null checks - ProductName = oi.Product != null ? oi.Product.Name : null, - Quantity = oi.Quantity - }) - }) - .ToListAsync(); - -// πŸ€” you must define DTO classes manually -public class OrderDto -{ - public int Id { get; set; } - public string? CustomerName { get; set; } - public string? CustomerCountry { get; set; } - public string? CustomerCity { get; set; } - public List Items { get; set; } = []; -} -// When child DTOs are deep, the problem worsens -public class OrderItemDto -{ - public string? ProductName { get; set; } - public int Quantity { get; set; } -} -``` - -Linqraft solves these problems by providing `SelectExpr`, which supports null-propagation operators and automatic DTO generation. - -```csharp -// ✨️ usable null-propagation operators -var orders = await dbContext.Orders - .SelectExpr(o => new - { - o.Id, - CustomerName = o.Customer?.Name, - CustomerCountry = o.Customer?.Address?.Country?.Name, - CustomerCity = o.Customer?.Address?.City?.Name, - Items = o.OrderItems.Select(oi => new OrderItemDto - { - ProductName = oi.Product?.Name, - oi.Quantity - }) - }) - .ToListAsync(); - -// ✨️ The definition of the DTO class is no longer necessary -``` - -This feature helps keep your codebase clean and significantly reduces cognitive overhead. - - ## 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. @@ -293,6 +197,16 @@ Install `Linqraft` from NuGet. 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 + +``` + ## Examples ### Anonymous pattern @@ -434,7 +348,7 @@ query.SelectExpr(e => new ChildNames = e.Child?.Select(c => c.Name).ToList(), // This also applies to auto-generated child classes. - // so the generated type is IEnumerable + // 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. @@ -450,22 +364,22 @@ query.SelectExpr(e => new // code snippet var converted = matchedQuery.Select(d => new global::EntityDto { - ChildNames = d.Child != null ? d.Child.Select(c => c.Name).ToList() : new System.Collections.Generic.List(), + ChildNames = d.Child != null ? d.Child.Select(c => c.Name).ToList() : new List(), ChildDtos = d.Child != null ? d.Child - .Select(c => new global::ChildDto_HASH1234 + .Select(c => new global::LinqraftGenerated_HASH1234.ChildDto { Name = c.Name, Description = c.Description - }) : new System.Collections.Generic.List(), + }) : Enumerable.Empty(), ExplicitNullableNames = d.Child != null ? d.Child.Select(c => c.Name).ToList() : null, }); // generated DTO class public partial class EntityDto { - public required System.Collections.Generic.List ChildNames { get; set; } - public required System.Collections.Generic.IEnumerable ChildDtos { get; set; } - public required System.Collections.Generic.List? ExplicitNullableNames { get; set; } + public required List ChildNames { get; set; } + public required IEnumerable ChildDtos { get; set; } + public required List? ExplicitNullableNames { get; set; } } ``` @@ -652,6 +566,9 @@ public partial class EntityDto ### Global Properties Linqraft supports several MSBuild properties to customize the generated code: +
+Available Properties + ```xml @@ -677,6 +594,8 @@ Linqraft supports several MSBuild properties to customize the generated code: ``` +
+ ## Performance
@@ -690,13 +609,17 @@ Intel Core i7-14700F 2.10GHz, 1 CPU, 28 logical and 20 physical cores DefaultJob : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 -| Method | Mean | Error | StdDev | Ratio | Rank | Gen0 | Gen1 | Allocated | Alloc Ratio | -|------------------------------ |---------:|--------:|--------:|------:|-----:|--------:|-------:|----------:|------------:| -| 'Linqraft Auto-Generated DTO' | 880.9 us | 5.00 us | 4.68 us | 0.91 | 1 | 13.6719 | 1.9531 | 245.69 KB | 1.00 | -| 'Linqraft Manual DTO' | 886.9 us | 7.25 us | 6.78 us | 0.92 | 1 | 13.6719 | 1.9531 | 245.93 KB | 1.00 | -| 'Traditional Manual DTO' | 898.2 us | 7.53 us | 7.04 us | 0.93 | 1 | 13.6719 | 1.9531 | 245.65 KB | 1.00 | -| 'Linqraft Anonymous' | 955.2 us | 6.37 us | 5.65 us | 0.99 | 2 | 13.6719 | 1.9531 | 245.2 KB | 0.99 | -| 'Traditional Anonymous' | 966.1 us | 6.50 us | 6.08 us | 1.00 | 2 | 13.6719 | 1.9531 | 246.73 KB | 1.00 | +| 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 | ```
@@ -704,8 +627,67 @@ Intel Core i7-14700F 2.10GHz, 1 CPU, 28 logical and 20 physical cores 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. + +
+ +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 -### Can I use Linqraft with EF Core only? +### 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? @@ -727,21 +709,5 @@ Alternatively, you can also output the generated code to files by adding the fol ``` -### DTO classes should be separated from query parts. -Short answer: I have no objection to doing so, but I think it's good to have a simpler option as well. - -
-Long answer: -Separating DTO classes from query logic can be a good approach in some cases. However, in my opinion, it often ends up being unnecessarily verbose. - -For example, when retrieving moderately complex data from a database to return as an API response, the DTOs used for the result are essentially disposable (and arguably should be). In such cases, separating the query and DTO components may actually reduce code readability and maintainability rather than improve them. I've lost count of how many times I've had to modify both the query definition and the DTO definition simultaneously. - -Also, something often overlooked in these discussions: personally, I find creating the DTO class definition itself a hassle. Why should we have to write similar code twice (or more!) when the type is already defined on the model side? - -Using Linqraft to integrate queries and DTOs, and automatically generating DTOs when needed, can sometimes be more efficient. At the very least, having that option available is valuable. - -Furthermore, you can always copy the generated code and decouple it whenever you want. So, in practice, you can even use it just for "drafting." -
- ## License This project is licensed under the Apache License 2.0. diff --git a/docs/README.nuget.md b/docs/README.nuget.md index 58c70a64..3724a7d3 100644 --- a/docs/README.nuget.md +++ b/docs/README.nuget.md @@ -1,8 +1,17 @@ # Linqraft -[![NuGet Version](https://img.shields.io/nuget/v/Linqraft?style=flat-square&logo=NuGet&color=0080CC)](https://www.nuget.org/packages/Linqraft/) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/arika0093/Linqraft/test.yaml?branch=main&label=Test&style=flat-square) [![DeepWiki](https://img.shields.io/badge/DeepWiki-arika0093%2FLinqraft-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/arika0093/Linqraft) +[![NuGet Version](https://img.shields.io/nuget/v/Linqraft?style=flat-square&logo=NuGet&color=0080CC)](https://www.nuget.org/packages/Linqraft/) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/arika0093/Linqraft/test.yaml?branch=main&label=Test&style=flat-square) [![DeepWiki](https://img.shields.io/badge/DeepWiki-Linqraft-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/arika0093/Linqraft) -Write Select queries easily with on-demand DTO generation and null-propagation operators. No depedendencies. +Linqraft is a Roslyn Source Generator for easily writing `IQueryable` projections. + +* **Query-based** automatic DTO generation + * You can freely define DTO structures in the query without predefining them. + * Based on anonymous types, "what you see is what you get" declarations. + * Supports nested DTOs, collections, and calculated fields. +* Null-propagation operator support (`?.`) in Expression Trees + * No more need to write `o.Customer != null ? o.Customer.Name : null`. +* Zero-dependency + * No runtime dependencies are required since it uses Source Generators and Interceptors. For Example: @@ -15,16 +24,20 @@ var orders = await dbContext.Orders // can use inferred member names o.Id, // null-propagation supported + // you can create flattened structures easily CustomerName = o.Customer?.Name, // also works for nested objects CustomerCountry = o.Customer?.Address?.Country?.Name, CustomerCity = o.Customer?.Address?.City?.Name, - // you can use anonymous types inside + // you can use anonymous types inside. great for grouping CustomerInfo = new { Email = o.Customer?.EmailAddress, Phone = o.Customer?.PhoneNumber, }, + // calculated fields? no problem! + LatestOrderDate = o.OrderItems.Max(oi => oi.OrderDate), + TotalAmount = o.OrderItems.Sum(oi => oi.Quantity * oi.UnitPrice), // collections available Items = o.OrderItems.Select(oi => new { @@ -40,7 +53,7 @@ will be generated as: ```csharp // -// This file is auto-generated by Linqraft. +// This file is auto-generated by Linqraft // #nullable enable #pragma warning disable IDE0060 @@ -49,17 +62,15 @@ will be generated as: #pragma warning disable CS8603 #pragma warning disable CS8604 #pragma warning disable CS8618 - using System; using System.Linq; using System.Collections.Generic; - namespace Linqraft { file static partial class GeneratedExpression { - [global::System.Runtime.CompilerServices.InterceptsLocationAttribute(1, "1rTP47TjaPKlTizGJTAHaXsBAABUdXRvcmlhbENhc2VUZXN0LmNz")] - public static IQueryable SelectExpr_54EA5DDB_8D42F5FB( + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute(1, "HWIj1D9ydZTCzRj7o0y/oYkBAABUdXRvcmlhbENhc2VUZXN0LmNz")] + public static IQueryable SelectExpr_CE7A5A7D_5A34E201( this IQueryable query, Func selector) { var matchedQuery = query as object as IQueryable; @@ -74,6 +85,8 @@ namespace Linqraft Email = o.Customer != null ? (string?)o.Customer.EmailAddress : null, Phone = o.Customer != null ? (string?)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 global::Tutorial.LinqraftGenerated_DE33EA40.ItemsDto { @@ -83,9 +96,9 @@ namespace Linqraft }); return converted as object as IQueryable; } + } } - namespace Tutorial { public partial class OrderDto @@ -95,9 +108,10 @@ namespace Tutorial public required string? CustomerCountry { get; set; } public required string? CustomerCity { get; set; } public required global::Tutorial.LinqraftGenerated_F1A64BF4.CustomerInfoDto? CustomerInfo { get; set; } + public required global::System.DateTime LatestOrderDate { get; set; } + public required decimal TotalAmount { get; set; } public required global::System.Collections.Generic.IEnumerable Items { get; set; } } - } namespace Tutorial.LinqraftGenerated_DE33EA40 { @@ -108,7 +122,6 @@ namespace Tutorial.LinqraftGenerated_DE33EA40 public required string? ProductName { get; set; } public required int Quantity { get; set; } } - } namespace Tutorial.LinqraftGenerated_F1A64BF4 { diff --git a/examples/Linqraft.Benchmark/AutoMapperConfig.cs b/examples/Linqraft.Benchmark/AutoMapperConfig.cs new file mode 100644 index 00000000..b61c7d88 --- /dev/null +++ b/examples/Linqraft.Benchmark/AutoMapperConfig.cs @@ -0,0 +1,58 @@ +using AutoMapper; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Linqraft.Benchmark; + +/// +/// AutoMapper configuration for benchmark. +/// Maps entities to DTOs using AutoMapper's standard profile-based configuration. +/// +public class AutoMapperProfile : Profile +{ + 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.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)); + + // 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)); + } +} + +/// +/// AutoMapper configuration provider for benchmark usage. +/// +public static class AutoMapperConfig +{ + private static IMapper? _mapper; + + /// + /// Gets the configured mapper instance. + /// + public static IMapper Mapper => _mapper ??= CreateMapper(); + + /// + /// Creates a new AutoMapper instance with the benchmark profile. + /// + public static IMapper CreateMapper() + { + var config = new MapperConfiguration( + cfg => cfg.AddProfile(), + NullLoggerFactory.Instance + ); + return config.CreateMapper(); + } + + /// + /// Gets the mapper configuration for ProjectTo operations. + /// + public static IConfigurationProvider Configuration => Mapper.ConfigurationProvider; +} diff --git a/examples/Linqraft.Benchmark/FacetDtos.cs b/examples/Linqraft.Benchmark/FacetDtos.cs new file mode 100644 index 00000000..98c8337e --- /dev/null +++ b/examples/Linqraft.Benchmark/FacetDtos.cs @@ -0,0 +1,54 @@ +using Facet; + +namespace Linqraft.Benchmark; + +/// +/// Facet-generated DTO for SampleChildChildClass (grandchild). +/// +[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), + exclude: ["SampleClassId", "SampleClass"], + NestedFacets = [typeof(FacetSampleChildChildDto)])] +public partial record FacetSampleChildDto; + +/// +/// Facet-generated DTO for SampleChildClass2 (optional second child). +/// +[Facet(typeof(SampleChildClass2), + exclude: ["SampleClassId", "SampleClass"])] +public partial record FacetSampleChildClass2Dto; + +/// +/// Facet-generated DTO for SampleChildChildClass2 (grandchild of Child3). +/// +[Facet(typeof(SampleChildChildClass2), + exclude: ["SampleChildClass3Id", "SampleChildClass3"])] +public partial record FacetSampleChildChildClass2Dto; + +/// +/// Facet-generated DTO for SampleChildClass3 (third child). +/// +[Facet(typeof(SampleChildClass3), + exclude: ["SampleClassId", "SampleClass"], + NestedFacets = [typeof(FacetSampleChildChildClass2Dto)])] +public partial record FacetSampleChildClass3Dto; + +/// +/// Facet-generated DTO for SampleClass. +/// The Facet source generator creates the mapping logic at compile time. +/// Uses standard Facet NestedFacets for nested object mapping. +/// +[Facet(typeof(SampleClass), + NestedFacets = [ + typeof(FacetSampleChildDto), + typeof(FacetSampleChildClass2Dto), + typeof(FacetSampleChildClass3Dto) + ])] +public partial record FacetSampleClassDto; diff --git a/examples/Linqraft.Benchmark/Linqraft.Benchmark.csproj b/examples/Linqraft.Benchmark/Linqraft.Benchmark.csproj index bab3d17c..4528a927 100644 --- a/examples/Linqraft.Benchmark/Linqraft.Benchmark.csproj +++ b/examples/Linqraft.Benchmark/Linqraft.Benchmark.csproj @@ -8,5 +8,13 @@ + + + + + + + + diff --git a/examples/Linqraft.Benchmark/MapperlyMapper.cs b/examples/Linqraft.Benchmark/MapperlyMapper.cs new file mode 100644 index 00000000..0be54917 --- /dev/null +++ b/examples/Linqraft.Benchmark/MapperlyMapper.cs @@ -0,0 +1,38 @@ +using Riok.Mapperly.Abstractions; + +namespace Linqraft.Benchmark; + +/// +/// Mapperly source-generated mapper for benchmark. +/// Mapperly generates mapping code at compile time, providing near hand-written performance. +/// +[Mapper] +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); + + /// + /// 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))] + private static partial ManualSampleClassDto MapSampleClass(SampleClass source); + + /// + /// Maps a single SampleChildClass to ManualSampleChildDto. + /// + [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))] + private static partial ManualSampleChildDto MapSampleChild(SampleChildClass source); +} diff --git a/examples/Linqraft.Benchmark/MapsterConfig.cs b/examples/Linqraft.Benchmark/MapsterConfig.cs new file mode 100644 index 00000000..93c6f81c --- /dev/null +++ b/examples/Linqraft.Benchmark/MapsterConfig.cs @@ -0,0 +1,37 @@ +using Mapster; + +namespace Linqraft.Benchmark; + +/// +/// Mapster configuration for benchmark. +/// Configures type mappings for entity to DTO projections. +/// +public static class MapsterConfig +{ + private static bool _configured; + + /// + /// Configures Mapster type mappings. + /// Call this once before using Mapster projections. + /// + public static void Configure() + { + if (_configured) return; + + // Configure SampleClass to ManualSampleClassDto mapping + 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); + + // Configure SampleChildClass to ManualSampleChildDto mapping + 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); + + _configured = true; + } +} diff --git a/examples/Linqraft.Benchmark/README.md b/examples/Linqraft.Benchmark/README.md index a5f6a20f..d85696ee 100644 --- a/examples/Linqraft.Benchmark/README.md +++ b/examples/Linqraft.Benchmark/README.md @@ -10,3 +10,44 @@ cd examples/Linqraft.Benchmark dotnet run -c Release ``` +## Benchmark Patterns + +The benchmark compares the following patterns: + +### Baseline +1. **Traditional Anonymous** - Traditional LINQ Select with anonymous type (baseline) +2. **Traditional Manual DTO** - Traditional LINQ Select with manually defined DTOs + +### Linqraft +3. **Linqraft Anonymous** - Linqraft SelectExpr with anonymous type +4. **Linqraft Auto-Generated DTO** - Linqraft SelectExpr with auto-generated DTOs +5. **Linqraft Manual DTO** - Linqraft SelectExpr with manually defined DTOs + +### Third-Party Mapping Libraries +6. **AutoMapper ProjectTo** - AutoMapper's IQueryable projection with `ProjectTo()` +7. **Mapperly Projection** - Mapperly's source-generated projection with `ProjectToDto()` +8. **Mapster ProjectToType** - Mapster's IQueryable projection with `ProjectToType()` +9. **Facet ToFacetsAsync** - Facet's source-generated DTO projection with `ToFacetsAsync()` + +## Library Notes + +### AutoMapper (v15+) +- Uses profile-based configuration +- Requires `MapperConfiguration` with `ILoggerFactory` parameter (v15 breaking change) +- Best for complex mapping scenarios with extensive customization + +### Mapperly (v4+) +- Source generator - generates mapping code at compile time +- Zero runtime overhead for mappings +- Requires `[Mapper]` attribute on mapper class + +### Mapster (v7+) +- Runtime configuration with `TypeAdapterConfig` +- Supports both compile-time and runtime mapping +- Good balance between flexibility and performance + +### Facet (v5+) +- Source generator that creates DTOs and projections from domain models +- Uses `[Facet]` attribute on partial record/class with `NestedFacets` for nested objects +- Automatic navigation property loading with EF Core +- Uses `ToFacetsAsync()` for better performance diff --git a/examples/Linqraft.Benchmark/SelectBenchmark.cs b/examples/Linqraft.Benchmark/SelectBenchmark.cs index 373fc38b..378e96a3 100644 --- a/examples/Linqraft.Benchmark/SelectBenchmark.cs +++ b/examples/Linqraft.Benchmark/SelectBenchmark.cs @@ -1,5 +1,9 @@ +using AutoMapper; +using AutoMapper.QueryableExtensions; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Order; +using Facet.Extensions.EFCore; +using Mapster; using Microsoft.EntityFrameworkCore; namespace Linqraft.Benchmark; @@ -10,11 +14,16 @@ namespace Linqraft.Benchmark; public class SelectBenchmark { private BenchmarkDbContext _dbContext = null!; + private IConfigurationProvider _autoMapperConfig = null!; private const int DataCount = 100; [GlobalSetup] public async Task Setup() { + // Configure mapping libraries + _autoMapperConfig = AutoMapperConfig.Configuration; + MapsterConfig.Configure(); + var options = new DbContextOptionsBuilder() .UseSqlite("Data Source=benchmark.db") .Options; @@ -66,9 +75,8 @@ public async Task Cleanup() // ============================================================ // Pattern 1: Traditional Select with Anonymous Type - // (Baseline) // ============================================================ - [Benchmark(Baseline = true, Description = "Traditional Anonymous")] + [Benchmark(Description = "Traditional Anonymous")] public async Task Traditional_Anonymous() { var results = await _dbContext @@ -167,7 +175,7 @@ public async Task Linqraft_Anonymous() // Pattern 4: Linqraft SelectExpr with Auto-Generated DTO // (Using Linqraft - Auto-Generated DTO) // ============================================================ - [Benchmark(Description = "Linqraft Auto-Generated DTO")] + [Benchmark(Baseline = true, Description = "Linqraft Auto-Generated DTO")] public async Task Linqraft_AutoGeneratedDto() { var results = await _dbContext @@ -224,4 +232,55 @@ public async Task Linqraftl_ManualDto() .ToListAsync(); return results.Count; } + + // ============================================================ + // Pattern 6: AutoMapper with ProjectTo + // (Using AutoMapper's IQueryable projection) + // ============================================================ + [Benchmark(Description = "AutoMapper ProjectTo")] + public async Task AutoMapper_ProjectTo() + { + var results = await _dbContext + .SampleClasses.ProjectTo(_autoMapperConfig) + .ToListAsync(); + return results.Count; + } + + // ============================================================ + // Pattern 7: Mapperly with IQueryable Projection + // (Using Mapperly's source-generated projection) + // ============================================================ + [Benchmark(Description = "Mapperly Projection")] + public async Task Mapperly_Projection() + { + var results = await _dbContext + .SampleClasses.ProjectToDto() + .ToListAsync(); + return results.Count; + } + + // ============================================================ + // Pattern 8: Mapster with ProjectToType + // (Using Mapster's IQueryable projection) + // ============================================================ + [Benchmark(Description = "Mapster ProjectToType")] + public async Task Mapster_ProjectToType() + { + var results = await _dbContext + .SampleClasses.ProjectToType() + .ToListAsync(); + return results.Count; + } + + // ============================================================ + // Pattern 9: Facet with EF Core Extension + // (Using Facet's source-generated DTO projection) + // ============================================================ + [Benchmark(Description = "Facet ToFacetsAsync")] + public async Task Facet_ToFacetsAsync() + { + var results = await _dbContext + .SampleClasses.ToFacetsAsync(); + return results.Count; + } } diff --git a/playground/Services/TemplateService.cs b/playground/Services/TemplateService.cs index fb6ceb3b..2d380ae5 100644 --- a/playground/Services/TemplateService.cs +++ b/playground/Services/TemplateService.cs @@ -17,7 +17,6 @@ public class TemplateService using System; using System.Linq; using System.Collections.Generic; - using System.Linq.Expressions; """; /// @@ -41,97 +40,23 @@ public List