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

Docs are built with docfx (`docfx.json`, `index.md`, `docs/`). The API reference is generated
Expand All @@ -37,6 +38,11 @@ public face of the library on nuget.org.
- 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.
- `JsonOptionsExtensions.AddSimplePatchConverters` registers `OptionalJsonConverterFactory` and
`PatchJsonConverterFactory`.
- The OpenAPI packages build the patch schema from the **source model's** schema, not from the
Expand Down
9 changes: 9 additions & 0 deletions PTrampert.SimplePatch.Test.External/ExternalInternalColor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace PTrampert.SimplePatch.Test.External;

// Internal, and this assembly grants no [InternalsVisibleTo] to EmitPatchClassBuilder's generated
// assemblies, so a patch class can't name it even when the source type's own assembly grants one.
internal enum ExternalInternalColor
{
Red,
Blue,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<!-- A second assembly for PTrampert.SimplePatch.Test, so a patch source type can have a
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>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="PTrampert.SimplePatch.Test" />
</ItemGroup>

</Project>
145 changes: 145 additions & 0 deletions PTrampert.SimplePatch.Test/EmitPatchClassBuilderTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using PTrampert.SimplePatch.Test.External;
using PTrampert.SimplePatch.Test.TestObjects;

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.
public class EmitPatchClassBuilderTest
{
private static readonly JsonSerializerOptions Options = CreateOptions();

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

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

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

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

[Test]
public void Patch_InternalClass_AppliesSetAndExplicitNullValues()
{
var patch = Deserialize<InternalClassTestObject>(
"""{ "display_name": null, "initOnly": "New", "converted": "value" }""");

var result = patch.Patch(new InternalClassTestObject
{
Name = "Old", InitOnly = "Old", Rating = 3, Converted = "Old",
});

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(result.Converted, Is.EqualTo("Internal:value"),
"The internal [JsonConverter] on the source property should be used.");
}));
}

[Test]
public void Validate_InternalClass_RunsTheSourceValidatorsOnSetProperties()
{
var invalid = Deserialize<InternalClassTestObject>("""{ "rating": 20 }""");
var omitted = Deserialize<InternalClassTestObject>("""{ "initOnly": "x" }""");
var invalidResults = new List<ValidationResult>();
var omittedResults = new List<ValidationResult>();

var invalidIsValid = Validator.TryValidateObject(invalid, new ValidationContext(invalid), invalidResults, true);
var omittedIsValid = Validator.TryValidateObject(omitted, new ValidationContext(omitted), omittedResults, true);

Assert.Multiple((Action)(() =>
{
Assert.That(invalidIsValid, Is.False);
Assert.That(invalidResults.Select(r => r.ErrorMessage),
Is.EqualTo(new[] { "The field Rating must be between 1 and 10." }));
Assert.That(omittedIsValid, Is.True, "Validation should skip a property the patch leaves out.");
}));
}

[Test]
public void Patch_InternalGenericArgument()
{
var patch = Deserialize<InternalListPropertyTestObject>("""{ "items": [{ "count": 2 }] }""");

var result = patch.Patch(new InternalListPropertyTestObject());

Assert.That(result.Items, Is.EqualTo(new[] { new InternalStructTestObject { Count = 2 } }));
}

[TestCase(typeof(PrivateNestedTestObject))]
[TestCase(typeof(PrivatePositionalRecordTestObject))]
public void GetPatchClassFor_PrivateNestedType_Throws(Type type)
{
var ex = Assert.Throws<NotSupportedException>(() => EmitPatchClassBuilder.GetPatchClassFor(type));

Assert.That(ex!.Message, Does.Contain($"'{type.FullName}', which the generated assembly can't access"));
}

[Test]
public void GetPatchClassFor_PrivateGetter_Throws()
{
var ex = Assert.Throws<NotSupportedException>(
() => EmitPatchClassBuilder.GetPatchClassFor(typeof(PrivateGetterTestObject)));

Assert.That(ex!.Message, Does.Contain("the getter of 'Name' isn't accessible"));
}

[Test]
public void Patch_InternalPrimaryConstructorClass_BindsGetOnlyPropertiesThroughTheConstructor()
{
var patch = Deserialize<InternalPrimaryConstructorTestObject>("""{ "name": "New" }""");

var result = patch.Patch(new InternalPrimaryConstructorTestObject("Old") { Color = "Red" });

Assert.Multiple((Action)(() =>
{
Assert.That(result.Name, Is.EqualTo("New"));
Assert.That(result.Color, Is.EqualTo("Red"));
}));
}

[Test]
public void Patch_InternalStruct()
{
var patch = Deserialize<InternalStructTestObject>("""{ "count": 2 }""");

var result = patch.Patch(new InternalStructTestObject { Name = "Kept", Count = 1 });

Assert.That(result, Is.EqualTo(new InternalStructTestObject { Name = "Kept", Count = 2 }));
}

[Test]
public void GetPatchClassFor_NonPublicPropertyTypeFromAnAssemblyWithoutTheGrant_ThrowsNamingThatAssembly()
{
var ex = Assert.Throws<NotSupportedException>(
() => EmitPatchClassBuilder.GetPatchClassFor(typeof(ExternalPropertyTypeTestObject)));

Assert.That(ex!.Message, Does.Contain(typeof(ExternalInternalColor).FullName)
.And.Contain($"[assembly: InternalsVisibleTo(\"{EmitPatchClassBuilder.AssemblyName}\")]")
.And.Contain("'PTrampert.SimplePatch.Test.External'"));
}

private class PrivateNestedTestObject
{
public string? Name { get; set; }

public string? Other { get; set; }
}

private record PrivatePositionalRecordTestObject(string Name, int Count);
}
6 changes: 6 additions & 0 deletions PTrampert.SimplePatch.Test/PTrampert.SimplePatch.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@
<PackageReference Include="coverlet.collector" Version="10.1.0"/>
</ItemGroup>

<!-- Lets EmitPatchClassBuilder's generated assemblies use this project's internal test types. -->
<ItemGroup>
<InternalsVisibleTo Include="PTrampert.SimplePatch.Emitted" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\PTrampert.SimplePatch\PTrampert.SimplePatch.csproj" />
<ProjectReference Include="..\PTrampert.SimplePatch.Test.External\PTrampert.SimplePatch.Test.External.csproj" />
</ItemGroup>

</Project>
Loading
Loading