Skip to content

Commit 4f7db03

Browse files
Copilotarika0093
andcommitted
Add pre-built expression tree caching for IQueryable operations (#234)
* Initial plan * Add pre-built expression tree configuration and implementation - Added LinqraftUsePrebuildExpression configuration option (default: false) - Created ExpressionTreeBuilder helper class for generating cached expression fields - Updated SelectExprInfoAnonymous to use pre-built expressions when enabled - Updated SelectExprInfoNamed to use pre-built expressions when enabled - Updated SelectExprInfoExplicitDto to use pre-built expressions when enabled - Pre-built expressions only apply to IQueryable (not IEnumerable) - Pre-built expressions are disabled when captures are used Co-authored-by: arika0093 <[email protected]> * Refactor static field generation to class level - Moved static field generation from method body to class level - Added GenerateStaticFields() virtual method to SelectExprInfo - Updated SelectExprGroups to collect and pass static fields - Updated GenerateSourceCodeSnippets to output static fields before methods - Fixed null reference warning in SelectExprGroups Co-authored-by: arika0093 <[email protected]> * Complete pre-built expression tree implementation - Fixed CompilerVisibleProperty list to include LinqraftUsePrebuildExpression - Anonymous types cannot use pre-built expressions (result type is compiler-generated) - Pre-built expressions only work for named/predefined/explicit DTO types - Added comprehensive tests for IQueryable with different DTO patterns - All tests passing with pre-built expressions enabled Co-authored-by: arika0093 <[email protected]> * Add LinqraftUsePrebuildExpression to all CompilerVisibleProperty lists - Updated examples/Directory.Build.props with new configuration property - Fixed playground/Services/CodeGenerationService.cs to use updated API signature - All projects now support the new LinqraftUsePrebuildExpression configuration Co-authored-by: arika0093 <[email protected]> * Add LinqraftUsePrebuildExpression property to project files * Fix code generation issues per review feedback - Use 'var' instead of explicit type in expression initialization (line 93) - Remove unnecessary '!' null-forgiving operator when fieldName is not null - Fix indentation of generated multi-line lambda expressions - Use explicit Expression<> type instead of 'as' operator (lambdas cannot use 'as') Co-authored-by: arika0093 <[email protected]> * Refactor to use direct expression initialization per review feedback - Updated documentation in global-properties.md for LinqraftUsePrebuildExpression - Added note about captures not supporting pre-built expressions - Refactored ExpressionTreeBuilder to directly initialize expression fields (no lazy init) - Removed GenerateNamedExpressionTreeInitialization and GenerateAnonymousExpressionTreeInitialization - Expression trees now initialized at field declaration, eliminating null checks - Updated SelectExprInfoNamed and SelectExprInfoExplicitDto to use new approach - Added test for multiple DTO types to verify pre-built expressions work correctly - All 6 tests passing Co-authored-by: arika0093 <[email protected]> * Address final review feedback: readonly, indentation, remove test, add playground toggle - Added 'readonly' keyword to static expression fields for immutability - Fixed indentation: ensured proper line ending with AppendLine for multi-line expressions - Deleted Linqraft.Tests.Configuration project as requested - Removed project reference from Linqraft.slnx - Added toggle for LinqraftUsePrebuildExpression in playground Settings section - All tests passing (130/130) Co-authored-by: arika0093 <[email protected]> * revert: reset Linqraft.Tests.Configuration * Fix indentation in BuildExprCodeSnippets for static fields * Add static fields generation to expression code snippets * formatt --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: arika0093 <[email protected]> Co-authored-by: Arika Ishinami <[email protected]> (cherry picked from commit 1761753)
1 parent 82faefa commit 4f7db03

File tree

16 files changed

+384
-116
lines changed

16 files changed

+384
-116
lines changed

docs/library/global-properties.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ Linqraft supports several MSBuild properties to customize code generation global
3030
<!-- Use hash-named namespace for nested DTOs (e.g., LinqraftGenerated_HASH.ItemsDto) -->
3131
<!-- When false, uses hash-suffixed class names (e.g., ItemsDto_HASH) -->
3232
<LinqraftNestedDtoUseHashNamespace>true</LinqraftNestedDtoUseHashNamespace>
33+
34+
<!-- Pre-build and cache expression trees for IQueryable operations -->
35+
<!-- Improves performance by avoiding repeated lambda-to-expression-tree conversion -->
36+
<!-- Only applies to IQueryable with named/predefined/explicit DTO types (not anonymous types) -->
37+
<LinqraftUsePrebuildExpression>false</LinqraftUsePrebuildExpression>
3338
</PropertyGroup>
3439
</Project>
3540
```
@@ -115,6 +120,40 @@ See [Array Nullability Removal](./array-nullability.md) for details.
115120

116121
See [Nested DTO Naming](./nested-dto-naming.md) for details.
117122

123+
### LinqraftUsePrebuildExpression
124+
125+
Pre-build and cache expression trees for IQueryable operations to improve performance:
126+
127+
```xml
128+
<LinqraftUsePrebuildExpression>true</LinqraftUsePrebuildExpression>
129+
```
130+
131+
When enabled, expression trees are constructed at compile-time and cached as static fields, avoiding the overhead of repeated lambda-to-expression-tree conversion at runtime.
132+
133+
**Important Notes:**
134+
- Only applies to IQueryable operations (not IEnumerable)
135+
- Only works with named, predefined, or explicit DTO types (not anonymous types)
136+
- Not applied when there are capture variables in the lambda expression
137+
138+
**Example:**
139+
140+
```csharp
141+
// Without pre-built expressions (default):
142+
// Expression tree is created on every call
143+
var results = dbContext.Orders
144+
.AsQueryable()
145+
.SelectExpr(o => new OrderDto { Id = o.Id, Name = o.Name })
146+
.ToList();
147+
148+
// With LinqraftUsePrebuildExpression=true:
149+
// Expression tree is created once and cached
150+
// Subsequent calls reuse the same expression tree
151+
```
152+
153+
**Performance Benefit:**
154+
155+
Eliminates the runtime overhead of converting lambda expressions to expression trees for IQueryable operations. The expression tree is built once at the field declaration and reused for all invocations.
156+
118157
## Viewing Generated Code
119158

120159
To inspect the generated code:

examples/Directory.Build.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,6 @@
2727
<CompilerVisibleProperty Include="LinqraftCommentOutput" />
2828
<CompilerVisibleProperty Include="LinqraftArrayNullabilityRemoval" />
2929
<CompilerVisibleProperty Include="LinqraftNestedDtoUseHashNamespace" />
30+
<CompilerVisibleProperty Include="LinqraftUsePrebuildExpression" />
3031
</ItemGroup>
3132
</Project>

examples/Linqraft.Benchmark/Linqraft.Benchmark.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
<Nullable>enable</Nullable>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<NoWarn>$(NoWarn);NU1608</NoWarn>
7+
<LinqraftUsePrebuildExpression>true</LinqraftUsePrebuildExpression>
8+
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
9+
<CompilerGeneratedFilesOutputPath>.generated</CompilerGeneratedFilesOutputPath>
710
</PropertyGroup>
811
<ItemGroup>
912
<PackageReference Include="BenchmarkDotNet" Version="*" />

playground/Components/Playground/Sidebar.razor

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,16 @@
154154
@onchange="@(e => UpdateConfig(c => c with { NestedDtoUseHashNamespace = (bool)(e.Value ?? false) }))" />
155155
<label for="nestedDtoUseHashNamespace" class="cursor-pointer text-xs text-gray-300">Nested DTO Use Hash Namespace</label>
156156
</div>
157+
158+
<!-- Use Prebuild Expression -->
159+
<div class="flex cursor-pointer items-center gap-2">
160+
<input type="checkbox"
161+
id="usePrebuildExpression"
162+
class="h-3 w-3"
163+
checked="@Configuration.UsePrebuildExpression"
164+
@onchange="@(e => UpdateConfig(c => c with { UsePrebuildExpression = (bool)(e.Value ?? false) }))" />
165+
<label for="usePrebuildExpression" class="cursor-pointer text-xs text-gray-300">Pre-build Expression Trees</label>
166+
</div>
157167
</div>
158168
}
159169
</div>

playground/Services/CodeGenerationService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,15 @@ public GeneratedOutput GenerateOutput(
7878
info.Invocation.SyntaxTree
7979
);
8080
var location = semanticModel.GetInterceptableLocation(info.Invocation);
81+
var fields = info.GenerateStaticFields();
8182
var selectExprCodes = info.GenerateSelectExprCodes(location!);
8283
var dtoClasses = info.GenerateDtoClasses()
8384
.GroupBy(c => c.FullName)
8485
.Select(g => g.First())
8586
.ToList();
8687

8788
queryExpressionBuilder.AppendLine(
88-
GenerateSourceCodeSnippets.BuildExprCodeSnippets(selectExprCodes)
89+
GenerateSourceCodeSnippets.BuildExprCodeSnippets(selectExprCodes, [fields])
8990
);
9091
dtoClassBuilder.AppendLine(
9192
GenerateSourceCodeSnippets.BuildDtoCodeSnippetsGroupedByNamespace(
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using System;
2+
using System.Text;
3+
4+
namespace Linqraft.Core;
5+
6+
/// <summary>
7+
/// Helper class for generating pre-built Expression Tree code
8+
/// This generates static cached expression fields with direct initialization,
9+
/// avoiding the overhead of building expression trees at runtime for IQueryable operations.
10+
/// </summary>
11+
public static class ExpressionTreeBuilder
12+
{
13+
/// <summary>
14+
/// Generates a static cached expression tree field with direct initialization
15+
/// </summary>
16+
/// <param name="sourceTypeFullName">The fully qualified source type name</param>
17+
/// <param name="resultTypeFullName">The fully qualified result type name (DTO type)</param>
18+
/// <param name="lambdaParameterName">The lambda parameter name</param>
19+
/// <param name="lambdaBody">The lambda body code</param>
20+
/// <param name="uniqueId">A unique identifier for this expression to avoid collisions</param>
21+
/// <returns>A tuple of (fieldDeclaration, fieldName)</returns>
22+
public static (string FieldDeclaration, string FieldName) GenerateExpressionTreeField(
23+
string sourceTypeFullName,
24+
string resultTypeFullName,
25+
string lambdaParameterName,
26+
string lambdaBody,
27+
string uniqueId
28+
)
29+
{
30+
// Generate a unique field name based on the hash
31+
var hash = HashUtility.GenerateSha256Hash(uniqueId).Substring(0, 8);
32+
var fieldName = $"_cachedExpression_{hash}";
33+
34+
// Build the expression tree field with direct initialization
35+
var sb = new StringBuilder();
36+
37+
// The lambda body may contain newlines, so we need to properly handle multi-line expressions
38+
var lines = lambdaBody.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
39+
var firstLine =
40+
$"private static readonly Expression<Func<{sourceTypeFullName}, {resultTypeFullName}>> {fieldName} = {lambdaParameterName} =>";
41+
if (lines.Length == 1)
42+
{
43+
// Single line lambda body
44+
sb.AppendLine($"{firstLine} {lambdaBody};");
45+
}
46+
else
47+
{
48+
// Multi-line lambda body - need to format it properly
49+
sb.AppendLine(firstLine);
50+
// Indent the lambda body properly - it should be at the same level as the lambda parameter
51+
// The field declaration starts at column 4 (after " ")
52+
// We want the body to align after "= " which is at column 4 + "private static readonly ... = ".length
53+
// For simplicity and consistency, indent by 2 additional levels from the field level
54+
var indentedBody = Formatting.CodeFormatter.IndentCode(
55+
lambdaBody.TrimEnd(),
56+
Formatting.CodeFormatter.IndentSize
57+
);
58+
sb.AppendLine(indentedBody + ";");
59+
}
60+
61+
return (sb.ToString(), fieldName);
62+
}
63+
}

src/Linqraft.Core/GenerateSourceCodeSnippets.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ public static void ExportAll(IncrementalGeneratorPostInitializationContext conte
2020
// Generate total code with DTOs that may have different namespaces.
2121
public static string BuildCodeSnippetAll(
2222
List<string> expressions,
23+
List<string> staticFields,
2324
List<GenerateDtoClassInfo> dtoClassInfos,
2425
LinqraftConfiguration configuration
2526
)
2627
{
27-
var exprPart = BuildExprCodeSnippets(expressions);
28+
var exprPart = BuildExprCodeSnippets(expressions, staticFields);
2829
var dtoPart = BuildDtoCodeSnippetsGroupedByNamespace(dtoClassInfos, configuration);
2930
return $$"""
3031
{{GenerateCommentHeaderPart()}}
@@ -35,8 +36,16 @@ LinqraftConfiguration configuration
3536
}
3637

3738
// Generate expression part
38-
public static string BuildExprCodeSnippets(List<string> expressions)
39+
public static string BuildExprCodeSnippets(List<string> expressions, List<string> staticFields)
3940
{
41+
var fieldsPart =
42+
staticFields.Count > 0
43+
? CodeFormatter.IndentCode(
44+
string.Join(CodeFormatter.DefaultNewLine, staticFields),
45+
CodeFormatter.IndentSize * 2
46+
) + CodeFormatter.DefaultNewLine
47+
: "";
48+
4049
var indentedExpr = CodeFormatter.IndentCode(
4150
string.Join(CodeFormatter.DefaultNewLine, expressions),
4251
CodeFormatter.IndentSize * 2
@@ -47,7 +56,7 @@ namespace Linqraft
4756
{
4857
file static partial class GeneratedExpression
4958
{
50-
{{indentedExpr}}
59+
{{fieldsPart}}{{indentedExpr}}
5160
}
5261
}
5362
""";
@@ -275,6 +284,7 @@ private static string GenerateCommentHeaderPart()
275284
private const string GenerateHeaderUsingPart = """
276285
using System;
277286
using System.Linq;
287+
using System.Linq.Expressions;
278288
using System.Collections.Generic;
279289
""";
280290

src/Linqraft.Core/LinqraftConfiguration.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public record LinqraftConfiguration
1616
"build_property.LinqraftArrayNullabilityRemoval";
1717
const string LinqraftNestedDtoUseHashNamespaceOptionKey =
1818
"build_property.LinqraftNestedDtoUseHashNamespace";
19+
const string LinqraftUsePrebuildExpressionOptionKey =
20+
"build_property.LinqraftUsePrebuildExpression";
1921

2022
/// <summary>
2123
/// The namespace where global namespace DTOs should exist.
@@ -62,6 +64,15 @@ public record LinqraftConfiguration
6264
/// </summary>
6365
public bool NestedDtoUseHashNamespace { get; init; } = true;
6466

67+
/// <summary>
68+
/// Whether to pre-build and cache Expression Trees for IQueryable operations to improve performance.
69+
/// When enabled, expression trees are built at compile-time and cached as static fields, avoiding runtime construction.
70+
/// Only applies to IQueryable patterns (not IEnumerable) and only for named/predefined/explicit DTO types (not anonymous types).
71+
/// Note: Pre-building is not applied when there are capture variables in the lambda expression.
72+
/// Default is false (disabled)
73+
/// </summary>
74+
public bool UsePrebuildExpression { get; init; } = false;
75+
6576
/// <summary>
6677
/// Gets the actual property accessor to use based on configuration
6778
/// </summary>
@@ -107,6 +118,10 @@ out var arrayNullabilityRemovalStr
107118
LinqraftNestedDtoUseHashNamespaceOptionKey,
108119
out var NestedDtoUseHashNamespaceStr
109120
);
121+
globalOptions.GlobalOptions.TryGetValue(
122+
LinqraftUsePrebuildExpressionOptionKey,
123+
out var usePrebuildExpressionStr
124+
);
110125

111126
var linqraftOptions = new LinqraftConfiguration();
112127
if (!string.IsNullOrWhiteSpace(globalNamespace))
@@ -155,6 +170,13 @@ out var commentOutputEnum
155170
NestedDtoUseHashNamespace = NestedDtoUseHashNamespace,
156171
};
157172
}
173+
if (bool.TryParse(usePrebuildExpressionStr, out var usePrebuildExpression))
174+
{
175+
linqraftOptions = linqraftOptions with
176+
{
177+
UsePrebuildExpression = usePrebuildExpression,
178+
};
179+
}
158180
return linqraftOptions;
159181
}
160182
}

src/Linqraft.Core/SelectExprInfo.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,16 @@ protected abstract string GenerateSelectExprMethod(
140140
InterceptableLocation location
141141
);
142142

143+
/// <summary>
144+
/// Generates static field declarations for pre-built expressions (if enabled)
145+
/// </summary>
146+
public virtual string? GenerateStaticFields()
147+
{
148+
// Default implementation returns null (no fields)
149+
// Derived classes can override this if they need static fields
150+
return null;
151+
}
152+
143153
/// <summary>
144154
/// Generates SelectExpr code for a given interceptable location
145155
/// </summary>

src/Linqraft.Core/SelectExprInfoAnonymous.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ protected override DtoStructure GenerateDtoStructure()
5555
// Get expression type string (for documentation)
5656
protected override string GetExprTypeString() => "anonymous";
5757

58+
/// <summary>
59+
/// Generates static field declarations for pre-built expressions (if enabled)
60+
/// </summary>
61+
public override string? GenerateStaticFields()
62+
{
63+
// Anonymous types cannot use pre-built expressions because we don't know the result type at compile time
64+
// The result type is inferred from the lambda and is an compiler-generated anonymous type
65+
return null;
66+
}
67+
5868
// Generate SelectExpr method
5969
protected override string GenerateSelectExprMethod(
6070
string dtoName,
@@ -67,6 +77,10 @@ InterceptableLocation location
6777
var sb = new StringBuilder();
6878

6979
var id = GetUniqueId();
80+
81+
// Anonymous types cannot use pre-built expressions (result type is unknown)
82+
// so we always use inline lambda
83+
7084
sb.AppendLine(GenerateMethodHeaderPart("anonymous type", location));
7185

7286
// Determine if we have capture parameters
@@ -113,6 +127,7 @@ InterceptableLocation location
113127
sb.AppendLine($" var capture = ({captureTypeName})captureParam;");
114128
}
115129

130+
// Anonymous types always use inline lambda (cannot use pre-built expressions)
116131
sb.AppendLine($" var converted = matchedQuery.Select({LambdaParameterName} => new");
117132
}
118133
else
@@ -126,6 +141,8 @@ InterceptableLocation location
126141
sb.AppendLine(
127142
$" var matchedQuery = query as object as {returnTypePrefix}<{sourceTypeFullName}>;"
128143
);
144+
145+
// Anonymous types always use inline lambda (cannot use pre-built expressions)
129146
sb.AppendLine($" var converted = matchedQuery.Select({LambdaParameterName} => new");
130147
}
131148

@@ -141,6 +158,7 @@ InterceptableLocation location
141158
.ToList();
142159
sb.AppendLine(string.Join($",{CodeFormatter.DefaultNewLine}", propertyAssignments));
143160
sb.AppendLine(" });");
161+
144162
sb.AppendLine($" return converted as object as {returnTypePrefix}<TResult>;");
145163
sb.AppendLine("}");
146164
sb.AppendLine();

0 commit comments

Comments
 (0)