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 @@
[](https://www.nuget.org/packages/Linqraft/)  [](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.

-## 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
-[](https://www.nuget.org/packages/Linqraft/)  [](https://deepwiki.com/arika0093/Linqraft)
+[](https://www.nuget.org/packages/Linqraft/)  [](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 GetTemplates()
{
return
[
+ CreateReadmeTemplate(),
CreateMinReproTemplate(),
- CreateSimpleSampleTemplate(),
CreateAnonymousTypeTemplate(),
CreateExplicitDtoTemplate(),
- CreateNestedObjectTemplate(),
];
}
- ///
- /// Minimal reproduction template for issue reporting
- /// Single Program.cs with simplified code using namespace MinRepro
- ///
- private static Template CreateMinReproTemplate()
- {
- return new Template
- {
- Name = "Min Repro",
- Description = "Minimal reproduction template for issue reporting",
- Files =
- [
- new ProjectFile
- {
- Name = "Program.cs",
- Path = "Program.cs",
- IsHidden = false,
- Content =
- CommonUsings
- + """
-
- using Linqraft;
-
- namespace MinRepro;
-
- public class Program
- {
- public void Execute()
- {
- var data = new List();
- var query = data.AsQueryable();
- // Reproduce your issue here
- var result = query.SelectExpr(x => new
- {
- x.Id,
- x.Name,
- ChildDescription = x.Child?.Description,
- ItemTitles = x.Items.Select(i => i.Title),
- });
- }
- }
-
- // here are some sample classes to work with
- public class Entity
- {
- public int Id { get; set; }
- public string Name { get; set; } = "";
- public Child? Child { get; set; }
- public List- Items { get; set; } = [];
- }
-
- public class Child
- {
- public string Description { get; set; } = "";
- }
-
- public class Item
- {
- public string Title { get; set; } = "";
- }
- """,
- },
- new ProjectFile
- {
- Name = "_LinqraftStub.cs",
- Path = "_LinqraftStub.cs",
- IsHidden = true,
- Content = CommonUsings + "\n" + SelectExprStub,
- },
- ],
- };
- }
-
///
/// Template based on examples/Linqraft.MinimumSample
///
- private static Template CreateSimpleSampleTemplate()
+ private static Template CreateReadmeTemplate()
{
return new Template
{
- Name = "Simple Sample",
+ Name = "Readme Sample",
Description =
- "Basic example showing SelectExpr with null-conditional operators (from examples/Linqraft.MinimumSample)",
+ "Example from Linqraft README demonstrating SelectExpr with nested properties",
Files =
[
new ProjectFile
@@ -139,24 +64,25 @@ private static Template CreateSimpleSampleTemplate()
Name = "SampleClasses.cs",
Path = "SampleClasses.cs",
IsHidden = false,
- // Content from: examples/Linqraft.MinimumSample/SampleClasses.cs
Content =
CommonUsings
+ """
- namespace Linqraft.MinimumSample;
+ namespace Tutorial;
public class Order
{
public int Id { get; set; }
public Customer? Customer { get; set; }
- public List OrderItems { get; set; } = new();
+ public List OrderItems { get; set; } = [];
}
public class Customer
{
public string Name { get; set; } = "";
public Address? Address { get; set; }
+ public string? EmailAddress { get; set; }
+ public string? PhoneNumber { get; set; }
}
public class Address
@@ -179,6 +105,8 @@ public class OrderItem
{
public Product? Product { get; set; }
public int Quantity { get; set; }
+ public decimal UnitPrice { get; set; }
+ public DateTime OrderDate { get; set; }
}
public class Product
@@ -197,26 +125,36 @@ public class Product
CommonUsings
+ """
- using Linqraft;
- using Linqraft.MinimumSample;
+ namespace Tutorial;
- public class Program
+ public class TutorialCaseTest
{
- public void Execute()
+ private readonly List Orders = [];
+
+ [Fact]
+ public void TryTutorialCaseExplicit()
{
- var orders = new List();
- var results = orders
+ var orders = Orders
.AsQueryable()
- .SelectExpr(s => new
+ // Order: input entity type
+ // OrderDto: output DTO type (auto-generated)
+ .SelectExpr(o => new
{
- Id = s.Id,
- CustomerName = s.Customer?.Name,
- CustomerCountry = s.Customer?.Address?.Country?.Name,
- CustomerCity = s.Customer?.Address?.City?.Name,
- Items = s.OrderItems.Select(oi => new
+ o.Id,
+ CustomerName = o.Customer?.Name,
+ CustomerCountry = o.Customer?.Address?.Country?.Name,
+ CustomerCity = o.Customer?.Address?.City?.Name,
+ CustomerInfo = new
+ {
+ Email = o.Customer?.EmailAddress,
+ Phone = o.Customer?.PhoneNumber,
+ },
+ LatestOrderDate = o.OrderItems.Max(oi => oi.OrderDate),
+ TotalAmount = o.OrderItems.Sum(oi => oi.Quantity * oi.UnitPrice),
+ Items = o.OrderItems.Select(oi => new
{
ProductName = oi.Product?.Name,
- Quantity = oi.Quantity,
+ oi.Quantity,
}),
})
.ToList();
@@ -236,6 +174,76 @@ public void Execute()
}
///
+ /// Minimal reproduction template for issue reporting
+ /// Single Program.cs with simplified code using namespace MinRepro
+ ///
+ private static Template CreateMinReproTemplate()
+ {
+ return new Template
+ {
+ Name = "Min Repro",
+ Description = "Minimal reproduction template for issue reporting",
+ Files =
+ [
+ new ProjectFile
+ {
+ Name = "Program.cs",
+ Path = "Program.cs",
+ IsHidden = false,
+ Content =
+ CommonUsings
+ + """
+
+ namespace MinRepro;
+
+ public class Program
+ {
+ public void Execute()
+ {
+ var data = new List();
+ var query = data.AsQueryable();
+ // Reproduce your issue here
+ var result = query.SelectExpr(x => new
+ {
+ x.Id,
+ x.Name,
+ ChildDescription = x.Child?.Description,
+ ItemTitles = x.Items.Select(i => i.Title),
+ });
+ }
+ }
+
+ // here are some sample classes to work with
+ public class Entity
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = "";
+ public Child? Child { get; set; }
+ public List
- Items { get; set; } = [];
+ }
+
+ public class Child
+ {
+ public string Description { get; set; } = "";
+ }
+
+ public class Item
+ {
+ public string Title { get; set; } = "";
+ }
+ """,
+ },
+ new ProjectFile
+ {
+ Name = "_LinqraftStub.cs",
+ Path = "_LinqraftStub.cs",
+ IsHidden = true,
+ Content = CommonUsings + "\n" + SelectExprStub,
+ },
+ ],
+ };
+ }
+
/// Template demonstrating anonymous type pattern
///
private static Template CreateAnonymousTypeTemplate()
@@ -282,8 +290,7 @@ public class Category
CommonUsings
+ """
- using Linqraft;
- using AnonymousTypeExample;
+ namespace AnonymousTypeExample;
public class QueryExample
{
@@ -370,8 +377,7 @@ public class OrderItem
CommonUsings
+ """
- using Linqraft;
- using ExplicitDtoExample;
+ namespace ExplicitDtoExample;
public class QueryExample
{
@@ -424,103 +430,4 @@ public static IQueryable SelectExpr(
],
};
}
-
- ///
- /// Template demonstrating nested object selections
- ///
- private static Template CreateNestedObjectTemplate()
- {
- return new Template
- {
- Name = "Nested Objects",
- Description = "Working with nested object selections and collections",
- Files =
- [
- new ProjectFile
- {
- Name = "Models.cs",
- Path = "Models.cs",
- IsHidden = false,
- Content =
- CommonUsings
- + """
-
- namespace NestedObjectExample;
-
- public class Company
- {
- public int Id { get; set; }
- public string Name { get; set; } = "";
- public Address? Headquarters { get; set; }
- public List Employees { get; set; } = new();
- }
-
- public class Address
- {
- public string Street { get; set; } = "";
- public string City { get; set; } = "";
- public string Country { get; set; } = "";
- }
-
- public class Employee
- {
- public int Id { get; set; }
- public string Name { get; set; } = "";
- public Department? Department { get; set; }
- }
-
- public class Department
- {
- public string Name { get; set; } = "";
- public string Code { get; set; } = "";
- }
- """,
- },
- new ProjectFile
- {
- Name = "Query.cs",
- Path = "Query.cs",
- IsHidden = false,
- Content =
- CommonUsings
- + """
-
- using Linqraft;
- using NestedObjectExample;
-
- public class QueryExample
- {
- public void Execute()
- {
- var companies = new List();
- // Nested object selection with null-conditional operators
- var query = companies.AsQueryable();
- var result = query.SelectExpr(c => new
- {
- c.Id,
- c.Name,
- // Nested address info
- HeadquartersCity = c.Headquarters?.City,
- HeadquartersCountry = c.Headquarters?.Country,
- // Nested employee with department
- EmployeeInfo = c.Employees.Select(e => new
- {
- e.Name,
- DepartmentName = e.Department?.Name,
- }),
- });
- }
- }
- """,
- },
- new ProjectFile
- {
- Name = "_LinqraftStub.cs",
- Path = "_LinqraftStub.cs",
- IsHidden = true,
- Content = CommonUsings + "\n" + SelectExprStub,
- },
- ],
- };
- }
}
diff --git a/playground/wwwroot/css/tailwind.css b/playground/wwwroot/css/tailwind.css
deleted file mode 100644
index 19959c05..00000000
--- a/playground/wwwroot/css/tailwind.css
+++ /dev/null
@@ -1,1237 +0,0 @@
-/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */
-@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Orbitron:wght@400..900&display=swap');
-@layer properties;
-@layer theme, base, components, utilities;
-@layer theme {
- :root, :host {
- --font-sans: 'Montserrat', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
- --font-mono: 'JetBrains Mono', ui-monospace, 'Monaco', 'Consolas', 'Courier New', monospace;
- --color-yellow-400: oklch(85.2% 0.199 91.936);
- --color-green-400: oklch(79.2% 0.209 151.711);
- --color-green-500: oklch(72.3% 0.219 149.579);
- --color-green-800: oklch(44.8% 0.119 151.328);
- --color-cyan-300: oklch(86.5% 0.127 207.078);
- --color-cyan-400: oklch(78.9% 0.154 211.53);
- --color-cyan-600: oklch(60.9% 0.126 221.723);
- --color-cyan-700: oklch(52% 0.105 223.128);
- --color-cyan-800: oklch(45% 0.085 224.283);
- --color-cyan-950: oklch(30.2% 0.056 229.695);
- --color-blue-300: oklch(80.9% 0.105 251.813);
- --color-blue-400: oklch(70.7% 0.165 254.624);
- --color-blue-500: oklch(62.3% 0.214 259.815);
- --color-blue-600: oklch(54.6% 0.245 262.881);
- --color-blue-800: oklch(42.4% 0.199 265.638);
- --color-blue-900: oklch(37.9% 0.146 265.522);
- --color-indigo-400: oklch(67.3% 0.182 276.935);
- --color-gray-100: oklch(96.7% 0.003 264.542);
- --color-gray-200: oklch(92.8% 0.006 264.531);
- --color-gray-300: oklch(87.2% 0.01 258.338);
- --color-gray-400: oklch(70.7% 0.022 261.325);
- --color-gray-500: oklch(55.1% 0.027 264.364);
- --color-gray-600: oklch(44.6% 0.03 256.802);
- --color-gray-700: oklch(37.3% 0.034 259.733);
- --color-gray-800: oklch(27.8% 0.033 256.848);
- --color-gray-900: oklch(21% 0.034 264.665);
- --color-gray-950: oklch(13% 0.028 261.692);
- --color-white: #fff;
- --spacing: 0.25rem;
- --container-3xl: 48rem;
- --container-5xl: 64rem;
- --container-6xl: 72rem;
- --container-7xl: 80rem;
- --text-xs: 0.75rem;
- --text-xs--line-height: calc(1 / 0.75);
- --text-sm: 0.875rem;
- --text-sm--line-height: calc(1.25 / 0.875);
- --text-base: 1rem;
- --text-base--line-height: calc(1.5 / 1);
- --text-lg: 1.125rem;
- --text-lg--line-height: calc(1.75 / 1.125);
- --text-xl: 1.25rem;
- --text-xl--line-height: calc(1.75 / 1.25);
- --text-3xl: 1.875rem;
- --text-3xl--line-height: calc(2.25 / 1.875);
- --text-4xl: 2.25rem;
- --text-4xl--line-height: calc(2.5 / 2.25);
- --text-5xl: 3rem;
- --text-5xl--line-height: 1;
- --font-weight-medium: 500;
- --font-weight-semibold: 600;
- --font-weight-bold: 700;
- --tracking-wide: 0.025em;
- --radius-md: 0.375rem;
- --radius-lg: 0.5rem;
- --radius-xl: 0.75rem;
- --animate-spin: spin 1s linear infinite;
- --animate-bounce: bounce 1s infinite;
- --default-transition-duration: 150ms;
- --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
- --default-font-family: var(--font-sans);
- --default-mono-font-family: var(--font-mono);
- --font-display: 'Orbitron', ui-sans-serif, sans-serif;
- }
-}
-@layer base {
- *, ::after, ::before, ::backdrop, ::file-selector-button {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
- border: 0 solid;
- }
- html, :host {
- line-height: 1.5;
- -webkit-text-size-adjust: 100%;
- tab-size: 4;
- font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
- font-feature-settings: var(--default-font-feature-settings, normal);
- font-variation-settings: var(--default-font-variation-settings, normal);
- -webkit-tap-highlight-color: transparent;
- }
- hr {
- height: 0;
- color: inherit;
- border-top-width: 1px;
- }
- abbr:where([title]) {
- -webkit-text-decoration: underline dotted;
- text-decoration: underline dotted;
- }
- h1, h2, h3, h4, h5, h6 {
- font-size: inherit;
- font-weight: inherit;
- }
- a {
- color: inherit;
- -webkit-text-decoration: inherit;
- text-decoration: inherit;
- }
- b, strong {
- font-weight: bolder;
- }
- code, kbd, samp, pre {
- font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
- font-feature-settings: var(--default-mono-font-feature-settings, normal);
- font-variation-settings: var(--default-mono-font-variation-settings, normal);
- font-size: 1em;
- }
- small {
- font-size: 80%;
- }
- sub, sup {
- font-size: 75%;
- line-height: 0;
- position: relative;
- vertical-align: baseline;
- }
- sub {
- bottom: -0.25em;
- }
- sup {
- top: -0.5em;
- }
- table {
- text-indent: 0;
- border-color: inherit;
- border-collapse: collapse;
- }
- :-moz-focusring {
- outline: auto;
- }
- progress {
- vertical-align: baseline;
- }
- summary {
- display: list-item;
- }
- ol, ul, menu {
- list-style: none;
- }
- img, svg, video, canvas, audio, iframe, embed, object {
- display: block;
- vertical-align: middle;
- }
- img, video {
- max-width: 100%;
- height: auto;
- }
- button, input, select, optgroup, textarea, ::file-selector-button {
- font: inherit;
- font-feature-settings: inherit;
- font-variation-settings: inherit;
- letter-spacing: inherit;
- color: inherit;
- border-radius: 0;
- background-color: transparent;
- opacity: 1;
- }
- :where(select:is([multiple], [size])) optgroup {
- font-weight: bolder;
- }
- :where(select:is([multiple], [size])) optgroup option {
- padding-inline-start: 20px;
- }
- ::file-selector-button {
- margin-inline-end: 4px;
- }
- ::placeholder {
- opacity: 1;
- }
- @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
- ::placeholder {
- color: currentcolor;
- @supports (color: color-mix(in lab, red, red)) {
- color: color-mix(in oklab, currentcolor 50%, transparent);
- }
- }
- }
- textarea {
- resize: vertical;
- }
- ::-webkit-search-decoration {
- -webkit-appearance: none;
- }
- ::-webkit-date-and-time-value {
- min-height: 1lh;
- text-align: inherit;
- }
- ::-webkit-datetime-edit {
- display: inline-flex;
- }
- ::-webkit-datetime-edit-fields-wrapper {
- padding: 0;
- }
- ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
- padding-block: 0;
- }
- ::-webkit-calendar-picker-indicator {
- line-height: 1;
- }
- :-moz-ui-invalid {
- box-shadow: none;
- }
- button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
- appearance: button;
- }
- ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
- height: auto;
- }
- [hidden]:where(:not([hidden='until-found'])) {
- display: none !important;
- }
-}
-@layer utilities {
- .visible {
- visibility: visible;
- }
- .absolute {
- position: absolute;
- }
- .fixed {
- position: fixed;
- }
- .relative {
- position: relative;
- }
- .static {
- position: static;
- }
- .top-0 {
- top: calc(var(--spacing) * 0);
- }
- .top-2 {
- top: calc(var(--spacing) * 2);
- }
- .right-2 {
- right: calc(var(--spacing) * 2);
- }
- .bottom-0 {
- bottom: calc(var(--spacing) * 0);
- }
- .left-0 {
- left: calc(var(--spacing) * 0);
- }
- .z-10 {
- z-index: 10;
- }
- .z-40 {
- z-index: 40;
- }
- .z-50 {
- z-index: 50;
- }
- .mx-auto {
- margin-inline: auto;
- }
- .my-3 {
- margin-block: calc(var(--spacing) * 3);
- }
- .mt-1 {
- margin-top: calc(var(--spacing) * 1);
- }
- .mt-4 {
- margin-top: calc(var(--spacing) * 4);
- }
- .mt-12 {
- margin-top: calc(var(--spacing) * 12);
- }
- .mb-2 {
- margin-bottom: calc(var(--spacing) * 2);
- }
- .mb-3 {
- margin-bottom: calc(var(--spacing) * 3);
- }
- .mb-4 {
- margin-bottom: calc(var(--spacing) * 4);
- }
- .mb-6 {
- margin-bottom: calc(var(--spacing) * 6);
- }
- .mb-8 {
- margin-bottom: calc(var(--spacing) * 8);
- }
- .mb-16 {
- margin-bottom: calc(var(--spacing) * 16);
- }
- .ml-1 {
- margin-left: calc(var(--spacing) * 1);
- }
- .flex {
- display: flex;
- }
- .grid {
- display: grid;
- }
- .hidden {
- display: none;
- }
- .inline-block {
- display: inline-block;
- }
- .inline-flex {
- display: inline-flex;
- }
- .h-1 {
- height: calc(var(--spacing) * 1);
- }
- .h-3 {
- height: calc(var(--spacing) * 3);
- }
- .h-5 {
- height: calc(var(--spacing) * 5);
- }
- .h-6 {
- height: calc(var(--spacing) * 6);
- }
- .h-10 {
- height: calc(var(--spacing) * 10);
- }
- .h-12 {
- height: calc(var(--spacing) * 12);
- }
- .h-24 {
- height: calc(var(--spacing) * 24);
- }
- .h-dvh {
- height: 100dvh;
- }
- .h-full {
- height: 100%;
- }
- .max-h-150 {
- max-height: calc(var(--spacing) * 150);
- }
- .min-h-\[50px\] {
- min-height: 50px;
- }
- .min-h-dvh {
- min-height: 100dvh;
- }
- .w-1 {
- width: calc(var(--spacing) * 1);
- }
- .w-3 {
- width: calc(var(--spacing) * 3);
- }
- .w-5 {
- width: calc(var(--spacing) * 5);
- }
- .w-6 {
- width: calc(var(--spacing) * 6);
- }
- .w-10 {
- width: calc(var(--spacing) * 10);
- }
- .w-24 {
- width: calc(var(--spacing) * 24);
- }
- .w-60 {
- width: calc(var(--spacing) * 60);
- }
- .w-full {
- width: 100%;
- }
- .max-w-3xl {
- max-width: var(--container-3xl);
- }
- .max-w-5xl {
- max-width: var(--container-5xl);
- }
- .max-w-6xl {
- max-width: var(--container-6xl);
- }
- .max-w-7xl {
- max-width: var(--container-7xl);
- }
- .max-w-full {
- max-width: 100%;
- }
- .min-w-\[2\.5rem\] {
- min-width: 2.5rem;
- }
- .min-w-\[15rem\] {
- min-width: 15rem;
- }
- .min-w-\[200px\] {
- min-width: 200px;
- }
- .min-w-\[300px\] {
- min-width: 300px;
- }
- .flex-1 {
- flex: 1;
- }
- .flex-grow {
- flex-grow: 1;
- }
- .animate-bounce {
- animation: var(--animate-bounce);
- }
- .animate-spin {
- animation: var(--animate-spin);
- }
- .cursor-pointer {
- cursor: pointer;
- }
- .flex-col {
- flex-direction: column;
- }
- .flex-wrap {
- flex-wrap: wrap;
- }
- .items-center {
- align-items: center;
- }
- .items-start {
- align-items: flex-start;
- }
- .justify-between {
- justify-content: space-between;
- }
- .justify-center {
- justify-content: center;
- }
- .gap-1\.5 {
- gap: calc(var(--spacing) * 1.5);
- }
- .gap-2 {
- gap: calc(var(--spacing) * 2);
- }
- .gap-3 {
- gap: calc(var(--spacing) * 3);
- }
- .gap-4 {
- gap: calc(var(--spacing) * 4);
- }
- .gap-6 {
- gap: calc(var(--spacing) * 6);
- }
- .gap-8 {
- gap: calc(var(--spacing) * 8);
- }
- .space-y-2 {
- :where(& > :not(:last-child)) {
- --tw-space-y-reverse: 0;
- margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
- margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
- }
- }
- .space-y-3 {
- :where(& > :not(:last-child)) {
- --tw-space-y-reverse: 0;
- margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));
- margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)));
- }
- }
- .space-y-4 {
- :where(& > :not(:last-child)) {
- --tw-space-y-reverse: 0;
- margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));
- margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)));
- }
- }
- .space-y-6 {
- :where(& > :not(:last-child)) {
- --tw-space-y-reverse: 0;
- margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));
- margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
- }
- }
- .overflow-auto {
- overflow: auto;
- }
- .overflow-hidden {
- overflow: hidden;
- }
- .overflow-y-auto {
- overflow-y: auto;
- }
- .rounded {
- border-radius: 0.25rem;
- }
- .rounded-full {
- border-radius: calc(infinity * 1px);
- }
- .rounded-lg {
- border-radius: var(--radius-lg);
- }
- .rounded-md {
- border-radius: var(--radius-md);
- }
- .rounded-xl {
- border-radius: var(--radius-xl);
- }
- .border {
- border-style: var(--tw-border-style);
- border-width: 1px;
- }
- .border-1 {
- border-style: var(--tw-border-style);
- border-width: 1px;
- }
- .border-8 {
- border-style: var(--tw-border-style);
- border-width: 8px;
- }
- .border-y {
- border-block-style: var(--tw-border-style);
- border-block-width: 1px;
- }
- .border-t {
- border-top-style: var(--tw-border-style);
- border-top-width: 1px;
- }
- .border-t-8 {
- border-top-style: var(--tw-border-style);
- border-top-width: 8px;
- }
- .border-r {
- border-right-style: var(--tw-border-style);
- border-right-width: 1px;
- }
- .border-b {
- border-bottom-style: var(--tw-border-style);
- border-bottom-width: 1px;
- }
- .border-solid {
- --tw-border-style: solid;
- border-style: solid;
- }
- .border-cyan-600 {
- border-color: var(--color-cyan-600);
- }
- .border-gray-200 {
- border-color: var(--color-gray-200);
- }
- .border-gray-600 {
- border-color: var(--color-gray-600);
- }
- .border-gray-700 {
- border-color: var(--color-gray-700);
- }
- .border-gray-800 {
- border-color: var(--color-gray-800);
- }
- .border-green-500 {
- border-color: var(--color-green-500);
- }
- .border-t-blue-500 {
- border-top-color: var(--color-blue-500);
- }
- .bg-blue-600 {
- background-color: var(--color-blue-600);
- }
- .bg-blue-800 {
- background-color: var(--color-blue-800);
- }
- .bg-blue-900 {
- background-color: var(--color-blue-900);
- }
- .bg-gray-700 {
- background-color: var(--color-gray-700);
- }
- .bg-gray-800 {
- background-color: var(--color-gray-800);
- }
- .bg-gray-800\/70 {
- background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 70%, transparent);
- @supports (color: color-mix(in lab, red, red)) {
- background-color: color-mix(in oklab, var(--color-gray-800) 70%, transparent);
- }
- }
- .bg-gray-900 {
- background-color: var(--color-gray-900);
- }
- .bg-gray-900\/70 {
- background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 70%, transparent);
- @supports (color: color-mix(in lab, red, red)) {
- background-color: color-mix(in oklab, var(--color-gray-900) 70%, transparent);
- }
- }
- .bg-gray-950 {
- background-color: var(--color-gray-950);
- }
- .bg-green-400\/20 {
- background-color: color-mix(in srgb, oklch(79.2% 0.209 151.711) 20%, transparent);
- @supports (color: color-mix(in lab, red, red)) {
- background-color: color-mix(in oklab, var(--color-green-400) 20%, transparent);
- }
- }
- .bg-green-800\/50 {
- background-color: color-mix(in srgb, oklch(44.8% 0.119 151.328) 50%, transparent);
- @supports (color: color-mix(in lab, red, red)) {
- background-color: color-mix(in oklab, var(--color-green-800) 50%, transparent);
- }
- }
- .bg-gradient-to-b {
- --tw-gradient-position: to bottom in oklab;
- background-image: linear-gradient(var(--tw-gradient-stops));
- }
- .bg-gradient-to-r {
- --tw-gradient-position: to right in oklab;
- background-image: linear-gradient(var(--tw-gradient-stops));
- }
- .from-cyan-950 {
- --tw-gradient-from: var(--color-cyan-950);
- --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
- }
- .from-gray-950 {
- --tw-gradient-from: var(--color-gray-950);
- --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
- }
- .to-cyan-700 {
- --tw-gradient-to: var(--color-cyan-700);
- --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
- }
- .to-gray-900 {
- --tw-gradient-to: var(--color-gray-900);
- --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
- }
- .p-2 {
- padding: calc(var(--spacing) * 2);
- }
- .p-4 {
- padding: calc(var(--spacing) * 4);
- }
- .p-5 {
- padding: calc(var(--spacing) * 5);
- }
- .px-1 {
- padding-inline: calc(var(--spacing) * 1);
- }
- .px-1\.5 {
- padding-inline: calc(var(--spacing) * 1.5);
- }
- .px-2 {
- padding-inline: calc(var(--spacing) * 2);
- }
- .px-3 {
- padding-inline: calc(var(--spacing) * 3);
- }
- .px-4 {
- padding-inline: calc(var(--spacing) * 4);
- }
- .px-6 {
- padding-inline: calc(var(--spacing) * 6);
- }
- .px-8 {
- padding-inline: calc(var(--spacing) * 8);
- }
- .py-0\.5 {
- padding-block: calc(var(--spacing) * 0.5);
- }
- .py-1 {
- padding-block: calc(var(--spacing) * 1);
- }
- .py-1\.5 {
- padding-block: calc(var(--spacing) * 1.5);
- }
- .py-2 {
- padding-block: calc(var(--spacing) * 2);
- }
- .py-3 {
- padding-block: calc(var(--spacing) * 3);
- }
- .py-6 {
- padding-block: calc(var(--spacing) * 6);
- }
- .py-8 {
- padding-block: calc(var(--spacing) * 8);
- }
- .py-12 {
- padding-block: calc(var(--spacing) * 12);
- }
- .pt-6 {
- padding-top: calc(var(--spacing) * 6);
- }
- .pt-9 {
- padding-top: calc(var(--spacing) * 9);
- }
- .pt-16 {
- padding-top: calc(var(--spacing) * 16);
- }
- .pb-9 {
- padding-bottom: calc(var(--spacing) * 9);
- }
- .pb-16 {
- padding-bottom: calc(var(--spacing) * 16);
- }
- .pl-6 {
- padding-left: calc(var(--spacing) * 6);
- }
- .text-center {
- text-align: center;
- }
- .font-display {
- font-family: var(--font-display);
- }
- .font-mono {
- font-family: var(--font-mono);
- }
- .text-3xl {
- font-size: var(--text-3xl);
- line-height: var(--tw-leading, var(--text-3xl--line-height));
- }
- .text-4xl {
- font-size: var(--text-4xl);
- line-height: var(--tw-leading, var(--text-4xl--line-height));
- }
- .text-lg {
- font-size: var(--text-lg);
- line-height: var(--tw-leading, var(--text-lg--line-height));
- }
- .text-sm {
- font-size: var(--text-sm);
- line-height: var(--tw-leading, var(--text-sm--line-height));
- }
- .text-xl {
- font-size: var(--text-xl);
- line-height: var(--tw-leading, var(--text-xl--line-height));
- }
- .text-xs {
- font-size: var(--text-xs);
- line-height: var(--tw-leading, var(--text-xs--line-height));
- }
- .font-bold {
- --tw-font-weight: var(--font-weight-bold);
- font-weight: var(--font-weight-bold);
- }
- .font-medium {
- --tw-font-weight: var(--font-weight-medium);
- font-weight: var(--font-weight-medium);
- }
- .font-semibold {
- --tw-font-weight: var(--font-weight-semibold);
- font-weight: var(--font-weight-semibold);
- }
- .tracking-wide {
- --tw-tracking: var(--tracking-wide);
- letter-spacing: var(--tracking-wide);
- }
- .text-blue-300 {
- color: var(--color-blue-300);
- }
- .text-blue-400 {
- color: var(--color-blue-400);
- }
- .text-cyan-300\/80 {
- color: color-mix(in srgb, oklch(86.5% 0.127 207.078) 80%, transparent);
- @supports (color: color-mix(in lab, red, red)) {
- color: color-mix(in oklab, var(--color-cyan-300) 80%, transparent);
- }
- }
- .text-cyan-400 {
- color: var(--color-cyan-400);
- }
- .text-gray-100 {
- color: var(--color-gray-100);
- }
- .text-gray-200 {
- color: var(--color-gray-200);
- }
- .text-gray-300 {
- color: var(--color-gray-300);
- }
- .text-gray-400 {
- color: var(--color-gray-400);
- }
- .text-gray-500 {
- color: var(--color-gray-500);
- }
- .text-green-400 {
- color: var(--color-green-400);
- }
- .text-green-500 {
- color: var(--color-green-500);
- }
- .text-indigo-400 {
- color: var(--color-indigo-400);
- }
- .text-white {
- color: var(--color-white);
- }
- .text-yellow-400 {
- color: var(--color-yellow-400);
- }
- .lowercase {
- text-transform: lowercase;
- }
- .uppercase {
- text-transform: uppercase;
- }
- .opacity-0 {
- opacity: 0%;
- }
- .opacity-50 {
- opacity: 50%;
- }
- .opacity-90 {
- opacity: 90%;
- }
- .opacity-100 {
- opacity: 100%;
- }
- .shadow-md {
- --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
- box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
- }
- .transition-all {
- transition-property: all;
- transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
- transition-duration: var(--tw-duration, var(--default-transition-duration));
- }
- .transition-colors {
- transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
- transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
- transition-duration: var(--tw-duration, var(--default-transition-duration));
- }
- .transition-opacity {
- transition-property: opacity;
- transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
- transition-duration: var(--tw-duration, var(--default-transition-duration));
- }
- .duration-300 {
- --tw-duration: 300ms;
- transition-duration: 300ms;
- }
- .duration-500 {
- --tw-duration: 500ms;
- transition-duration: 500ms;
- }
- .select-none {
- -webkit-user-select: none;
- user-select: none;
- }
- .hover\:border-cyan-400 {
- &:hover {
- @media (hover: hover) {
- border-color: var(--color-cyan-400);
- }
- }
- }
- .hover\:border-gray-500 {
- &:hover {
- @media (hover: hover) {
- border-color: var(--color-gray-500);
- }
- }
- }
- .hover\:bg-blue-500 {
- &:hover {
- @media (hover: hover) {
- background-color: var(--color-blue-500);
- }
- }
- }
- .hover\:bg-blue-600 {
- &:hover {
- @media (hover: hover) {
- background-color: var(--color-blue-600);
- }
- }
- }
- .hover\:bg-cyan-800 {
- &:hover {
- @media (hover: hover) {
- background-color: var(--color-cyan-800);
- }
- }
- }
- .hover\:bg-gray-600 {
- &:hover {
- @media (hover: hover) {
- background-color: var(--color-gray-600);
- }
- }
- }
- .hover\:bg-gray-700 {
- &:hover {
- @media (hover: hover) {
- background-color: var(--color-gray-700);
- }
- }
- }
- .hover\:bg-gray-700\/70 {
- &:hover {
- @media (hover: hover) {
- background-color: color-mix(in srgb, oklch(37.3% 0.034 259.733) 70%, transparent);
- @supports (color: color-mix(in lab, red, red)) {
- background-color: color-mix(in oklab, var(--color-gray-700) 70%, transparent);
- }
- }
- }
- }
- .hover\:bg-gray-800 {
- &:hover {
- @media (hover: hover) {
- background-color: var(--color-gray-800);
- }
- }
- }
- .hover\:underline {
- &:hover {
- @media (hover: hover) {
- text-decoration-line: underline;
- }
- }
- }
- .hover\:opacity-90 {
- &:hover {
- @media (hover: hover) {
- opacity: 90%;
- }
- }
- }
- .hover\:opacity-100 {
- &:hover {
- @media (hover: hover) {
- opacity: 100%;
- }
- }
- }
- .sm\:text-5xl {
- @media (width >= 40rem) {
- font-size: var(--text-5xl);
- line-height: var(--tw-leading, var(--text-5xl--line-height));
- }
- }
- .sm\:text-base {
- @media (width >= 40rem) {
- font-size: var(--text-base);
- line-height: var(--tw-leading, var(--text-base--line-height));
- }
- }
- .sm\:text-sm {
- @media (width >= 40rem) {
- font-size: var(--text-sm);
- line-height: var(--tw-leading, var(--text-sm--line-height));
- }
- }
- .sm\:text-xl {
- @media (width >= 40rem) {
- font-size: var(--text-xl);
- line-height: var(--tw-leading, var(--text-xl--line-height));
- }
- }
- .md\:relative {
- @media (width >= 48rem) {
- position: relative;
- }
- }
- .md\:grid-cols-2 {
- @media (width >= 48rem) {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
- }
- .md\:py-32 {
- @media (width >= 48rem) {
- padding-block: calc(var(--spacing) * 32);
- }
- }
- .md\:text-right {
- @media (width >= 48rem) {
- text-align: right;
- }
- }
- .lg\:col-span-6 {
- @media (width >= 64rem) {
- grid-column: span 6 / span 6;
- }
- }
- .lg\:col-span-12 {
- @media (width >= 64rem) {
- grid-column: span 12 / span 12;
- }
- }
- .lg\:mt-\[-16px\] {
- @media (width >= 64rem) {
- margin-top: -16px;
- }
- }
- .lg\:mb-0 {
- @media (width >= 64rem) {
- margin-bottom: calc(var(--spacing) * 0);
- }
- }
- .lg\:block {
- @media (width >= 64rem) {
- display: block;
- }
- }
- .lg\:grid {
- @media (width >= 64rem) {
- display: grid;
- }
- }
- .lg\:grid-cols-2 {
- @media (width >= 64rem) {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
- }
- .lg\:grid-cols-12 {
- @media (width >= 64rem) {
- grid-template-columns: repeat(12, minmax(0, 1fr));
- }
- }
- .lg\:items-start {
- @media (width >= 64rem) {
- align-items: flex-start;
- }
- }
- .lg\:justify-end {
- @media (width >= 64rem) {
- justify-content: flex-end;
- }
- }
- .lg\:justify-start {
- @media (width >= 64rem) {
- justify-content: flex-start;
- }
- }
- .lg\:gap-3 {
- @media (width >= 64rem) {
- gap: calc(var(--spacing) * 3);
- }
- }
- .lg\:py-20 {
- @media (width >= 64rem) {
- padding-block: calc(var(--spacing) * 20);
- }
- }
- .lg\:text-left {
- @media (width >= 64rem) {
- text-align: left;
- }
- }
-}
-::-webkit-scrollbar {
- width: 7px;
- height: 7px;
-}
-::-webkit-scrollbar-thumb {
- background: #888;
- border-radius: 5px;
-}
-::-webkit-scrollbar-track {
- background: #242525;
-}
-::-webkit-scrollbar-corner {
- background: transparent;
-}
-@property --tw-space-y-reverse {
- syntax: "*";
- inherits: false;
- initial-value: 0;
-}
-@property --tw-border-style {
- syntax: "*";
- inherits: false;
- initial-value: solid;
-}
-@property --tw-gradient-position {
- syntax: "*";
- inherits: false;
-}
-@property --tw-gradient-from {
- syntax: "";
- inherits: false;
- initial-value: #0000;
-}
-@property --tw-gradient-via {
- syntax: "";
- inherits: false;
- initial-value: #0000;
-}
-@property --tw-gradient-to {
- syntax: "";
- inherits: false;
- initial-value: #0000;
-}
-@property --tw-gradient-stops {
- syntax: "*";
- inherits: false;
-}
-@property --tw-gradient-via-stops {
- syntax: "*";
- inherits: false;
-}
-@property --tw-gradient-from-position {
- syntax: "";
- inherits: false;
- initial-value: 0%;
-}
-@property --tw-gradient-via-position {
- syntax: "";
- inherits: false;
- initial-value: 50%;
-}
-@property --tw-gradient-to-position {
- syntax: "";
- inherits: false;
- initial-value: 100%;
-}
-@property --tw-font-weight {
- syntax: "*";
- inherits: false;
-}
-@property --tw-tracking {
- syntax: "*";
- inherits: false;
-}
-@property --tw-shadow {
- syntax: "*";
- inherits: false;
- initial-value: 0 0 #0000;
-}
-@property --tw-shadow-color {
- syntax: "*";
- inherits: false;
-}
-@property --tw-shadow-alpha {
- syntax: "";
- inherits: false;
- initial-value: 100%;
-}
-@property --tw-inset-shadow {
- syntax: "*";
- inherits: false;
- initial-value: 0 0 #0000;
-}
-@property --tw-inset-shadow-color {
- syntax: "*";
- inherits: false;
-}
-@property --tw-inset-shadow-alpha {
- syntax: "";
- inherits: false;
- initial-value: 100%;
-}
-@property --tw-ring-color {
- syntax: "*";
- inherits: false;
-}
-@property --tw-ring-shadow {
- syntax: "*";
- inherits: false;
- initial-value: 0 0 #0000;
-}
-@property --tw-inset-ring-color {
- syntax: "*";
- inherits: false;
-}
-@property --tw-inset-ring-shadow {
- syntax: "*";
- inherits: false;
- initial-value: 0 0 #0000;
-}
-@property --tw-ring-inset {
- syntax: "*";
- inherits: false;
-}
-@property --tw-ring-offset-width {
- syntax: "";
- inherits: false;
- initial-value: 0px;
-}
-@property --tw-ring-offset-color {
- syntax: "*";
- inherits: false;
- initial-value: #fff;
-}
-@property --tw-ring-offset-shadow {
- syntax: "*";
- inherits: false;
- initial-value: 0 0 #0000;
-}
-@property --tw-duration {
- syntax: "*";
- inherits: false;
-}
-@keyframes spin {
- to {
- transform: rotate(360deg);
- }
-}
-@keyframes bounce {
- 0%, 100% {
- transform: translateY(-25%);
- animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
- }
- 50% {
- transform: none;
- animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
- }
-}
-@layer properties {
- @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
- *, ::before, ::after, ::backdrop {
- --tw-space-y-reverse: 0;
- --tw-border-style: solid;
- --tw-gradient-position: initial;
- --tw-gradient-from: #0000;
- --tw-gradient-via: #0000;
- --tw-gradient-to: #0000;
- --tw-gradient-stops: initial;
- --tw-gradient-via-stops: initial;
- --tw-gradient-from-position: 0%;
- --tw-gradient-via-position: 50%;
- --tw-gradient-to-position: 100%;
- --tw-font-weight: initial;
- --tw-tracking: initial;
- --tw-shadow: 0 0 #0000;
- --tw-shadow-color: initial;
- --tw-shadow-alpha: 100%;
- --tw-inset-shadow: 0 0 #0000;
- --tw-inset-shadow-color: initial;
- --tw-inset-shadow-alpha: 100%;
- --tw-ring-color: initial;
- --tw-ring-shadow: 0 0 #0000;
- --tw-inset-ring-color: initial;
- --tw-inset-ring-shadow: 0 0 #0000;
- --tw-ring-inset: initial;
- --tw-ring-offset-width: 0px;
- --tw-ring-offset-color: #fff;
- --tw-ring-offset-shadow: 0 0 #0000;
- --tw-duration: initial;
- }
- }
-}
diff --git a/src/Linqraft.Core/DtoProperty.cs b/src/Linqraft.Core/DtoProperty.cs
index a8ee96d1..a822c9c9 100644
--- a/src/Linqraft.Core/DtoProperty.cs
+++ b/src/Linqraft.Core/DtoProperty.cs
@@ -21,7 +21,17 @@ public record DtoProperty(
DtoStructure? NestedStructure,
string? Accessibility = null,
bool IsNestedFromNamedType = false,
- DocumentationCommentHelper.DocumentationInfo? Documentation = null
+ DocumentationCommentHelper.DocumentationInfo? Documentation = null,
+ ///
+ /// The explicit DTO type specified in SelectExpr<TIn, TResult> generic arguments.
+ /// When this is set, the generated expression should use this type instead of auto-generating one.
+ ///
+ ITypeSymbol? ExplicitNestedDtoType = null,
+ ///
+ /// The explicit DTO type name as a string, used when the type symbol might be an error type
+ /// (because the DTO is generated by another SelectExpr). This stores the fully qualified name.
+ ///
+ string? ExplicitNestedDtoTypeName = null
)
{
///
@@ -140,9 +150,96 @@ TypeSymbol is IErrorTypeSymbol
isNullable = true;
}
- // Detect nested Select (e.g., s.Childs.Select(...) or s.Childs.Select(...).ToList())
+ // Detect nested SelectExpr first (e.g., s.Childs.SelectExpr- (...) or nested SelectExpr calls)
+ // When SelectExpr is used inside another SelectExpr, it should be treated like Select for structure analysis
DtoStructure? nestedStructure = null;
bool isNestedFromNamedType = false;
+ ITypeSymbol? explicitNestedDtoType = null;
+ string? explicitNestedDtoTypeName = null;
+ var selectExprInvocation = FindSelectExprInvocation(expression);
+ if (selectExprInvocation is not null && selectExprInvocation.ArgumentList.Arguments.Count > 0)
+ {
+ var lambdaArg = selectExprInvocation.ArgumentList.Arguments[0].Expression;
+ if (lambdaArg is LambdaExpressionSyntax nestedLambda)
+ {
+ // Get collection element type from the SelectExpr's source
+ ITypeSymbol? collectionType = null;
+
+ if (selectExprInvocation.Expression is MemberAccessExpressionSyntax selectExprMemberAccess)
+ {
+ // For generic SelectExpr, get the base expression before the .SelectExpr call
+ var baseExpr = selectExprMemberAccess.Expression;
+ collectionType = semanticModel.GetTypeInfo(baseExpr).Type;
+
+ // Extract the explicit DTO type (TResult) from SelectExpr
+ if (selectExprMemberAccess.Name is GenericNameSyntax genericName
+ && genericName.TypeArgumentList.Arguments.Count >= 2)
+ {
+ var tResultSyntax = genericName.TypeArgumentList.Arguments[1];
+ explicitNestedDtoType = semanticModel.GetTypeInfo(tResultSyntax).Type;
+
+ // Also get the type name from syntax - this is reliable even if the type doesn't exist yet
+ // (e.g., if it will be generated by the inner SelectExpr)
+ var callerNamespace = selectExprInvocation
+ .Ancestors()
+ .OfType()
+ .FirstOrDefault()
+ ?.Name.ToString() ?? "";
+
+ // Get the simple type name from the syntax
+ var simpleTypeName = tResultSyntax.ToString();
+
+ // Build the fully qualified name
+ if (!string.IsNullOrEmpty(callerNamespace))
+ {
+ explicitNestedDtoTypeName = $"global::{callerNamespace}.{simpleTypeName}";
+ }
+ else
+ {
+ explicitNestedDtoTypeName = $"global::{simpleTypeName}";
+ }
+ }
+ }
+
+ if (
+ collectionType is INamedTypeSymbol namedCollectionType
+ && namedCollectionType.TypeArguments.Length > 0
+ )
+ {
+ var elementType = namedCollectionType.TypeArguments[0];
+
+ // Support both anonymous types and named types in the lambda body
+ if (
+ nestedLambda.Body is AnonymousObjectCreationExpressionSyntax nestedAnonymous
+ )
+ {
+ nestedStructure = DtoStructure.AnalyzeAnonymousType(
+ nestedAnonymous,
+ semanticModel,
+ elementType,
+ propertyName,
+ configuration
+ );
+ }
+ else if (nestedLambda.Body is ObjectCreationExpressionSyntax nestedNamed)
+ {
+ nestedStructure = DtoStructure.AnalyzeNamedType(
+ nestedNamed,
+ semanticModel,
+ elementType,
+ propertyName,
+ configuration
+ );
+ isNestedFromNamedType = true;
+ }
+ }
+ }
+ }
+
+ // Detect nested Select (e.g., s.Childs.Select(...) or s.Childs.Select(...).ToList())
+ // Only if we didn't find a SelectExpr
+ if (nestedStructure is null)
+ {
// First, try to find Select invocation (handles both direct Select and chained methods like ToList)
var selectInvocation = FindSelectInvocation(expression);
if (selectInvocation is not null && selectInvocation.ArgumentList.Arguments.Count > 0)
@@ -266,6 +363,7 @@ nestedLambda.Body is AnonymousObjectCreationExpressionSyntax nestedAnonymous
}
}
}
+ } // Close the "if (nestedStructure is null)" block for Select detection
// Detect nested SelectMany (e.g., s.Childs.SelectMany(c => c.GrandChilds))
if (nestedStructure is null)
@@ -572,7 +670,9 @@ underlyingType is INamedTypeSymbol collectionType
NestedStructure: nestedStructure,
Accessibility: accessibility,
IsNestedFromNamedType: isNestedFromNamedType,
- Documentation: documentation
+ Documentation: documentation,
+ ExplicitNestedDtoType: explicitNestedDtoType,
+ ExplicitNestedDtoTypeName: explicitNestedDtoTypeName
);
}
@@ -679,6 +779,11 @@ private static bool HasNullableAccess(ExpressionSyntax expression)
return LinqMethodHelper.FindLinqMethodInvocation(expression, "SelectMany");
}
+ private static InvocationExpressionSyntax? FindSelectExprInvocation(ExpressionSyntax expression)
+ {
+ return LinqMethodHelper.FindLinqMethodInvocation(expression, "SelectExpr");
+ }
+
///
/// Extracts documentation information from the source symbol of an expression
///
diff --git a/src/Linqraft.Core/GenerateDtoClassInfo.cs b/src/Linqraft.Core/GenerateDtoClassInfo.cs
index a0e2535f..804ccc94 100644
--- a/src/Linqraft.Core/GenerateDtoClassInfo.cs
+++ b/src/Linqraft.Core/GenerateDtoClassInfo.cs
@@ -166,11 +166,57 @@ public string BuildCode(LinqraftConfiguration configuration)
}
}
- var propertyType = prop.TypeName;
+ var propertyType = prop.TypeName ?? "object";
+ // For nested structures with explicit DTO types from SelectExpr,
+ // use the explicit DTO type name instead of auto-generating one
+ if (!string.IsNullOrEmpty(prop.ExplicitNestedDtoTypeName) && prop.NestedStructure is not null)
+ {
+ // Use the explicit DTO type name from SelectExpr
+ var explicitDtoName = prop.ExplicitNestedDtoTypeName;
+
+ // Handle nullable types: temporarily remove the ? suffix if present
+ var isTypeNullable = RoslynTypeHelper.IsNullableTypeByString(propertyType);
+ var typeWithoutNullable = isTypeNullable
+ ? RoslynTypeHelper.RemoveNullableSuffixFromString(propertyType)
+ : propertyType;
+
+ // Determine whether to re-apply nullable marker
+ var shouldReapplyNullable = isTypeNullable && prop.IsNullable;
+
+ if (RoslynTypeHelper.IsAnonymousTypeByString(typeWithoutNullable))
+ {
+ // Direct anonymous type
+ propertyType = explicitDtoName;
+ if (shouldReapplyNullable)
+ {
+ propertyType = $"{propertyType}?";
+ }
+ }
+ else if (RoslynTypeHelper.IsGenericTypeByString(typeWithoutNullable))
+ {
+ // Collection type (e.g., List<...>, IEnumerable<...>)
+ // Extract the simple type name from the fully qualified name
+ var simpleTypeName = explicitDtoName!.Replace("global::", "");
+ var baseType = typeWithoutNullable[..typeWithoutNullable.IndexOf("<")];
+ propertyType = $"{baseType}<{simpleTypeName}>";
+ if (shouldReapplyNullable)
+ {
+ propertyType = $"{propertyType}?";
+ }
+ }
+ else
+ {
+ propertyType = explicitDtoName!;
+ if (shouldReapplyNullable)
+ {
+ propertyType = $"{propertyType}?";
+ }
+ }
+ }
// For nested structures, recursively generate DTOs (add first)
// But skip if IsNestedFromNamedType is true - in that case, keep the original named type
- if (prop.NestedStructure is not null && !prop.IsNestedFromNamedType)
+ else if (prop.NestedStructure is not null && !prop.IsNestedFromNamedType)
{
var nestStructure = prop.NestedStructure;
@@ -261,6 +307,9 @@ public string BuildCode(LinqraftConfiguration configuration)
}
}
+ // Ensure propertyType is never null at this point for subsequent operations
+ propertyType ??= "object";
+
// Add nullable annotation if the property is nullable and not already marked
if (prop.IsNullable && !RoslynTypeHelper.IsNullableTypeByString(propertyType))
{
diff --git a/src/Linqraft.Core/RoslynHelpers/RoslynTypeHelper.cs b/src/Linqraft.Core/RoslynHelpers/RoslynTypeHelper.cs
index a2e0d238..111702b4 100644
--- a/src/Linqraft.Core/RoslynHelpers/RoslynTypeHelper.cs
+++ b/src/Linqraft.Core/RoslynHelpers/RoslynTypeHelper.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
@@ -239,11 +240,12 @@ public static bool IsGlobalNamespace(INamespaceSymbol? namespaceSymbol)
}
///
- /// Determines whether an expression contains a Select method invocation.
+ /// Determines whether an expression contains a method invocation with the specified name.
///
/// The expression to check
- /// True if the expression contains a Select method invocation
- public static bool ContainsSelectInvocation(ExpressionSyntax expression)
+ /// The method name to search for
+ /// True if the expression contains an invocation of the specified method
+ private static bool ContainsMethodInvocation(ExpressionSyntax? expression, string methodName)
{
if (expression == null)
return false;
@@ -253,30 +255,56 @@ public static bool ContainsSelectInvocation(ExpressionSyntax expression)
.OfType()
.Any(inv =>
(inv.Expression is MemberAccessExpressionSyntax ma
- && ma.Name.Identifier.Text == "Select")
+ && ma.Name.Identifier.Text == methodName)
|| (inv.Expression is MemberBindingExpressionSyntax mb
- && mb.Name.Identifier.Text == "Select")
+ && mb.Name.Identifier.Text == methodName)
);
}
+ ///
+ /// Determines whether an expression contains a Select method invocation.
+ ///
+ /// The expression to check
+ /// True if the expression contains a Select method invocation
+ public static bool ContainsSelectInvocation(ExpressionSyntax expression)
+ => ContainsMethodInvocation(expression, "Select");
+
///
/// Determines whether an expression contains a SelectMany method invocation.
///
/// The expression to check
/// True if the expression contains a SelectMany method invocation
public static bool ContainsSelectManyInvocation(ExpressionSyntax expression)
+ => ContainsMethodInvocation(expression, "SelectMany");
+
+ ///
+ /// Determines whether an expression contains a SelectExpr method invocation.
+ ///
+ /// The expression to check
+ /// True if the expression contains a SelectExpr method invocation
+ public static bool ContainsSelectExprInvocation(ExpressionSyntax expression)
+ => ContainsMethodInvocation(expression, "SelectExpr");
+
+ ///
+ /// Finds all SelectExpr invocations in an expression.
+ ///
+ /// The expression to search
+ /// All SelectExpr invocations found
+ public static IEnumerable FindSelectExprInvocations(
+ ExpressionSyntax expression
+ )
{
if (expression == null)
- return false;
+ return [];
return expression
.DescendantNodesAndSelf()
.OfType()
- .Any(inv =>
+ .Where(inv =>
(inv.Expression is MemberAccessExpressionSyntax ma
- && ma.Name.Identifier.Text == "SelectMany")
+ && ma.Name.Identifier.Text == "SelectExpr")
|| (inv.Expression is MemberBindingExpressionSyntax mb
- && mb.Name.Identifier.Text == "SelectMany")
+ && mb.Name.Identifier.Text == "SelectExpr")
);
}
diff --git a/src/Linqraft.Core/SelectExprInfo.cs b/src/Linqraft.Core/SelectExprInfo.cs
index d5513774..58fba0d7 100644
--- a/src/Linqraft.Core/SelectExprInfo.cs
+++ b/src/Linqraft.Core/SelectExprInfo.cs
@@ -11,6 +11,20 @@
namespace Linqraft.Core;
+///
+/// Information about a LINQ expression for code generation.
+/// Used to represent extracted and processed LINQ invocation data.
+///
+public record LinqExpressionInfo
+{
+ public required string BaseExpression { get; init; }
+ public required string ParamName { get; init; }
+ public required string ChainedMethods { get; init; }
+ public required bool HasNullableAccess { get; init; }
+ public string? CoalescingDefaultValue { get; init; }
+ public string? NullCheckExpression { get; init; }
+}
+
///
/// Base class for SelectExpr information, providing common functionality for
/// analyzing LINQ Select expressions and generating corresponding DTO structures
@@ -278,6 +292,18 @@ protected string GeneratePropertyAssignment(DtoProperty property, int indents)
return convertedSelectMany;
}
+ // Check if this contains SelectExpr - convert to Select and handle nested structure
+ if (RoslynTypeHelper.ContainsSelectExprInvocation(syntax))
+ {
+ // For nested SelectExpr, convert to Select and handle the nested structure
+ var convertedSelectExpr = ConvertNestedSelectExprWithRoslyn(syntax, property, indents);
+ if (convertedSelectExpr != expression)
+ {
+ return convertedSelectExpr;
+ }
+ // Fall through to other handling if conversion failed
+ }
+
// Check if this contains Select - use dedicated formatting for better output
if (RoslynTypeHelper.ContainsSelectInvocation(syntax))
{
@@ -1001,12 +1027,35 @@ protected string ConvertNestedSelectWithRoslyn(
DtoProperty property,
int indents
)
+ {
+ // Use Roslyn to extract Select information
+ var selectInfo = ExtractSelectInfoFromSyntax(syntax);
+ if (selectInfo is null)
+ {
+ // Fallback to string representation
+ return syntax.ToString();
+ }
+
+ // Use shared helper for Select expression generation
+ return GenerateSelectExpression(property, indents, selectInfo);
+ }
+
+ ///
+ /// Converts nested Select expressions with named types (not anonymous).
+ /// Preserves the original named type with fully qualified type names.
+ ///
+ protected string ConvertNestedSelectWithNamedType(
+ ExpressionSyntax syntax,
+ DtoProperty property,
+ int indents
+ )
{
var nestedStructure = property.NestedStructure!;
var spaces = CodeFormatter.IndentSpaces(indents);
var innerSpaces = CodeFormatter.IndentSpaces(indents + CodeFormatter.IndentSize);
- // For anonymous types (empty class name), GetNestedDtoFullNameFromStructure returns empty string
- var nestedDtoName = GetNestedDtoFullNameFromStructure(nestedStructure);
+
+ // Get the fully qualified type name of the named type
+ var namedTypeName = nestedStructure.SourceTypeFullName;
// Use Roslyn to extract Select information
var selectInfo = ExtractSelectInfoFromSyntax(syntax);
@@ -1016,14 +1065,12 @@ int indents
return syntax.ToString();
}
- var (
- baseExpression,
- paramName,
- chainedMethods,
- hasNullableAccess,
- coalescingDefaultValue,
- nullCheckExpression
- ) = selectInfo.Value;
+ var baseExpression = selectInfo.BaseExpression;
+ var paramName = selectInfo.ParamName;
+ var chainedMethods = selectInfo.ChainedMethods;
+ var hasNullableAccess = selectInfo.HasNullableAccess;
+ var coalescingDefaultValue = selectInfo.CoalescingDefaultValue;
+ var nullCheckExpression = selectInfo.NullCheckExpression;
// Normalize baseExpression: remove unnecessary whitespace and newlines
baseExpression = System.Text.RegularExpressions.Regex.Replace(
@@ -1053,8 +1100,7 @@ int indents
);
}
- // Generate property assignments for nested DTO with proper formatting
- // Properties should be indented two levels from the base (one for Select block, one for properties)
+ // Generate property assignments using the named type's structure
var propertyIndentSpaces = CodeFormatter.IndentSpaces(
indents + CodeFormatter.IndentSize * 2
);
@@ -1069,45 +1115,36 @@ int indents
}
var propertiesCode = string.Join($",{CodeFormatter.DefaultNewLine}", propertyAssignments);
- // Format chained methods with proper indentation (one level from base)
+ // Format chained methods with proper indentation
var formattedChainedMethods = FormatChainedMethods(chainedMethods, innerSpaces);
- // Build the Select expression with proper formatting
- // The .Select, {, }, and chained methods should all be indented one level from the property assignment
+ // Build the Select expression using the named type (not DTO)
if (hasNullableAccess)
{
- // Use nullCheckExpression for the null check (defaults to baseExpression if not provided)
var checkExpr = nullCheckExpression ?? baseExpression;
- // Determine default value based on the expression's type
- // If it's a collection type, use empty enumerable/list; otherwise use null
+ // For named types, use the same default value logic
string defaultValue;
if (coalescingDefaultValue is not null)
{
defaultValue = coalescingDefaultValue;
}
- else if (
- string.IsNullOrEmpty(nestedDtoName)
- || !RoslynTypeHelper.IsCollectionType(property.TypeSymbol)
- )
+ else if (!RoslynTypeHelper.IsCollectionType(property.TypeSymbol))
{
- // For single element results or anonymous types, default is null
defaultValue = "null";
}
else
{
- // For collections, determine the appropriate empty collection type
- // Check if the result type is a List (chained with ToList())
defaultValue = GetEmptyCollectionExpression(
property.TypeSymbol,
- nestedDtoName,
+ namedTypeName,
chainedMethods
);
}
var code = $$"""
{{checkExpr}} != null ? {{baseExpression}}
- {{innerSpaces}}.Select({{paramName}} => new {{nestedDtoName}}
+ {{innerSpaces}}.Select({{paramName}} => new {{namedTypeName}}
{{innerSpaces}}{
{{propertiesCode}}
{{innerSpaces}}}){{formattedChainedMethods}} : {{defaultValue}}
@@ -1118,7 +1155,7 @@ int indents
{
var code = $$"""
{{baseExpression}}
- {{innerSpaces}}.Select({{paramName}} => new {{nestedDtoName}}
+ {{innerSpaces}}.Select({{paramName}} => new {{namedTypeName}}
{{innerSpaces}}{
{{propertiesCode}}
{{innerSpaces}}}){{formattedChainedMethods}}
@@ -1128,38 +1165,85 @@ int indents
}
///
- /// Converts nested Select expressions with named types (not anonymous).
- /// Preserves the original named type with fully qualified type names.
+ /// Converts nested SelectExpr expressions to Select.
+ /// When SelectExpr is used inside another SelectExpr, the inner SelectExpr should be
+ /// converted to a regular Select call.
///
- protected string ConvertNestedSelectWithNamedType(
+ protected string ConvertNestedSelectExprWithRoslyn(
ExpressionSyntax syntax,
DtoProperty property,
int indents
)
+ {
+ // Use Roslyn to extract SelectExpr information (treat it like Select)
+ var selectExprInfo = ExtractSelectExprInfoFromSyntax(syntax);
+ if (selectExprInfo is null)
+ {
+ // Fallback to string representation
+ return syntax.ToString();
+ }
+
+ // Reuse the common conversion logic (SelectExpr -> Select conversion uses same format)
+ return GenerateSelectExpression(property, indents, selectExprInfo);
+ }
+
+ ///
+ /// Generates the Select expression code from extracted LINQ invocation info.
+ /// This is shared between ConvertNestedSelectWithRoslyn and ConvertNestedSelectExprWithRoslyn.
+ ///
+ private string GenerateSelectExpression(
+ DtoProperty property,
+ int indents,
+ LinqExpressionInfo info
+ )
{
var nestedStructure = property.NestedStructure!;
- var spaces = CodeFormatter.IndentSpaces(indents);
var innerSpaces = CodeFormatter.IndentSpaces(indents + CodeFormatter.IndentSize);
- // Get the fully qualified type name of the named type
- var namedTypeName = nestedStructure.SourceTypeFullName;
+ // Use explicit DTO type if available (from SelectExpr generic arguments)
+ // Otherwise, use the auto-generated DTO name from the structure
+ string nestedDtoName;
+ if (property.ExplicitNestedDtoType is not null && property.ExplicitNestedDtoType is not IErrorTypeSymbol)
+ {
+ // Get the fully qualified name including namespace
+ var typeSymbol = property.ExplicitNestedDtoType;
+ var displayString = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- // Use Roslyn to extract Select information
- var selectInfo = ExtractSelectInfoFromSyntax(syntax);
- if (selectInfo is null)
+ // Ensure the name includes global:: prefix for fully qualified reference
+ if (!displayString.StartsWith("global::") && typeSymbol.ContainingNamespace != null)
+ {
+ var namespaceName = typeSymbol.ContainingNamespace.ToDisplayString();
+ if (!string.IsNullOrEmpty(namespaceName))
+ {
+ nestedDtoName = $"global::{namespaceName}.{typeSymbol.Name}";
+ }
+ else
+ {
+ nestedDtoName = $"global::{typeSymbol.Name}";
+ }
+ }
+ else
+ {
+ nestedDtoName = displayString;
+ }
+ }
+ else if (!string.IsNullOrEmpty(property.ExplicitNestedDtoTypeName))
{
- // Fallback to string representation
- return syntax.ToString();
+ // Use the syntax-level type name when the type symbol is not available
+ // (e.g., when the DTO is generated by another SelectExpr)
+ nestedDtoName = property.ExplicitNestedDtoTypeName!;
+ }
+ else
+ {
+ nestedDtoName = GetNestedDtoFullNameFromStructure(nestedStructure);
}
- var (
- baseExpression,
- paramName,
- chainedMethods,
- hasNullableAccess,
- coalescingDefaultValue,
- nullCheckExpression
- ) = selectInfo.Value;
+ var baseExpression = info.BaseExpression;
+ var paramName = info.ParamName;
+ var chainedMethods = info.ChainedMethods;
+ var hasNullableAccess = info.HasNullableAccess;
+ var coalescingDefaultValue = info.CoalescingDefaultValue;
+ var nullCheckExpression = info.NullCheckExpression;
// Normalize baseExpression: remove unnecessary whitespace and newlines
baseExpression = System.Text.RegularExpressions.Regex.Replace(
@@ -1189,7 +1273,7 @@ int indents
);
}
- // Generate property assignments using the named type's structure
+ // Generate property assignments for nested DTO with proper formatting
var propertyIndentSpaces = CodeFormatter.IndentSpaces(
indents + CodeFormatter.IndentSize * 2
);
@@ -1207,18 +1291,20 @@ int indents
// Format chained methods with proper indentation
var formattedChainedMethods = FormatChainedMethods(chainedMethods, innerSpaces);
- // Build the Select expression using the named type (not DTO)
+ // Build the Select expression
if (hasNullableAccess)
{
var checkExpr = nullCheckExpression ?? baseExpression;
- // For named types, use the same default value logic
string defaultValue;
if (coalescingDefaultValue is not null)
{
defaultValue = coalescingDefaultValue;
}
- else if (!RoslynTypeHelper.IsCollectionType(property.TypeSymbol))
+ else if (
+ string.IsNullOrEmpty(nestedDtoName)
+ || !RoslynTypeHelper.IsCollectionType(property.TypeSymbol)
+ )
{
defaultValue = "null";
}
@@ -1226,14 +1312,14 @@ int indents
{
defaultValue = GetEmptyCollectionExpression(
property.TypeSymbol,
- namedTypeName,
+ nestedDtoName!,
chainedMethods
);
}
var code = $$"""
{{checkExpr}} != null ? {{baseExpression}}
- {{innerSpaces}}.Select({{paramName}} => new {{namedTypeName}}
+ {{innerSpaces}}.Select({{paramName}} => new {{nestedDtoName}}
{{innerSpaces}}{
{{propertiesCode}}
{{innerSpaces}}}){{formattedChainedMethods}} : {{defaultValue}}
@@ -1244,7 +1330,7 @@ int indents
{
var code = $$"""
{{baseExpression}}
- {{innerSpaces}}.Select({{paramName}} => new {{namedTypeName}}
+ {{innerSpaces}}.Select({{paramName}} => new {{nestedDtoName}}
{{innerSpaces}}{
{{propertiesCode}}
{{innerSpaces}}}){{formattedChainedMethods}}
@@ -1375,14 +1461,12 @@ int indents
return syntax.ToString();
}
- var (
- baseExpression,
- paramName,
- chainedMethods,
- hasNullableAccess,
- coalescingDefaultValue,
- nullCheckExpression
- ) = selectManyInfo.Value;
+ var baseExpression = selectManyInfo.BaseExpression;
+ var paramName = selectManyInfo.ParamName;
+ var chainedMethods = selectManyInfo.ChainedMethods;
+ var hasNullableAccess = selectManyInfo.HasNullableAccess;
+ var coalescingDefaultValue = selectManyInfo.CoalescingDefaultValue;
+ var nullCheckExpression = selectManyInfo.NullCheckExpression;
// Normalize baseExpression: remove unnecessary whitespace and newlines
baseExpression = System.Text.RegularExpressions.Regex.Replace(
@@ -1548,14 +1632,7 @@ private static T RemoveComments(T node)
);
}
- private (
- string baseExpression,
- string paramName,
- string chainedMethods,
- bool hasNullableAccess,
- string? coalescingDefaultValue,
- string? nullCheckExpression
- )? ExtractSelectManyInfoFromSyntax(ExpressionSyntax syntax)
+ private LinqExpressionInfo? ExtractSelectManyInfoFromSyntax(ExpressionSyntax syntax)
{
var info = LinqMethodHelper.ExtractLinqInvocationInfo(syntax, "SelectMany");
if (info is null)
@@ -1577,24 +1654,18 @@ private static T RemoveComments(T node)
.ToString();
}
- return (
- cleanedBaseExpression,
- info.ParameterName,
- info.ChainedMethods,
- info.HasNullableAccess,
- info.CoalescingDefaultValue,
- cleanedNullCheckExpression
- );
+ return new LinqExpressionInfo
+ {
+ BaseExpression = cleanedBaseExpression,
+ ParamName = info.ParameterName,
+ ChainedMethods = info.ChainedMethods,
+ HasNullableAccess = info.HasNullableAccess,
+ CoalescingDefaultValue = info.CoalescingDefaultValue,
+ NullCheckExpression = cleanedNullCheckExpression
+ };
}
- private (
- string baseExpression,
- string paramName,
- string chainedMethods,
- bool hasNullableAccess,
- string? coalescingDefaultValue,
- string? nullCheckExpression
- )? ExtractSelectInfoFromSyntax(ExpressionSyntax syntax)
+ private LinqExpressionInfo? ExtractSelectInfoFromSyntax(ExpressionSyntax syntax)
{
var info = LinqMethodHelper.ExtractLinqInvocationInfo(syntax, "Select");
if (info is null)
@@ -1623,14 +1694,55 @@ private static T RemoveComments(T node)
// Fully qualify static references in chained methods (issue #157)
var fullyQualifiedChainedMethods = FullyQualifyChainedMethods(info);
- return (
- cleanedBaseExpression,
- info.ParameterName,
- fullyQualifiedChainedMethods,
- info.HasNullableAccess,
- info.CoalescingDefaultValue,
- cleanedNullCheckExpression
- );
+ return new LinqExpressionInfo
+ {
+ BaseExpression = cleanedBaseExpression,
+ ParamName = info.ParameterName,
+ ChainedMethods = fullyQualifiedChainedMethods,
+ HasNullableAccess = info.HasNullableAccess,
+ CoalescingDefaultValue = info.CoalescingDefaultValue,
+ NullCheckExpression = cleanedNullCheckExpression
+ };
+ }
+
+ private LinqExpressionInfo? ExtractSelectExprInfoFromSyntax(ExpressionSyntax syntax)
+ {
+ // Extract info for SelectExpr (treat it like Select for the purpose of code generation)
+ var info = LinqMethodHelper.ExtractLinqInvocationInfo(syntax, "SelectExpr");
+ if (info is null)
+ return null;
+
+ // Fully qualify static references in base expression
+ var fullyQualifiedBaseExpression = FullyQualifyBaseExpression(info);
+
+ // Apply comment removal to base expression
+ var cleanedBaseExpression = RemoveComments(
+ SyntaxFactory.ParseExpression(fullyQualifiedBaseExpression)
+ )
+ .ToString();
+
+ // Apply comment removal to null check expression if present
+ string? cleanedNullCheckExpression = null;
+ if (info.NullCheckExpression is not null)
+ {
+ cleanedNullCheckExpression = RemoveComments(
+ SyntaxFactory.ParseExpression(info.NullCheckExpression)
+ )
+ .ToString();
+ }
+
+ // Fully qualify static references in chained methods
+ var fullyQualifiedChainedMethods = FullyQualifyChainedMethods(info);
+
+ return new LinqExpressionInfo
+ {
+ BaseExpression = cleanedBaseExpression,
+ ParamName = info.ParameterName,
+ ChainedMethods = fullyQualifiedChainedMethods,
+ HasNullableAccess = info.HasNullableAccess,
+ CoalescingDefaultValue = info.CoalescingDefaultValue,
+ NullCheckExpression = cleanedNullCheckExpression
+ };
}
///
diff --git a/src/Linqraft.Core/SelectExprInfoExplicitDto.cs b/src/Linqraft.Core/SelectExprInfoExplicitDto.cs
index bb34e871..b0bf1144 100644
--- a/src/Linqraft.Core/SelectExprInfoExplicitDto.cs
+++ b/src/Linqraft.Core/SelectExprInfoExplicitDto.cs
@@ -52,7 +52,8 @@ private List GenerateDtoClasses(
DtoStructure structure,
string? overrideClassName = null,
List? nestedParentClasses = null,
- List? nestedParentAccessibilities = null
+ List? nestedParentAccessibilities = null,
+ bool isExplicitFromNestedSelectExpr = false
)
{
var result = new List();
@@ -68,6 +69,8 @@ private List GenerateDtoClasses(
// Get existing properties from the TResultType (only for the main DTO, not nested)
var existingProperties = new HashSet();
var isMainDto = overrideClassName == ExplicitDtoName;
+ // DTOs from nested SelectExpr with explicit type should also be treated as explicit root DTOs
+ var isExplicitDto = isMainDto || isExplicitFromNestedSelectExpr;
if (isMainDto)
{
// This is the main DTO, check for existing properties
@@ -86,13 +89,43 @@ private List GenerateDtoClasses(
// Named types should preserve the original type, not create DTOs
if (prop.NestedStructure is not null && !prop.IsNestedFromNamedType)
{
+ // Check if this property has an explicit DTO type name from a nested SelectExpr
+ // If so, use that name instead of auto-generating one
+ string? explicitDtoClassName = null;
+ bool propHasExplicitNestedSelectExpr = false;
+ if (!string.IsNullOrEmpty(prop.ExplicitNestedDtoTypeName))
+ {
+ const string GlobalPrefix = "global::";
+
+ // Remove "global::" prefix if present before extracting class name
+ var typeName = prop.ExplicitNestedDtoTypeName!;
+ if (typeName.StartsWith(GlobalPrefix))
+ {
+ typeName = typeName.Substring(GlobalPrefix.Length);
+ }
+
+ // Extract just the class name from the fully qualified name
+ // e.g., "Linqraft.Tests.NestedItem207Dto" -> "NestedItem207Dto"
+ var lastDotIndex = typeName.LastIndexOf('.');
+ if (lastDotIndex >= 0)
+ {
+ explicitDtoClassName = typeName.Substring(lastDotIndex + 1);
+ }
+ else
+ {
+ explicitDtoClassName = typeName;
+ }
+ propHasExplicitNestedSelectExpr = true;
+ }
+
// Recursively generate nested DTO classes with the same parent info
result.AddRange(
GenerateDtoClasses(
prop.NestedStructure,
- overrideClassName: null,
+ overrideClassName: explicitDtoClassName,
nestedParentClasses: currentParentClasses,
- nestedParentAccessibilities: currentParentAccessibilities
+ nestedParentAccessibilities: currentParentAccessibilities,
+ isExplicitFromNestedSelectExpr: propHasExplicitNestedSelectExpr
)
);
}
@@ -103,7 +136,8 @@ private List GenerateDtoClasses(
// When NestedDtoUseHashNamespace option is enabled, child DTOs are placed in
// a hash-named sub-namespace (e.g., LinqraftGenerated_{Hash}.ClassName)
- if (!isMainDto && Configuration?.NestedDtoUseHashNamespace == true)
+ // However, DTOs with explicit names from nested SelectExpr should NOT use hash namespace
+ if (!isExplicitDto && Configuration?.NestedDtoUseHashNamespace == true)
{
var hash = structure.GetUniqueId();
actualNamespace = string.IsNullOrEmpty(actualNamespace)
@@ -121,7 +155,7 @@ private List GenerateDtoClasses(
ParentClasses = currentParentClasses,
ParentAccessibilities = currentParentAccessibilities,
ExistingProperties = existingProperties,
- IsExplicitRootDto = isMainDto, // Mark explicit root DTOs to avoid adding the attribute
+ IsExplicitRootDto = isExplicitDto, // Mark explicit DTOs (main or from nested SelectExpr) to avoid adding the attribute
};
result.Add(dtoClassInfo);
return result;
diff --git a/src/Linqraft.Core/SyntaxHelpers/LinqMethodHelper.cs b/src/Linqraft.Core/SyntaxHelpers/LinqMethodHelper.cs
index 899a6d6a..c5103c74 100644
--- a/src/Linqraft.Core/SyntaxHelpers/LinqMethodHelper.cs
+++ b/src/Linqraft.Core/SyntaxHelpers/LinqMethodHelper.cs
@@ -97,6 +97,16 @@ public static bool IsSelectManyInvocation(ExpressionSyntax expression)
return FindLinqMethodInvocation(expression, "SelectMany") is not null;
}
+ ///
+ /// Finds a SelectExpr invocation in an expression
+ ///
+ /// The expression to search
+ /// The invocation expression, or null if not found
+ public static InvocationExpressionSyntax? FindSelectExprInvocation(ExpressionSyntax expression)
+ {
+ return FindLinqMethodInvocation(expression, "SelectExpr");
+ }
+
///
/// Information about a LINQ invocation
///
diff --git a/src/Linqraft.SourceGenerator/SelectExprGenerator.cs b/src/Linqraft.SourceGenerator/SelectExprGenerator.cs
index 8cfeb2ed..34dc844c 100644
--- a/src/Linqraft.SourceGenerator/SelectExprGenerator.cs
+++ b/src/Linqraft.SourceGenerator/SelectExprGenerator.cs
@@ -97,7 +97,39 @@ private static bool IsSelectExprInvocation(SyntaxNode node)
var expression = invocation.Expression;
// Use shared helper for syntax-level check
- return SelectExprHelper.IsSelectExprInvocationSyntax(expression);
+ if (!SelectExprHelper.IsSelectExprInvocationSyntax(expression))
+ return false;
+
+ // Skip if this SelectExpr is nested inside another SelectExpr.
+ // When SelectExpr is used inside another SelectExpr (nested SelectExpr),
+ // only the outermost SelectExpr should generate an interceptor.
+ // The inner SelectExpr will be converted to a regular Select call by the outer one.
+ if (IsNestedInsideAnotherSelectExpr(invocation))
+ return false;
+
+ return true;
+ }
+
+ ///
+ /// Checks if the given SelectExpr invocation is nested inside another SelectExpr invocation.
+ ///
+ private static bool IsNestedInsideAnotherSelectExpr(InvocationExpressionSyntax invocation)
+ {
+ // Walk up the syntax tree to find any ancestor that is also a SelectExpr invocation
+ var current = invocation.Parent;
+ while (current is not null)
+ {
+ // If we find a parent InvocationExpression that is also a SelectExpr, we are nested
+ if (current is InvocationExpressionSyntax parentInvocation)
+ {
+ if (SelectExprHelper.IsSelectExprInvocationSyntax(parentInvocation.Expression))
+ {
+ return true;
+ }
+ }
+ current = current.Parent;
+ }
+ return false;
}
private static SelectExprInfo? GetSelectExprInfo(GeneratorSyntaxContext context)
diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props
index 73b9c1ad..d002fff4 100644
--- a/tests/Directory.Build.props
+++ b/tests/Directory.Build.props
@@ -7,6 +7,7 @@
false
true
$(InterceptorsNamespaces);Linqraft
+ true
diff --git a/tests/Linqraft.Tests/Issue207_NestedSelectExprTest.cs b/tests/Linqraft.Tests/Issue207_NestedSelectExprTest.cs
new file mode 100644
index 00000000..5ee9d547
--- /dev/null
+++ b/tests/Linqraft.Tests/Issue207_NestedSelectExprTest.cs
@@ -0,0 +1,153 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Linqraft.Tests;
+
+///
+/// Test case for nested SelectExpr calls.
+/// When SelectExpr is used inside another SelectExpr, the inner SelectExpr should be
+/// converted to a regular Select call and only the outer SelectExpr should generate an interceptor.
+///
+public class Issue207_NestedSelectExprTest
+{
+ private readonly List TestData =
+ [
+ new NestedEntity207
+ {
+ Id = 1,
+ Name = "Entity1",
+ Items =
+ [
+ new NestedItem207
+ {
+ Id = 101,
+ Title = "Item1-1",
+ SubItems =
+ [
+ new NestedSubItem207 { Id = 1001, Value = "SubItem1-1-1" },
+ new NestedSubItem207 { Id = 1002, Value = "SubItem1-1-2" },
+ ],
+ },
+ new NestedItem207
+ {
+ Id = 102,
+ Title = "Item1-2",
+ SubItems = [new NestedSubItem207 { Id = 1003, Value = "SubItem1-2-1" }],
+ },
+ ],
+ },
+ new NestedEntity207
+ {
+ Id = 2,
+ Name = "Entity2",
+ Items =
+ [
+ new NestedItem207
+ {
+ Id = 201,
+ Title = "Item2-1",
+ SubItems = [],
+ },
+ ],
+ },
+ ];
+
+ // !WARNING: This test (inside-SelectExpr) is only work above .NET 9 (reason is unknown...)
+#if NET9_0_OR_GREATER
+
+ ///
+ /// Test: Outer SelectExpr with inner SelectExpr for nested DTO types.
+ /// This verifies the nested SelectExpr behavior where:
+ /// - Outer SelectExpr generates NestedEntity207Dto in user namespace
+ /// - Inner SelectExpr generates NestedItem207Dto in user namespace (explicit type)
+ /// - Innermost Select generates SubItemDto in LinqraftGenerated_ namespace (auto-generated)
+ ///
+ [Fact]
+ public void NestedSelectExpr_WithExplicitDtoTypes_ShouldWork()
+ {
+ var query = TestData.AsQueryable();
+
+ // Outer SelectExpr with explicit DTO type
+ // Items uses inner SelectExpr with explicit DTO type
+ // SubItems uses regular Select - the DTO will be auto-generated
+ var result = query
+ .SelectExpr(x => new
+ {
+ x.Id,
+ x.Name,
+ // Using SelectExpr with explicit DTO type - NestedItem207Dto should be in user namespace
+ Items = x.Items.SelectExpr(i => new
+ {
+ i.Id,
+ i.Title,
+ // Using regular Select - SubItemDto should be auto-generated in LinqraftGenerated_ namespace
+ SubItem = i.SubItems.Select(si => new { si.Id, si.Value }),
+ }),
+ })
+ .ToList();
+
+ result.Count.ShouldBe(2);
+
+ // Verify first entity
+ var first = result[0];
+ first.Id.ShouldBe(1);
+ first.Name.ShouldBe("Entity1");
+ first.Items.Count().ShouldBe(2);
+
+ var firstItem = first.Items.First();
+ firstItem.Id.ShouldBe(101);
+ firstItem.Title.ShouldBe("Item1-1");
+ firstItem.SubItem.Count().ShouldBe(2);
+
+ var firstSubItem = firstItem.SubItem.First();
+ firstSubItem.Id.ShouldBe(1001);
+ firstSubItem.Value.ShouldBe("SubItem1-1-1");
+
+ // Verify second entity
+ var second = result[1];
+ second.Id.ShouldBe(2);
+ second.Items.Count().ShouldBe(1);
+
+ // Verify that NestedEntity207Dto is NOT in the LinqraftGenerated_ namespace
+ var nestedEntityDtoType = typeof(NestedEntity207Dto);
+ nestedEntityDtoType.Namespace!.ShouldNotContain("LinqraftGenerated");
+ nestedEntityDtoType.Namespace.ShouldBe("Linqraft.Tests");
+
+ // Verify that NestedItem207Dto (explicit type) is NOT in the LinqraftGenerated_ namespace
+ var nestedItemDtoType = typeof(NestedItem207Dto);
+ nestedItemDtoType.Namespace!.ShouldNotContain("LinqraftGenerated");
+ nestedItemDtoType.Namespace.ShouldBe("Linqraft.Tests");
+
+ // Verify that the auto-generated SubItemDto IS in the LinqraftGenerated_ namespace
+ // Access SubItem type through reflection on NestedItem207Dto
+ var subItemProperty = nestedItemDtoType.GetProperty("SubItem");
+ subItemProperty.ShouldNotBeNull();
+ var subItemElementType = subItemProperty
+ .PropertyType.GetGenericArguments()
+ .FirstOrDefault();
+ subItemElementType.ShouldNotBeNull();
+ subItemElementType!.Namespace!.ShouldContain("LinqraftGenerated");
+ }
+#endif
+
+ // Test data classes for the nested SelectExpr test
+ internal class NestedEntity207
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = null!;
+ public List Items { get; set; } = [];
+ }
+
+ internal class NestedItem207
+ {
+ public int Id { get; set; }
+ public string Title { get; set; } = null!;
+ public List SubItems { get; set; } = [];
+ }
+
+ internal class NestedSubItem207
+ {
+ public int Id { get; set; }
+ public string Value { get; set; } = null!;
+ }
+}
diff --git a/tests/Linqraft.Tests/TutorialCaseTest.cs b/tests/Linqraft.Tests/TutorialCaseTest.cs
index ab06cef0..6cadc5cc 100644
--- a/tests/Linqraft.Tests/TutorialCaseTest.cs
+++ b/tests/Linqraft.Tests/TutorialCaseTest.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Linq;
@@ -25,6 +26,8 @@ public void TryTutorialCaseExplicit()
Email = o.Customer?.EmailAddress,
Phone = o.Customer?.PhoneNumber,
},
+ LatestOrderDate = o.OrderItems.Max(oi => oi.OrderDate),
+ TotalAmount = o.OrderItems.Sum(oi => oi.Quantity * oi.UnitPrice),
Items = o.OrderItems.Select(oi => new
{
ProductName = oi.Product?.Name,
@@ -70,6 +73,8 @@ public class OrderItem
{
public Product? Product { get; set; }
public int Quantity { get; set; }
+ public decimal UnitPrice { get; set; }
+ public DateTime OrderDate { get; set; }
}
public class Product