Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions PTrampert.SimplePatch.Test/EmitPatchClassBuilderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ private static JsonSerializerOptions CreateOptions()
}

private static IPatchObject<T> Deserialize<T>(string json) =>
(IPatchObject<T>)JsonSerializer.Deserialize(json, EmitPatchClassBuilder.GetPatchClassFor(typeof(T)), Options)!;
(IPatchObject<T>)JsonSerializer.Deserialize(json, EmitPatchClassBuilder.Instance.GetPatchClassFor(typeof(T)), Options)!;

[Test]
public void GetPatchClassFor_ReturnsTheSameTypeEachTime()
{
var first = EmitPatchClassBuilder.GetPatchClassFor(typeof(InternalClassTestObject));
var first = EmitPatchClassBuilder.Instance.GetPatchClassFor(typeof(InternalClassTestObject));

Assert.That(EmitPatchClassBuilder.GetPatchClassFor(typeof(InternalClassTestObject)), Is.SameAs(first));
Assert.That(EmitPatchClassBuilder.Instance.GetPatchClassFor(typeof(InternalClassTestObject)), Is.SameAs(first));
}

[Test]
Expand Down Expand Up @@ -85,7 +85,7 @@ public void Patch_InternalGenericArgument()
[TestCase(typeof(PrivatePositionalRecordTestObject))]
public void GetPatchClassFor_PrivateNestedType_Throws(Type type)
{
var ex = Assert.Throws<NotSupportedException>(() => EmitPatchClassBuilder.GetPatchClassFor(type));
var ex = Assert.Throws<NotSupportedException>(() => EmitPatchClassBuilder.Instance.GetPatchClassFor(type));

Assert.That(ex!.Message, Does.Contain($"'{type.FullName}', which the generated assembly can't access"));
}
Expand All @@ -94,7 +94,7 @@ public void GetPatchClassFor_PrivateNestedType_Throws(Type type)
public void GetPatchClassFor_PrivateGetter_Throws()
{
var ex = Assert.Throws<NotSupportedException>(
() => EmitPatchClassBuilder.GetPatchClassFor(typeof(PrivateGetterTestObject)));
() => EmitPatchClassBuilder.Instance.GetPatchClassFor(typeof(PrivateGetterTestObject)));

Assert.That(ex!.Message, Does.Contain("the getter of 'Name' isn't accessible"));
}
Expand Down Expand Up @@ -127,7 +127,7 @@ public void Patch_InternalStruct()
public void GetPatchClassFor_NonPublicPropertyTypeFromAnAssemblyWithoutTheGrant_ThrowsNamingThatAssembly()
{
var ex = Assert.Throws<NotSupportedException>(
() => EmitPatchClassBuilder.GetPatchClassFor(typeof(ExternalPropertyTypeTestObject)));
() => EmitPatchClassBuilder.Instance.GetPatchClassFor(typeof(ExternalPropertyTypeTestObject)));

Assert.That(ex!.Message, Does.Contain(typeof(ExternalInternalColor).FullName)
.And.Contain($"[assembly: InternalsVisibleTo(\"{EmitPatchClassBuilder.AssemblyName}\")]")
Expand Down
4 changes: 2 additions & 2 deletions PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ namespace PTrampert.SimplePatch.Test;
// Runs every case against each way of building a patch class, so the two builders can't drift apart.
// Cases that only one builder supports, or that test its caching, are in that builder's own fixture.
[TestFixtureSource(typeof(PatchClassBuilders), nameof(PatchClassBuilders.All))]
public class PatchClassBuilderTest(Func<Type, Type> getPatchClassFor)
public class PatchClassBuilderTest(IPatchClassBuilder builder)
{
private Type GetPatchClassFor(Type type) => getPatchClassFor(type);
private Type GetPatchClassFor(Type type) => builder.GetPatchClassFor(type);

// Deserializes straight into this fixture's patch class. Deserializing IPatchObject<T> would
// go through PatchJsonConverterFactory, which always uses PatchClassBuilder.Instance.
Expand Down
4 changes: 2 additions & 2 deletions PTrampert.SimplePatch.Test/PatchClassBuilders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ public static class PatchClassBuilders
{
public static IEnumerable<TestFixtureData> All()
{
yield return new TestFixtureData((Func<Type, Type>)PatchClassBuilder.Instance.GetPatchClassFor)
yield return new TestFixtureData(RoslynPatchClassBuilder.Instance)
.SetArgDisplayNames("Roslyn");
yield return new TestFixtureData((Func<Type, Type>)EmitPatchClassBuilder.GetPatchClassFor)
yield return new TestFixtureData(EmitPatchClassBuilder.Instance)
.SetArgDisplayNames("Emit");
}
}
10 changes: 6 additions & 4 deletions PTrampert.SimplePatch.Test/RoslynPatchClassBuilderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace PTrampert.SimplePatch.Test;

// Cases specific to the Roslyn builder behind PatchClassBuilder.Instance: its cache, and the
// Cases specific to the Roslyn builder that PatchClassBuilder delegates to: its cache, and the
// public-only restriction that comes from compiling C#. Cases it shares with the Emit builder are
// in PatchClassBuilderTest.
public class RoslynPatchClassBuilderTest
Expand All @@ -22,6 +22,8 @@ public void GetPatchClassFor_SharesGeneratedTypesAcrossBuilders()
Assert.That(second, Is.SameAs(first),
"Every builder should resolve a source type to one generated patch type, rather than each emitting its own dynamic assembly for it.");
Assert.That(PatchClassBuilder.Instance.GetPatchClassFor(typeof(OptionalsBuilderTestObject)), Is.SameAs(first));
Assert.That(RoslynPatchClassBuilder.Instance.GetPatchClassFor(typeof(OptionalsBuilderTestObject)), Is.SameAs(first),
"PatchClassBuilder should hand out the Roslyn builder's types.");
}));
}

Expand All @@ -36,7 +38,7 @@ public void GetPatchClassFor_GeneratesOnceUnderConcurrentFirstUse()
.Select(i => new Thread(() =>
{
barrier.SignalAndWait();
results[i] = PatchClassBuilder.Instance.GetPatchClassFor(sourceType);
results[i] = RoslynPatchClassBuilder.Instance.GetPatchClassFor(sourceType);
}))
.ToList();

Expand Down Expand Up @@ -64,7 +66,7 @@ public void GetPatchClassFor_GeneratesOnceUnderConcurrentFirstUse()
public void GetPatchClassFor_ThrowsNotSupportedForInternalTypes()
{
var ex = Assert.Throws<NotSupportedException>(
(Action)(() => PatchClassBuilder.Instance.GetPatchClassFor(typeof(InternalTestObject))));
(Action)(() => RoslynPatchClassBuilder.Instance.GetPatchClassFor(typeof(InternalTestObject))));

Assert.That(ex!.Message, Does.Contain(typeof(InternalTestObject).FullName).And.Contain("must be public"));
}
Expand All @@ -73,7 +75,7 @@ public void GetPatchClassFor_ThrowsNotSupportedForInternalTypes()
public void GetPatchClassFor_ThrowsNotSupportedForPrivateNestedTypes()
{
var ex = Assert.Throws<NotSupportedException>(
(Action)(() => PatchClassBuilder.Instance.GetPatchClassFor(typeof(PrivateNestedTestObject))));
(Action)(() => RoslynPatchClassBuilder.Instance.GetPatchClassFor(typeof(PrivateNestedTestObject))));

Assert.That(ex!.Message, Does.Contain(typeof(PrivateNestedTestObject).FullName).And.Contain("must be public"));
}
Expand Down
27 changes: 18 additions & 9 deletions PTrampert.SimplePatch/EmitPatchClassBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace PTrampert.SimplePatch;

/// <summary>
/// Generates classes that implement <see cref="IPatchObject{T}"/> by emitting IL with
/// Reflection.Emit, rather than compiling C# with Roslyn as <see cref="PatchClassBuilder"/> does.
/// Reflection.Emit, rather than compiling C# with Roslyn as <see cref="RoslynPatchClassBuilder"/> does.
/// Unlike that builder, it supports internal source types, provided their assembly grants
/// <c>[InternalsVisibleTo]</c> to <see cref="AssemblyName"/>.
/// </summary>
Expand All @@ -22,9 +22,9 @@ namespace PTrampert.SimplePatch;
/// grant, but it isn't officially supported (https://github.com/dotnet/runtime/issues/37875).
/// <c>[InternalsVisibleTo]</c> doesn't reach <c>private</c> or <c>protected</c> members, so private
/// nested source types aren't supported. The class emitted here has the same shape as the one
/// <see cref="PatchClassBuilder"/> compiles: both are built from <see cref="PatchClassModel"/>.
/// <see cref="RoslynPatchClassBuilder"/> compiles: both are built from <see cref="PatchClassModel"/>.
/// </remarks>
internal static class EmitPatchClassBuilder
internal sealed class EmitPatchClassBuilder : IPatchClassBuilder
{
/// <summary>
/// The name of every assembly this builder emits. An assembly whose internal types are patched
Expand All @@ -34,10 +34,19 @@ internal static class EmitPatchClassBuilder

private const string GlobalNamespaceFallback = "PTrampert.SimplePatch.Generated";

// Separate from PatchClassBuilder's cache, so each builder hands out only the types it built.
// Separate from RoslynPatchClassBuilder's cache, so each builder hands out only the types it built.
// Lazy for the same reason as there: concurrent first use should emit one assembly, not one per thread.
private static readonly ConcurrentDictionary<Type, Lazy<Type>> PatchClasses = new();

/// <summary>
/// The builder. Its cache is static, so there is no reason for a second instance.
/// </summary>
public static EmitPatchClassBuilder Instance { get; } = new();

private EmitPatchClassBuilder()
{
}

/// <summary>
/// Gets or creates the patch class for <paramref name="type"/>.
/// </summary>
Expand All @@ -46,7 +55,7 @@ internal static class EmitPatchClassBuilder
/// properties has no getter, or the patch class would name a type or getter that the emitted
/// assembly can't access.
/// </exception>
public static Type GetPatchClassFor(Type type)
public Type GetPatchClassFor(Type type)
{
return PatchClasses.GetOrAdd(type, t => new Lazy<Type>(() => CreatePatchClass(t))).Value;
}
Expand All @@ -59,7 +68,7 @@ private static Type CreatePatchClass(Type type)
// Each source type gets its own assembly, so the patch type's name can't collide with
// another and needs neither a random suffix nor cleaning up into a C# identifier. The
// assemblies all share one name, because that name is what [InternalsVisibleTo] grants.
// Load it where the source type lives, as PatchClassBuilder does with its compiled assembly.
// Load it where the source type lives, as RoslynPatchClassBuilder does with its compiled assembly.
using var contextScope = AssemblyLoadContext.EnterContextualReflection(type.Assembly);
var assembly = AssemblyBuilder.DefineDynamicAssembly(
new System.Reflection.AssemblyName(AssemblyName), AssemblyBuilderAccess.Run);
Expand Down Expand Up @@ -188,7 +197,7 @@ private static bool GrantsInternalsAccess(Assembly assembly) =>

/// <summary>
/// Defines the <see cref="Optional{T}"/> backing field and property for one source property,
/// with the attributes <see cref="PatchClassBuilder"/> gives it, and returns the field.
/// with the attributes <see cref="RoslynPatchClassBuilder"/> gives it, and returns the field.
/// </summary>
private static FieldBuilder DefineOptionalProperty(
TypeBuilder typeBuilder, PatchClassModel model, OptionalPropertyModel optionalProperty)
Expand Down Expand Up @@ -240,7 +249,7 @@ private static FieldBuilder DefineOptionalProperty(
}

/// <summary>
/// Emits <c>Patch(T target)</c>. It builds the result as <see cref="PatchClassBuilder"/>'s C#
/// Emits <c>Patch(T target)</c>. It builds the result as <see cref="RoslynPatchClassBuilder"/>'s C#
/// does: a <c>with</c> clone for a record, otherwise the chosen constructor followed by the
/// setters for the remaining properties.
/// </summary>
Expand All @@ -258,7 +267,7 @@ private static void DefinePatchMethod(
typeBuilder.DefineMethodOverride(method, patchInterface.GetMethod(nameof(IPatchObject<object>.Patch))!);
var il = method.GetILGenerator();

// As in the C# PatchClassBuilder generates, the clone made by `with` already carries the
// As in the C# RoslynPatchClassBuilder generates, the clone made by `with` already carries the
// ignored properties over, so only a newly constructed instance has to copy them.
var assigned = model.OptionalProperties.Select(p => p.Property)
.Concat(model.IsRecord ? [] : model.IgnoredProperties)
Expand Down
17 changes: 17 additions & 0 deletions PTrampert.SimplePatch/IPatchClassBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace PTrampert.SimplePatch;

/// <summary>
/// Generates classes that implement <see cref="IPatchObject{T}"/> for a given type.
/// </summary>
public interface IPatchClassBuilder
{
/// <summary>
/// Gets or creates a class that implements <see cref="IPatchObject{T}"/> for the specified type.
/// The class has an <see cref="Optional{T}"/> property for each patchable property of the type,
/// and a <c>Patch</c> method that applies the properties that are set to a target instance.
/// </summary>
/// <param name="type">The type to get a patch type for.</param>
/// <returns>The generated patch type.</returns>
/// <exception cref="NotSupportedException">The builder can't generate a patch class for <paramref name="type"/>.</exception>
Type GetPatchClassFor(Type type);
}
Loading
Loading