Skip to content

Commit ce45cd3

Browse files
authored
Merge pull request #733 from stakx/record-cloning
Preserve proxy type when cloning a record class proxy
2 parents 02cce94 + c512079 commit ce45cd3

File tree

9 files changed

+345
-26
lines changed

9 files changed

+345
-26
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Enhancements:
1010
- Minimally improved support for methods having `ref struct` parameter and return types, such as `Span<T>`: Intercepting such methods caused the runtime to throw `InvalidProgramException` and `NullReferenceException` due to forbidden conversions of `ref struct` values when transferring them into & out of `IInvocation` instances. To prevent these exceptions from being thrown, such values now get replaced with `null` in `IInvocation`, and with `default` values in return values and `out` arguments. When proceeding to a target, the target methods likewise receive such nullified values. (@stakx, #665)
1111
- Restore ability on .NET 9 and later to save dynamic assemblies to disk using `PersistentProxyBuilder` (@stakx, #718)
1212
- Configure SourceLink & `.snupkg` symbols package format (@Romfos, #722)
13+
- Support for C# `with { ... }` expressions. Cloning a record proxy using `with` now produces another proxy of the same type (instead of an instance of the proxied type, as before). The cloning process can still be changed by intercepting the record class' `<Clone>$` method. (@stakx, #733)
1314
- Dependencies were updated
1415

1516
Bugfixes:
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright 2004-2026 Castle Project - http://www.castleproject.org/
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
#if !NET5_0_OR_GREATER
16+
17+
namespace System.Runtime.CompilerServices
18+
{
19+
// required for records with primary constructors and/or `init` property accessors:
20+
internal class IsExternalInit
21+
{
22+
}
23+
}
24+
25+
#endif
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright 2004-2026 Castle Project - http://www.castleproject.org/
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
#nullable enable
16+
17+
namespace Castle.DynamicProxy.Tests.Records
18+
{
19+
internal record class IdentifiableRecord
20+
{
21+
public string? Id { get; init; }
22+
}
23+
}

src/Castle.Core.Tests/DynamicProxy.Tests/RecordsTestCase.cs

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2004-2022 Castle Project - http://www.castleproject.org/
1+
// Copyright 2004-2026 Castle Project - http://www.castleproject.org/
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -12,12 +12,16 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
using Castle.DynamicProxy.Tests.Records;
16-
17-
using NUnit.Framework;
18-
1915
namespace Castle.DynamicProxy.Tests
2016
{
17+
using System;
18+
using System.Reflection;
19+
20+
using Castle.DynamicProxy.Tests.Interceptors;
21+
using Castle.DynamicProxy.Tests.Records;
22+
23+
using NUnit.Framework;
24+
2125
[TestFixture]
2226
public class RecordsTestCase : BasePEVerifyTestCase
2327
{
@@ -44,5 +48,85 @@ public void Can_proxy_record_derived_from_empty_generic_record()
4448
{
4549
_ = generator.CreateClassProxy<DerivedFromEmptyGenericRecord>(new StandardInterceptor());
4650
}
51+
52+
[Test]
53+
public void Cloning_proxied_record_preserves_proxy_type()
54+
{
55+
var proxy = generator.CreateClassProxy<EmptyRecord>();
56+
var clonedProxy = proxy with { };
57+
Assert.True(ProxyUtil.IsProxy(clonedProxy));
58+
Assert.AreSame(proxy.GetType(), clonedProxy.GetType());
59+
}
60+
61+
[Test]
62+
public void Cloning_proxied_record_preserves_proxy_type_even_when_not_intercepting()
63+
{
64+
var proxy = generator.CreateClassProxy<EmptyRecord>(new ProxyGenerationOptions(new InterceptNothingHook()));
65+
var clonedProxy = proxy with { };
66+
Assert.True(ProxyUtil.IsProxy(clonedProxy));
67+
Assert.AreSame(proxy.GetType(), clonedProxy.GetType());
68+
}
69+
70+
[Test]
71+
public void Can_intercept_clone_method()
72+
{
73+
var expectedClone = new DerivedFromEmptyRecord();
74+
var interceptor = new WithCallbackInterceptor(invocation =>
75+
{
76+
if (invocation.Method.Name == "<Clone>$")
77+
{
78+
invocation.ReturnValue = expectedClone;
79+
}
80+
});
81+
var proxy = generator.CreateClassProxy<EmptyRecord>(interceptor);
82+
var actualClone = proxy with { };
83+
Assert.AreSame(expectedClone, actualClone);
84+
}
85+
86+
[Test]
87+
public void Can_proceed_for_intercepted_clone_method()
88+
{
89+
var interceptor = new WithCallbackInterceptor(invocation =>
90+
{
91+
if (invocation.Method.Name == "<Clone>$")
92+
{
93+
invocation.Proceed();
94+
}
95+
});
96+
var proxy = generator.CreateClassProxy<EmptyRecord>(interceptor);
97+
var clonedProxy = proxy with { };
98+
Assert.AreSame(proxy.GetType(), clonedProxy.GetType());
99+
}
100+
101+
[Test]
102+
public void Can_proceed_to_record_target()
103+
{
104+
var target = new IdentifiableRecord { Id = "target" };
105+
var proxy = generator.CreateClassProxyWithTarget(target, new StandardInterceptor());
106+
var clonedProxy = proxy with { };
107+
Assert.False(clonedProxy == proxy);
108+
Assert.False(ProxyUtil.IsProxy(clonedProxy));
109+
Assert.True(clonedProxy == target);
110+
}
111+
112+
[Test]
113+
public void Can_proceed_to_record_target_proxy()
114+
{
115+
var target = generator.CreateClassProxy<IdentifiableRecord>() with { Id = "target" };
116+
var proxy = generator.CreateClassProxyWithTarget(target, new StandardInterceptor());
117+
var clonedProxy = proxy with { };
118+
Assert.False(clonedProxy == proxy);
119+
Assert.True(ProxyUtil.IsProxy(clonedProxy));
120+
Assert.True(clonedProxy == target);
121+
}
122+
123+
[Serializable]
124+
public record class InterceptNothingHook : IProxyGenerationHook
125+
{
126+
public bool ShouldInterceptMethod(Type type, MethodInfo methodInfo) => false;
127+
128+
void IProxyGenerationHook.MethodsInspected() { }
129+
void IProxyGenerationHook.NonProxyableMemberNotification(Type type, MemberInfo memberInfo) { }
130+
}
47131
}
48132
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright 2004-2026 Castle Project - http://www.castleproject.org/
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
#nullable enable
16+
17+
namespace Castle.DynamicProxy.Contributors
18+
{
19+
using System;
20+
using System.Reflection;
21+
22+
using Castle.DynamicProxy.Generators;
23+
using Castle.DynamicProxy.Generators.Emitters;
24+
using Castle.DynamicProxy.Generators.Emitters.SimpleAST;
25+
26+
internal sealed class RecordCloningContributor : ITypeContributor
27+
{
28+
private readonly Type targetType;
29+
private readonly INamingScope namingScope;
30+
31+
private MethodInfo? baseCloneMethod;
32+
private bool overridable;
33+
private bool shouldIntercept;
34+
35+
public RecordCloningContributor(Type targetType, INamingScope namingScope)
36+
{
37+
this.targetType = targetType;
38+
this.namingScope = namingScope;
39+
}
40+
41+
public void CollectElementsToProxy(IProxyGenerationHook hook, MetaType model)
42+
{
43+
baseCloneMethod = targetType.GetMethod("<Clone>$", BindingFlags.Public | BindingFlags.Instance);
44+
if (baseCloneMethod == null)
45+
{
46+
return;
47+
}
48+
49+
var cloneMetaMethod = model.FindMethod(baseCloneMethod);
50+
if (cloneMetaMethod != null)
51+
{
52+
// The target contributor may have chosen to generate interception code for this method.
53+
// We override that decision here. This effectively renders `<Clone>$` uninterceptable,
54+
// in favor of some default behavior provided by DynamicProxy. This may be a bad idea.
55+
cloneMetaMethod.Ignore = true;
56+
}
57+
58+
overridable = baseCloneMethod.IsVirtual && !baseCloneMethod.IsFinal;
59+
shouldIntercept = overridable && hook.ShouldInterceptMethod(targetType, baseCloneMethod);
60+
}
61+
62+
public void Generate(ClassEmitter @class)
63+
{
64+
if (baseCloneMethod == null) return;
65+
66+
ImplementCopyConstructor(@class, out var copyCtor);
67+
ImplementCloneMethod(@class, copyCtor);
68+
}
69+
70+
private void ImplementCopyConstructor(ClassEmitter @class, out ConstructorInfo copyCtor)
71+
{
72+
var other = new ArgumentReference(@class.TypeBuilder);
73+
var copyCtorEmitter = @class.CreateConstructor(other);
74+
var baseCopyCtor = targetType.GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null, [targetType], null);
75+
76+
copyCtorEmitter.CodeBuilder.AddStatement(
77+
new ConstructorInvocationStatement(
78+
baseCopyCtor,
79+
other));
80+
81+
foreach (var field in @class.GetAllFields())
82+
{
83+
if (field.Reference.IsStatic) continue;
84+
85+
copyCtorEmitter.CodeBuilder.AddStatement(
86+
new AssignStatement(
87+
field,
88+
new FieldReference(
89+
field.Reference,
90+
other)));
91+
}
92+
93+
copyCtorEmitter.CodeBuilder.AddStatement(ReturnStatement.Instance);
94+
95+
copyCtor = copyCtorEmitter.ConstructorBuilder;
96+
}
97+
98+
private void ImplementCloneMethod(ClassEmitter @class, ConstructorInfo copyCtor)
99+
{
100+
if (shouldIntercept)
101+
{
102+
var cloneCallbackMethod = CreateCallbackMethod(@class, copyCtor);
103+
var cloneMetaMethod = new MetaMethod(baseCloneMethod!, cloneCallbackMethod, true, true, true);
104+
var invocationType = CreateInvocationType(@class, cloneMetaMethod, cloneCallbackMethod);
105+
106+
var cloneMethodGenerator = new MethodWithInvocationGenerator(
107+
cloneMetaMethod,
108+
@class.GetField("__interceptors"),
109+
invocationType,
110+
(c, m) => new TypeTokenExpression(@class.TypeBuilder),
111+
@class.CreateMethod,
112+
null);
113+
114+
cloneMethodGenerator.Generate(@class, namingScope);
115+
}
116+
else if (overridable)
117+
{
118+
var cloneMethodEmitter = @class.CreateMethod(
119+
name: baseCloneMethod!.Name,
120+
attrs: (baseCloneMethod.Attributes & MethodAttributes.MemberAccessMask) | MethodAttributes.ReuseSlot | MethodAttributes.HideBySig | MethodAttributes.Virtual,
121+
returnType: baseCloneMethod.ReturnType, // no need to use covariant return type
122+
argumentTypes: []);
123+
124+
cloneMethodEmitter.CodeBuilder.AddStatement(
125+
new ReturnStatement(
126+
new NewInstanceExpression(
127+
copyCtor,
128+
ThisExpression.Instance)));
129+
}
130+
}
131+
132+
private MethodInfo CreateCallbackMethod(ClassEmitter @class, ConstructorInfo copyCtor)
133+
{
134+
var callbackMethod = @class.CreateMethod(
135+
name: baseCloneMethod!.Name + "_callback",
136+
attrs: MethodAttributes.Public | MethodAttributes.HideBySig,
137+
returnType: copyCtor.DeclaringType,
138+
argumentTypes: Type.EmptyTypes);
139+
140+
callbackMethod.CodeBuilder.AddStatement(
141+
new ReturnStatement(
142+
new NewInstanceExpression(
143+
copyCtor,
144+
ThisExpression.Instance)));
145+
146+
return callbackMethod.MethodBuilder;
147+
}
148+
149+
private Type CreateInvocationType(ClassEmitter @class, MetaMethod cloneMetaMethod, MethodInfo cloneCallbackMethod)
150+
{
151+
var generator = new InheritanceInvocationTypeGenerator(
152+
targetType,
153+
cloneMetaMethod,
154+
cloneCallbackMethod,
155+
null);
156+
157+
return generator.Generate(@class, namingScope).BuildType();
158+
}
159+
}
160+
}

src/Castle.Core/DynamicProxy/Generators/BaseClassProxyGenerator.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2004-2021 Castle Project - http://www.castleproject.org/
1+
// Copyright 2004-2026 Castle Project - http://www.castleproject.org/
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -12,6 +12,8 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
#nullable enable
16+
1517
namespace Castle.DynamicProxy.Generators
1618
{
1719
using System;
@@ -25,13 +27,13 @@ namespace Castle.DynamicProxy.Generators
2527

2628
internal abstract class BaseClassProxyGenerator : BaseProxyGenerator
2729
{
28-
protected BaseClassProxyGenerator(ModuleScope scope, Type targetType, Type[] interfaces, ProxyGenerationOptions options)
30+
protected BaseClassProxyGenerator(ModuleScope scope, Type targetType, Type[]? interfaces, ProxyGenerationOptions options)
2931
: base(scope, targetType, interfaces, options)
3032
{
3133
EnsureDoesNotImplementIProxyTargetAccessor(targetType, nameof(targetType));
3234
}
3335

34-
protected abstract FieldReference TargetField { get; }
36+
protected abstract FieldReference? TargetField { get; }
3537

3638
#if FEATURE_SERIALIZATION
3739
protected abstract SerializableContributor GetSerializableContributor();
@@ -41,6 +43,8 @@ protected BaseClassProxyGenerator(ModuleScope scope, Type targetType, Type[] int
4143

4244
protected abstract ProxyTargetAccessorContributor GetProxyTargetAccessorContributor();
4345

46+
protected abstract RecordCloningContributor? GetRecordCloningContributor(INamingScope namingScope);
47+
4448
protected sealed override Type GenerateType(string name, INamingScope namingScope)
4549
{
4650
IEnumerable<ITypeContributor> contributors;
@@ -192,6 +196,12 @@ private IEnumerable<Type> GetTypeImplementerMapping(out IEnumerable<ITypeContrib
192196
}
193197
#endif
194198

199+
var recordCloningContributor = GetRecordCloningContributor(namingScope);
200+
if (recordCloningContributor != null)
201+
{
202+
contributorsList.Add(recordCloningContributor);
203+
}
204+
195205
var proxyTargetAccessorContributor = GetProxyTargetAccessorContributor();
196206
contributorsList.Add(proxyTargetAccessorContributor);
197207
try

0 commit comments

Comments
 (0)