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
59 changes: 32 additions & 27 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,43 +11,48 @@ omitted apart from one that was explicitly set to `null` or to a value. A contro

## Layout

| Project | Target | Purpose |
| --- | --- | --- |
| `PTrampert.SimplePatch` | net8.0 | Core package: `Optional<T>`, `IPatchObject<T>`, `PatchClassBuilder`, JSON converters, validation |
| `PTrampert.SimplePatch.Schema` | net8.0 | `PatchSchemaTransform`: the OpenAPI schema rewrite shared by both integration packages |
| `PTrampert.SimplePatch.Swashbuckle` | net8.0 | Swashbuckle schema filter |
| `PTrampert.SimplePatch.OpenApi` | net10.0 | `Microsoft.AspNetCore.OpenApi` schema transformer. It is net10.0 only because it needs `GetOrCreateSchemaAsync`. |
| `*.Test` | match their subject | NUnit test projects, one per shipped package (except `Schema`, which the integration tests cover) |
| `PTrampert.SimplePatch.Test.External` | net8.0 | A second assembly for the core tests, holding non-public types they use from another assembly. Not packed. |
| `PTrampert.SimplePatch.Sample` | net8.0 | Sample web API, not packed |
Every project targets `net10.0` only. Don't add another target framework without an issue for it.

| Project | Purpose |
| --- | --- |
| `PTrampert.SimplePatch` | Core package: `Optional<T>`, `IPatchObject<T>`, `PatchClassBuilder`, JSON converters, validation |
| `PTrampert.SimplePatch.Schema` | `PatchSchemaTransform`: the OpenAPI schema rewrite shared by both integration packages |
| `PTrampert.SimplePatch.Swashbuckle` | Swashbuckle schema filter |
| `PTrampert.SimplePatch.OpenApi` | `Microsoft.AspNetCore.OpenApi` schema transformer |
| `*.Test` | NUnit test projects, one per shipped package (except `Schema`, which the integration tests cover) |
| `PTrampert.SimplePatch.Test.External` | A second assembly for the core tests, holding non-public types they use from another assembly. Not packed. |
| `PTrampert.SimplePatch.Sample` | Sample web API, not packed |

Docs are built with docfx (`docfx.json`, `index.md`, `docs/`). The API reference is generated
into `api/` from XML doc comments. `README.md` is packed into every NuGet package, so it is the
public face of the library on nuget.org.

## How it works

- `PatchClassBuilder.GetPatchClassFor(type)` generates C# source with CodeDom, compiles it with
Roslyn into its own in-memory assembly, and caches the result in a **static** dictionary.
`PatchClassBuilder.Instance` is the only instance to use. The public constructor is obsolete.
- The generated class has one `Optional<T>` property per patchable source property and a `Patch`
method. Records are patched with a `with` expression. Other types go through constructor binding
and an object initializer.
- Because the generated assembly is separate, it can only reference **public** types and public
setters or init accessors. Non-public source types throw `NotSupportedException`, unless the
experimental Emit builder below is turned on.
- `PatchClassBuilder` is a `static class`. Its `Instance` is typed `IPatchClassBuilder` and returns
the internal `EmitPatchClassBuilder.Instance` itself, which builds the patch class with
Reflection.Emit, one dynamic assembly per source type, and caches the result in a **static**
dictionary.
- `PatchClassModel` decides what the class contains. The generated class has one `Optional<T>`
property per patchable source property and a `Patch` method. Records are patched by cloning, as
a `with` expression does. Other types go through constructor binding and then the setters or init
accessors.
- Because the generated assembly is separate, the runtime checks its access to the source type
and every type and accessor it uses. Every such assembly is named `PTrampert.SimplePatch.Emitted`,
so internal source types are supported when their assembly declares
`[InternalsVisibleTo("PTrampert.SimplePatch.Emitted")]`, as Castle DynamicProxy does. Private and
protected nested types aren't supported, and throw `NotSupportedException`.
- Source property attributes are carried over: `[JsonConverter]` becomes
`[OptionalConverter]`, `[JsonPropertyName]` is copied, and each `ValidationAttribute` becomes an
`[OptionalValidation(type, index)]` that runs only when the property is present.
- The internal `EmitPatchClassBuilder` builds the same class from the same `PatchClassModel` with
Reflection.Emit, one dynamic assembly per source type. Every such assembly is named
`PTrampert.SimplePatch.Emitted`, so it also supports internal source types whose assembly declares
`[InternalsVisibleTo("PTrampert.SimplePatch.Emitted")]`, as Castle DynamicProxy does. Private
nested types aren't supported. `PatchClassBuilderTest` runs against both builders.
- The public static `PatchClassBuilder.UseExperimentalDynamicClassBuilder` flag (off by default)
makes `PatchClassBuilder.GetPatchClassFor` delegate to `EmitPatchClassBuilder` instead of
`RoslynPatchClassBuilder`. It is process-wide, so the OpenAPI integrations follow it too. Tests
that set it are `[NonParallelizable]` and reset it in `TearDown`.
- Nothing on the runtime path reads assembly files from disk, so single-file publishing works.
Native AOT doesn't, because the library generates code at runtime.
- The internal `RoslynPatchClassBuilder` (CodeDom source compiled with Roslyn, public source types
only) is **unused at runtime** but kept, and still tested, pending #144, which decides whether it
becomes a compile-time source generator. It is why the core package still references
`Microsoft.CodeAnalysis.CSharp` and `System.CodeDom`. `PatchClassBuilderTest` runs against both
builders directly, so they can't drift apart; `PatchClassBuilderDelegationTest` covers the public
entry point.
- `JsonOptionsExtensions.AddSimplePatchConverters` registers `OptionalJsonConverterFactory` and
`PatchJsonConverterFactory`.
- The OpenAPI packages build the patch schema from the **source model's** schema, not from the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,13 @@

namespace PTrampert.SimplePatch.OpenApi.Test;

// PatchClassBuilder.UseExperimentalDynamicClassBuilder is process-wide, so these tests must not
// overlap with others, and each one puts it back afterwards.
[NonParallelizable]
public class ExperimentalDynamicClassBuilderTest
// An internal write model, which the test project grants to the generated assemblies with
// InternalsVisibleTo in its project file.
public class InternalWriteModelTest
{
[TearDown]
public void TearDown() => PatchClassBuilder.UseExperimentalDynamicClassBuilder = false;

[Test]
public async Task PatchSchema_DescribesAnInternalModelWhenTheFlagIsOn()
public async Task PatchSchema_DescribesAnInternalModel()
{
PatchClassBuilder.UseExperimentalDynamicClassBuilder = true;
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.AddSimplePatchConverters());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
</ItemGroup>

<ItemGroup>
<!-- Lets the experimental Emit builder's generated assemblies see the internal test models. -->
<!-- Lets the generated patch assemblies see the internal test models. -->
<InternalsVisibleTo Include="PTrampert.SimplePatch.Emitted" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace PTrampert.SimplePatch.OpenApi.Test.TestObjects;

// Internal, which only the experimental Emit builder can patch. The test project grants the
// Internal, to check that internal write models are patchable. The test project grants the
// generated assemblies access with InternalsVisibleTo in its project file.
internal record InternalPersonTestModel
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<!--
net10.0 only: the transformer needs OpenApiSchemaTransformerContext.GetOrCreateSchemaAsync
to obtain the source model's schema, which .NET 9 does not have. See the OpenAPI proposal
in docs/proposals for the .NET 9 story.
-->
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<!--
net8.0 so that both integration packages can consume it: PTrampert.SimplePatch.Swashbuckle
targets net8.0 and PTrampert.SimplePatch.OpenApi targets net10.0.
-->
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,13 @@

namespace PTrampert.SimplePatch.Swashbuckle.Test;

// PatchClassBuilder.UseExperimentalDynamicClassBuilder is process-wide, so these tests must not
// overlap with others, and each one puts it back afterwards.
[NonParallelizable]
public class ExperimentalDynamicClassBuilderTest
// An internal write model, which the test project grants to the generated assemblies with
// InternalsVisibleTo in its project file.
public class InternalWriteModelTest
{
[TearDown]
public void TearDown() => PatchClassBuilder.UseExperimentalDynamicClassBuilder = false;

[Test]
public void PatchSchema_DescribesAnInternalModelWhenTheFlagIsOn()
public void PatchSchema_DescribesAnInternalModel()
{
PatchClassBuilder.UseExperimentalDynamicClassBuilder = true;
var services = new ServiceCollection();
services.AddSwaggerGen(options => options.AddSimplePatchSchemas());
using var provider = services.BuildServiceProvider();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

Expand All @@ -21,7 +21,7 @@
</ItemGroup>

<ItemGroup>
<!-- Lets the experimental Emit builder's generated assemblies see the internal test models. -->
<!-- Lets the generated patch assemblies see the internal test models. -->
<InternalsVisibleTo Include="PTrampert.SimplePatch.Emitted" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace PTrampert.SimplePatch.Swashbuckle.Test.TestObjects;

// Internal, which only the experimental Emit builder can patch. The test project grants the
// Internal, to check that internal write models are patchable. The test project grants the
// generated assemblies access with InternalsVisibleTo in its project file.
internal record InternalPersonTestModel
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
non-public property type from an assembly that, unlike the test project, grants no access
to the generated patch classes. -->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
Expand Down
41 changes: 38 additions & 3 deletions PTrampert.SimplePatch.Test/EmitPatchClassBuilderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@

namespace PTrampert.SimplePatch.Test;

// Cases only the Emit builder supports: internal source types, which this assembly grants to the
// generated assemblies, and the errors for types it can't reach. Cases it shares with the Roslyn
// builder are in PatchClassBuilderTest.
// Cases specific to the Emit builder: its cache, internal source types, which this assembly grants
// to the generated assemblies, and the errors for types it can't reach. Cases it shares with the
// Roslyn builder are in PatchClassBuilderTest.
public class EmitPatchClassBuilderTest
{
private static readonly JsonSerializerOptions Options = CreateOptions();
Expand All @@ -30,6 +30,41 @@ public void GetPatchClassFor_ReturnsTheSameTypeEachTime()
Assert.That(EmitPatchClassBuilder.Instance.GetPatchClassFor(typeof(InternalClassTestObject)), Is.SameAs(first));
}

[Test]
public void GetPatchClassFor_GeneratesOnceUnderConcurrentFirstUse()
{
const int threadCount = 16;
var sourceType = typeof(ConcurrentFirstUseTestObject);
var results = new Type[threadCount];
using var barrier = new Barrier(threadCount);
var threads = Enumerable.Range(0, threadCount)
.Select(i => new Thread(() =>
{
barrier.SignalAndWait();
results[i] = EmitPatchClassBuilder.Instance.GetPatchClassFor(sourceType);
}))
.ToList();

threads.ForEach(t => t.Start());
threads.ForEach(t => t.Join());

// Every generation defines its own dynamic assembly, so count the loaded types that patch
// the source type: a discarded duplicate would still show up here.
var patchInterface = typeof(IPatchObject<>).MakeGenericType(sourceType);
var generatedTypes = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => a.IsDynamic && a.GetName().Name == EmitPatchClassBuilder.AssemblyName)
.SelectMany(a => a.GetTypes())
.Where(patchInterface.IsAssignableFrom)
.ToList();

Assert.Multiple((Action)(() =>
{
Assert.That(results, Has.All.SameAs(results[0]));
Assert.That(generatedTypes, Is.EquivalentTo(new[] { results[0] }),
"Concurrent first use should generate the patch class once, not once per racing thread.");
}));
}

[Test]
public void Patch_InternalClass_AppliesSetAndExplicitNullValues()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

Expand Down
69 changes: 69 additions & 0 deletions PTrampert.SimplePatch.Test/PatchClassBuilderDelegationTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using PTrampert.SimplePatch.Test.TestObjects;

namespace PTrampert.SimplePatch.Test;

// Cases for the public entry point: PatchClassBuilder hands out the Emit builder's types, so what
// the Emit builder supports, consumers get. The shape of the patch class is covered against each
// builder in PatchClassBuilderTest.
public class PatchClassBuilderDelegationTest
{
private static readonly JsonSerializerOptions Options = CreateOptions();

private static JsonSerializerOptions CreateOptions()
{
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
options.AddSimplePatchConverters();
return options;
}

[Test]
public void Instance_IsTheEmitBuilder()
{
Assert.Multiple((Action)(() =>
{
Assert.That(PatchClassBuilder.Instance, Is.SameAs(EmitPatchClassBuilder.Instance));
Assert.That(PatchClassBuilder.Instance.GetPatchClassFor(typeof(OptionalsBuilderTestObject)),
Is.SameAs(EmitPatchClassBuilder.Instance.GetPatchClassFor(typeof(OptionalsBuilderTestObject))),
"Every caller should resolve a source type to one generated patch type.");
}));
}

[Test]
public void Deserialize_InternalType_PatchesAndValidates()
{
// Through IPatchObject<T>, as a controller binds it. This assembly grants the generated
// assemblies access with [InternalsVisibleTo] in its project file.
var patch = JsonSerializer.Deserialize<IPatchObject<InternalClassTestObject>>(
"""{ "display_name": null, "initOnly": "New" }""", Options)!;
var invalid = JsonSerializer.Deserialize<IPatchObject<InternalClassTestObject>>(
"""{ "rating": 20 }""", Options)!;
var result = patch.Patch(new InternalClassTestObject { Name = "Old", InitOnly = "Old", Rating = 3 });
var validationResults = new List<ValidationResult>();

Assert.Multiple((Action)(() =>
{
Assert.That(result.Name, Is.Null, "An explicit null should be applied.");
Assert.That(result.InitOnly, Is.EqualTo("New"));
Assert.That(result.Rating, Is.EqualTo(3), "A property the patch leaves out should keep its value.");
Assert.That(Validator.TryValidateObject(patch, new ValidationContext(patch), validationResults, true),
Is.True);
Assert.That(Validator.TryValidateObject(invalid, new ValidationContext(invalid), validationResults, true),
Is.False, "The source property's [Range] should run on the patch.");
}));
}

[Test]
public void GetPatchClassFor_PrivateNestedType_ThrowsNotSupported()
{
Assert.That(() => PatchClassBuilder.Instance.GetPatchClassFor(typeof(PrivateNestedTestObject)),
Throws.TypeOf<NotSupportedException>()
.With.Message.Contains(typeof(PrivateNestedTestObject).FullName));
}

private class PrivateNestedTestObject
{
public string? Name { get; set; }
}
}
24 changes: 6 additions & 18 deletions PTrampert.SimplePatch.Test/RoslynPatchClassBuilderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,17 @@

namespace PTrampert.SimplePatch.Test;

// 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.
// Cases specific to the Roslyn builder, which isn't used at runtime but is kept pending #144: 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
{
[Test]
public void GetPatchClassFor_SharesGeneratedTypesAcrossBuilders()
public void GetPatchClassFor_ReturnsTheSameTypeEachTime()
{
// Deliberately the obsolete constructor: the point of this test is that separately
// constructed builders still share one cache, for as long as that constructor exists.
#pragma warning disable CS0618
var first = new PatchClassBuilder().GetPatchClassFor(typeof(OptionalsBuilderTestObject));
var second = new PatchClassBuilder().GetPatchClassFor(typeof(OptionalsBuilderTestObject));
#pragma warning restore CS0618
var first = RoslynPatchClassBuilder.Instance.GetPatchClassFor(typeof(OptionalsBuilderTestObject));

Assert.Multiple((Action)(() =>
{
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.");
}));
Assert.That(RoslynPatchClassBuilder.Instance.GetPatchClassFor(typeof(OptionalsBuilderTestObject)), Is.SameAs(first));
}

[Test]
Expand Down
Loading
Loading