diff --git a/playground/Services/CodeGenerationService.cs b/playground/Services/CodeGenerationService.cs
index b0f87732..738fd476 100644
--- a/playground/Services/CodeGenerationService.cs
+++ b/playground/Services/CodeGenerationService.cs
@@ -66,7 +66,7 @@ public GeneratedOutput GenerateOutput(
// Generate code for each SelectExpr
var queryExpressionBuilder = new StringBuilder();
- var dtoClassBuilder = new StringBuilder();
+ var allDtoClasses = new List
();
foreach (var info in allSelectExprInfos)
{
@@ -78,19 +78,17 @@ public GeneratedOutput GenerateOutput(
info.Invocation.SyntaxTree
);
var location = semanticModel.GetInterceptableLocation(info.Invocation);
+ var fields = info.GenerateStaticFields();
var selectExprCodes = info.GenerateSelectExprCodes(location!);
- var dtoClasses = info.GenerateDtoClasses()
- .GroupBy(c => c.FullName)
- .Select(g => g.First())
- .ToList();
+
+ // Collect DTOs from all SelectExpr calls
+ var dtoClasses = info.GenerateDtoClasses();
+ allDtoClasses.AddRange(dtoClasses);
queryExpressionBuilder.AppendLine(
- GenerateSourceCodeSnippets.BuildExprCodeSnippets(selectExprCodes)
- );
- dtoClassBuilder.AppendLine(
- GenerateSourceCodeSnippets.BuildDtoCodeSnippetsGroupedByNamespace(
- dtoClasses,
- config
+ GenerateSourceCodeSnippets.BuildExprCodeSnippets(
+ selectExprCodes,
+ fields != null ? [fields] : []
)
);
}
@@ -102,8 +100,13 @@ public GeneratedOutput GenerateOutput(
}
}
+ // Generate DTOs with global deduplication
+ var dtoCode = GenerateSourceCodeSnippets.BuildGlobalDtoCodeSnippet(
+ allDtoClasses,
+ config
+ );
+
var expressionCode = queryExpressionBuilder.ToString().TrimEnd();
- var dtoCode = dtoClassBuilder.ToString().TrimEnd();
// Filter out internal attributes from the DTO output for cleaner display
dtoCode = FilterInternalAttributes(dtoCode);
@@ -161,7 +164,17 @@ SemanticModel semanticModel
private static bool IsSelectExprInvocation(InvocationExpressionSyntax invocation)
{
var expression = invocation.Expression;
- 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 (SelectExprHelper.IsNestedInsideAnotherSelectExpr(invocation))
+ return false;
+
+ return true;
}
private static SelectExprInfo? GetSelectExprInfo(
diff --git a/src/Linqraft.Analyzer/AutoGeneratedDtoUsageAnalyzer.cs b/src/Linqraft.Analyzer/AutoGeneratedDtoUsageAnalyzer.cs
index 870da620..d7fbead4 100644
--- a/src/Linqraft.Analyzer/AutoGeneratedDtoUsageAnalyzer.cs
+++ b/src/Linqraft.Analyzer/AutoGeneratedDtoUsageAnalyzer.cs
@@ -38,16 +38,25 @@ public class AutoGeneratedDtoUsageAnalyzer : BaseLinqraftAnalyzer
protected override void RegisterActions(AnalysisContext context)
{
// Check object creation expressions (new GeneratedDto(...))
- context.RegisterSyntaxNodeAction(AnalyzeObjectCreation, SyntaxKind.ObjectCreationExpression);
+ context.RegisterSyntaxNodeAction(
+ AnalyzeObjectCreation,
+ SyntaxKind.ObjectCreationExpression
+ );
// Check parameter declarations (void Foo(GeneratedDto dto))
context.RegisterSyntaxNodeAction(AnalyzeParameter, SyntaxKind.Parameter);
// Check variable declarations (GeneratedDto dto = ...)
- context.RegisterSyntaxNodeAction(AnalyzeVariableDeclaration, SyntaxKind.VariableDeclaration);
+ context.RegisterSyntaxNodeAction(
+ AnalyzeVariableDeclaration,
+ SyntaxKind.VariableDeclaration
+ );
// Check property declarations (public GeneratedDto MyDto { get; set; })
- context.RegisterSyntaxNodeAction(AnalyzePropertyDeclaration, SyntaxKind.PropertyDeclaration);
+ context.RegisterSyntaxNodeAction(
+ AnalyzePropertyDeclaration,
+ SyntaxKind.PropertyDeclaration
+ );
// Check field declarations (private GeneratedDto _myDto;)
context.RegisterSyntaxNodeAction(AnalyzeFieldDeclaration, SyntaxKind.FieldDeclaration);
@@ -102,7 +111,10 @@ private void AnalyzeVariableDeclaration(SyntaxNodeAnalysisContext context)
return;
}
- var typeInfo = context.SemanticModel.GetTypeInfo(variableDeclaration.Type, context.CancellationToken);
+ var typeInfo = context.SemanticModel.GetTypeInfo(
+ variableDeclaration.Type,
+ context.CancellationToken
+ );
var type = typeInfo.Type;
if (type != null && HasAutoGeneratedDtoAttribute(type))
@@ -115,7 +127,10 @@ private void AnalyzePropertyDeclaration(SyntaxNodeAnalysisContext context)
{
var propertyDeclaration = (PropertyDeclarationSyntax)context.Node;
- var typeInfo = context.SemanticModel.GetTypeInfo(propertyDeclaration.Type, context.CancellationToken);
+ var typeInfo = context.SemanticModel.GetTypeInfo(
+ propertyDeclaration.Type,
+ context.CancellationToken
+ );
var type = typeInfo.Type;
if (type != null && HasAutoGeneratedDtoAttribute(type))
@@ -128,7 +143,10 @@ private void AnalyzeFieldDeclaration(SyntaxNodeAnalysisContext context)
{
var fieldDeclaration = (FieldDeclarationSyntax)context.Node;
- var typeInfo = context.SemanticModel.GetTypeInfo(fieldDeclaration.Declaration.Type, context.CancellationToken);
+ var typeInfo = context.SemanticModel.GetTypeInfo(
+ fieldDeclaration.Declaration.Type,
+ context.CancellationToken
+ );
var type = typeInfo.Type;
if (type != null && HasAutoGeneratedDtoAttribute(type))
@@ -143,35 +161,36 @@ private void AnalyzeFieldDeclaration(SyntaxNodeAnalysisContext context)
private static bool HasAutoGeneratedDtoAttribute(ITypeSymbol type)
{
// Check direct attributes on the type
- return type.GetAttributes().Any(attr =>
- {
- var attrClass = attr.AttributeClass;
- if (attrClass == null)
+ return type.GetAttributes()
+ .Any(attr =>
{
- return false;
- }
-
- // Optimized check: compare Name and ContainingNamespace instead of ToDisplayString()
- if (attrClass.Name != "LinqraftAutoGeneratedDtoAttribute")
- {
- return false;
- }
-
- var containingNamespace = attrClass.ContainingNamespace;
- return containingNamespace != null
- && !containingNamespace.IsGlobalNamespace
- && containingNamespace.Name == "Linqraft"
- && containingNamespace.ContainingNamespace?.IsGlobalNamespace == true;
- });
+ var attrClass = attr.AttributeClass;
+ if (attrClass == null)
+ {
+ return false;
+ }
+
+ // Optimized check: compare Name and ContainingNamespace instead of ToDisplayString()
+ if (attrClass.Name != "LinqraftAutoGeneratedDtoAttribute")
+ {
+ return false;
+ }
+
+ var containingNamespace = attrClass.ContainingNamespace;
+ return containingNamespace != null
+ && !containingNamespace.IsGlobalNamespace
+ && containingNamespace.Name == "Linqraft"
+ && containingNamespace.ContainingNamespace?.IsGlobalNamespace == true;
+ });
}
- private void ReportDiagnostic(SyntaxNodeAnalysisContext context, Location location, string typeName)
+ private void ReportDiagnostic(
+ SyntaxNodeAnalysisContext context,
+ Location location,
+ string typeName
+ )
{
- var diagnostic = Diagnostic.Create(
- RuleInstance,
- location,
- typeName
- );
+ var diagnostic = Diagnostic.Create(RuleInstance, location, typeName);
context.ReportDiagnostic(diagnostic);
}
}
diff --git a/src/Linqraft.Analyzer/GroupByAnonymousKeyAnalyzer.cs b/src/Linqraft.Analyzer/GroupByAnonymousKeyAnalyzer.cs
index cec53b3e..e03b1f26 100644
--- a/src/Linqraft.Analyzer/GroupByAnonymousKeyAnalyzer.cs
+++ b/src/Linqraft.Analyzer/GroupByAnonymousKeyAnalyzer.cs
@@ -38,10 +38,7 @@ public class GroupByAnonymousKeyAnalyzer : BaseLinqraftAnalyzer
protected override void RegisterActions(AnalysisContext context)
{
- context.RegisterSyntaxNodeAction(
- AnalyzeInvocation,
- SyntaxKind.InvocationExpression
- );
+ context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
}
private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
@@ -74,10 +71,7 @@ private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
}
// Report diagnostic on the anonymous type in GroupBy
- var diagnostic = Diagnostic.Create(
- RuleInstance,
- anonymousObject.GetLocation()
- );
+ var diagnostic = Diagnostic.Create(RuleInstance, anonymousObject.GetLocation());
context.ReportDiagnostic(diagnostic);
}
diff --git a/src/Linqraft.Analyzer/GroupByAnonymousKeyCodeFixProvider.cs b/src/Linqraft.Analyzer/GroupByAnonymousKeyCodeFixProvider.cs
index 057758ad..2210f895 100644
--- a/src/Linqraft.Analyzer/GroupByAnonymousKeyCodeFixProvider.cs
+++ b/src/Linqraft.Analyzer/GroupByAnonymousKeyCodeFixProvider.cs
@@ -106,8 +106,8 @@ AnonymousObjectCreationExpressionSyntax anonymousObject
}
// Fallback to generic name based on property names
- var propertyNames = anonymousObject.Initializers
- .Select(i => GetPropertyName(i))
+ var propertyNames = anonymousObject
+ .Initializers.Select(i => GetPropertyName(i))
.Where(n => !string.IsNullOrEmpty(n))
.Take(2)
.ToList();
diff --git a/src/Linqraft.Core/DtoProperty.cs b/src/Linqraft.Core/DtoProperty.cs
index a822c9c9..ad23f9d5 100644
--- a/src/Linqraft.Core/DtoProperty.cs
+++ b/src/Linqraft.Core/DtoProperty.cs
@@ -157,7 +157,10 @@ TypeSymbol is IErrorTypeSymbol
ITypeSymbol? explicitNestedDtoType = null;
string? explicitNestedDtoTypeName = null;
var selectExprInvocation = FindSelectExprInvocation(expression);
- if (selectExprInvocation is not null && selectExprInvocation.ArgumentList.Arguments.Count > 0)
+ if (
+ selectExprInvocation is not null
+ && selectExprInvocation.ArgumentList.Arguments.Count > 0
+ )
{
var lambdaArg = selectExprInvocation.ArgumentList.Arguments[0].Expression;
if (lambdaArg is LambdaExpressionSyntax nestedLambda)
@@ -165,38 +168,65 @@ TypeSymbol is IErrorTypeSymbol
// Get collection element type from the SelectExpr's source
ITypeSymbol? collectionType = null;
- if (selectExprInvocation.Expression is MemberAccessExpressionSyntax selectExprMemberAccess)
+ 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)
+ 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))
+ // Use the type symbol to get the fully qualified name (including parent classes)
+ // If the type symbol is not available or is an error type, use syntax-based name extraction
+ if (
+ explicitNestedDtoType is not null
+ && explicitNestedDtoType is not IErrorTypeSymbol
+ )
{
- explicitNestedDtoTypeName = $"global::{callerNamespace}.{simpleTypeName}";
+ explicitNestedDtoTypeName = explicitNestedDtoType.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ );
}
else
{
- explicitNestedDtoTypeName = $"global::{simpleTypeName}";
+ // Fallback: build the name from syntax
+ // This happens when the DTO is generated by another SelectExpr and doesn't exist yet
+
+ var callerNamespace =
+ selectExprInvocation
+ .Ancestors()
+ .OfType()
+ .FirstOrDefault()
+ ?.Name.ToString()
+ ?? "";
+
+ // Get the type name from syntax - it might already include parent class qualifiers
+ // e.g., "Issue_NestedSelectExprTest.NestedItemDtoEnumerable" or just "NestedItem207Dto"
+ var typeName = tResultSyntax.ToString();
+
+ // If the type name contains a dot, it's already qualified (e.g., "ClassName.DtoName")
+ // In that case, respect the qualification and don't modify it
+ // Otherwise, assume the DTO will be generated at the namespace level (NOT nested)
+ // unless the user has provided a partial class declaration indicating it should be nested
+
+ if (!string.IsNullOrEmpty(callerNamespace))
+ {
+ explicitNestedDtoTypeName = $"global::{callerNamespace}.{typeName}";
+ }
+ else
+ {
+ explicitNestedDtoTypeName = $"global::{typeName}";
+ }
}
}
}
@@ -240,129 +270,133 @@ nestedLambda.Body is AnonymousObjectCreationExpressionSyntax nestedAnonymous
// 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)
- {
- var lambdaArg = selectInvocation.ArgumentList.Arguments[0].Expression;
- if (lambdaArg is LambdaExpressionSyntax nestedLambda)
+ // 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)
{
- // Get collection element type from the Select's source
- ITypeSymbol? collectionType = null;
-
- if (selectInvocation.Expression is MemberAccessExpressionSyntax selectMemberAccess)
+ var lambdaArg = selectInvocation.ArgumentList.Arguments[0].Expression;
+ if (lambdaArg is LambdaExpressionSyntax nestedLambda)
{
- // Check if the base expression is a MemberBindingExpressionSyntax (part of conditional access)
- // e.g., d.InnerData?.Childs.Select(...) - here .Childs is a MemberBindingExpressionSyntax
- if (selectMemberAccess.Expression is MemberBindingExpressionSyntax)
+ // Get collection element type from the Select's source
+ ITypeSymbol? collectionType = null;
+
+ if (
+ selectInvocation.Expression
+ is MemberAccessExpressionSyntax selectMemberAccess
+ )
{
- // For conditional access chains (e.g., d.InnerData?.Childs.Select),
- // we need to find the conditional access expression and get the type from there
- var conditionalAccess = expression
- .DescendantNodesAndSelf()
- .OfType()
- .FirstOrDefault();
- if (conditionalAccess is not null)
+ // Check if the base expression is a MemberBindingExpressionSyntax (part of conditional access)
+ // e.g., d.InnerData?.Childs.Select(...) - here .Childs is a MemberBindingExpressionSyntax
+ if (selectMemberAccess.Expression is MemberBindingExpressionSyntax)
{
- // Get the type of the WhenNotNull part up to the collection
- // For d.InnerData?.Childs.Select, we need to get the type of d.InnerData.Childs
- // The semantic model can resolve the type of the conditional access expression result
- // which includes the null-conditional path
- var memberBinding = (MemberBindingExpressionSyntax)
- selectMemberAccess.Expression;
- var memberName = memberBinding.Name.Identifier.Text;
-
- // Get the type of the expression before the ?. operator
- var baseType = semanticModel
- .GetTypeInfo(conditionalAccess.Expression)
- .Type;
- if (baseType is not null)
+ // For conditional access chains (e.g., d.InnerData?.Childs.Select),
+ // we need to find the conditional access expression and get the type from there
+ var conditionalAccess = expression
+ .DescendantNodesAndSelf()
+ .OfType()
+ .FirstOrDefault();
+ if (conditionalAccess is not null)
{
- // Get the non-nullable underlying type if it's a nullable type
- // e.g., ChildData? -> ChildData
- var nonNullableBaseType =
- RoslynTypeHelper.GetNonNullableType(baseType) ?? baseType;
-
- // Find the member with the specified name on the base type
- var memberSymbol =
- nonNullableBaseType
- .GetMembers(memberName)
- .OfType()
- .FirstOrDefault()
- ?? (ISymbol?)
+ // Get the type of the WhenNotNull part up to the collection
+ // For d.InnerData?.Childs.Select, we need to get the type of d.InnerData.Childs
+ // The semantic model can resolve the type of the conditional access expression result
+ // which includes the null-conditional path
+ var memberBinding = (MemberBindingExpressionSyntax)
+ selectMemberAccess.Expression;
+ var memberName = memberBinding.Name.Identifier.Text;
+
+ // Get the type of the expression before the ?. operator
+ var baseType = semanticModel
+ .GetTypeInfo(conditionalAccess.Expression)
+ .Type;
+ if (baseType is not null)
+ {
+ // Get the non-nullable underlying type if it's a nullable type
+ // e.g., ChildData? -> ChildData
+ var nonNullableBaseType =
+ RoslynTypeHelper.GetNonNullableType(baseType) ?? baseType;
+
+ // Find the member with the specified name on the base type
+ var memberSymbol =
nonNullableBaseType
.GetMembers(memberName)
- .OfType()
- .FirstOrDefault();
- if (memberSymbol is IPropertySymbol propSymbol)
- {
- collectionType = propSymbol.Type;
- }
- else if (memberSymbol is IFieldSymbol fieldSymbol)
- {
- collectionType = fieldSymbol.Type;
+ .OfType()
+ .FirstOrDefault()
+ ?? (ISymbol?)
+ nonNullableBaseType
+ .GetMembers(memberName)
+ .OfType()
+ .FirstOrDefault();
+ if (memberSymbol is IPropertySymbol propSymbol)
+ {
+ collectionType = propSymbol.Type;
+ }
+ else if (memberSymbol is IFieldSymbol fieldSymbol)
+ {
+ collectionType = fieldSymbol.Type;
+ }
}
}
}
+ else
+ {
+ // Normal case: direct member access
+ collectionType = semanticModel
+ .GetTypeInfo(selectMemberAccess.Expression)
+ .Type;
+ }
}
- else
+ else if (selectInvocation.Expression is MemberBindingExpressionSyntax)
{
- // Normal case: direct member access
- collectionType = semanticModel
- .GetTypeInfo(selectMemberAccess.Expression)
- .Type;
- }
- }
- else if (selectInvocation.Expression is MemberBindingExpressionSyntax)
- {
- // For conditional access (?.Select), we need to find the base expression
- // Look for ConditionalAccessExpressionSyntax in ancestors
- var conditionalAccess = expression
- .DescendantNodesAndSelf()
- .OfType()
- .FirstOrDefault();
- if (conditionalAccess is not null)
- {
- collectionType = semanticModel
- .GetTypeInfo(conditionalAccess.Expression)
- .Type;
+ // For conditional access (?.Select), we need to find the base expression
+ // Look for ConditionalAccessExpressionSyntax in ancestors
+ var conditionalAccess = expression
+ .DescendantNodesAndSelf()
+ .OfType()
+ .FirstOrDefault();
+ if (conditionalAccess is not null)
+ {
+ collectionType = semanticModel
+ .GetTypeInfo(conditionalAccess.Expression)
+ .Type;
+ }
}
- }
- if (
- collectionType is INamedTypeSymbol namedCollectionType
- && namedCollectionType.TypeArguments.Length > 0
- )
- {
- var elementType = namedCollectionType.TypeArguments[0];
-
- // Support both anonymous types and named types
if (
- nestedLambda.Body is AnonymousObjectCreationExpressionSyntax nestedAnonymous
+ collectionType is INamedTypeSymbol namedCollectionType
+ && namedCollectionType.TypeArguments.Length > 0
)
{
- 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;
+ var elementType = namedCollectionType.TypeArguments[0];
+
+ // Support both anonymous types and named types
+ 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;
+ }
}
}
}
- }
} // Close the "if (nestedStructure is null)" block for Select detection
// Detect nested SelectMany (e.g., s.Childs.SelectMany(c => c.GrandChilds))
@@ -1161,6 +1195,7 @@ private static string SimplifySourceReference(string expressionStr)
///
/// Abbreviates all method calls with arguments (any method pattern like .MethodName(args))
+ /// Also abbreviates generic type arguments like .MethodName<Type1, Type2>(args) -> .MethodName(...)
///
private static string AbbreviateAllMethodCalls(string input)
{
@@ -1197,7 +1232,12 @@ private static string AbbreviateAllMethodCalls(string input)
methodNameEnd++;
}
- // Check if there's an opening parenthesis after the method name
+ var methodName = input.Substring(dotIndex + 1, methodNameEnd - dotIndex - 1);
+
+ // Skip generic type arguments if present
+ methodNameEnd = SkipGenericTypeArguments(input, methodNameEnd);
+
+ // Check if there's an opening parenthesis after the method name (and optional generic args)
if (methodNameEnd >= input.Length || input[methodNameEnd] != '(')
{
// Not a method call, just a property access
@@ -1207,22 +1247,20 @@ private static string AbbreviateAllMethodCalls(string input)
}
// This is a method call with arguments
- var methodName = input.Substring(dotIndex + 1, methodNameEnd - dotIndex - 1);
-
// Append everything before the method call
result.Append(input.Substring(pos, dotIndex - pos));
// Find the matching closing parenthesis
var parenStart = methodNameEnd;
- var depth = 1;
+ var depth2 = 1;
var endIndex = parenStart + 1;
- while (endIndex < input.Length && depth > 0)
+ while (endIndex < input.Length && depth2 > 0)
{
if (input[endIndex] == '(')
- depth++;
+ depth2++;
else if (input[endIndex] == ')')
- depth--;
+ depth2--;
endIndex++;
}
@@ -1237,7 +1275,7 @@ private static string AbbreviateAllMethodCalls(string input)
}
else
{
- // Has arguments, abbreviate
+ // Has arguments, abbreviate (including any generic type arguments)
result.Append($".{methodName}(...)");
}
pos = endIndex;
@@ -1245,4 +1283,35 @@ private static string AbbreviateAllMethodCalls(string input)
return result.ToString();
}
+
+ ///
+ /// Skips generic type arguments in a string if present at the given position.
+ /// Returns the position after the generic type arguments, or the original position if none found.
+ ///
+ /// The input string
+ /// The position to start checking for generic arguments
+ /// The position after the generic type arguments
+ private static int SkipGenericTypeArguments(string input, int startPos)
+ {
+ // Check for generic type arguments ()
+ if (startPos < input.Length && input[startPos] == '<')
+ {
+ // Find the matching closing angle bracket
+ var depth = 1;
+ var pos = startPos + 1;
+
+ while (pos < input.Length && depth > 0)
+ {
+ if (input[pos] == '<')
+ depth++;
+ else if (input[pos] == '>')
+ depth--;
+ pos++;
+ }
+
+ return pos;
+ }
+
+ return startPos;
+ }
}
diff --git a/src/Linqraft.Core/ExpressionTreeBuilder.cs b/src/Linqraft.Core/ExpressionTreeBuilder.cs
new file mode 100644
index 00000000..1cfd66fb
--- /dev/null
+++ b/src/Linqraft.Core/ExpressionTreeBuilder.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Text;
+
+namespace Linqraft.Core;
+
+///
+/// Helper class for generating pre-built Expression Tree code
+/// This generates static cached expression fields with direct initialization,
+/// avoiding the overhead of building expression trees at runtime for IQueryable operations.
+///
+public static class ExpressionTreeBuilder
+{
+ ///
+ /// Generates a static cached expression tree field with direct initialization
+ ///
+ /// The fully qualified source type name
+ /// The fully qualified result type name (DTO type)
+ /// The lambda parameter name
+ /// The lambda body code
+ /// A unique identifier for this expression to avoid collisions
+ /// A tuple of (fieldDeclaration, fieldName)
+ public static (string FieldDeclaration, string FieldName) GenerateExpressionTreeField(
+ string sourceTypeFullName,
+ string resultTypeFullName,
+ string lambdaParameterName,
+ string lambdaBody,
+ string uniqueId
+ )
+ {
+ // Generate a unique field name based on the hash
+ var hash = HashUtility.GenerateSha256Hash(uniqueId).Substring(0, 8);
+ var fieldName = $"_cachedExpression_{hash}";
+
+ // Build the expression tree field with direct initialization
+ var sb = new StringBuilder();
+
+ // The lambda body may contain newlines, so we need to properly handle multi-line expressions
+ var lines = lambdaBody.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
+ var firstLine =
+ $"private static readonly Expression> {fieldName} = {lambdaParameterName} =>";
+ if (lines.Length == 1)
+ {
+ // Single line lambda body
+ sb.AppendLine($"{firstLine} {lambdaBody};");
+ }
+ else
+ {
+ // Multi-line lambda body - need to format it properly
+ sb.AppendLine(firstLine);
+ // Indent the lambda body properly - it should be at the same level as the lambda parameter
+ // The field declaration starts at column 4 (after " ")
+ // We want the body to align after "= " which is at column 4 + "private static readonly ... = ".length
+ // For simplicity and consistency, indent by 2 additional levels from the field level
+ var indentedBody = Formatting.CodeFormatter.IndentCode(
+ lambdaBody.TrimEnd(),
+ Formatting.CodeFormatter.IndentSize
+ );
+ sb.AppendLine(indentedBody + ";");
+ }
+
+ return (sb.ToString(), fieldName);
+ }
+}
diff --git a/src/Linqraft.Core/GenerateDtoClassInfo.cs b/src/Linqraft.Core/GenerateDtoClassInfo.cs
index 804ccc94..321c09de 100644
--- a/src/Linqraft.Core/GenerateDtoClassInfo.cs
+++ b/src/Linqraft.Core/GenerateDtoClassInfo.cs
@@ -4,6 +4,7 @@
using System.Text;
using Linqraft.Core.Formatting;
using Linqraft.Core.RoslynHelpers;
+using Microsoft.CodeAnalysis;
namespace Linqraft.Core;
@@ -136,8 +137,9 @@ public string BuildCode(LinqraftConfiguration configuration)
// This attribute helps analyzers detect explicit usage of auto-generated DTOs
if (!IsExplicitRootDto)
{
- sb.AppendLine($"{classIndent}[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]");
- sb.AppendLine($"{classIndent}[Linqraft.LinqraftAutoGeneratedDtoAttribute]");
+ var dtoAttrs = GenerateSourceCodeSnippets.BuildDtoCodeAttributes();
+ var indentedAttrs = CodeFormatter.IndentCodeByLevel(dtoAttrs, ParentClasses.Count);
+ sb.AppendLine(indentedAttrs);
}
sb.AppendLine($"{classIndent}{Accessibility} partial {typeKeyword} {ClassName}");
@@ -170,7 +172,10 @@ public string BuildCode(LinqraftConfiguration configuration)
// 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)
+ if (
+ !string.IsNullOrEmpty(prop.ExplicitNestedDtoTypeName)
+ && prop.NestedStructure is not null
+ )
{
// Use the explicit DTO type name from SelectExpr
var explicitDtoName = prop.ExplicitNestedDtoTypeName;
@@ -184,22 +189,40 @@ public string BuildCode(LinqraftConfiguration configuration)
// Determine whether to re-apply nullable marker
var shouldReapplyNullable = isTypeNullable && prop.IsNullable;
- if (RoslynTypeHelper.IsAnonymousTypeByString(typeWithoutNullable))
+ // Check if it's an array type
+ var isArrayType = IsArrayType(prop, typeWithoutNullable);
+
+ // Remove [] suffix from the type string if present
+ // This is needed to extract the base type for replacement
+ var typeWithoutArray = typeWithoutNullable;
+ if (typeWithoutNullable.EndsWith("[]"))
+ {
+ typeWithoutArray = typeWithoutNullable[..^2];
+ }
+
+ if (RoslynTypeHelper.IsAnonymousTypeByString(typeWithoutArray))
{
// Direct anonymous type
propertyType = explicitDtoName;
+ if (isArrayType)
+ {
+ propertyType = $"{propertyType}[]";
+ }
if (shouldReapplyNullable)
{
propertyType = $"{propertyType}?";
}
}
- else if (RoslynTypeHelper.IsGenericTypeByString(typeWithoutNullable))
+ else if (RoslynTypeHelper.IsGenericTypeByString(typeWithoutArray))
{
// 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}>";
+ // Keep the fully qualified name including global:: prefix
+ var baseType = typeWithoutArray[..typeWithoutArray.IndexOf("<")];
+ propertyType = $"{baseType}<{explicitDtoName}>";
+ if (isArrayType)
+ {
+ propertyType = $"{propertyType}[]";
+ }
if (shouldReapplyNullable)
{
propertyType = $"{propertyType}?";
@@ -208,6 +231,10 @@ public string BuildCode(LinqraftConfiguration configuration)
else
{
propertyType = explicitDtoName!;
+ if (isArrayType)
+ {
+ propertyType = $"{propertyType}[]";
+ }
if (shouldReapplyNullable)
{
propertyType = $"{propertyType}?";
@@ -400,4 +427,18 @@ private static int GetAccessibilityLevel(string accessibility)
_ => 5, // Default to public
};
}
+
+ ///
+ /// Determines if a property type represents an array type by checking:
+ /// 1. The type symbol (IArrayTypeSymbol) - preferred when semantic model information is available
+ /// 2. The type string pattern (ends with [])
+ /// 3. The original expression syntax (ends with .ToArray()) - a necessary compromise for cases
+ /// where semantic model information is not available or the type symbol doesn't reflect the array type
+ ///
+ private static bool IsArrayType(DtoProperty prop, string typeString)
+ {
+ return prop.TypeSymbol is IArrayTypeSymbol
+ || typeString.EndsWith("[]")
+ || prop.OriginalExpression.Trim().EndsWith(".ToArray()");
+ }
}
diff --git a/src/Linqraft.Core/GenerateSourceCodeSnippets.cs b/src/Linqraft.Core/GenerateSourceCodeSnippets.cs
index aa92f004..6b95b736 100644
--- a/src/Linqraft.Core/GenerateSourceCodeSnippets.cs
+++ b/src/Linqraft.Core/GenerateSourceCodeSnippets.cs
@@ -10,21 +10,27 @@ namespace Linqraft.Core;
public static class GenerateSourceCodeSnippets
{
// Export all source codes
- public static void ExportAll(IncrementalGeneratorPostInitializationContext context)
+ public static void ExportAllConstantSnippets(
+ IncrementalGeneratorPostInitializationContext context
+ )
{
context.AddSource("InterceptsLocationAttribute.g.cs", InterceptsLocationAttribute);
context.AddSource("SelectExprExtensions.g.cs", SelectExprExtensions);
- context.AddSource("LinqraftAutoGeneratedDtoAttribute.g.cs", LinqraftAutoGeneratedDtoAttribute);
+ context.AddSource(
+ "LinqraftAutoGeneratedDtoAttribute.g.cs",
+ LinqraftAutoGeneratedDtoAttribute
+ );
}
// Generate total code with DTOs that may have different namespaces.
public static string BuildCodeSnippetAll(
List expressions,
+ List staticFields,
List dtoClassInfos,
LinqraftConfiguration configuration
)
{
- var exprPart = BuildExprCodeSnippets(expressions);
+ var exprPart = BuildExprCodeSnippets(expressions, staticFields);
var dtoPart = BuildDtoCodeSnippetsGroupedByNamespace(dtoClassInfos, configuration);
return $$"""
{{GenerateCommentHeaderPart()}}
@@ -35,8 +41,16 @@ LinqraftConfiguration configuration
}
// Generate expression part
- public static string BuildExprCodeSnippets(List expressions)
+ public static string BuildExprCodeSnippets(List expressions, List staticFields)
{
+ var fieldsPart =
+ staticFields.Count > 0
+ ? CodeFormatter.IndentCode(
+ string.Join(CodeFormatter.DefaultNewLine, staticFields),
+ CodeFormatter.IndentSize * 2
+ ) + CodeFormatter.DefaultNewLine
+ : "";
+
var indentedExpr = CodeFormatter.IndentCode(
string.Join(CodeFormatter.DefaultNewLine, expressions),
CodeFormatter.IndentSize * 2
@@ -47,12 +61,41 @@ namespace Linqraft
{
file static partial class GeneratedExpression
{
- {{indentedExpr}}
+ {{fieldsPart}}{{indentedExpr}}
}
}
""";
}
+ // Generate all DTOs in a single shared source file with global deduplication
+ public static string BuildGlobalDtoCodeSnippet(
+ List allDtoClassInfos,
+ LinqraftConfiguration configuration
+ )
+ {
+ // Deduplicate DTOs globally by FullName
+ var globallyUniqueDtos = allDtoClassInfos
+ .GroupBy(c => c.FullName)
+ .Select(g => g.First())
+ .ToList();
+
+ if (globallyUniqueDtos.Count == 0)
+ {
+ return string.Empty;
+ }
+
+ var dtoSourceCode = BuildDtoCodeSnippetsGroupedByNamespace(
+ globallyUniqueDtos,
+ configuration
+ );
+
+ return $$"""
+ {{GenerateCommentHeaderPart()}}
+ {{GenerateHeaderFlagsPart}}
+ {{dtoSourceCode}}
+ """;
+ }
+
// Generate DTO part
public static string BuildDtoCodeSnippets(List dtoClasses, string namespaceName)
{
@@ -77,10 +120,7 @@ namespace {{namespaceName}}
}
}
- ///
- /// Generate DTO part with DTOs grouped by their namespace.
- /// Each namespace gets its own block.
- ///
+ // Generate DTO part with DTOs grouped by their namespace.
public static string BuildDtoCodeSnippetsGroupedByNamespace(
List dtoClassInfos,
LinqraftConfiguration configuration
@@ -125,14 +165,26 @@ namespace {{namespaceName}}
return string.Join(CodeFormatter.DefaultNewLine, result);
}
+ // Attributes for DTO classes
+ public static string BuildDtoCodeAttributes()
+ {
+ return """
+ [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+ [global::Linqraft.LinqraftAutoGeneratedDtoAttribute]
+ """;
+ }
+
[StringSyntax("csharp")]
public const string InterceptsLocationAttribute = $$"""
- {{CommonHeader}}
+ {{CommentHeaderPartOnProd}}
+ #nullable enable
using System;
+ using System.ComponentModel;
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+ [EditorBrowsable(EditorBrowsableState.Never)]
internal sealed class InterceptsLocationAttribute : Attribute
{
public InterceptsLocationAttribute(int version, string data)
@@ -147,13 +199,32 @@ public InterceptsLocationAttribute(int version, string data)
}
""";
+ ///
+ /// Attribute to mark auto-generated DTO classes.
+ /// This attribute is used by Linqraft analyzers to detect explicit usage of auto-generated DTOs.
+ ///
[StringSyntax("csharp")]
- public const string SelectExprExtensions = $$""""
- {{CommonHeader}}
-
+ public const string LinqraftAutoGeneratedDtoAttribute = $$"""
+ {{CommentHeaderPartOnProd}}
+ #nullable enable
using System;
- using System.Collections.Generic;
- using System.Linq;
+ using System.ComponentModel;
+
+ namespace Linqraft
+ {
+ [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false, AllowMultiple = false)]
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ internal sealed class LinqraftAutoGeneratedDtoAttribute : Attribute
+ {
+ }
+ }
+ """;
+
+ [StringSyntax("csharp")]
+ public const string SelectExprExtensions = $$""""
+ {{CommentHeaderPartOnProd}}
+ #nullable enable
+ {{GenerateHeaderUsingPart}}
///
/// Dummy expression methods for Linqraft to compile correctly.
@@ -234,12 +305,7 @@ public static IEnumerable SelectExpr(this IEnumerable
- #nullable enable
- """;
-
- private static string GenerateCommentHeaderPart()
+ public static string GenerateCommentHeaderPart()
{
#if DEBUG
var now = DateTime.Now;
@@ -254,15 +320,17 @@ private static string GenerateCommentHeaderPart()
//
""";
#else
- return $"""
- //
- // This file is auto-generated by Linqraft (ver. {ThisAssembly.AssemblyInformationalVersion})
- //
- """;
+ return CommentHeaderPartOnProd;
#endif
}
- private const string GenerateHeaderFlagsPart = """
+ private const string CommentHeaderPartOnProd = $"""
+ //
+ // This file is auto-generated by Linqraft (ver. {ThisAssembly.AssemblyVersion})
+ //
+ """;
+
+ public const string GenerateHeaderFlagsPart = """
#nullable enable
#pragma warning disable IDE0060
#pragma warning disable CS8601
@@ -275,7 +343,9 @@ private static string GenerateCommentHeaderPart()
private const string GenerateHeaderUsingPart = """
using System;
using System.Linq;
+ using System.Linq.Expressions;
using System.Collections.Generic;
+ using System.Collections.Immutable;
""";
// OverloadPriorityAttribute is usable C# 13 or later.
@@ -286,26 +356,4 @@ private static string GenerateCommentHeaderPart()
[System.Runtime.CompilerServices.OverloadResolutionPriorityAttribute(-1)]
#endif
""";
-
- ///
- /// Attribute to mark auto-generated DTO classes.
- /// This attribute is used by Linqraft analyzers to detect explicit usage of auto-generated DTOs.
- ///
- [StringSyntax("csharp")]
- public const string LinqraftAutoGeneratedDtoAttribute = $$"""
- {{CommonHeader}}
- using System;
-
- namespace Linqraft
- {
- ///
- /// Indicates that a class is an auto-generated DTO created by the Linqraft source generator.
- /// This attribute is used to detect explicit usage of auto-generated DTOs in user code.
- ///
- [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false, AllowMultiple = false)]
- internal sealed class LinqraftAutoGeneratedDtoAttribute : Attribute
- {
- }
- }
- """;
}
diff --git a/src/Linqraft.Core/LinqraftConfiguration.cs b/src/Linqraft.Core/LinqraftConfiguration.cs
index e0972f77..34148203 100644
--- a/src/Linqraft.Core/LinqraftConfiguration.cs
+++ b/src/Linqraft.Core/LinqraftConfiguration.cs
@@ -16,6 +16,8 @@ public record LinqraftConfiguration
"build_property.LinqraftArrayNullabilityRemoval";
const string LinqraftNestedDtoUseHashNamespaceOptionKey =
"build_property.LinqraftNestedDtoUseHashNamespace";
+ const string LinqraftUsePrebuildExpressionOptionKey =
+ "build_property.LinqraftUsePrebuildExpression";
///
/// The namespace where global namespace DTOs should exist.
@@ -62,6 +64,15 @@ public record LinqraftConfiguration
///
public bool NestedDtoUseHashNamespace { get; init; } = true;
+ ///
+ /// Whether to pre-build and cache Expression Trees for IQueryable operations to improve performance.
+ /// When enabled, expression trees are built at compile-time and cached as static fields, avoiding runtime construction.
+ /// Only applies to IQueryable patterns (not IEnumerable) and only for named/predefined/explicit DTO types (not anonymous types).
+ /// Note: Pre-building is not applied when there are capture variables in the lambda expression.
+ /// Default is false (disabled)
+ ///
+ public bool UsePrebuildExpression { get; init; } = false;
+
///
/// Gets the actual property accessor to use based on configuration
///
@@ -107,6 +118,10 @@ out var arrayNullabilityRemovalStr
LinqraftNestedDtoUseHashNamespaceOptionKey,
out var NestedDtoUseHashNamespaceStr
);
+ globalOptions.GlobalOptions.TryGetValue(
+ LinqraftUsePrebuildExpressionOptionKey,
+ out var usePrebuildExpressionStr
+ );
var linqraftOptions = new LinqraftConfiguration();
if (!string.IsNullOrWhiteSpace(globalNamespace))
@@ -155,6 +170,13 @@ out var commentOutputEnum
NestedDtoUseHashNamespace = NestedDtoUseHashNamespace,
};
}
+ if (bool.TryParse(usePrebuildExpressionStr, out var usePrebuildExpression))
+ {
+ linqraftOptions = linqraftOptions with
+ {
+ UsePrebuildExpression = usePrebuildExpression,
+ };
+ }
return linqraftOptions;
}
}
diff --git a/src/Linqraft.Core/RoslynHelpers/RoslynTypeHelper.cs b/src/Linqraft.Core/RoslynHelpers/RoslynTypeHelper.cs
index 111702b4..eb36d82f 100644
--- a/src/Linqraft.Core/RoslynHelpers/RoslynTypeHelper.cs
+++ b/src/Linqraft.Core/RoslynHelpers/RoslynTypeHelper.cs
@@ -254,10 +254,14 @@ private static bool ContainsMethodInvocation(ExpressionSyntax? expression, strin
.DescendantNodesAndSelf()
.OfType()
.Any(inv =>
- (inv.Expression is MemberAccessExpressionSyntax ma
- && ma.Name.Identifier.Text == methodName)
- || (inv.Expression is MemberBindingExpressionSyntax mb
- && mb.Name.Identifier.Text == methodName)
+ (
+ inv.Expression is MemberAccessExpressionSyntax ma
+ && ma.Name.Identifier.Text == methodName
+ )
+ || (
+ inv.Expression is MemberBindingExpressionSyntax mb
+ && mb.Name.Identifier.Text == methodName
+ )
);
}
@@ -266,24 +270,24 @@ private static bool ContainsMethodInvocation(ExpressionSyntax? expression, strin
///
/// The expression to check
/// True if the expression contains a Select method invocation
- public static bool ContainsSelectInvocation(ExpressionSyntax expression)
- => ContainsMethodInvocation(expression, "Select");
+ 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");
+ 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");
+ public static bool ContainsSelectExprInvocation(ExpressionSyntax expression) =>
+ ContainsMethodInvocation(expression, "SelectExpr");
///
/// Finds all SelectExpr invocations in an expression.
@@ -301,10 +305,14 @@ ExpressionSyntax expression
.DescendantNodesAndSelf()
.OfType()
.Where(inv =>
- (inv.Expression is MemberAccessExpressionSyntax ma
- && ma.Name.Identifier.Text == "SelectExpr")
- || (inv.Expression is MemberBindingExpressionSyntax mb
- && mb.Name.Identifier.Text == "SelectExpr")
+ (
+ inv.Expression is MemberAccessExpressionSyntax ma
+ && ma.Name.Identifier.Text == "SelectExpr"
+ )
+ || (
+ inv.Expression is MemberBindingExpressionSyntax mb
+ && mb.Name.Identifier.Text == "SelectExpr"
+ )
);
}
diff --git a/src/Linqraft.Core/SelectExprHelper.cs b/src/Linqraft.Core/SelectExprHelper.cs
index 10b5602d..5ad0b8e3 100644
--- a/src/Linqraft.Core/SelectExprHelper.cs
+++ b/src/Linqraft.Core/SelectExprHelper.cs
@@ -82,4 +82,31 @@ SemanticModel semanticModel
var symbolInfo = semanticModel.GetSymbolInfo(invocation);
return IsSelectExprMethod(symbolInfo.Symbol);
}
+
+ ///
+ /// Checks if the given SelectExpr invocation is nested inside another SelectExpr invocation.
+ /// 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.
+ ///
+ /// The invocation expression to check
+ /// True if the invocation is nested inside another SelectExpr
+ public 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 (IsSelectExprInvocationSyntax(parentInvocation.Expression))
+ {
+ return true;
+ }
+ }
+ current = current.Parent;
+ }
+ return false;
+ }
}
diff --git a/src/Linqraft.Core/SelectExprInfo.cs b/src/Linqraft.Core/SelectExprInfo.cs
index 58fba0d7..50ebff17 100644
--- a/src/Linqraft.Core/SelectExprInfo.cs
+++ b/src/Linqraft.Core/SelectExprInfo.cs
@@ -140,6 +140,16 @@ protected abstract string GenerateSelectExprMethod(
InterceptableLocation location
);
+ ///
+ /// Generates static field declarations for pre-built expressions (if enabled)
+ ///
+ public virtual string? GenerateStaticFields()
+ {
+ // Default implementation returns null (no fields)
+ // Derived classes can override this if they need static fields
+ return null;
+ }
+
///
/// Generates SelectExpr code for a given interceptable location
///
@@ -296,7 +306,11 @@ protected string GeneratePropertyAssignment(DtoProperty property, int indents)
if (RoslynTypeHelper.ContainsSelectExprInvocation(syntax))
{
// For nested SelectExpr, convert to Select and handle the nested structure
- var convertedSelectExpr = ConvertNestedSelectExprWithRoslyn(syntax, property, indents);
+ var convertedSelectExpr = ConvertNestedSelectExprWithRoslyn(
+ syntax,
+ property,
+ indents
+ );
if (convertedSelectExpr != expression)
{
return convertedSelectExpr;
@@ -1203,29 +1217,14 @@ LinqExpressionInfo info
// 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)
+ if (
+ property.ExplicitNestedDtoType is not null
+ && property.ExplicitNestedDtoType is not IErrorTypeSymbol
+ )
{
- // Get the fully qualified name including namespace
+ // Get the fully qualified name including namespace and parent classes
var typeSymbol = property.ExplicitNestedDtoType;
- var displayString = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
-
- // 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;
- }
+ nestedDtoName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
}
else if (!string.IsNullOrEmpty(property.ExplicitNestedDtoTypeName))
{
@@ -1591,9 +1590,15 @@ property is not null
{
defaultValue = coalescingDefaultValue;
}
- else if (property is not null && RoslynTypeHelper.IsCollectionType(property.TypeSymbol))
+ else if (
+ property is not null
+ && RoslynTypeHelper.IsCollectionType(property.TypeSymbol)
+ )
{
- defaultValue = GetEmptyCollectionExpressionForType(property.TypeSymbol, syntax.ToString());
+ defaultValue = GetEmptyCollectionExpressionForType(
+ property.TypeSymbol,
+ syntax.ToString()
+ );
}
else
{
@@ -1661,7 +1666,7 @@ private static T RemoveComments(T node)
ChainedMethods = info.ChainedMethods,
HasNullableAccess = info.HasNullableAccess,
CoalescingDefaultValue = info.CoalescingDefaultValue,
- NullCheckExpression = cleanedNullCheckExpression
+ NullCheckExpression = cleanedNullCheckExpression,
};
}
@@ -1701,7 +1706,7 @@ private static T RemoveComments(T node)
ChainedMethods = fullyQualifiedChainedMethods,
HasNullableAccess = info.HasNullableAccess,
CoalescingDefaultValue = info.CoalescingDefaultValue,
- NullCheckExpression = cleanedNullCheckExpression
+ NullCheckExpression = cleanedNullCheckExpression,
};
}
@@ -1741,7 +1746,7 @@ private static T RemoveComments(T node)
ChainedMethods = fullyQualifiedChainedMethods,
HasNullableAccess = info.HasNullableAccess,
CoalescingDefaultValue = info.CoalescingDefaultValue,
- NullCheckExpression = cleanedNullCheckExpression
+ NullCheckExpression = cleanedNullCheckExpression,
};
}
@@ -1874,8 +1879,8 @@ protected string ConvertNullableAccessToExplicitCheckWithRoslyn(
ITypeSymbol typeSymbol
)
{
- // Example: c.Child?.Id → c.Child != null ? (int?)c.Child.Id : null
- // Example: s.Child3?.Child?.Id → s.Child3 != null && s.Child3.Child != null ? (int?)s.Child3.Child.Id : null
+ // Example: c.Child?.Id → c.Child != null ? c.Child.Id : default(int?)
+ // Example: s.Child3?.Child?.Id → s.Child3 != null && s.Child3.Child != null ? s.Child3.Child.Id : default(int?)
// Example: d.InnerData?.Childs.Select(c => c.Id).ToList() → d.InnerData != null ? d.InnerData.Childs.Select(c => c.Id).ToList() : new List()
// Use Roslyn to verify this uses conditional access
@@ -1932,7 +1937,6 @@ ITypeSymbol typeSymbol
// Only use empty collection fallback for collections that use Select/SelectMany
// and when ArrayNullabilityRemoval is enabled
string defaultValue;
- string typeAnnotation;
if (
Configuration.ArrayNullabilityRemoval
@@ -1942,19 +1946,15 @@ ITypeSymbol typeSymbol
{
// For collection types with Select/SelectMany, use an empty collection as the default value
defaultValue = GetEmptyCollectionExpressionForType(typeSymbol, cleanExpression);
- // No need for nullable type annotation for collections since they use empty collection fallback
- typeAnnotation = "";
}
else
{
// For non-collection types or collections without Select/SelectMany (e.g., byte[]),
// use null or default value with nullable type annotation
- var typeSymbolValue = typeSymbol.ToDisplayString();
- typeAnnotation = typeSymbolValue != "?" ? $"({typeSymbolValue})" : "";
defaultValue = GetDefaultValueForType(typeSymbol);
}
- return $"{nullCheckPart} ? {typeAnnotation}{accessPath} : {defaultValue}";
+ return $"{nullCheckPart} ? {accessPath} : {defaultValue}";
}
///
@@ -2012,11 +2012,14 @@ string expressionText
///
protected string GetDefaultValueForType(ITypeSymbol typeSymbol)
{
- if (
- typeSymbol.IsReferenceType
- || typeSymbol.NullableAnnotation == NullableAnnotation.Annotated
- )
+ var typeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ if (RoslynTypeHelper.IsNullableType(typeSymbol))
{
+ // // Nullable (nullable value type) needs default(T?) syntax
+ if (typeSymbol.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)
+ {
+ return $"default({typeName})";
+ }
return "null";
}
return typeSymbol.SpecialType switch
diff --git a/src/Linqraft.Core/SelectExprInfoAnonymous.cs b/src/Linqraft.Core/SelectExprInfoAnonymous.cs
index 19402a56..da566503 100644
--- a/src/Linqraft.Core/SelectExprInfoAnonymous.cs
+++ b/src/Linqraft.Core/SelectExprInfoAnonymous.cs
@@ -55,6 +55,16 @@ protected override DtoStructure GenerateDtoStructure()
// Get expression type string (for documentation)
protected override string GetExprTypeString() => "anonymous";
+ ///
+ /// Generates static field declarations for pre-built expressions (if enabled)
+ ///
+ public override string? GenerateStaticFields()
+ {
+ // Anonymous types cannot use pre-built expressions because we don't know the result type at compile time
+ // The result type is inferred from the lambda and is an compiler-generated anonymous type
+ return null;
+ }
+
// Generate SelectExpr method
protected override string GenerateSelectExprMethod(
string dtoName,
@@ -67,6 +77,10 @@ InterceptableLocation location
var sb = new StringBuilder();
var id = GetUniqueId();
+
+ // Anonymous types cannot use pre-built expressions (result type is unknown)
+ // so we always use inline lambda
+
sb.AppendLine(GenerateMethodHeaderPart("anonymous type", location));
// Determine if we have capture parameters
@@ -113,6 +127,7 @@ InterceptableLocation location
sb.AppendLine($" var capture = ({captureTypeName})captureParam;");
}
+ // Anonymous types always use inline lambda (cannot use pre-built expressions)
sb.AppendLine($" var converted = matchedQuery.Select({LambdaParameterName} => new");
}
else
@@ -126,6 +141,8 @@ InterceptableLocation location
sb.AppendLine(
$" var matchedQuery = query as object as {returnTypePrefix}<{sourceTypeFullName}>;"
);
+
+ // Anonymous types always use inline lambda (cannot use pre-built expressions)
sb.AppendLine($" var converted = matchedQuery.Select({LambdaParameterName} => new");
}
@@ -141,6 +158,7 @@ InterceptableLocation location
.ToList();
sb.AppendLine(string.Join($",{CodeFormatter.DefaultNewLine}", propertyAssignments));
sb.AppendLine(" });");
+
sb.AppendLine($" return converted as object as {returnTypePrefix};");
sb.AppendLine("}");
sb.AppendLine();
diff --git a/src/Linqraft.Core/SelectExprInfoExplicitDto.cs b/src/Linqraft.Core/SelectExprInfoExplicitDto.cs
index b0bf1144..f35e4987 100644
--- a/src/Linqraft.Core/SelectExprInfoExplicitDto.cs
+++ b/src/Linqraft.Core/SelectExprInfoExplicitDto.cs
@@ -135,14 +135,23 @@ private List GenerateDtoClasses(
var actualNamespace = GetActualDtoNamespace();
// When NestedDtoUseHashNamespace option is enabled, child DTOs are placed in
- // a hash-named sub-namespace (e.g., LinqraftGenerated_{Hash}.ClassName)
+ // a hash-named sub-namespace (e.g., LinqraftGenerated_{Hash}) WITHOUT parent class nesting
// However, DTOs with explicit names from nested SelectExpr should NOT use hash namespace
+ // and should maintain their parent class structure
+ List finalParentClasses = currentParentClasses;
+ List finalParentAccessibilities = currentParentAccessibilities;
+
if (!isExplicitDto && Configuration?.NestedDtoUseHashNamespace == true)
{
var hash = structure.GetUniqueId();
actualNamespace = string.IsNullOrEmpty(actualNamespace)
? $"LinqraftGenerated_{hash}"
: $"{actualNamespace}.LinqraftGenerated_{hash}";
+
+ // Implicit DTOs in hash namespace should NOT be nested inside parent classes
+ // They are managed by the hash, so they don't need to exist within a class
+ finalParentClasses = [];
+ finalParentAccessibilities = [];
}
var dtoClassInfo = new GenerateDtoClassInfo
@@ -152,8 +161,8 @@ private List GenerateDtoClasses(
ClassName = className,
Structure = structure,
NestedClasses = [.. result],
- ParentClasses = currentParentClasses,
- ParentAccessibilities = currentParentAccessibilities,
+ ParentClasses = finalParentClasses,
+ ParentAccessibilities = finalParentAccessibilities,
ExistingProperties = existingProperties,
IsExplicitRootDto = isExplicitDto, // Mark explicit DTOs (main or from nested SelectExpr) to avoid adding the attribute
};
@@ -256,9 +265,80 @@ protected override string GetClassName(DtoStructure structure)
// Get expression type string (for documentation)
protected override string GetExprTypeString() => "explicit";
+ ///
+ /// Generates static field declarations for pre-built expressions (if enabled)
+ ///
+ public override string? GenerateStaticFields()
+ {
+ // Check if we should use pre-built expressions (only for IQueryable, not IEnumerable)
+ var usePrebuildExpression =
+ Configuration.UsePrebuildExpression && !IsEnumerableInvocation();
+
+ // Don't generate fields if captures are used (they don't work well with closures)
+ var hasCapture = CaptureArgumentExpression != null && CaptureArgumentType != null;
+
+ if (!usePrebuildExpression || hasCapture)
+ {
+ return null;
+ }
+
+ var structure = GenerateDtoStructure();
+ var sourceTypeFullName = structure.SourceTypeFullName;
+ var actualNamespace = GetActualDtoNamespace();
+ var dtoName = GetParentDtoClassName(structure);
+
+ // Build full DTO name with parent classes if nested
+ string dtoFullName;
+ if (string.IsNullOrEmpty(actualNamespace))
+ {
+ // Global namespace: no namespace prefix
+ dtoFullName =
+ ParentClasses.Count > 0
+ ? $"global::{string.Join(".", ParentClasses)}.{dtoName}"
+ : $"global::{dtoName}";
+ }
+ else
+ {
+ // Regular namespace case
+ dtoFullName =
+ ParentClasses.Count > 0
+ ? $"global::{actualNamespace}.{string.Join(".", ParentClasses)}.{dtoName}"
+ : $"global::{actualNamespace}.{dtoName}";
+ }
+
+ var id = GetUniqueId();
+
+ // Build the lambda body
+ var lambdaBodyBuilder = new StringBuilder();
+ lambdaBodyBuilder.AppendLine($"new {dtoFullName}");
+ lambdaBodyBuilder.AppendLine($" {{");
+ var propertyAssignments = structure
+ .Properties.Select(prop =>
+ {
+ var assignment = GeneratePropertyAssignment(prop, CodeFormatter.IndentSize * 2);
+ return $"{CodeFormatter.Indent(2)}{prop.Name} = {assignment}";
+ })
+ .ToList();
+ lambdaBodyBuilder.AppendLine(
+ string.Join($",{CodeFormatter.DefaultNewLine}", propertyAssignments)
+ );
+ lambdaBodyBuilder.Append(" }");
+
+ var (fieldDecl, _) = ExpressionTreeBuilder.GenerateExpressionTreeField(
+ sourceTypeFullName,
+ dtoFullName,
+ LambdaParameterName,
+ lambdaBodyBuilder.ToString(),
+ id
+ );
+
+ return fieldDecl;
+ }
+
///
/// Gets the full name for a nested DTO class using the structure.
- /// When NestedDtoUseHashNamespace is enabled, includes the LinqraftGenerated_{hash} namespace.
+ /// When NestedDtoUseHashNamespace is enabled, includes the LinqraftGenerated_{hash} namespace
+ /// WITHOUT parent class nesting (implicit DTOs are managed by hash, not class hierarchy).
///
protected override string GetNestedDtoFullNameFromStructure(DtoStructure nestedStructure)
{
@@ -269,6 +349,8 @@ protected override string GetNestedDtoFullNameFromStructure(DtoStructure nestedS
var actualNamespace = GetActualDtoNamespace();
// When NestedDtoUseHashNamespace option is enabled, include LinqraftGenerated_{hash} in namespace
+ // Implicit DTOs should NOT be nested inside parent classes - they are placed directly
+ // in the LinqraftGenerated_{hash} namespace
if (Configuration?.NestedDtoUseHashNamespace == true)
{
var hash = nestedStructure.GetUniqueId();
@@ -276,15 +358,11 @@ protected override string GetNestedDtoFullNameFromStructure(DtoStructure nestedS
? $"LinqraftGenerated_{hash}"
: $"{actualNamespace}.LinqraftGenerated_{hash}";
- // Handle parent classes
- if (ParentClasses.Count > 0)
- {
- return $"global::{generatedNamespace}.{string.Join(".", ParentClasses)}.{className}";
- }
+ // Implicit DTOs in hash namespace should NOT include parent classes
return $"global::{generatedNamespace}.{className}";
}
- // Default behavior: use GetNestedDtoFullName
+ // Default behavior: use GetNestedDtoFullName (includes parent classes)
return GetNestedDtoFullName(className);
}
@@ -365,11 +443,41 @@ InterceptableLocation location
var sb = new StringBuilder();
var id = GetUniqueId();
+
+ // Check if we should use pre-built expressions (only for IQueryable, not IEnumerable)
+ var usePrebuildExpression =
+ Configuration.UsePrebuildExpression && !IsEnumerableInvocation();
+
sb.AppendLine(GenerateMethodHeaderPart(dtoName, location));
// Determine if we have capture parameters
var hasCapture = CaptureArgumentExpression != null && CaptureArgumentType != null;
+ if (usePrebuildExpression && !hasCapture)
+ {
+ // Get the field name (we need to generate the same hash as in GenerateStaticFields)
+ var hash = HashUtility.GenerateSha256Hash(id).Substring(0, 8);
+ var fieldName = $"_cachedExpression_{hash}";
+
+ // Use the cached expression directly (no initialization needed, it's done in the field declaration)
+ sb.AppendLine(
+ $$"""
+ public static {{returnTypePrefix}} SelectExpr_{{id}}(
+ this {{returnTypePrefix}} query, Func selector)
+ {
+ return query.Provider.CreateQuery(
+ Expression.Call(null, _methodInfo_{{id}}, query.Expression, _unaryExpression_{{id}}));
+ }
+ private static readonly UnaryExpression _unaryExpression_{{id}} = Expression.Quote({{fieldName}});
+ private static readonly System.Reflection.MethodInfo _methodInfo_{{id}} = new Func<
+ IQueryable<{{sourceTypeFullName}}>,
+ Expression>,
+ IQueryable<{{dtoFullName}}>>(Queryable.Select).Method;
+ """
+ );
+ return sb.ToString();
+ }
+
if (hasCapture)
{
// Generate method with capture parameter that creates closure variables
@@ -409,6 +517,9 @@ InterceptableLocation location
sb.AppendLine($" var capture = ({captureTypeName})captureParam;");
}
+ // Note: Pre-built expressions don't work well with captures because the closure
+ // variables would be captured at compile time, not at runtime. So we disable
+ // pre-built expressions when captures are used.
sb.AppendLine(
$" var converted = matchedQuery.Select({LambdaParameterName} => new {dtoFullName}"
);
@@ -421,6 +532,7 @@ InterceptableLocation location
);
sb.AppendLine($" this {returnTypePrefix} query, Func selector)");
sb.AppendLine($"{{");
+
sb.AppendLine(
$" var matchedQuery = query as object as {returnTypePrefix}<{sourceTypeFullName}>;"
);
@@ -428,7 +540,6 @@ InterceptableLocation location
$" var converted = matchedQuery.Select({LambdaParameterName} => new {dtoFullName}"
);
}
-
sb.AppendLine($" {{");
// Generate property assignments
diff --git a/src/Linqraft.Core/SelectExprInfoNamed.cs b/src/Linqraft.Core/SelectExprInfoNamed.cs
index 1b6d1f15..2183c83f 100644
--- a/src/Linqraft.Core/SelectExprInfoNamed.cs
+++ b/src/Linqraft.Core/SelectExprInfoNamed.cs
@@ -57,6 +57,57 @@ protected override string GetDtoNamespace() =>
// Get expression type string (for documentation)
protected override string GetExprTypeString() => "predefined";
+ ///
+ /// Generates static field declarations for pre-built expressions (if enabled)
+ ///
+ public override string? GenerateStaticFields()
+ {
+ // Check if we should use pre-built expressions (only for IQueryable, not IEnumerable)
+ var usePrebuildExpression =
+ Configuration.UsePrebuildExpression && !IsEnumerableInvocation();
+
+ // Don't generate fields if captures are used (they don't work well with closures)
+ var hasCapture = CaptureArgumentExpression != null && CaptureArgumentType != null;
+
+ if (!usePrebuildExpression || hasCapture)
+ {
+ return null;
+ }
+
+ var querySourceTypeFullName = SourceType.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ );
+ var structure = GenerateDtoStructure();
+ var dtoName = GetParentDtoClassName(structure);
+ var id = GetUniqueId();
+
+ // Build the lambda body
+ var lambdaBodyBuilder = new StringBuilder();
+ lambdaBodyBuilder.AppendLine($"new {dtoName}");
+ lambdaBodyBuilder.AppendLine($" {{");
+ var propertyAssignments = structure
+ .Properties.Select(prop =>
+ {
+ var assignment = GeneratePropertyAssignment(prop, CodeFormatter.IndentSize * 2);
+ return $"{CodeFormatter.Indent(2)}{prop.Name} = {assignment}";
+ })
+ .ToList();
+ lambdaBodyBuilder.AppendLine(
+ string.Join($",{CodeFormatter.DefaultNewLine}", propertyAssignments)
+ );
+ lambdaBodyBuilder.Append(" }");
+
+ var (fieldDecl, _) = ExpressionTreeBuilder.GenerateExpressionTreeField(
+ querySourceTypeFullName,
+ dtoName,
+ LambdaParameterName,
+ lambdaBodyBuilder.ToString(),
+ id
+ );
+
+ return fieldDecl;
+ }
+
///
/// Generates the SelectExpr method code
///
@@ -74,11 +125,41 @@ InterceptableLocation location
var sb = new StringBuilder();
var id = GetUniqueId();
+
+ // Check if we should use pre-built expressions (only for IQueryable, not IEnumerable)
+ var usePrebuildExpression =
+ Configuration.UsePrebuildExpression && !IsEnumerableInvocation();
+
sb.AppendLine(GenerateMethodHeaderPart(dtoName, location));
// Determine if we have capture parameters
var hasCapture = CaptureArgumentExpression != null && CaptureArgumentType != null;
+ if (usePrebuildExpression && !hasCapture)
+ {
+ // Get the field name (we need to generate the same hash as in GenerateStaticFields)
+ var hash = HashUtility.GenerateSha256Hash(id).Substring(0, 8);
+ var fieldName = $"_cachedExpression_{hash}";
+
+ // Use the cached expression directly (no initialization needed, it's done in the field declaration)
+ sb.AppendLine(
+ $$"""
+ public static {{returnTypePrefix}} SelectExpr_{{id}}(
+ this {{returnTypePrefix}} query, Func selector)
+ {
+ return query.Provider.CreateQuery(
+ Expression.Call(null, _methodInfo_{{id}}, query.Expression, _unaryExpression_{{id}}));
+ }
+ private static readonly UnaryExpression _unaryExpression_{{id}} = Expression.Quote({{fieldName}});
+ private static readonly System.Reflection.MethodInfo _methodInfo_{{id}} = new Func<
+ IQueryable<{{querySourceTypeFullName}}>,
+ Expression>,
+ IQueryable<{{dtoName}}>>(Queryable.Select).Method;
+ """
+ );
+ return sb.ToString();
+ }
+
if (hasCapture)
{
// Generate method with capture parameter that creates closure variables
@@ -118,6 +199,9 @@ InterceptableLocation location
sb.AppendLine($" var capture = ({captureTypeName})captureParam;");
}
+ // Note: Pre-built expressions don't work well with captures because the closure
+ // variables would be captured at compile time, not at runtime. So we disable
+ // pre-built expressions when captures are used.
sb.AppendLine(
$" var converted = matchedQuery.Select({LambdaParameterName} => new {dtoName}"
);
@@ -133,6 +217,7 @@ InterceptableLocation location
sb.AppendLine(
$" var matchedQuery = query as object as {returnTypePrefix}<{querySourceTypeFullName}>;"
);
+
sb.AppendLine(
$" var converted = matchedQuery.Select({LambdaParameterName} => new {dtoName}"
);
@@ -151,6 +236,7 @@ InterceptableLocation location
sb.AppendLine(string.Join($",{CodeFormatter.DefaultNewLine}", propertyAssignments));
sb.AppendLine($" }});");
+
sb.AppendLine($" return converted as object as {returnTypePrefix};");
sb.AppendLine("}");
return sb.ToString();
diff --git a/src/Linqraft.SourceGenerator/SelectExprGenerator.cs b/src/Linqraft.SourceGenerator/SelectExprGenerator.cs
index 34dc844c..364e67ab 100644
--- a/src/Linqraft.SourceGenerator/SelectExprGenerator.cs
+++ b/src/Linqraft.SourceGenerator/SelectExprGenerator.cs
@@ -20,7 +20,9 @@ public partial class SelectExprGenerator : IIncrementalGenerator
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Generate pre-defined source code
- context.RegisterPostInitializationOutput(ctx => GenerateSourceCodeSnippets.ExportAll(ctx));
+ context.RegisterPostInitializationOutput(
+ GenerateSourceCodeSnippets.ExportAllConstantSnippets
+ );
// Read MSBuild properties for configuration
var configurationProvider = context.AnalyzerConfigOptionsProvider.Select(
@@ -79,10 +81,31 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
})
.ToList();
- // Generate code for explicit DTO infos (one method per group)
+ // Collect all DTOs from all groups and deduplicate globally
+ var allDtoClassInfos = new List();
+ foreach (var exprGroup in exprGroups)
+ {
+ foreach (var expr in exprGroup.Exprs)
+ {
+ var classInfos = expr.Info.GenerateDtoClasses();
+ allDtoClassInfos.AddRange(classInfos);
+ }
+ }
+
+ // Generate all DTOs in a single shared source file
+ var dtoCode = GenerateSourceCodeSnippets.BuildGlobalDtoCodeSnippet(
+ allDtoClassInfos,
+ config
+ );
+ if (!string.IsNullOrEmpty(dtoCode))
+ {
+ spc.AddSource("GeneratedDtos.g.cs", dtoCode);
+ }
+
+ // Generate code for expression methods (without DTOs)
foreach (var exprGroup in exprGroups)
{
- exprGroup.GenerateCode(spc);
+ exprGroup.GenerateCodeWithoutDtos(spc);
}
}
);
@@ -104,34 +127,12 @@ private static bool IsSelectExprInvocation(SyntaxNode node)
// 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))
+ if (SelectExprHelper.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)
{
var invocation = (InvocationExpressionSyntax)context.Node;
diff --git a/src/Linqraft.SourceGenerator/SelectExprGroups.cs b/src/Linqraft.SourceGenerator/SelectExprGroups.cs
index 66d5cb74..ba24706e 100644
--- a/src/Linqraft.SourceGenerator/SelectExprGroups.cs
+++ b/src/Linqraft.SourceGenerator/SelectExprGroups.cs
@@ -43,21 +43,28 @@ public string GetUniqueId()
return $"{targetNsReplaced}_{filenameReplaced}";
}
- // Generate source code
+ // Generate source code with DTOs
public virtual void GenerateCode(SourceProductionContext context)
{
try
{
var dtoClassInfos = new List();
var selectExprMethods = new List();
+ var staticFields = new List();
foreach (var expr in Exprs)
{
var info = expr.Info;
var classInfos = info.GenerateDtoClasses();
var exprMethods = info.GenerateSelectExprCodes(expr.Location);
+ var fields = info.GenerateStaticFields();
+
dtoClassInfos.AddRange(classInfos);
selectExprMethods.AddRange(exprMethods);
+ if (fields != null)
+ {
+ staticFields.Add(fields);
+ }
}
// drop duplicate DTO classes based on full name
@@ -69,6 +76,7 @@ public virtual void GenerateCode(SourceProductionContext context)
// Build final source code using the new method that groups DTOs by namespace
var sourceCode = GenerateSourceCodeSnippets.BuildCodeSnippetAll(
selectExprMethods,
+ staticFields,
dtoClassesDistinct,
Configuration
);
@@ -90,6 +98,56 @@ public virtual void GenerateCode(SourceProductionContext context)
context.AddSource($"GeneratorError_{hash}.g.cs", errorMessage);
}
}
+
+ // Generate source code without DTOs (for global DTO generation)
+ public virtual void GenerateCodeWithoutDtos(SourceProductionContext context)
+ {
+ try
+ {
+ var selectExprMethods = new List();
+ var staticFields = new List();
+
+ foreach (var expr in Exprs)
+ {
+ var info = expr.Info;
+ var exprMethods = info.GenerateSelectExprCodes(expr.Location);
+ var fields = info.GenerateStaticFields();
+
+ selectExprMethods.AddRange(exprMethods);
+ if (fields != null)
+ {
+ staticFields.Add(fields);
+ }
+ }
+
+ // Generate only expression methods without DTOs
+ var exprPart = GenerateSourceCodeSnippets.BuildExprCodeSnippets(
+ selectExprMethods,
+ staticFields
+ );
+ var sourceCode = $$"""
+ {{GenerateSourceCodeSnippets.GenerateCommentHeaderPart()}}
+ {{GenerateSourceCodeSnippets.GenerateHeaderFlagsPart}}
+ {{exprPart}}
+ """;
+
+ // Register with Source Generator
+ var uniqueId = GetUniqueId();
+ context.AddSource($"GeneratedExpression_{uniqueId}.g.cs", sourceCode);
+ }
+ catch (Exception ex)
+ {
+ // Output error information for debugging
+ var errorMessage = $"""
+ /*
+ * Source Generator Error: {ex.Message}
+ * Stack Trace: {ex.StackTrace}
+ */
+ """;
+ var hash = HashUtility.GenerateRandomIdentifier();
+ context.AddSource($"GeneratorError_{hash}.g.cs", errorMessage);
+ }
+ }
}
internal class SelectExprLocations
diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props
index d002fff4..37dc07c8 100644
--- a/tests/Directory.Build.props
+++ b/tests/Directory.Build.props
@@ -42,6 +42,7 @@
+
diff --git a/tests/Linqraft.Analyzer.Tests/AutoGeneratedDtoUsageAnalyzerTests.cs b/tests/Linqraft.Analyzer.Tests/AutoGeneratedDtoUsageAnalyzerTests.cs
index 573319ee..fb423bdb 100644
--- a/tests/Linqraft.Analyzer.Tests/AutoGeneratedDtoUsageAnalyzerTests.cs
+++ b/tests/Linqraft.Analyzer.Tests/AutoGeneratedDtoUsageAnalyzerTests.cs
@@ -9,7 +9,8 @@ namespace Linqraft.Analyzer.Tests;
public class AutoGeneratedDtoUsageAnalyzerTests
{
- private const string LinqraftAutoGeneratedDtoAttributeDefinition = @"
+ private const string LinqraftAutoGeneratedDtoAttributeDefinition =
+ @"
namespace Linqraft
{
[System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)]
@@ -279,7 +280,14 @@ void Method({|#2:GeneratedDto|} dto)
.WithSeverity(DiagnosticSeverity.Warning)
.WithArguments("GeneratedDto");
- await VerifyCS.VerifyAnalyzerAsync(test, expected0, expected1, expected2, expected3, expected4);
+ await VerifyCS.VerifyAnalyzerAsync(
+ test,
+ expected0,
+ expected1,
+ expected2,
+ expected3,
+ expected4
+ );
}
[Fact]
diff --git a/tests/Linqraft.Tests.Configuration/Linqraft.Tests.Configuration.csproj b/tests/Linqraft.Tests.Configuration/Linqraft.Tests.Configuration.csproj
index 717e7e25..c5db5e3c 100644
--- a/tests/Linqraft.Tests.Configuration/Linqraft.Tests.Configuration.csproj
+++ b/tests/Linqraft.Tests.Configuration/Linqraft.Tests.Configuration.csproj
@@ -6,6 +6,7 @@
false
None
true
+ true
true
.generated
diff --git a/tests/Linqraft.Tests.Configuration/NestedDtoNamespaceTest.cs b/tests/Linqraft.Tests.Configuration/NestedDtoNamespaceTest.cs
deleted file mode 100644
index 63351820..00000000
--- a/tests/Linqraft.Tests.Configuration/NestedDtoNamespaceTest.cs
+++ /dev/null
@@ -1,84 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-
-namespace Linqraft.Tests.Configuration.NestedDtoNSTest;
-
-public class NestedDtoUseHashNamespaceTest
-{
- private readonly List Orders =
- [
- new()
- {
- Id = 1,
- Customer = new() { Name = "John Doe" },
- OrderItems =
- [
- new()
- {
- Product = new() { Name = "Laptop" },
- Quantity = 1,
- },
- new()
- {
- Product = new() { Name = "Mouse" },
- Quantity = 2,
- },
- ],
- },
- ];
-
- [Fact]
- public void NestedDtoUseHashNamespace_ShouldGenerateChildDtoInHashNamespace()
- {
- // Test that nested DTOs are generated in LinqraftGenerated_{hash} namespace
- var results = Orders
- .AsQueryable()
- .SelectExpr(s => new
- {
- Id = s.Id,
- CustomerName = s.Customer?.Name,
- Items = s
- .OrderItems.Select(oi => new
- {
- ProductName = oi.Product?.Name,
- Quantity = oi.Quantity,
- })
- .ToList(),
- })
- .ToList();
-
- results.Count.ShouldBe(1);
- var first = results[0];
- first.Id.ShouldBe(1);
- first.CustomerName.ShouldBe("John Doe");
- first.Items.Count.ShouldBe(2);
- first.Items[0].ProductName.ShouldBe("Laptop");
- first.Items[0].Quantity.ShouldBe(1);
- first.Items[1].ProductName.ShouldBe("Mouse");
- first.Items[1].Quantity.ShouldBe(2);
- }
-
- // Data model definitions
- public class OrderNS
- {
- public int Id { get; set; }
- public Customer? Customer { get; set; }
- public List OrderItems { get; set; } = [];
- }
-
- public class CustomerNS
- {
- public string Name { get; set; } = "";
- }
-
- public class OrderItemNS
- {
- public Product? Product { get; set; }
- public int Quantity { get; set; }
- }
-
- public class ProductNS
- {
- public string Name { get; set; } = "";
- }
-}
diff --git a/tests/Linqraft.Tests/Issue172_PredefinedNestedNamedTypesTest.cs b/tests/Linqraft.Tests/Issue172_PredefinedNestedNamedTypesTest.cs
index 38f69a97..1705f701 100644
--- a/tests/Linqraft.Tests/Issue172_PredefinedNestedNamedTypesTest.cs
+++ b/tests/Linqraft.Tests/Issue172_PredefinedNestedNamedTypesTest.cs
@@ -49,9 +49,10 @@ public void SelectExpr_PredefinedDto_NestedSelectWithNamedType_ShouldBeFullyQual
{
Title = i.Title,
// This nested Select with named type should use fully qualified names
- Childs = i.Childs != null
- ? i.Childs.Select(c => new Issue172_ItemChildDto { Test = c.Test })
- : null,
+ Childs =
+ i.Childs != null
+ ? i.Childs.Select(c => new Issue172_ItemChildDto { Test = c.Test })
+ : null,
}),
})
.ToList();
@@ -88,9 +89,7 @@ public void SelectExpr_PredefinedDto_TernaryWithNamedType_ShouldBeFullyQualified
{
Title = i.Title,
// These ternary expressions with named types should use fully qualified names
- Child2 = i.Childs != null
- ? new Issue172_ItemChildDto { Test = "" }
- : null,
+ Child2 = i.Childs != null ? new Issue172_ItemChildDto { Test = "" } : null,
}),
})
.ToList();
diff --git a/tests/Linqraft.Tests/Issue193_NullConditionalInInitializerTest.cs b/tests/Linqraft.Tests/Issue193_NullConditionalInInitializerTest.cs
index 5a680cac..adb5dc32 100644
--- a/tests/Linqraft.Tests/Issue193_NullConditionalInInitializerTest.cs
+++ b/tests/Linqraft.Tests/Issue193_NullConditionalInInitializerTest.cs
@@ -15,11 +15,7 @@ public class Issue193_NullConditionalInInitializerTest
{
Id = 1,
Name = "Entity1",
- Items =
- [
- new Issue193_Item { Title = "Item1" },
- new Issue193_Item { Title = null },
- ],
+ Items = [new Issue193_Item { Title = "Item1" }, new Issue193_Item { Title = null }],
},
];
@@ -84,23 +80,20 @@ public void SelectExpr_PredefinedDto_TernaryWithEmptyArrayLiteral_ShouldUseFully
{
Id = 2,
Name = "Entity2",
- NullableItems =
- [
- new Issue193_Item { Title = "Item1" },
- ],
+ NullableItems = [new Issue193_Item { Title = "Item1" }],
},
};
- var result = data
- .AsQueryable()
+ var result = data.AsQueryable()
.SelectExpr(x => new Issue193_EntityWithNullableItemsDto
{
Id = x.Id,
Name = x.Name,
// Empty array literal fallback - should become global::System.Linq.Enumerable.Empty<...>()
- Items = x.NullableItems != null
- ? x.NullableItems.Select(i => new Issue193_ItemChildDto { Test = i.Title })
- : [],
+ Items =
+ x.NullableItems != null
+ ? x.NullableItems.Select(i => new Issue193_ItemChildDto { Test = i.Title })
+ : [],
})
.ToList();
@@ -137,23 +130,20 @@ public void SelectExpr_PredefinedDto_TernaryWithEnumerableEmpty_ShouldUseFullyQu
{
Id = 2,
Name = "Entity2",
- NullableItems =
- [
- new Issue193_Item { Title = "Item1" },
- ],
+ NullableItems = [new Issue193_Item { Title = "Item1" }],
},
};
- var result = data
- .AsQueryable()
+ var result = data.AsQueryable()
.SelectExpr(x => new Issue193_EntityWithNullableItemsDto
{
Id = x.Id,
Name = x.Name,
// Enumerable.Empty() fallback - should become global::System.Linq.Enumerable.Empty()
- Items = x.NullableItems != null
- ? x.NullableItems.Select(i => new Issue193_ItemChildDto { Test = i.Title })
- : Enumerable.Empty(),
+ Items =
+ x.NullableItems != null
+ ? x.NullableItems.Select(i => new Issue193_ItemChildDto { Test = i.Title })
+ : Enumerable.Empty(),
})
.ToList();
diff --git a/tests/Linqraft.Tests/Issue207_NestedSelectExprTest.cs b/tests/Linqraft.Tests/Issue207_NestedSelectExprTest.cs
index 5ee9d547..c32dc78a 100644
--- a/tests/Linqraft.Tests/Issue207_NestedSelectExprTest.cs
+++ b/tests/Linqraft.Tests/Issue207_NestedSelectExprTest.cs
@@ -8,7 +8,7 @@ namespace Linqraft.Tests;
/// 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
+public partial class Issue207_NestedSelectExprTest
{
private readonly List TestData =
[
@@ -52,9 +52,6 @@ public class Issue207_NestedSelectExprTest
},
];
- // !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:
@@ -128,7 +125,6 @@ public void NestedSelectExpr_WithExplicitDtoTypes_ShouldWork()
subItemElementType.ShouldNotBeNull();
subItemElementType!.Namespace!.ShouldContain("LinqraftGenerated");
}
-#endif
// Test data classes for the nested SelectExpr test
internal class NestedEntity207
@@ -150,4 +146,8 @@ internal class NestedSubItem207
public int Id { get; set; }
public string Value { get; set; } = null!;
}
+
+ public partial class NestedEntity207Dto;
+
+ public partial class NestedItem207Dto;
}
diff --git a/tests/Linqraft.Tests/Issue217_NestedSelectExprTest.cs b/tests/Linqraft.Tests/Issue217_NestedSelectExprTest.cs
new file mode 100644
index 00000000..27bedb4f
--- /dev/null
+++ b/tests/Linqraft.Tests/Issue217_NestedSelectExprTest.cs
@@ -0,0 +1,71 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace MinRepro;
+
+public partial class Issue_NestedSelectExprTest
+{
+ private readonly List TestData = [];
+
+ public void NestedSelectExpr_WithExplicitDtoTypes_ShouldWork()
+ {
+ var query = TestData.AsQueryable();
+
+ var result = query
+ .SelectExpr(x => new
+ {
+ x.Id,
+ x.Name,
+ // Test without qualifier - expecting DTO generation in same place as calling class
+ ItemsEnumerable = x.Items.SelectExpr(i => new
+ {
+ i.Id,
+ }),
+ ItemsList = x
+ .Items.SelectExpr(i => new { i.Id })
+ .ToList(),
+ ItemsArray = x
+ .Items.SelectExpr(i => new
+ {
+ i.Id,
+ i.Title,
+ SubItem = i.SubItems.Select(si => new { si.Id, si.Value }),
+ SubItemWithExpr = i.SubItems.SelectExpr(
+ si => new { si.Id, si.Value }
+ ),
+ })
+ .ToArray(),
+ })
+ .ToList();
+ }
+
+ internal class NestedEntity
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = null!;
+ public List Items { get; set; } = [];
+ }
+
+ internal class NestedItem
+ {
+ public int Id { get; set; }
+ public string Title { get; set; } = null!;
+ public List SubItems { get; set; } = [];
+ }
+
+ internal class NestedSubItem
+ {
+ public int Id { get; set; }
+ public string Value { get; set; } = null!;
+ }
+
+ internal partial class NestedEntityDto;
+
+ internal partial class NestedItemDtoEnumerable;
+
+ internal partial class NestedItemDtoList;
+
+ internal partial class NestedItemDtoArray;
+
+ internal partial class NestedSubItemDto;
+}
diff --git a/tests/Linqraft.Tests/Issue239_DuplicateChildDtoTest.cs b/tests/Linqraft.Tests/Issue239_DuplicateChildDtoTest.cs
new file mode 100644
index 00000000..127c21c9
--- /dev/null
+++ b/tests/Linqraft.Tests/Issue239_DuplicateChildDtoTest.cs
@@ -0,0 +1,81 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Linqraft.Tests;
+
+///
+/// Issue #239: When ChildDto of the same shape appears multiple times,
+/// the definition of ChildDto should be generated only once.
+/// This test is a duplicate of the minimal repro from issue #239.
+///
+public partial class Issue239_DuplicateChildDtoTest
+{
+ private readonly List _testData =
+ [
+ new Entity
+ {
+ Id = 1,
+ Name = "Entity1",
+ Child = new Child { Description = "Child1" },
+ Items = [new Item { Title = "Item1" }, new Item { Title = "Item2" }],
+ },
+ ];
+
+ [Fact]
+ public void ShouldGenerateSingleItemDtoForMultipleSelectExprWithSameShape()
+ {
+ // Arrange & Act - Two separate SelectExpr calls with identical anonymous structure
+ // This should generate only ONE ItemDto class definition, not two
+ var result1 = _testData
+ .AsQueryable()
+ .SelectExpr(x => new
+ {
+ x.Id,
+ x.Name,
+ ChildDescription = x.Child?.Description,
+ ItemTitles = x.Items.Select(i => new { i.Title }),
+ })
+ .ToList();
+
+ var result2 = _testData
+ .AsQueryable()
+ .SelectExpr(x => new
+ {
+ x.Id,
+ x.Name,
+ ChildDescription = x.Child?.Description,
+ ItemTitles = x.Items.Select(i => new { i.Title }),
+ })
+ .ToList();
+
+ // Assert
+ result1.ShouldNotBeNull();
+ result1.Count.ShouldBe(1);
+ result1[0].Id.ShouldBe(1);
+ result1[0].Name.ShouldBe("Entity1");
+ result1[0].ChildDescription.ShouldBe("Child1");
+ result1[0].ItemTitles.Count().ShouldBe(2);
+
+ result2.ShouldNotBeNull();
+ result2.Count.ShouldBe(1);
+ result2[0].Id.ShouldBe(1);
+ }
+
+ internal class Entity
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = "";
+ public Child? Child { get; set; }
+ public List- Items { get; set; } = [];
+ }
+
+ internal class Child
+ {
+ public string Description { get; set; } = "";
+ }
+
+ internal class Item
+ {
+ public string Title { get; set; } = "";
+ }
+}
diff --git a/tests/Linqraft.Tests/Issue239_MinimalReproTest.cs b/tests/Linqraft.Tests/Issue239_MinimalReproTest.cs
new file mode 100644
index 00000000..36ff7154
--- /dev/null
+++ b/tests/Linqraft.Tests/Issue239_MinimalReproTest.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Linq;
+using System.Collections.Generic;
+
+namespace Linqraft.Tests;
+
+///
+/// Issue #239: Minimal reproduction test case
+///
+public class Issue239_MinimalReproTest
+{
+ private readonly List _testData =
+ [
+ new Entity
+ {
+ Id = 1,
+ Name = "Test",
+ Child = new Child { Description = "Desc" },
+ Items = [new Item { Title = "Title1" }],
+ },
+ ];
+
+ [Fact]
+ public void TwoSelectExprWithSameStructureShouldShareNestedDto()
+ {
+ var data = _testData.AsQueryable();
+
+ // result1 and result2 use the exact same anonymous structure
+ // They should share the same ItemTitlesDto definition
+ var result1 = data.SelectExpr(x => new
+ {
+ x.Id,
+ x.Name,
+ ChildDescription = x.Child?.Description,
+ ItemTitles = x.Items.Select(i => new{i.Title}),
+ }).ToList();
+
+ var result2 = data.SelectExpr(x => new
+ {
+ x.Id,
+ x.Name,
+ ChildDescription = x.Child?.Description,
+ ItemTitles = x.Items.Select(i => new{i.Title}),
+ }).ToList();
+
+ result1.Count.ShouldBe(1);
+ result2.Count.ShouldBe(1);
+ }
+
+ internal class Entity
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = "";
+ public Child? Child { get; set; }
+ public List
- Items { get; set; } = [];
+ }
+
+ internal class Child
+ {
+ public string Description { get; set; } = "";
+ }
+
+ internal class Item
+ {
+ public string Title { get; set; } = "";
+ }
+}
diff --git a/tests/Linqraft.Tests/Issue_ClassInClassGeneratedTest.cs b/tests/Linqraft.Tests/Issue_ClassInClassGeneratedTest.cs
new file mode 100644
index 00000000..d33a093f
--- /dev/null
+++ b/tests/Linqraft.Tests/Issue_ClassInClassGeneratedTest.cs
@@ -0,0 +1,64 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Linqraft.Tests;
+
+///
+/// Test case for issue: Minor issues when generating multi-layer DTO classes for classes nested within other classes
+/// When NestedDtoUseHashNamespace is enabled (default), implicit DTOs should be placed in LinqraftGenerated_{hash} namespace
+/// WITHOUT being nested inside the parent class
+///
+public partial class ClassInClassGeneratedExpr
+{
+ private readonly List TestData = [];
+
+ [Fact]
+ public void NestedSelectExpr_WithExplicitDtoTypes_ShouldWork()
+ {
+ var query = TestData.AsQueryable();
+
+ var result = query
+ .SelectExpr(x => new
+ {
+ x.Id,
+ x.Name,
+ Items = x.Items.Select(i => new { i.Id }),
+ })
+ .ToList();
+
+ result.Count.ShouldBe(0);
+
+ // Verify that NestedEntityDto is NOT in the LinqraftGenerated_ namespace
+ var nestedEntityDtoType = typeof(NestedEntityDto);
+ nestedEntityDtoType.Namespace!.ShouldNotContain("LinqraftGenerated");
+ nestedEntityDtoType.Namespace.ShouldBe("Linqraft.Tests");
+
+ // Verify that the auto-generated ItemsDto IS in the LinqraftGenerated_ namespace
+ // and NOT nested inside ClassInClassGeneratedExpr
+ var itemsProperty = nestedEntityDtoType.GetProperty("Items");
+ itemsProperty.ShouldNotBeNull();
+ var itemsElementType = itemsProperty!.PropertyType.GetGenericArguments().FirstOrDefault();
+ itemsElementType.ShouldNotBeNull();
+ itemsElementType!.Namespace!.ShouldContain("LinqraftGenerated");
+
+ // The key assertion: ItemsDto should NOT be nested inside ClassInClassGeneratedExpr
+ // It should be directly in the LinqraftGenerated_{hash} namespace
+ var itemsDtoFullName = itemsElementType.FullName!;
+ itemsDtoFullName.ShouldNotContain("ClassInClassGeneratedExpr");
+ }
+
+ internal class NestedEntity
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = null!;
+ public List Items { get; set; } = [];
+ }
+
+ internal class NestedItem
+ {
+ public int Id { get; set; }
+ public string Title { get; set; } = null!;
+ }
+
+ internal partial class NestedEntityDto;
+}
diff --git a/tests/Linqraft.Tests/Issue_TernaryAndNullConditionalWithAnonymousTypeTest.cs b/tests/Linqraft.Tests/Issue_TernaryAndNullConditionalWithAnonymousTypeTest.cs
index d5478e99..83cbd6f0 100644
--- a/tests/Linqraft.Tests/Issue_TernaryAndNullConditionalWithAnonymousTypeTest.cs
+++ b/tests/Linqraft.Tests/Issue_TernaryAndNullConditionalWithAnonymousTypeTest.cs
@@ -34,11 +34,7 @@ internal class Item
Items = [new Item { Title = "Item1" }],
NullableItems = [new Item { Title = "NullableItem1" }],
},
- new Container
- {
- Items = null,
- NullableItems = null,
- },
+ new Container { Items = null, NullableItems = null },
];
///
@@ -53,9 +49,7 @@ public void TernaryWithSelect_EmptyArrayLiteral_ShouldGenerateEnumerableEmpty()
.AsQueryable()
.SelectExpr(x => new
{
- Items = x.Items != null
- ? x.Items.Select(i => new { i.Title })
- : [],
+ Items = x.Items != null ? x.Items.Select(i => new { i.Title }) : [],
})
.ToList();