Skip to content

Commit 50386f8

Browse files
authored
Merge pull request #42 from lofcz/feat-cloning-by-member-visibility
Feat cloning by member visibility
2 parents b77fcb4 + 04126b9 commit 50386f8

35 files changed

Lines changed: 2923 additions & 215 deletions

.bench-harness-current/BenchHarness.csproj

Lines changed: 0 additions & 11 deletions
This file was deleted.

.bench-harness-current/Program.cs

Lines changed: 0 additions & 34 deletions
This file was deleted.

.github/workflows/benchmark.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ on:
2121
default: true
2222

2323
env:
24-
DOTNET_VERSION: "10.0.103"
24+
DOTNET_VERSION: "10.0.x"
2525
BASELINE_BRANCH: "next"
2626

2727
permissions:

.github/workflows/dotnet.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on:
77
branches: [ "next" ]
88

99
env:
10-
DOTNET_VERSION: '10.0.103'
10+
DOTNET_VERSION: '10.0.x'
1111
NODE_VERSION: '24'
1212

1313
permissions:

README.md

Lines changed: 19 additions & 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:
@@ -239,6 +239,24 @@ if (ctx.TryClone(obj, out var cloned))
239239
}
240240
```
241241

242+
### Member Visibility
243+
244+
By default, all members are eligible for cloning regardless of access modifier. Apply `[FastClonerVisibility]` to a type to restrict cloning to a specific subset:
245+
246+
```csharp
247+
[FastClonerVisibility(FastClonerMemberVisibility.Public | FastClonerMemberVisibility.Internal)]
248+
public class Dto
249+
{
250+
public int Id { get; set; } // cloned
251+
internal string Tag; // cloned
252+
private string _secret; // skipped
253+
}
254+
```
255+
256+
The policy applies to both reflection and source-generated paths; excluded members are left at their default value on the clone.
257+
258+
The visibility filter runs before the behavior pipeline and is bypassed for any member carrying a member-level behavior attribute (`[FastClonerBehavior]`, `[FastClonerIgnore]`, `[FastClonerShallow]`, `[FastClonerReference]`), so those members are always included with their declared behavior.
259+
242260
### Nullability Trust
243261

244262
The generator can be instructed to fully trust nullability annotations. When `[FastClonerTrustNullability]` attribute is applied, FastCloner will skip null checks for non-nullable reference types (e.g., `string` vs `string?`), assuming the contract is valid.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
using System.Runtime.CompilerServices;
2+
3+
[assembly: InternalsVisibleTo("FastCloner.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100bd882e7dcc8a5cfdd6e2193c66bb144ab67e83964b825035b4b05ccdc40a5a7218a497799f98f98e628c38492fa227ad1579c7d701934ea2e30459a4dabfcfa498fc9f4dc6d3e3f118e4df6615aced3da480ea45d30832ddbfd56acebc957ee6345944d2e9b82705a725276146baadf08cf7a8612fd4f3ceb8b8ec9529b975c2")]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace FastCloner.SourceGenerator;
2+
3+
internal sealed record BridgeMethodSpec(
4+
string Name,
5+
string ReturnTypeFqn,
6+
EquatableArray<string> ParameterTypeFqns)
7+
{
8+
public bool IsVoid => ReturnTypeFqn == "void";
9+
}
10+
11+
internal sealed record BridgeContract(
12+
string BridgeTypeMetadataName,
13+
string ProxyTypeFullName,
14+
EquatableArray<BridgeMethodSpec> Methods,
15+
bool IsAvailable)
16+
{
17+
public static BridgeContract Empty { get; } = new BridgeContract(
18+
BridgeTypeMetadataName: string.Empty,
19+
ProxyTypeFullName: string.Empty,
20+
Methods: EquatableArray<BridgeMethodSpec>.Empty,
21+
IsAvailable: false);
22+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using Microsoft.CodeAnalysis;
4+
5+
namespace FastCloner.SourceGenerator;
6+
7+
internal static class BridgeContractCollector
8+
{
9+
private const string BridgeTypeMetadataName = "FastCloner.FastClonerSourceGeneratorBridge";
10+
private const string BridgeAttrFqn = "FastCloner.FastClonerSourceGeneratorBridgeAttribute";
11+
private const string BridgeMemberAttrFqn = "FastCloner.FastClonerSourceGeneratorBridgeMemberAttribute";
12+
13+
public static BridgeContract Collect(Compilation compilation)
14+
{
15+
INamedTypeSymbol? bridge = compilation.GetTypeByMetadataName(BridgeTypeMetadataName);
16+
17+
AttributeData? bridgeAttr = bridge?.GetAttributes()
18+
.FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == BridgeAttrFqn);
19+
if (bridgeAttr == null || bridgeAttr.ConstructorArguments.Length == 0 || bridgeAttr.ConstructorArguments[0].Value is not string proxyFqn || string.IsNullOrEmpty(proxyFqn))
20+
return BridgeContract.Empty;
21+
22+
List<BridgeMethodSpec> methods = [];
23+
24+
foreach (ISymbol member in bridge?.GetMembers() ?? [])
25+
{
26+
if (member is not IMethodSymbol method)
27+
continue;
28+
if (method.MethodKind != MethodKind.Ordinary || !method.IsStatic)
29+
continue;
30+
31+
bool hasMemberAttr = method.GetAttributes()
32+
.Any(a => a.AttributeClass?.ToDisplayString() == BridgeMemberAttrFqn);
33+
if (!hasMemberAttr)
34+
continue;
35+
36+
string returnTypeFqn = method.ReturnsVoid
37+
? "void"
38+
: method.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
39+
40+
string[] paramFqns = new string[method.Parameters.Length];
41+
for (int i = 0; i < method.Parameters.Length; i++)
42+
{
43+
paramFqns[i] = method.Parameters[i].Type
44+
.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
45+
}
46+
47+
methods.Add(new BridgeMethodSpec(
48+
Name: method.Name,
49+
ReturnTypeFqn: returnTypeFqn,
50+
ParameterTypeFqns: new EquatableArray<string>(paramFqns)));
51+
}
52+
53+
if (methods.Count == 0)
54+
return BridgeContract.Empty;
55+
56+
BridgeMethodSpec[] sortedMethods = methods.OrderBy(m => m.Name, System.StringComparer.Ordinal).ToArray();
57+
58+
return new BridgeContract(
59+
BridgeTypeMetadataName: BridgeTypeMetadataName,
60+
ProxyTypeFullName: proxyFqn!,
61+
Methods: new EquatableArray<BridgeMethodSpec>(sortedMethods),
62+
IsAvailable: true);
63+
}
64+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
using System.Text;
2+
3+
namespace FastCloner.SourceGenerator;
4+
5+
internal static class BridgeProxyEmitter
6+
{
7+
public const string HintName = "FastCloner_SGBridgeProxy.g.cs";
8+
9+
public static bool ShouldEmit(TargetFramework targetFramework, BridgeContract contract)
10+
{
11+
if (targetFramework >= TargetFramework.Net8)
12+
return false;
13+
14+
return contract is { IsAvailable: true, Methods.Count: > 0 };
15+
}
16+
17+
public static string Emit(BridgeContract contract)
18+
{
19+
string proxyFqn = contract.ProxyTypeFullName;
20+
int lastDot = proxyFqn.LastIndexOf('.');
21+
string proxyNamespace = lastDot >= 0 ? proxyFqn.Substring(0, lastDot) : string.Empty;
22+
string proxyTypeName = lastDot >= 0 ? proxyFqn.Substring(lastDot + 1) : proxyFqn;
23+
24+
StringBuilder sb = new StringBuilder(2048);
25+
26+
sb.AppendLine("#nullable disable");
27+
sb.AppendLine("using System;");
28+
sb.AppendLine("using System.Reflection;");
29+
sb.AppendLine();
30+
31+
bool hasNamespace = proxyNamespace.Length > 0;
32+
if (hasNamespace)
33+
{
34+
sb.Append("namespace ").Append(proxyNamespace).AppendLine();
35+
sb.AppendLine("{");
36+
}
37+
38+
string indent = hasNamespace ? " " : string.Empty;
39+
40+
sb.Append(indent).AppendLine("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]");
41+
sb.Append(indent).AppendLine("[global::System.Runtime.CompilerServices.CompilerGenerated]");
42+
sb.Append(indent).Append("internal static class ").AppendLine(proxyTypeName);
43+
sb.Append(indent).AppendLine("{");
44+
45+
string body = hasNamespace ? indent + " " : " ";
46+
47+
sb.Append(body).Append("private const string BridgeTypeName = \"").Append(contract.BridgeTypeMetadataName).AppendLine("\";");
48+
sb.AppendLine();
49+
sb.Append(body).AppendLine("private static readonly Type BridgeType = ResolveBridgeType();");
50+
51+
foreach (BridgeMethodSpec method in contract.Methods)
52+
{
53+
sb.AppendLine();
54+
string delegateType = BuildDelegateType(method);
55+
string parameterTypeofList = BuildTypeofList(method.ParameterTypeFqns);
56+
57+
sb.Append(body).Append("internal static readonly ").Append(delegateType).Append(' ').Append(method.Name).AppendLine(" =");
58+
sb.Append(body).Append(" (").Append(delegateType).AppendLine(")Delegate.CreateDelegate(");
59+
sb.Append(body).Append(" typeof(").Append(delegateType).AppendLine("),");
60+
sb.Append(body).Append(" ResolveStaticMethod(\"").Append(method.Name).Append('\"');
61+
if (parameterTypeofList.Length > 0)
62+
{
63+
sb.Append(", ").Append(parameterTypeofList);
64+
}
65+
sb.AppendLine("));");
66+
}
67+
68+
sb.AppendLine();
69+
sb.Append(body).AppendLine("private static Type ResolveBridgeType()");
70+
sb.Append(body).AppendLine("{");
71+
sb.Append(body).AppendLine(" Assembly runtimeAssembly = typeof(global::FastCloner.Code.FastClonerGenerator).Assembly;");
72+
sb.Append(body).AppendLine(" Type type = runtimeAssembly.GetType(BridgeTypeName, throwOnError: false);");
73+
sb.Append(body).AppendLine(" if (type == null)");
74+
sb.Append(body).AppendLine(" {");
75+
sb.Append(body).AppendLine(" throw new InvalidOperationException(");
76+
sb.Append(body).AppendLine(" \"FastCloner source generator: the runtime bridge type '\" + BridgeTypeName +");
77+
sb.Append(body).AppendLine(" \"' was not found in assembly '\" + runtimeAssembly.FullName +");
78+
sb.Append(body).AppendLine(" \"'. The FastCloner runtime version may be incompatible with the source generator. \" +");
79+
sb.Append(body).AppendLine(" \"Update the FastCloner package to a matching version.\");");
80+
sb.Append(body).AppendLine(" }");
81+
sb.Append(body).AppendLine(" return type;");
82+
sb.Append(body).AppendLine("}");
83+
84+
sb.AppendLine();
85+
sb.Append(body).AppendLine("private static MethodInfo ResolveStaticMethod(string name, params Type[] parameterTypes)");
86+
sb.Append(body).AppendLine("{");
87+
sb.Append(body).AppendLine(" const BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public;");
88+
sb.Append(body).AppendLine(" MethodInfo method = BridgeType.GetMethod(name, flags, binder: null, types: parameterTypes, modifiers: null);");
89+
sb.Append(body).AppendLine(" if (method == null)");
90+
sb.Append(body).AppendLine(" {");
91+
sb.Append(body).AppendLine(" throw new InvalidOperationException(");
92+
sb.Append(body).AppendLine(" \"FastCloner source generator: bridge method '\" + name +");
93+
sb.Append(body).AppendLine(" \"' was not found on '\" + BridgeType.FullName +");
94+
sb.Append(body).AppendLine(" \"'. The FastCloner runtime version may be incompatible with the source generator.\");");
95+
sb.Append(body).AppendLine(" }");
96+
sb.Append(body).AppendLine(" return method;");
97+
sb.Append(body).AppendLine("}");
98+
99+
sb.Append(indent).AppendLine("}");
100+
101+
if (hasNamespace)
102+
{
103+
sb.AppendLine("}");
104+
}
105+
106+
return sb.ToString();
107+
}
108+
109+
private static string BuildDelegateType(BridgeMethodSpec method)
110+
{
111+
if (method.IsVoid)
112+
{
113+
if (method.ParameterTypeFqns.Count == 0)
114+
{
115+
return "global::System.Action";
116+
}
117+
118+
return "global::System.Action<" + Join(method.ParameterTypeFqns) + ">";
119+
}
120+
121+
if (method.ParameterTypeFqns.Count == 0)
122+
{
123+
return "global::System.Func<" + method.ReturnTypeFqn + ">";
124+
}
125+
126+
return "global::System.Func<" + Join(method.ParameterTypeFqns) + ", " + method.ReturnTypeFqn + ">";
127+
}
128+
129+
private static string BuildTypeofList(EquatableArray<string> parameterTypeFqns)
130+
{
131+
string[]? items = parameterTypeFqns.GetArray();
132+
if (items is null || items.Length == 0)
133+
return string.Empty;
134+
135+
StringBuilder sb = new StringBuilder();
136+
for (int i = 0; i < items.Length; i++)
137+
{
138+
if (i > 0) sb.Append(", ");
139+
sb.Append("typeof(").Append(items[i]).Append(')');
140+
}
141+
return sb.ToString();
142+
}
143+
144+
private static string Join(EquatableArray<string> items)
145+
{
146+
string[]? array = items.GetArray();
147+
if (array is null || array.Length == 0)
148+
return string.Empty;
149+
150+
StringBuilder sb = new StringBuilder();
151+
for (int i = 0; i < array.Length; i++)
152+
{
153+
if (i > 0) sb.Append(", ");
154+
sb.Append(array[i]);
155+
}
156+
return sb.ToString();
157+
}
158+
}

src/FastCloner.SourceGenerator/ClassCloneBodyGenerator.cs

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,18 @@ public static void WriteClassCloneBody(
6868
List<string> initOnlyMembers = [];
6969
foreach (MemberModel member in ctx.Model.Members)
7070
{
71-
if (member is { IsProperty: true, IsInitOnly: true } || member.IsRequired)
71+
bool participatesInInitializer =
72+
(member is { IsProperty: true, IsInitOnly: true } || member.IsRequired);
73+
if (!participatesInInitializer)
74+
continue;
75+
76+
if (member.AccessorStrategy != NonPublicAccessorStrategy.None)
77+
continue;
78+
79+
string assignment = MemberCloneGenerator.GetMemberAssignment(ctx, member, sourceVarName, stateVar, " ");
80+
if (!string.IsNullOrEmpty(assignment))
7281
{
73-
string assignment = MemberCloneGenerator.GetMemberAssignment(ctx, member, sourceVarName, stateVar, " ");
74-
if (!string.IsNullOrEmpty(assignment))
75-
{
76-
initOnlyMembers.Add($" {assignment}");
77-
}
82+
initOnlyMembers.Add($" {assignment}");
7883
}
7984
}
8085

@@ -92,13 +97,13 @@ public static void WriteClassCloneBody(
9297

9398
foreach (MemberModel member in ctx.Model.Members)
9499
{
95-
if (member is { IsProperty: true, IsInitOnly: true } || member.IsRequired)
100+
bool participatedInInitializer =
101+
(member is { IsProperty: true, IsInitOnly: true } || member.IsRequired)
102+
&& member.AccessorStrategy == NonPublicAccessorStrategy.None;
103+
if (participatedInInitializer)
96104
continue;
97-
98-
if (!member.IsProperty || !member.IsInitOnly)
99-
{
100-
MemberCloneGenerator.WriteMemberCloning(ctx, member, "result", sourceVarName, stateVar);
101-
}
105+
106+
MemberCloneGenerator.WriteMemberCloning(ctx, member, "result", sourceVarName, stateVar);
102107
}
103108
}
104109
else

0 commit comments

Comments
 (0)