Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 60 additions & 23 deletions src/Linqraft.Core/DtoProperty.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,25 +178,40 @@ TypeSymbol is IErrorTypeSymbol
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<BaseNamespaceDeclarationSyntax>()
.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<BaseNamespaceDeclarationSyntax>()
.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 we're in a scenario where it's actually predeclared as nested

if (!string.IsNullOrEmpty(callerNamespace))
{
explicitNestedDtoTypeName = $"global::{callerNamespace}.{typeName}";
}
else
{
explicitNestedDtoTypeName = $"global::{typeName}";
}
}
}
}
Expand Down Expand Up @@ -1161,6 +1176,7 @@ private static string SimplifySourceReference(string expressionStr)

/// <summary>
/// Abbreviates all method calls with arguments (any method pattern like .MethodName(args))
/// Also abbreviates generic type arguments like .MethodName&lt;Type1, Type2&gt;(args) -> .MethodName(...)
/// </summary>
private static string AbbreviateAllMethodCalls(string input)
{
Expand Down Expand Up @@ -1197,7 +1213,30 @@ 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);

// Check for generic type arguments (<Type1, Type2, ...>)
if (methodNameEnd < input.Length && input[methodNameEnd] == '<')
{
// Find the matching closing angle bracket
var genericStart = methodNameEnd;
var depth = 1;
var genericEnd = genericStart + 1;

while (genericEnd < input.Length && depth > 0)
{
if (input[genericEnd] == '<')
depth++;
else if (input[genericEnd] == '>')
depth--;
genericEnd++;
}

// Skip the generic type arguments
methodNameEnd = genericEnd;
}

// 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
Expand All @@ -1207,22 +1246,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++;
}

Expand All @@ -1237,7 +1274,7 @@ private static string AbbreviateAllMethodCalls(string input)
}
else
{
// Has arguments, abbreviate
// Has arguments, abbreviate (including any generic type arguments)
result.Append($".{methodName}(...)");
}
pos = endIndex;
Expand Down
48 changes: 42 additions & 6 deletions src/Linqraft.Core/GenerateDtoClassInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Text;
using Linqraft.Core.Formatting;
using Linqraft.Core.RoslynHelpers;
using Microsoft.CodeAnalysis;

namespace Linqraft.Core;

Expand Down Expand Up @@ -185,22 +186,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}?";
Expand All @@ -209,6 +228,10 @@ public string BuildCode(LinqraftConfiguration configuration)
else
{
propertyType = explicitDtoName!;
if (isArrayType)
{
propertyType = $"{propertyType}[]";
}
if (shouldReapplyNullable)
{
propertyType = $"{propertyType}?";
Expand Down Expand Up @@ -401,4 +424,17 @@ private static int GetAccessibilityLevel(string accessibility)
_ => 5, // Default to public
};
}

/// <summary>
/// Determines if a property type represents an array type by checking:
/// 1. The type symbol (IArrayTypeSymbol)
/// 2. The type string pattern (ends with [])
/// 3. The original expression syntax (ends with .ToArray())
/// </summary>
private static bool IsArrayType(DtoProperty prop, string typeString)
{
return prop.TypeSymbol is IArrayTypeSymbol
|| typeString.EndsWith("[]")
|| prop.OriginalExpression.Trim().EndsWith(".ToArray()");
}
}
22 changes: 2 additions & 20 deletions src/Linqraft.Core/SelectExprInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1205,27 +1205,9 @@ LinqExpressionInfo info
string nestedDtoName;
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))
{
Expand Down
63 changes: 63 additions & 0 deletions tests/Linqraft.Tests/Issue_NestedSelectExprBugTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Collections.Generic;
using System.Linq;

namespace MinRepro;

public partial class Issue_NestedSelectExprTest
{
private readonly List<NestedEntity> TestData = [];

public void NestedSelectExpr_WithExplicitDtoTypes_ShouldWork()
{
var query = TestData.AsQueryable();

var result = query
.SelectExpr<NestedEntity, NestedEntityDto>(x => new
{
x.Id,
x.Name,
// Use qualified name for nested partial classes
ItemsEnumerable = x.Items.SelectExpr<NestedItem, Issue_NestedSelectExprTest.NestedItemDtoEnumerable>(i => new
{
i.Id,
}),
ItemsList = x
.Items.SelectExpr<NestedItem, Issue_NestedSelectExprTest.NestedItemDtoList>(i => new { i.Id })
.ToList(),
ItemsArray = x
.Items.SelectExpr<NestedItem, Issue_NestedSelectExprTest.NestedItemDtoArray>(i => new
{
i.Id,
i.Title,
SubItem = i.SubItems.Select(si => new { si.Id, si.Value }),
SubItemWithExpr = i.SubItems.SelectExpr<NestedSubItem, Issue_NestedSelectExprTest.NestedSubItemDto>(
si => new { si.Id, si.Value }
),
})
.ToArray(),
})
.ToList();
}

internal class NestedEntity
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public List<NestedItem> Items { get; set; } = [];
}

internal class NestedItem
{
public int Id { get; set; }
public string Title { get; set; } = null!;
public List<NestedSubItem> SubItems { get; set; } = [];
}

internal class NestedSubItem
{
public int Id { get; set; }
public string Value { get; set; } = null!;
}

internal partial class NestedEntityDto;
}
Loading