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
2 changes: 1 addition & 1 deletion PTrampert.SimplePatch.Test/PatchClassBuilders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public static class PatchClassBuilders
{
public static IEnumerable<TestFixtureData> All()
{
yield return new TestFixtureData(PatchClassBuilder.Instance)
yield return new TestFixtureData(RoslynPatchClassBuilder.Instance)
.SetArgDisplayNames("Roslyn");
yield return new TestFixtureData(EmitPatchClassBuilder.Instance)
.SetArgDisplayNames("Emit");
Expand Down
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
14 changes: 7 additions & 7 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,7 +22,7 @@ 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 sealed class EmitPatchClassBuilder : IPatchClassBuilder
{
Expand All @@ -34,7 +34,7 @@ internal sealed class EmitPatchClassBuilder : IPatchClassBuilder

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();

Expand Down Expand Up @@ -68,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 @@ -197,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 @@ -249,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 @@ -267,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
200 changes: 6 additions & 194 deletions PTrampert.SimplePatch/PatchClassBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,35 +1,17 @@
using System.CodeDom;
using System.Collections.Concurrent;
using System.Reflection;
using System.Runtime.Loader;
using System.Text;
using System.Text.Json.Serialization;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CSharp;

namespace PTrampert.SimplePatch;

/// <summary>
/// Generates classes that implement <see cref="IPatchObject{T}"/> for a given type.
/// </summary>
/// <remarks>
/// This is the library's default builder, and the one <see cref="PatchJsonConverterFactory"/> uses.
/// It delegates to the builder that generates the classes, so that the implementation can change
/// without changing this public type.
/// </remarks>
public class PatchClassBuilder : IPatchClassBuilder
{
private const string ApplyTargetParamName = "target";

/// <summary>
/// Namespace used for patch classes generated from source types that are not themselves
/// in a namespace.
/// </summary>
private const string GlobalNamespaceFallback = "PTrampert.SimplePatch.Generated";

// Static so that every builder — the one used by PatchJsonConverterFactory, and any the
// OpenAPI integrations or user code create — resolves a given source type to the same
// generated patch type, instead of each emitting its own dynamic assembly for it.
// Lazy (ExecutionAndPublication) because GetOrAdd may run its factory on several threads at
// once; Lazy makes them all wait on one generation rather than each loading an assembly.
private static readonly ConcurrentDictionary<Type, Lazy<Type>> OptionalsClasses = new();

/// <summary>
/// The builder. Use this rather than constructing your own: all instances share one cache, so
/// a new instance buys nothing but an allocation.
Expand Down Expand Up @@ -66,175 +48,5 @@ public PatchClassBuilder()
/// <exception cref="NotSupportedException">
/// <paramref name="type"/> is not public, or is nested in or constructed from a type that is not public.
/// </exception>
public Type GetPatchClassFor(Type type)
{
return OptionalsClasses.GetOrAdd(type, t => new Lazy<Type>(() => CreatePatchClass(t))).Value;
}

private static Type CreatePatchClass(Type type)
{
// The patch class is compiled into its own assembly, which can only refer to public types.
// IsVisible is false if the type, any declaring type, or any generic type argument isn't public.
if (!type.IsVisible)
{
throw new NotSupportedException(
$"Cannot create a patch class for '{type.FullName}' because it is not public. Patch source "
+ "types must be public, as must any types they are nested in and any generic type arguments.");
}

// The Patch method body is a hand-written snippet, so every name in it has to be formatted
// as C# here; CodeDom only does that for the parts of the class it generates itself.
var provider = new CSharpCodeProvider();
var unit = new CodeCompileUnit();
var namespaceRoot = string.IsNullOrEmpty(type.Namespace) ? GlobalNamespaceFallback : type.Namespace;
var ns = new CodeNamespace($"{namespaceRoot}.Optionals");
unit.Namespaces.Add(ns);
// type.Name can contain characters that aren't valid in an identifier, such as the ` in
// Gen`1. Dropping them is safe because the random suffix keeps the name unique.
var typeName = new string(type.Name.Where(c => char.IsLetterOrDigit(c) || c == '_').ToArray());
var className = $"{typeName}_Optionals_{Path.GetRandomFileName().Replace('.', '_')}";
var classType = new CodeTypeDeclaration(className)
{
IsClass = true,
TypeAttributes = TypeAttributes.Public | TypeAttributes.Sealed,
};
ns.Types.Add(classType);

classType.BaseTypes.Add(typeof(IPatchObject<>).MakeGenericType(type));
var applyMethod = new CodeMemberMethod
{
Name = nameof(IPatchObject<object>.Patch),
ReturnType = new CodeTypeReference(type),
Attributes = MemberAttributes.Public | MemberAttributes.Final,
Parameters =
{
new CodeParameterDeclarationExpression(type, ApplyTargetParamName)
}
};
classType.Members.Add(applyMethod);

var model = PatchClassModel.For(type);
var patchedValues = new Dictionary<PropertyInfo, string>();

foreach (var optionalProperty in model.OptionalProperties)
{
var property = optionalProperty.Property;
var optionalType = typeof(Optional<>).MakeGenericType(property.PropertyType);

var backingField = new CodeMemberField(optionalType, $"_{property.Name}")
{
Attributes = MemberAttributes.Private
};

var codegenProperty = new CodeMemberProperty
{
Name = property.Name,
Type = new CodeTypeReference(optionalType),
Attributes = MemberAttributes.Public | MemberAttributes.Final,
HasGet = true,
GetStatements =
{
new CodeMethodReturnStatement(new CodeFieldReferenceExpression(new CodeThisReferenceExpression(),
backingField.Name))
},
HasSet = true,
SetStatements =
{
new CodeAssignStatement(
new CodeFieldReferenceExpression(new CodeThisReferenceExpression(), backingField.Name),
new CodePropertySetValueReferenceExpression())
},
};

if (optionalProperty.HasConverter)
{
codegenProperty.CustomAttributes.Add(new CodeAttributeDeclaration(
new CodeTypeReference(typeof(OptionalConverterAttribute)),
new CodeAttributeArgument(new CodeTypeOfExpression(type)),
new CodeAttributeArgument(new CodePrimitiveExpression(property.Name))));
}

if (optionalProperty.JsonPropertyName is { } jsonPropertyName)
{
codegenProperty.CustomAttributes.Add(new CodeAttributeDeclaration(
new CodeTypeReference(typeof(JsonPropertyNameAttribute)),
new CodeAttributeArgument(new CodePrimitiveExpression(jsonPropertyName))));
}

foreach (var validator in optionalProperty.Validators)
{
codegenProperty.CustomAttributes.Add(new CodeAttributeDeclaration(
new CodeTypeReference(typeof(OptionalValidationAttribute)),
new CodeAttributeArgument(new CodeTypeOfExpression(validator.ValidatorType)),
new CodeAttributeArgument(new CodePrimitiveExpression(validator.Index)))
);
}

classType.Members.Add(backingField);
classType.Members.Add(codegenProperty);

var propertyName = provider.CreateEscapedIdentifier(property.Name);
patchedValues[property] = $"this.{backingField.Name}.{nameof(Optional<object>.HasValue)} ? this.{backingField.Name}.{nameof(Optional<object>.Value)} : {ApplyTargetParamName}.{propertyName}";
}

// The clone made by `with` already carries the ignored properties over.
if (!model.IsRecord)
{
foreach (var ignoredProperty in model.IgnoredProperties)
{
var propertyName = provider.CreateEscapedIdentifier(ignoredProperty.Name);
patchedValues[ignoredProperty] = $"{ApplyTargetParamName}.{propertyName}";
}
}

// Constructor-bound properties go to the constructor; the rest go in the object initializer.
var initString = new StringBuilder();
if (model.IsRecord)
{
initString.AppendLine($"{ApplyTargetParamName} with {{");
}
else
{
initString.Append($"new {provider.GetTypeOutput(new CodeTypeReference(type))}(");
initString.Append(string.Join(", ", model.ConstructorProperties.Select(p => $"({patchedValues[p]})")));
initString.AppendLine(") {");
}
foreach (var property in patchedValues.Keys.Except(model.ConstructorProperties))
{
initString.AppendLine($"{provider.CreateEscapedIdentifier(property.Name)} = {patchedValues[property]},");
}

initString.AppendLine("};");

var applyMethodBody = new CodeMethodReturnStatement(new CodeSnippetExpression(initString.ToString()));
applyMethod.Statements.Add(applyMethodBody);

var writer = new StringWriter();
provider.GenerateCodeFromCompileUnit(unit, writer, null);
var source = writer.ToString();
var syntaxTree = CSharpSyntaxTree.ParseText(source);
var assemblyName = Path.GetRandomFileName();
var compilation = CSharpCompilation.Create(
assemblyName,
new[] { syntaxTree },
AppDomain.CurrentDomain.GetAssemblies()
.Where(a => !string.IsNullOrEmpty(a.Location))
.Select(a => MetadataReference.CreateFromFile(a.Location)),
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
);
using var ms = new MemoryStream();
var result = compilation.Emit(ms);

if (!result.Success)
{
throw new Exception(string.Join(Environment.NewLine, result.Diagnostics.Select(d => d.ToString())));
}

ms.Seek(0, SeekOrigin.Begin);

var callingContext = AssemblyLoadContext.GetLoadContext(type.Assembly);
var newAssembly = callingContext?.LoadFromStream(ms) ?? AssemblyLoadContext.Default.LoadFromStream(ms);

return newAssembly.GetType($"{ns.Name}.{className}")!;
}
public Type GetPatchClassFor(Type type) => RoslynPatchClassBuilder.Instance.GetPatchClassFor(type);
}
4 changes: 2 additions & 2 deletions PTrampert.SimplePatch/PatchClassModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ namespace PTrampert.SimplePatch;
/// which properties it exposes as <see cref="Optional{T}"/>, and which attributes each carries.
/// </summary>
/// <remarks>
/// Kept apart from <see cref="PatchClassBuilder"/> so that every builder makes these decisions in one
/// place. Builders that each made them inline could drift apart, and the patch class would then
/// Kept apart from <see cref="RoslynPatchClassBuilder"/> and <see cref="EmitPatchClassBuilder"/> so
/// that every builder makes these decisions in one place. Builders that each made them inline could drift apart, and the patch class would then
/// stop matching the JSON contract System.Text.Json uses for the source type. Restrictions that
/// belong to a particular way of generating the class, such as the generated assembly only being
/// able to see public types, stay in that builder.
Expand Down
Loading
Loading