Skip to content

Commit d06bd3a

Browse files
arika0093Copilotcoderabbitai[bot]
authored
fix: member access for anonymous captures (#278)
* refactor: simplify variable capture logic and enhance test coverage for SelectExpr * feat: implement anonymous capture member access aliases and enhance test coverage for member access scenarios * refactor: update anonymous capture handling and improve conversion to IQueryable for SelectExprInfo * fix: handle nested member access in anonymous capture aliases (#279) * Initial plan * fix: handle nested member access in anonymous capture aliases Co-authored-by: arika0093 <4524647+arika0093@users.noreply.github.com> * refactor: improve code clarity per review feedback Co-authored-by: arika0093 <4524647+arika0093@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: arika0093 <4524647+arika0093@users.noreply.github.com> * Update src/Linqraft.Core/SelectExprInfo.cs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: arika0093 <4524647+arika0093@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent d4ee6a3 commit d06bd3a

File tree

8 files changed

+471
-43
lines changed

8 files changed

+471
-43
lines changed

src/Linqraft.Analyzer/LocalVariableCaptureAnalyzer.cs

Lines changed: 52 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -117,24 +117,7 @@ SemanticModel semanticModel
117117
// Extract variable names from the capture anonymous object
118118
if (argument.Expression is AnonymousObjectCreationExpressionSyntax anonymousObject)
119119
{
120-
foreach (var initializer in anonymousObject.Initializers)
121-
{
122-
// Get the property name from the initializer
123-
string propertyName;
124-
if (initializer.NameEquals != null)
125-
{
126-
propertyName = initializer.NameEquals.Name.Identifier.Text;
127-
}
128-
else if (initializer.Expression is IdentifierNameSyntax identifier)
129-
{
130-
propertyName = identifier.Identifier.Text;
131-
}
132-
else
133-
{
134-
continue;
135-
}
136-
capturedVariables.Add(propertyName);
137-
}
120+
AddCapturedVariablesFromAnonymousObject(anonymousObject, capturedVariables);
138121
}
139122
break;
140123
}
@@ -146,29 +129,61 @@ SemanticModel semanticModel
146129
var secondArg = invocation.ArgumentList.Arguments[1];
147130
if (secondArg.Expression is AnonymousObjectCreationExpressionSyntax anonymousObject)
148131
{
149-
foreach (var initializer in anonymousObject.Initializers)
150-
{
151-
string propertyName;
152-
if (initializer.NameEquals != null)
153-
{
154-
propertyName = initializer.NameEquals.Name.Identifier.Text;
155-
}
156-
else if (initializer.Expression is IdentifierNameSyntax identifier)
157-
{
158-
propertyName = identifier.Identifier.Text;
159-
}
160-
else
161-
{
162-
continue;
163-
}
164-
capturedVariables.Add(propertyName);
165-
}
132+
AddCapturedVariablesFromAnonymousObject(anonymousObject, capturedVariables);
166133
}
167134
}
168135

169136
return capturedVariables;
170137
}
171138

139+
private static void AddCapturedVariablesFromAnonymousObject(
140+
AnonymousObjectCreationExpressionSyntax anonymousObject,
141+
HashSet<string> capturedVariables
142+
)
143+
{
144+
foreach (var initializer in anonymousObject.Initializers)
145+
{
146+
// Add the property name if explicitly provided
147+
if (initializer.NameEquals != null)
148+
{
149+
var propertyName = initializer.NameEquals.Name.Identifier.Text;
150+
capturedVariables.Add(propertyName);
151+
}
152+
153+
AddCapturedVariablesFromExpression(initializer.Expression, capturedVariables);
154+
}
155+
}
156+
157+
private static void AddCapturedVariablesFromExpression(
158+
ExpressionSyntax expression,
159+
HashSet<string> capturedVariables
160+
)
161+
{
162+
switch (expression)
163+
{
164+
case IdentifierNameSyntax identifier:
165+
capturedVariables.Add(identifier.Identifier.Text);
166+
break;
167+
case MemberAccessExpressionSyntax memberAccess:
168+
var rootIdentifier = GetRootIdentifierName(memberAccess.Expression);
169+
if (rootIdentifier != null)
170+
{
171+
capturedVariables.Add(rootIdentifier);
172+
}
173+
break;
174+
}
175+
}
176+
177+
private static string? GetRootIdentifierName(ExpressionSyntax expression)
178+
{
179+
return expression switch
180+
{
181+
IdentifierNameSyntax identifier => identifier.Identifier.Text,
182+
MemberAccessExpressionSyntax memberAccess => GetRootIdentifierName(memberAccess.Expression),
183+
_ => null,
184+
};
185+
}
186+
172187
private static bool HasCaptureParameter(InvocationExpressionSyntax invocation)
173188
{
174189
// Check if there's a named argument called "capture"
@@ -389,8 +404,8 @@ memberAccess.Expression is IdentifierNameSyntax exprId
389404
&& !lambda.Span.Contains(exprSymbolLocation.SourceSpan)
390405
)
391406
{
392-
var memberName = memberAccess.Name.Identifier.Text;
393-
variablesToCapture.Add((memberName, memberAccess.GetLocation()));
407+
var captureName = exprIdentifier.Identifier.Text;
408+
variablesToCapture.Add((captureName, memberAccess.GetLocation()));
394409
}
395410
}
396411
}

src/Linqraft.Core/SelectExprInfo.cs

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,183 @@ public abstract record SelectExprInfo
130130
// Get expression type string (for documentation)
131131
public abstract string GetExprTypeString();
132132

133+
/// <summary>
134+
/// Builds capture alias variables for anonymous capture objects that contain member access.
135+
/// This allows expressions like request.FromDate to work when capture is new { request.FromDate }.
136+
/// </summary>
137+
protected string GenerateAnonymousCaptureMemberAccessAliases()
138+
{
139+
if (CaptureArgumentExpression is not AnonymousObjectCreationExpressionSyntax anonymousCapture)
140+
{
141+
return string.Empty;
142+
}
143+
144+
var capturePropertyNames = new HashSet<string>();
145+
var aliasMap = new Dictionary<string, List<(string MemberName, string CapturePropertyName)>>();
146+
147+
foreach (var initializer in anonymousCapture.Initializers)
148+
{
149+
var capturePropertyName = GetCapturePropertyName(initializer);
150+
if (capturePropertyName != null)
151+
{
152+
capturePropertyNames.Add(capturePropertyName);
153+
}
154+
155+
if (initializer.Expression is MemberAccessExpressionSyntax memberAccess)
156+
{
157+
var rootName = GetRootIdentifierName(memberAccess);
158+
if (rootName == null)
159+
{
160+
continue;
161+
}
162+
163+
var memberPath = GetMemberPathFromRoot(memberAccess, rootName);
164+
var captureName = capturePropertyName ?? memberAccess.Name.Identifier.Text;
165+
166+
if (!aliasMap.TryGetValue(rootName, out var members))
167+
{
168+
members = new List<(string MemberName, string CapturePropertyName)>();
169+
aliasMap[rootName] = members;
170+
}
171+
172+
members.Add((memberPath, captureName));
173+
}
174+
}
175+
176+
// Remove any root names that are explicitly captured (they don't need aliases)
177+
foreach (var rootName in capturePropertyNames)
178+
{
179+
aliasMap.Remove(rootName);
180+
}
181+
182+
if (aliasMap.Count == 0)
183+
{
184+
return string.Empty;
185+
}
186+
187+
var sb = new StringBuilder();
188+
foreach (var kvp in aliasMap)
189+
{
190+
var rootName = kvp.Key;
191+
var members = kvp.Value;
192+
193+
// Build a tree structure for nested member paths
194+
var tree = new Dictionary<string, object>();
195+
foreach (var memberPair in members)
196+
{
197+
var memberPath = memberPair.MemberName;
198+
var captureName = memberPair.CapturePropertyName;
199+
200+
var pathParts = memberPath.Split('.');
201+
var current = tree;
202+
203+
for (int i = 0; i < pathParts.Length - 1; i++)
204+
{
205+
var part = pathParts[i];
206+
if (!current.TryGetValue(part, out var child))
207+
{
208+
child = new Dictionary<string, object>();
209+
current[part] = child;
210+
}
211+
// If an earlier capture added this as a leaf, skip this longer path
212+
if (child is not Dictionary<string, object> childDict)
213+
{
214+
break;
215+
}
216+
current = childDict;
217+
}
218+
219+
// Add the leaf node with the capture name
220+
var lastPart = pathParts[^1];
221+
if (!current.ContainsKey(lastPart))
222+
{
223+
current[lastPart] = captureName;
224+
}
225+
}
226+
227+
// Generate the nested anonymous object
228+
sb.AppendLine($" var {rootName} = new");
229+
sb.AppendLine(" {");
230+
GenerateNestedAnonymousObject(sb, tree, 2, "captureObj");
231+
sb.AppendLine(" };");
232+
}
233+
234+
return sb.ToString();
235+
}
236+
237+
private static void GenerateNestedAnonymousObject(StringBuilder sb, Dictionary<string, object> tree, int indentLevel, string captureObjName)
238+
{
239+
var indent = new string(' ', indentLevel * 4);
240+
foreach (var kvp in tree)
241+
{
242+
var memberName = kvp.Key;
243+
var value = kvp.Value;
244+
245+
if (value is string captureName)
246+
{
247+
// Leaf node: simple assignment
248+
sb.AppendLine($"{indent}{memberName} = {captureObjName}.{captureName},");
249+
}
250+
else if (value is Dictionary<string, object> nested)
251+
{
252+
// Nested node: create nested anonymous object
253+
sb.AppendLine($"{indent}{memberName} = new");
254+
sb.AppendLine($"{indent}{{");
255+
GenerateNestedAnonymousObject(sb, nested, indentLevel + 1, captureObjName);
256+
sb.AppendLine($"{indent}}},");
257+
}
258+
}
259+
}
260+
261+
private static string? GetCapturePropertyName(AnonymousObjectMemberDeclaratorSyntax initializer)
262+
{
263+
if (initializer.NameEquals != null)
264+
{
265+
return initializer.NameEquals.Name.Identifier.Text;
266+
}
267+
268+
return initializer.Expression switch
269+
{
270+
IdentifierNameSyntax identifier => identifier.Identifier.Text,
271+
MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.Text,
272+
_ => null,
273+
};
274+
}
275+
276+
private static string? GetRootIdentifierName(ExpressionSyntax expression)
277+
{
278+
return expression switch
279+
{
280+
IdentifierNameSyntax identifier => identifier.Identifier.Text,
281+
MemberAccessExpressionSyntax memberAccess => GetRootIdentifierName(memberAccess.Expression),
282+
_ => null,
283+
};
284+
}
285+
286+
private static string GetMemberPathFromRoot(ExpressionSyntax expression, string rootName)
287+
{
288+
var parts = new List<string>();
289+
var current = expression;
290+
291+
while (current is MemberAccessExpressionSyntax memberAccess)
292+
{
293+
parts.Insert(0, memberAccess.Name.Identifier.Text);
294+
current = memberAccess.Expression;
295+
}
296+
297+
// If current is the root identifier, we've collected all member parts
298+
if (current is IdentifierNameSyntax identifier && identifier.Identifier.Text == rootName)
299+
{
300+
return string.Join(".", parts);
301+
}
302+
303+
// Fallback: This should not happen in normal usage since we only call this method
304+
// after verifying GetRootIdentifierName succeeds. Return the last member name as
305+
// a safe fallback to avoid breaking the generation if the expression structure
306+
// is unexpected (e.g., complex expressions that aren't simple member chains).
307+
return parts.Count > 0 ? parts[^1] : "";
308+
}
309+
133310
/// <summary>
134311
/// Gets the full name for a nested DTO class using the structure.
135312
/// This allows derived classes to compute namespace-based naming using the structure's hash.

src/Linqraft.Core/SelectExprInfoAnonymous.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ InterceptableLocation location
122122
);
123123
sb.AppendLine($" {propTypeName} {prop.Name} = captureObj.{prop.Name};");
124124
}
125+
126+
sb.Append(GenerateAnonymousCaptureMemberAccessAliases());
125127
}
126128
else
127129
{

src/Linqraft.Core/SelectExprInfoExplicitDto.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,8 @@ InterceptableLocation location
532532
);
533533
sb.AppendLine($" {propTypeName} {prop.Name} = captureObj.{prop.Name};");
534534
}
535+
536+
sb.Append(GenerateAnonymousCaptureMemberAccessAliases());
535537
}
536538
else
537539
{
@@ -545,8 +547,9 @@ InterceptableLocation location
545547
// Note: Pre-built expressions don't work well with captures because the closure
546548
// variables would be captured at compile time, not at runtime. So we disable
547549
// pre-built expressions when captures are used.
550+
// Also, convert to IEnumerable to avoid expression tree compilation with dynamic
548551
sb.AppendLine(
549-
$" var converted = matchedQuery.Select({LambdaParameterName} => new {dtoFullName}"
552+
$" var converted = matchedQuery.AsEnumerable().Select({LambdaParameterName} => new {dtoFullName}"
550553
);
551554
}
552555
else
@@ -578,7 +581,7 @@ InterceptableLocation location
578581
sb.AppendLine(string.Join($",{CodeFormatter.DefaultNewLine}", propertyAssignments));
579582

580583
sb.AppendLine($" }});");
581-
sb.AppendLine($" return converted as object as {returnTypePrefix}<TResult>;");
584+
sb.AppendLine($" return converted.AsQueryable() as object as {returnTypePrefix}<TResult>;");
582585
sb.AppendLine($"}}");
583586
return sb.ToString();
584587
}

src/Linqraft.Core/SelectExprInfoNamed.cs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ InterceptableLocation location
196196
);
197197
sb.AppendLine($" {propTypeName} {prop.Name} = captureObj.{prop.Name};");
198198
}
199+
200+
sb.Append(GenerateAnonymousCaptureMemberAccessAliases());
199201
}
200202
else
201203
{
@@ -209,8 +211,9 @@ InterceptableLocation location
209211
// Note: Pre-built expressions don't work well with captures because the closure
210212
// variables would be captured at compile time, not at runtime. So we disable
211213
// pre-built expressions when captures are used.
214+
// Also, convert to IEnumerable to avoid expression tree compilation with dynamic
212215
sb.AppendLine(
213-
$" var converted = matchedQuery.Select({LambdaParameterName} => new {dtoName}"
216+
$" var converted = matchedQuery.AsEnumerable().Select({LambdaParameterName} => new {dtoName}"
214217
);
215218
}
216219
else
@@ -244,7 +247,15 @@ InterceptableLocation location
244247

245248
sb.AppendLine($" }});");
246249

247-
sb.AppendLine($" return converted as object as {returnTypePrefix}<TResult>;");
250+
// For methods with capture that use AsEnumerable, convert back to IQueryable
251+
if (hasCapture)
252+
{
253+
sb.AppendLine($" return converted.AsQueryable() as object as {returnTypePrefix}<TResult>;");
254+
}
255+
else
256+
{
257+
sb.AppendLine($" return converted as object as {returnTypePrefix}<TResult>;");
258+
}
248259
sb.AppendLine("}");
249260
return sb.ToString();
250261
}

0 commit comments

Comments
 (0)