Skip to content

Commit 004d5c1

Browse files
committed
handle edge case
1 parent 5444f3f commit 004d5c1

5 files changed

Lines changed: 441 additions & 16 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ FastCloner is a zero-dependency deep cloning library for .NET, from <code>.NET 4
2424
- **Precise control** - Override clone behavior per type or member with `Clone`, `Reference`, `Shallow`, or `Ignore`, at compile time or runtime
2525
- **Selective tracking** - FastCloner avoids identity and cycle-tracking overhead by default, but enables it when graph shape or `[FastClonerPreserveIdentity]` requires it
2626
- **Easy Integration** - `FastDeepClone()` for AOT cloning, `DeepClone()` for reflection cloning. FastCloner respects standard .NET attributes like `[NonSerialized]`, so you can adopt it without depending on library-specific annotations
27-
- **Production Ready** - Used by projects like [Foundatio](https://github.com/FoundatioFx/Foundatio), [Jobbr](https://jobbr.readthedocs.io/en/latest), [TarkovSP](https://sp-tarkov.com), [SnapX](https://github.com/SnapXL/SnapX), and [WinPaletter](https://github.com/Abdelrhman-AK/WinPaletter), with over [300K downloads on NuGet](https://www.nuget.org/packages/fastCloner#usedby-body-tab)
27+
- **Production Ready** - Used by projects like [Foundatio](https://github.com/FoundatioFx/Foundatio), [Jobbr](https://jobbr.readthedocs.io/en/latest), [TarkovSP](https://sp-tarkov.com), [SnapX](https://github.com/SnapXL/SnapX), and [WinPaletter](https://github.com/Abdelrhman-AK/WinPaletter), with over [500K downloads on NuGet](https://www.nuget.org/packages/fastCloner#usedby-body-tab)
2828
## Getting Started
2929

3030
Install the package via NuGet:

src/FastCloner.SourceGenerator/NonPublicAccessorEmitter.cs

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -208,23 +208,43 @@ private static void WriteRuntimeBridgeCall(
208208
{
209209
string accessorPrefix = context.GetNonPublicAccessorPrefix();
210210
string proxyFqn = "global::" + context.BridgeContract.ProxyTypeFullName;
211-
211+
212+
if (accessor.DeclaringTypeIsStruct)
213+
{
214+
string boxVar = $"__nptarget_{context.GetNextVariableId()}";
215+
string structFqn = context.Model.FullyQualifiedName;
216+
sb.AppendLine($"{indent}{{");
217+
sb.AppendLine($"{indent} object {boxVar} = (object){resultVar};");
218+
WriteRuntimeBridgeCallStatement(sb, indent + " ", proxyFqn, accessorPrefix, accessor, member, sourceVar, boxVar);
219+
sb.AppendLine($"{indent} {resultVar} = ({structFqn}){boxVar};");
220+
sb.AppendLine($"{indent}}}");
221+
}
222+
else
223+
{
224+
WriteRuntimeBridgeCallStatement(sb, indent, proxyFqn, accessorPrefix, accessor, member, sourceVar, resultVar);
225+
}
226+
}
227+
228+
private static void WriteRuntimeBridgeCallStatement(
229+
StringBuilder sb,
230+
string indent,
231+
string proxyFqn,
232+
string accessorPrefix,
233+
NonPublicAccessor accessor,
234+
MemberModel member,
235+
string sourceVar,
236+
string targetVar)
237+
{
212238
if (accessor.IsBackingFieldStorage)
213239
{
214240
string fiRef = $"{accessorPrefix}{accessor.AccessorMethodName}_FI";
215-
if (member.IsShallowClone)
216-
{
217-
sb.AppendLine($"{indent}{proxyFqn}.CopyField({sourceVar}, {resultVar}, {fiRef});");
218-
}
219-
else
220-
{
221-
sb.AppendLine($"{indent}{proxyFqn}.DeepCloneField({sourceVar}, {resultVar}, {fiRef});");
222-
}
241+
string method = member.IsShallowClone ? "CopyField" : "DeepCloneField";
242+
sb.AppendLine($"{indent}{proxyFqn}.{method}({sourceVar}, {targetVar}, {fiRef});");
223243
}
224244
else
225245
{
226246
string piRef = $"{accessorPrefix}{accessor.AccessorMethodName}_PI";
227-
sb.AppendLine($"{indent}{proxyFqn}.DeepCloneProperty({sourceVar}, {resultVar}, {piRef});");
247+
sb.AppendLine($"{indent}{proxyFqn}.DeepCloneProperty({sourceVar}, {targetVar}, {piRef});");
228248
}
229249
}
230250

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
using System.Text;
2+
using FastCloner.SourceGenerator;
3+
4+
namespace FastCloner.Tests;
5+
6+
public class NonPublicAccessorEmitterTests
7+
{
8+
private const string ProxyFqn = "FastCloner.SourceGenerated.__FastClonerSGBridgeProxy";
9+
private const string GlobalProxyFqn = "global::" + ProxyFqn;
10+
11+
private static BridgeContract MakeBridgeContract() => new BridgeContract(
12+
BridgeTypeMetadataName: "FastCloner.FastClonerSourceGeneratorBridge",
13+
ProxyTypeFullName: ProxyFqn,
14+
Methods: new EquatableArray<BridgeMethodSpec>(
15+
[
16+
new BridgeMethodSpec("DeepCloneField", "void",
17+
new EquatableArray<string>(["object", "object", "global::System.Reflection.FieldInfo"])),
18+
new BridgeMethodSpec("CopyField", "void",
19+
new EquatableArray<string>(["object", "object", "global::System.Reflection.FieldInfo"])),
20+
new BridgeMethodSpec("DeepCloneProperty", "void",
21+
new EquatableArray<string>(["object", "object", "global::System.Reflection.PropertyInfo"])),
22+
new BridgeMethodSpec("ResolveDeclaredField", "global::System.Reflection.FieldInfo",
23+
new EquatableArray<string>(["global::System.Type", "string"])),
24+
new BridgeMethodSpec("ResolveDeclaredProperty", "global::System.Reflection.PropertyInfo",
25+
new EquatableArray<string>(["global::System.Type", "string"])),
26+
]),
27+
IsAvailable: true);
28+
29+
private static TypeModel MakeTypeModel(bool isStruct, string fullyQualifiedName, EquatableArray<MemberModel>? members = null) =>
30+
new TypeModel(
31+
Namespace: "Test",
32+
Name: "T",
33+
FullyQualifiedName: fullyQualifiedName,
34+
IsStruct: isStruct,
35+
IsSealed: false,
36+
IsAbstract: false,
37+
IsRecord: false,
38+
HasClonableBaseClass: false,
39+
CanHaveCircularReferences: false,
40+
NeedsStateTracking: false,
41+
IsFastClonerAvailable: true,
42+
Members: members ?? EquatableArray<MemberModel>.Empty,
43+
TypeParameters: EquatableArray<string>.Empty,
44+
TypeConstraints: EquatableArray<string>.Empty,
45+
RelatedTypes: EquatableArray<TypeModel>.Empty,
46+
NestedTypes: EquatableArray<MemberModel>.Empty,
47+
DerivedTypes: EquatableArray<TypeModel>.Empty,
48+
NullabilityEnabled: true,
49+
TrustNullability: false,
50+
TargetFramework: TargetFramework.NetStandard20);
51+
52+
private static MemberModel MakePrivateField(string name = "_privateField", string type = "int", bool isShallow = false) =>
53+
new MemberModel(
54+
Name: name,
55+
TypeFullName: type,
56+
IsReadOnly: false,
57+
IsProperty: false,
58+
IsField: true,
59+
TypeKind: MemberTypeKind.Safe,
60+
ElementTypeName: null,
61+
KeyTypeName: null,
62+
ValueTypeName: null,
63+
ElementIsSafe: false,
64+
ElementHasClonableAttr: false,
65+
KeyIsSafe: false,
66+
KeyIsClonable: false,
67+
ValueIsSafe: false,
68+
ValueIsClonable: false,
69+
RequiresFastCloner: false,
70+
CollectionKind: CollectionKind.None,
71+
ConcreteTypeFullName: null,
72+
IsValueType: true,
73+
IsInitOnly: false,
74+
IsRequired: false,
75+
ArrayRank: 0,
76+
IsNullable: false,
77+
HasGetter: true,
78+
HasSetter: true,
79+
SetterIsAccessible: false,
80+
MemberBehavior: isShallow ? MemberCloneBehavior.Shallow : MemberCloneBehavior.Clone,
81+
AccessorStrategy: NonPublicAccessorStrategy.Field,
82+
GetterIsAccessible: false);
83+
84+
private static MemberModel MakePrivateSetterProperty(string name = "PrivateSetterProp", string type = "int") =>
85+
new MemberModel(
86+
Name: name,
87+
TypeFullName: type,
88+
IsReadOnly: false,
89+
IsProperty: true,
90+
IsField: false,
91+
TypeKind: MemberTypeKind.Safe,
92+
ElementTypeName: null,
93+
KeyTypeName: null,
94+
ValueTypeName: null,
95+
ElementIsSafe: false,
96+
ElementHasClonableAttr: false,
97+
KeyIsSafe: false,
98+
KeyIsClonable: false,
99+
ValueIsSafe: false,
100+
ValueIsClonable: false,
101+
RequiresFastCloner: false,
102+
CollectionKind: CollectionKind.None,
103+
ConcreteTypeFullName: null,
104+
IsValueType: true,
105+
IsInitOnly: false,
106+
IsRequired: false,
107+
ArrayRank: 0,
108+
IsNullable: false,
109+
HasGetter: true,
110+
HasSetter: true,
111+
SetterIsAccessible: false,
112+
MemberBehavior: MemberCloneBehavior.Clone,
113+
AccessorStrategy: NonPublicAccessorStrategy.SetterMethod,
114+
GetterIsAccessible: true);
115+
116+
[Test]
117+
public async Task RuntimeBridge_StructReceiver_FieldStorage_EmitsBoxMutateUnbox()
118+
{
119+
MemberModel member = MakePrivateField();
120+
TypeModel model = MakeTypeModel(isStruct: true, "global::Test.MyStruct", new EquatableArray<MemberModel>([member]));
121+
CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract());
122+
StringBuilder sb = new StringBuilder();
123+
124+
NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, member, "result", "source", "state", " ");
125+
126+
string emitted = sb.ToString();
127+
128+
await Assert.That(emitted).Contains("object __nptarget_");
129+
await Assert.That(emitted).Contains("= (object)result;");
130+
await Assert.That(emitted).Contains($"{GlobalProxyFqn}.DeepCloneField(source,");
131+
await Assert.That(emitted).Contains("result = (global::Test.MyStruct)__nptarget_");
132+
133+
// The bridge call must target the box, not the local 'result'.
134+
await Assert.That(emitted).DoesNotContain($"{GlobalProxyFqn}.DeepCloneField(source, result,");
135+
}
136+
137+
[Test]
138+
public async Task RuntimeBridge_StructReceiver_ShallowField_EmitsCopyField_ThroughBox()
139+
{
140+
MemberModel member = MakePrivateField(isShallow: true);
141+
TypeModel model = MakeTypeModel(isStruct: true, "global::Test.MyStruct", new EquatableArray<MemberModel>([member]));
142+
CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract());
143+
StringBuilder sb = new StringBuilder();
144+
145+
NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, member, "result", "source", "state", " ");
146+
147+
string emitted = sb.ToString();
148+
149+
await Assert.That(emitted).Contains("object __nptarget_");
150+
await Assert.That(emitted).Contains($"{GlobalProxyFqn}.CopyField(source,");
151+
await Assert.That(emitted).Contains("result = (global::Test.MyStruct)__nptarget_");
152+
await Assert.That(emitted).DoesNotContain($"{GlobalProxyFqn}.CopyField(source, result,");
153+
}
154+
155+
[Test]
156+
public async Task RuntimeBridge_StructReceiver_PropertySetter_EmitsBoxMutateUnbox()
157+
{
158+
MemberModel member = MakePrivateSetterProperty();
159+
TypeModel model = MakeTypeModel(isStruct: true, "global::Test.MyStruct", new EquatableArray<MemberModel>([member]));
160+
CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract());
161+
StringBuilder sb = new StringBuilder();
162+
163+
NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, member, "result", "source", "state", " ");
164+
165+
string emitted = sb.ToString();
166+
167+
await Assert.That(emitted).Contains("object __nptarget_");
168+
await Assert.That(emitted).Contains($"{GlobalProxyFqn}.DeepCloneProperty(source,");
169+
await Assert.That(emitted).Contains("result = (global::Test.MyStruct)__nptarget_");
170+
await Assert.That(emitted).DoesNotContain($"{GlobalProxyFqn}.DeepCloneProperty(source, result,");
171+
}
172+
173+
[Test]
174+
public async Task RuntimeBridge_ClassReceiver_DoesNotBoxOrUnbox()
175+
{
176+
// Reference types should keep using the simple direct call -- boxing is a
177+
// no-op and the generator should not pay for an extra copy.
178+
MemberModel member = MakePrivateField();
179+
TypeModel model = MakeTypeModel(isStruct: false, "global::Test.MyClass", new EquatableArray<MemberModel>([member]));
180+
CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract());
181+
StringBuilder sb = new StringBuilder();
182+
183+
NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, member, "result", "source", "state", " ");
184+
185+
string emitted = sb.ToString();
186+
187+
await Assert.That(emitted).Contains($"{GlobalProxyFqn}.DeepCloneField(source, result,");
188+
await Assert.That(emitted).DoesNotContain("__nptarget_");
189+
await Assert.That(emitted).DoesNotContain("(object)result");
190+
}
191+
192+
[Test]
193+
public async Task RuntimeBridge_StructReceiver_MultipleCalls_UseDistinctBoxLocals()
194+
{
195+
// Two non-public members on the same struct must use distinct local
196+
// names so the emitted method compiles. The generator uses
197+
// GetNextVariableId() for this.
198+
MemberModel a = MakePrivateField("_a");
199+
MemberModel b = MakePrivateField("_b");
200+
TypeModel model = MakeTypeModel(isStruct: true, "global::Test.MyStruct",
201+
new EquatableArray<MemberModel>([a, b]));
202+
CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract());
203+
StringBuilder sb = new StringBuilder();
204+
205+
NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, a, "result", "source", "state", " ");
206+
NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, b, "result", "source", "state", " ");
207+
208+
string emitted = sb.ToString();
209+
210+
// Find both __nptarget_<n> identifiers and verify they differ.
211+
System.Text.RegularExpressions.MatchCollection matches =
212+
System.Text.RegularExpressions.Regex.Matches(emitted, @"__nptarget_(\d+)");
213+
HashSet<string> ids = [];
214+
foreach (System.Text.RegularExpressions.Match m in matches)
215+
{
216+
ids.Add(m.Groups[1].Value);
217+
}
218+
219+
await Assert.That(ids.Count).IsGreaterThanOrEqualTo(2);
220+
}
221+
222+
[Test]
223+
public async Task TfmAtLeastNet8_DoesNotUseBridge_RegardlessOfStructness()
224+
{
225+
// Sanity: on .NET 8+ the emitter uses UnsafeAccessor and never boxes.
226+
MemberModel member = MakePrivateField();
227+
TypeModel model = MakeTypeModel(isStruct: true, "global::Test.MyStruct", new EquatableArray<MemberModel>([member]))
228+
with { TargetFramework = TargetFramework.Net8 };
229+
CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract());
230+
StringBuilder sb = new StringBuilder();
231+
232+
NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, member, "result", "source", "state", " ");
233+
234+
string emitted = sb.ToString();
235+
236+
await Assert.That(emitted).DoesNotContain(GlobalProxyFqn);
237+
await Assert.That(emitted).DoesNotContain("__nptarget_");
238+
}
239+
}

0 commit comments

Comments
 (0)