Skip to content

Commit 16b287e

Browse files
Copilotarika0093
andauthored
Deduplicate DTO class definitions globally across all generated files (#240)
* Initial plan * Fix duplicate ChildDto generation for same-shaped nested DTOs in Select expressions Co-authored-by: arika0093 <4524647+arika0093@users.noreply.github.com> * Improve code comments based on review feedback Co-authored-by: arika0093 <4524647+arika0093@users.noreply.github.com> * Fix duplicate DTO class definitions by deduplicating at generation point Reverted changes to DtoProperty.cs and implemented deduplication in GenerateSourceCodeSnippets.BuildDtoCodeSnippetsGroupedByNamespace instead. This ensures DTOs with the same FullName (namespace + class name) are only generated once, even when used across multiple SelectExpr calls. Co-authored-by: arika0093 <4524647+arika0093@users.noreply.github.com> * Implement global DTO deduplication across all files - Collect all DTOs from all SelectExpr groups - Deduplicate globally by FullName before generation - Generate all DTOs in a single shared GeneratedDtos.g.cs file - Each expression group file now only contains expression methods - This ensures DTOs are not duplicated even when used across different files Co-authored-by: arika0093 <4524647+arika0093@users.noreply.github.com> * Refactor DTO generation based on code review feedback - Moved global DTO deduplication logic to GenerateSourceCodeSnippets.BuildGlobalDtoCodeSnippet - Separated SelectExprGroups.GenerateCode into two methods: GenerateCode and GenerateCodeWithoutDtos - Made GenerateCommentHeaderPart and GenerateHeaderFlagsPart public for cross-namespace use - Renamed test files to Issue239_* pattern for clarity - Updated playground CodeGenerationService to use new global DTO deduplication - Fixed null reference warning in playground service 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>
1 parent 9bc3057 commit 16b287e

File tree

6 files changed

+267
-17
lines changed

6 files changed

+267
-17
lines changed

playground/Services/CodeGenerationService.cs

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public GeneratedOutput GenerateOutput(
6666

6767
// Generate code for each SelectExpr
6868
var queryExpressionBuilder = new StringBuilder();
69-
var dtoClassBuilder = new StringBuilder();
69+
var allDtoClasses = new List<GenerateDtoClassInfo>();
7070

7171
foreach (var info in allSelectExprInfos)
7272
{
@@ -80,18 +80,15 @@ public GeneratedOutput GenerateOutput(
8080
var location = semanticModel.GetInterceptableLocation(info.Invocation);
8181
var fields = info.GenerateStaticFields();
8282
var selectExprCodes = info.GenerateSelectExprCodes(location!);
83-
var dtoClasses = info.GenerateDtoClasses()
84-
.GroupBy(c => c.FullName)
85-
.Select(g => g.First())
86-
.ToList();
83+
84+
// Collect DTOs from all SelectExpr calls
85+
var dtoClasses = info.GenerateDtoClasses();
86+
allDtoClasses.AddRange(dtoClasses);
8787

8888
queryExpressionBuilder.AppendLine(
89-
GenerateSourceCodeSnippets.BuildExprCodeSnippets(selectExprCodes, [fields])
90-
);
91-
dtoClassBuilder.AppendLine(
92-
GenerateSourceCodeSnippets.BuildDtoCodeSnippetsGroupedByNamespace(
93-
dtoClasses,
94-
config
89+
GenerateSourceCodeSnippets.BuildExprCodeSnippets(
90+
selectExprCodes,
91+
fields != null ? [fields] : []
9592
)
9693
);
9794
}
@@ -103,8 +100,13 @@ public GeneratedOutput GenerateOutput(
103100
}
104101
}
105102

103+
// Generate DTOs with global deduplication
104+
var dtoCode = GenerateSourceCodeSnippets.BuildGlobalDtoCodeSnippet(
105+
allDtoClasses,
106+
config
107+
);
108+
106109
var expressionCode = queryExpressionBuilder.ToString().TrimEnd();
107-
var dtoCode = dtoClassBuilder.ToString().TrimEnd();
108110

109111
// Filter out internal attributes from the DTO output for cleaner display
110112
dtoCode = FilterInternalAttributes(dtoCode);

src/Linqraft.Core/GenerateSourceCodeSnippets.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,35 @@ file static partial class GeneratedExpression
6767
""";
6868
}
6969

70+
// Generate all DTOs in a single shared source file with global deduplication
71+
public static string BuildGlobalDtoCodeSnippet(
72+
List<GenerateDtoClassInfo> allDtoClassInfos,
73+
LinqraftConfiguration configuration
74+
)
75+
{
76+
// Deduplicate DTOs globally by FullName
77+
var globallyUniqueDtos = allDtoClassInfos
78+
.GroupBy(c => c.FullName)
79+
.Select(g => g.First())
80+
.ToList();
81+
82+
if (globallyUniqueDtos.Count == 0)
83+
{
84+
return string.Empty;
85+
}
86+
87+
var dtoSourceCode = BuildDtoCodeSnippetsGroupedByNamespace(
88+
globallyUniqueDtos,
89+
configuration
90+
);
91+
92+
return $$"""
93+
{{GenerateCommentHeaderPart()}}
94+
{{GenerateHeaderFlagsPart}}
95+
{{dtoSourceCode}}
96+
""";
97+
}
98+
7099
// Generate DTO part
71100
public static string BuildDtoCodeSnippets(List<string> dtoClasses, string namespaceName)
72101
{
@@ -276,7 +305,7 @@ public static IEnumerable<TResult> SelectExpr<TIn, TResult>(this IEnumerable<TIn
276305
https://github.com/arika0093/Linqraft/issues
277306
""";
278307

279-
private static string GenerateCommentHeaderPart()
308+
public static string GenerateCommentHeaderPart()
280309
{
281310
#if DEBUG
282311
var now = DateTime.Now;
@@ -301,7 +330,7 @@ private static string GenerateCommentHeaderPart()
301330
// </auto-generated>
302331
""";
303332

304-
private const string GenerateHeaderFlagsPart = """
333+
public const string GenerateHeaderFlagsPart = """
305334
#nullable enable
306335
#pragma warning disable IDE0060
307336
#pragma warning disable CS8601

src/Linqraft.SourceGenerator/SelectExprGenerator.cs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,31 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
8181
})
8282
.ToList();
8383

84-
// Generate code for explicit DTO infos (one method per group)
84+
// Collect all DTOs from all groups and deduplicate globally
85+
var allDtoClassInfos = new List<GenerateDtoClassInfo>();
8586
foreach (var exprGroup in exprGroups)
8687
{
87-
exprGroup.GenerateCode(spc);
88+
foreach (var expr in exprGroup.Exprs)
89+
{
90+
var classInfos = expr.Info.GenerateDtoClasses();
91+
allDtoClassInfos.AddRange(classInfos);
92+
}
93+
}
94+
95+
// Generate all DTOs in a single shared source file
96+
var dtoCode = GenerateSourceCodeSnippets.BuildGlobalDtoCodeSnippet(
97+
allDtoClassInfos,
98+
config
99+
);
100+
if (!string.IsNullOrEmpty(dtoCode))
101+
{
102+
spc.AddSource("GeneratedDtos.g.cs", dtoCode);
103+
}
104+
105+
// Generate code for expression methods (without DTOs)
106+
foreach (var exprGroup in exprGroups)
107+
{
108+
exprGroup.GenerateCodeWithoutDtos(spc);
88109
}
89110
}
90111
);

src/Linqraft.SourceGenerator/SelectExprGroups.cs

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public string GetUniqueId()
4343
return $"{targetNsReplaced}_{filenameReplaced}";
4444
}
4545

46-
// Generate source code
46+
// Generate source code with DTOs
4747
public virtual void GenerateCode(SourceProductionContext context)
4848
{
4949
try
@@ -98,6 +98,56 @@ public virtual void GenerateCode(SourceProductionContext context)
9898
context.AddSource($"GeneratorError_{hash}.g.cs", errorMessage);
9999
}
100100
}
101+
102+
// Generate source code without DTOs (for global DTO generation)
103+
public virtual void GenerateCodeWithoutDtos(SourceProductionContext context)
104+
{
105+
try
106+
{
107+
var selectExprMethods = new List<string>();
108+
var staticFields = new List<string>();
109+
110+
foreach (var expr in Exprs)
111+
{
112+
var info = expr.Info;
113+
var exprMethods = info.GenerateSelectExprCodes(expr.Location);
114+
var fields = info.GenerateStaticFields();
115+
116+
selectExprMethods.AddRange(exprMethods);
117+
if (fields != null)
118+
{
119+
staticFields.Add(fields);
120+
}
121+
}
122+
123+
// Generate only expression methods without DTOs
124+
var exprPart = GenerateSourceCodeSnippets.BuildExprCodeSnippets(
125+
selectExprMethods,
126+
staticFields
127+
);
128+
var sourceCode = $$"""
129+
{{GenerateSourceCodeSnippets.GenerateCommentHeaderPart()}}
130+
{{GenerateSourceCodeSnippets.GenerateHeaderFlagsPart}}
131+
{{exprPart}}
132+
""";
133+
134+
// Register with Source Generator
135+
var uniqueId = GetUniqueId();
136+
context.AddSource($"GeneratedExpression_{uniqueId}.g.cs", sourceCode);
137+
}
138+
catch (Exception ex)
139+
{
140+
// Output error information for debugging
141+
var errorMessage = $"""
142+
/*
143+
* Source Generator Error: {ex.Message}
144+
* Stack Trace: {ex.StackTrace}
145+
*/
146+
""";
147+
var hash = HashUtility.GenerateRandomIdentifier();
148+
context.AddSource($"GeneratorError_{hash}.g.cs", errorMessage);
149+
}
150+
}
101151
}
102152

103153
internal class SelectExprLocations
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
4+
namespace Linqraft.Tests;
5+
6+
/// <summary>
7+
/// Issue #239: When ChildDto of the same shape appears multiple times,
8+
/// the definition of ChildDto should be generated only once.
9+
/// This test is a duplicate of the minimal repro from issue #239.
10+
/// </summary>
11+
public partial class Issue239_DuplicateChildDtoTest
12+
{
13+
private readonly List<Entity> _testData =
14+
[
15+
new Entity
16+
{
17+
Id = 1,
18+
Name = "Entity1",
19+
Child = new Child { Description = "Child1" },
20+
Items = [new Item { Title = "Item1" }, new Item { Title = "Item2" }],
21+
},
22+
];
23+
24+
[Fact]
25+
public void ShouldGenerateSingleItemDtoForMultipleSelectExprWithSameShape()
26+
{
27+
// Arrange & Act - Two separate SelectExpr calls with identical anonymous structure
28+
// This should generate only ONE ItemDto class definition, not two
29+
var result1 = _testData
30+
.AsQueryable()
31+
.SelectExpr(x => new
32+
{
33+
x.Id,
34+
x.Name,
35+
ChildDescription = x.Child?.Description,
36+
ItemTitles = x.Items.Select(i => new { i.Title }),
37+
})
38+
.ToList();
39+
40+
var result2 = _testData
41+
.AsQueryable()
42+
.SelectExpr(x => new
43+
{
44+
x.Id,
45+
x.Name,
46+
ChildDescription = x.Child?.Description,
47+
ItemTitles = x.Items.Select(i => new { i.Title }),
48+
})
49+
.ToList();
50+
51+
// Assert
52+
result1.ShouldNotBeNull();
53+
result1.Count.ShouldBe(1);
54+
result1[0].Id.ShouldBe(1);
55+
result1[0].Name.ShouldBe("Entity1");
56+
result1[0].ChildDescription.ShouldBe("Child1");
57+
result1[0].ItemTitles.Count().ShouldBe(2);
58+
59+
result2.ShouldNotBeNull();
60+
result2.Count.ShouldBe(1);
61+
result2[0].Id.ShouldBe(1);
62+
}
63+
64+
internal class Entity
65+
{
66+
public int Id { get; set; }
67+
public string Name { get; set; } = "";
68+
public Child? Child { get; set; }
69+
public List<Item> Items { get; set; } = [];
70+
}
71+
72+
internal class Child
73+
{
74+
public string Description { get; set; } = "";
75+
}
76+
77+
internal class Item
78+
{
79+
public string Title { get; set; } = "";
80+
}
81+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
using System;
2+
using System.Linq;
3+
using System.Collections.Generic;
4+
5+
namespace Linqraft.Tests;
6+
7+
/// <summary>
8+
/// Issue #239: Minimal reproduction test case
9+
/// </summary>
10+
public class Issue239_MinimalReproTest
11+
{
12+
private readonly List<Entity> _testData =
13+
[
14+
new Entity
15+
{
16+
Id = 1,
17+
Name = "Test",
18+
Child = new Child { Description = "Desc" },
19+
Items = [new Item { Title = "Title1" }],
20+
},
21+
];
22+
23+
[Fact]
24+
public void TwoSelectExprWithSameStructureShouldShareNestedDto()
25+
{
26+
var data = _testData.AsQueryable();
27+
28+
// result1 and result2 use the exact same anonymous structure
29+
// They should share the same ItemTitlesDto definition
30+
var result1 = data.SelectExpr(x => new
31+
{
32+
x.Id,
33+
x.Name,
34+
ChildDescription = x.Child?.Description,
35+
ItemTitles = x.Items.Select(i => new{i.Title}),
36+
}).ToList();
37+
38+
var result2 = data.SelectExpr(x => new
39+
{
40+
x.Id,
41+
x.Name,
42+
ChildDescription = x.Child?.Description,
43+
ItemTitles = x.Items.Select(i => new{i.Title}),
44+
}).ToList();
45+
46+
result1.Count.ShouldBe(1);
47+
result2.Count.ShouldBe(1);
48+
}
49+
50+
internal class Entity
51+
{
52+
public int Id { get; set; }
53+
public string Name { get; set; } = "";
54+
public Child? Child { get; set; }
55+
public List<Item> Items { get; set; } = [];
56+
}
57+
58+
internal class Child
59+
{
60+
public string Description { get; set; } = "";
61+
}
62+
63+
internal class Item
64+
{
65+
public string Title { get; set; } = "";
66+
}
67+
}

0 commit comments

Comments
 (0)