diff --git a/PTrampert.SimplePatch.Test/EmitPatchClassBuilderTest.cs b/PTrampert.SimplePatch.Test/EmitPatchClassBuilderTest.cs index 4c4300b..05be56b 100644 --- a/PTrampert.SimplePatch.Test/EmitPatchClassBuilderTest.cs +++ b/PTrampert.SimplePatch.Test/EmitPatchClassBuilderTest.cs @@ -20,14 +20,14 @@ private static JsonSerializerOptions CreateOptions() } private static IPatchObject Deserialize(string json) => - (IPatchObject)JsonSerializer.Deserialize(json, EmitPatchClassBuilder.GetPatchClassFor(typeof(T)), Options)!; + (IPatchObject)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] @@ -85,7 +85,7 @@ public void Patch_InternalGenericArgument() [TestCase(typeof(PrivatePositionalRecordTestObject))] public void GetPatchClassFor_PrivateNestedType_Throws(Type type) { - var ex = Assert.Throws(() => EmitPatchClassBuilder.GetPatchClassFor(type)); + var ex = Assert.Throws(() => EmitPatchClassBuilder.Instance.GetPatchClassFor(type)); Assert.That(ex!.Message, Does.Contain($"'{type.FullName}', which the generated assembly can't access")); } @@ -94,7 +94,7 @@ public void GetPatchClassFor_PrivateNestedType_Throws(Type type) public void GetPatchClassFor_PrivateGetter_Throws() { var ex = Assert.Throws( - () => EmitPatchClassBuilder.GetPatchClassFor(typeof(PrivateGetterTestObject))); + () => EmitPatchClassBuilder.Instance.GetPatchClassFor(typeof(PrivateGetterTestObject))); Assert.That(ex!.Message, Does.Contain("the getter of 'Name' isn't accessible")); } @@ -127,7 +127,7 @@ public void Patch_InternalStruct() public void GetPatchClassFor_NonPublicPropertyTypeFromAnAssemblyWithoutTheGrant_ThrowsNamingThatAssembly() { var ex = Assert.Throws( - () => EmitPatchClassBuilder.GetPatchClassFor(typeof(ExternalPropertyTypeTestObject))); + () => EmitPatchClassBuilder.Instance.GetPatchClassFor(typeof(ExternalPropertyTypeTestObject))); Assert.That(ex!.Message, Does.Contain(typeof(ExternalInternalColor).FullName) .And.Contain($"[assembly: InternalsVisibleTo(\"{EmitPatchClassBuilder.AssemblyName}\")]") diff --git a/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs b/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs index 35a5bb5..005ba99 100644 --- a/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs +++ b/PTrampert.SimplePatch.Test/PatchClassBuilderTest.cs @@ -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 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 would // go through PatchJsonConverterFactory, which always uses PatchClassBuilder.Instance. diff --git a/PTrampert.SimplePatch.Test/PatchClassBuilders.cs b/PTrampert.SimplePatch.Test/PatchClassBuilders.cs index e8adf12..15edf24 100644 --- a/PTrampert.SimplePatch.Test/PatchClassBuilders.cs +++ b/PTrampert.SimplePatch.Test/PatchClassBuilders.cs @@ -7,9 +7,9 @@ public static class PatchClassBuilders { public static IEnumerable All() { - yield return new TestFixtureData((Func)PatchClassBuilder.Instance.GetPatchClassFor) + yield return new TestFixtureData(RoslynPatchClassBuilder.Instance) .SetArgDisplayNames("Roslyn"); - yield return new TestFixtureData((Func)EmitPatchClassBuilder.GetPatchClassFor) + yield return new TestFixtureData(EmitPatchClassBuilder.Instance) .SetArgDisplayNames("Emit"); } } diff --git a/PTrampert.SimplePatch.Test/RoslynPatchClassBuilderTest.cs b/PTrampert.SimplePatch.Test/RoslynPatchClassBuilderTest.cs index ddd59d0..e59c855 100644 --- a/PTrampert.SimplePatch.Test/RoslynPatchClassBuilderTest.cs +++ b/PTrampert.SimplePatch.Test/RoslynPatchClassBuilderTest.cs @@ -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 @@ -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."); })); } @@ -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(); @@ -64,7 +66,7 @@ public void GetPatchClassFor_GeneratesOnceUnderConcurrentFirstUse() public void GetPatchClassFor_ThrowsNotSupportedForInternalTypes() { var ex = Assert.Throws( - (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")); } @@ -73,7 +75,7 @@ public void GetPatchClassFor_ThrowsNotSupportedForInternalTypes() public void GetPatchClassFor_ThrowsNotSupportedForPrivateNestedTypes() { var ex = Assert.Throws( - (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")); } diff --git a/PTrampert.SimplePatch/EmitPatchClassBuilder.cs b/PTrampert.SimplePatch/EmitPatchClassBuilder.cs index 1a3315d..6dee00b 100644 --- a/PTrampert.SimplePatch/EmitPatchClassBuilder.cs +++ b/PTrampert.SimplePatch/EmitPatchClassBuilder.cs @@ -9,7 +9,7 @@ namespace PTrampert.SimplePatch; /// /// Generates classes that implement by emitting IL with -/// Reflection.Emit, rather than compiling C# with Roslyn as does. +/// Reflection.Emit, rather than compiling C# with Roslyn as does. /// Unlike that builder, it supports internal source types, provided their assembly grants /// [InternalsVisibleTo] to . /// @@ -22,9 +22,9 @@ namespace PTrampert.SimplePatch; /// grant, but it isn't officially supported (https://github.com/dotnet/runtime/issues/37875). /// [InternalsVisibleTo] doesn't reach private or protected members, so private /// nested source types aren't supported. The class emitted here has the same shape as the one -/// compiles: both are built from . +/// compiles: both are built from . /// -internal static class EmitPatchClassBuilder +internal sealed class EmitPatchClassBuilder : IPatchClassBuilder { /// /// The name of every assembly this builder emits. An assembly whose internal types are patched @@ -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> PatchClasses = new(); + /// + /// The builder. Its cache is static, so there is no reason for a second instance. + /// + public static EmitPatchClassBuilder Instance { get; } = new(); + + private EmitPatchClassBuilder() + { + } + /// /// Gets or creates the patch class for . /// @@ -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. /// - public static Type GetPatchClassFor(Type type) + public Type GetPatchClassFor(Type type) { return PatchClasses.GetOrAdd(type, t => new Lazy(() => CreatePatchClass(t))).Value; } @@ -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); @@ -188,7 +197,7 @@ private static bool GrantsInternalsAccess(Assembly assembly) => /// /// Defines the backing field and property for one source property, - /// with the attributes gives it, and returns the field. + /// with the attributes gives it, and returns the field. /// private static FieldBuilder DefineOptionalProperty( TypeBuilder typeBuilder, PatchClassModel model, OptionalPropertyModel optionalProperty) @@ -240,7 +249,7 @@ private static FieldBuilder DefineOptionalProperty( } /// - /// Emits Patch(T target). It builds the result as 's C# + /// Emits Patch(T target). It builds the result as 's C# /// does: a with clone for a record, otherwise the chosen constructor followed by the /// setters for the remaining properties. /// @@ -258,7 +267,7 @@ private static void DefinePatchMethod( typeBuilder.DefineMethodOverride(method, patchInterface.GetMethod(nameof(IPatchObject.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) diff --git a/PTrampert.SimplePatch/IPatchClassBuilder.cs b/PTrampert.SimplePatch/IPatchClassBuilder.cs new file mode 100644 index 0000000..07a7ed3 --- /dev/null +++ b/PTrampert.SimplePatch/IPatchClassBuilder.cs @@ -0,0 +1,17 @@ +namespace PTrampert.SimplePatch; + +/// +/// Generates classes that implement for a given type. +/// +public interface IPatchClassBuilder +{ + /// + /// Gets or creates a class that implements for the specified type. + /// The class has an property for each patchable property of the type, + /// and a Patch method that applies the properties that are set to a target instance. + /// + /// The type to get a patch type for. + /// The generated patch type. + /// The builder can't generate a patch class for . + Type GetPatchClassFor(Type type); +} diff --git a/PTrampert.SimplePatch/PatchClassBuilder.cs b/PTrampert.SimplePatch/PatchClassBuilder.cs index 77f7b8f..97d6319 100644 --- a/PTrampert.SimplePatch/PatchClassBuilder.cs +++ b/PTrampert.SimplePatch/PatchClassBuilder.cs @@ -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; /// /// Generates classes that implement for a given type. /// -public class PatchClassBuilder +/// +/// This is the library's default builder, and the one uses. +/// It delegates to the builder that generates the classes, so that the implementation can change +/// without changing this public type. +/// +public class PatchClassBuilder : IPatchClassBuilder { - private const string ApplyTargetParamName = "target"; - - /// - /// Namespace used for patch classes generated from source types that are not themselves - /// in a namespace. - /// - 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> OptionalsClasses = new(); - /// /// The builder. Use this rather than constructing your own: all instances share one cache, so /// a new instance buys nothing but an allocation. @@ -66,175 +48,5 @@ public PatchClassBuilder() /// /// is not public, or is nested in or constructed from a type that is not public. /// - public Type GetPatchClassFor(Type type) - { - return OptionalsClasses.GetOrAdd(type, t => new Lazy(() => 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.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(); - - 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.HasValue)} ? this.{backingField.Name}.{nameof(Optional.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); } diff --git a/PTrampert.SimplePatch/PatchClassModel.cs b/PTrampert.SimplePatch/PatchClassModel.cs index 59dbfd4..75f5084 100644 --- a/PTrampert.SimplePatch/PatchClassModel.cs +++ b/PTrampert.SimplePatch/PatchClassModel.cs @@ -9,8 +9,8 @@ namespace PTrampert.SimplePatch; /// which properties it exposes as , and which attributes each carries. /// /// -/// Kept apart from 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 and 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. diff --git a/PTrampert.SimplePatch/RoslynPatchClassBuilder.cs b/PTrampert.SimplePatch/RoslynPatchClassBuilder.cs new file mode 100644 index 0000000..91fe7b6 --- /dev/null +++ b/PTrampert.SimplePatch/RoslynPatchClassBuilder.cs @@ -0,0 +1,219 @@ +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; + +/// +/// Generates classes that implement by generating C# with CodeDom and +/// compiling it with Roslyn into an in-memory assembly. delegates to it. +/// +/// +/// The compiled assembly is separate from the source type's, so it can only name public types. +/// builds the same class without that restriction. +/// +internal sealed class RoslynPatchClassBuilder : IPatchClassBuilder +{ + private const string ApplyTargetParamName = "target"; + + /// + /// Namespace used for patch classes generated from source types that are not themselves + /// in a namespace. + /// + private const string GlobalNamespaceFallback = "PTrampert.SimplePatch.Generated"; + + // Static so that every caller — PatchJsonConverterFactory, the OpenAPI integrations, and user + // code through any PatchClassBuilder instance — 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> OptionalsClasses = new(); + + /// + /// The builder. Its cache is static, so there is no reason for a second instance. + /// + public static RoslynPatchClassBuilder Instance { get; } = new(); + + private RoslynPatchClassBuilder() + { + } + + /// + public Type GetPatchClassFor(Type type) + { + return OptionalsClasses.GetOrAdd(type, t => new Lazy(() => 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.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(); + + 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.HasValue)} ? this.{backingField.Name}.{nameof(Optional.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}")!; + } +}