Skip to content

selmanmade/OptionPatch

Repository files navigation

OptionPatch

A source-generated PATCH semantics library for .NET. Define your patch DTOs with Option<T> properties, and the generator writes the boring mapping code at build time — zero reflection, zero runtime cost.

Table of Contents


Installation

Install the NuGet package:

dotnet add package SelmanMade.OptionPatch

Or via the Package Manager Console in Visual Studio:

Install-Package SelmanMade.OptionPatch

The package includes both the runtime types (Option<T>, PatchEntry, attributes) and the source generator. No additional packages are needed.


Quick Start

using OptionPatch;

// 1. Mark your patch DTO with the target entity type
[GeneratePatchApplier(typeof(Person))]
public class UpdatePerson
{
    public Option<string?> Name { get; set; }
    public Option<int?> Age { get; set; }
}

// 2. Apply a patch — only set properties are touched
var person = new Person { Name = "Alice", Age = 30 };
var patch = new UpdatePerson { Name = "Bob" };

UpdatePersonPatchApplier.Apply(patch, person);
// person.Name == "Bob", person.Age == 30 (unchanged)

// 3. Or generate path-based entries for Cosmos DB / JSON Patch
var entries = UpdatePersonPatchApplier.ToPatchEntries(patch);
// entries[0]: Set(/name, Bob)

Step 1: Define Your Entities

These are your normal domain/entity classes. Nothing special required.

public class Person
{
    public string? Name { get; set; }
    public int? Age { get; set; }
    public Address? Address { get; set; }
}

public class Address
{
    public string? Street { get; set; }
    public string? City { get; set; }
}

Step 2: Define Patch DTOs

Create a class for each entity you want to patch. Use Option<T> for every property and annotate the class with [GeneratePatchApplier(typeof(TargetEntity))].

using OptionPatch;

[GeneratePatchApplier(typeof(Person))]
public class UpdatePerson
{
    public Option<string?> Name { get; set; }
    public Option<int?> Age { get; set; }
    public Option<UpdateAddress?> Address { get; set; }
}

[GeneratePatchApplier(typeof(Address))]
public class UpdateAddress
{
    public Option<string?> Street { get; set; }
    public Option<string?> City { get; set; }
}

Key rules:

Rule Why
Property names must match the entity The generator matches by name
Entity properties must have a setter Read-only properties are skipped (with a warning)
Use Option<T> from OptionPatch The generator only processes properties with the [Option]-marked struct

Understanding Option<T>

Option<T> distinguishes between "not provided" and "explicitly set to null" — the core problem with PATCH APIs:

var patch = new UpdatePerson();
// patch.Name.IsSet == false  → "Name was not in the request, don't touch it"

var patch2 = new UpdatePerson { Name = "Alice" };
// patch2.Name.IsSet == true, patch2.Name.Value == "Alice"  → "Set Name to Alice"

var patch3 = new UpdatePerson { Name = (string?)null };
// patch3.Name.IsSet == true, patch3.Name.Value == null  → "Clear Name"

Step 3: Apply Patches to Entities

At build time, the generator creates a static {DtoName}PatchApplier class with an Apply method:

var person = new Person { Name = "Alice", Age = 30 };

var patch = new UpdatePerson { Name = "Bob" };
// Age is not set — it won't be touched

UpdatePersonPatchApplier.Apply(patch, person);

// person.Name == "Bob"
// person.Age == 30  (unchanged)

Only properties where IsSet == true are applied. Everything else is left untouched.


Step 4: Generate Patch Entries

The generator also creates a ToPatchEntries method that produces a list of PatchEntry objects — a generic intermediate representation with JSON paths and values. This is what you use for Cosmos DB, JSON Patch, or any path-based patch format.

var patch = new UpdatePerson
{
    Name = "Bob",
    Age = (int?)null
};

IReadOnlyList<PatchEntry> entries = UpdatePersonPatchApplier.ToPatchEntries(patch);

// entries[0]: Set(/name, Bob)
// entries[1]: Set(/age, null)

Each PatchEntry has:

Property Type Description
Path string JSON pointer path (e.g. /name, /address/city)
Value object? The value to set, or null
IsRemoval bool true if this is a remove operation

Nested DTOs

When a patch DTO property is itself a patch DTO (annotated with [GeneratePatchApplier]), the generator handles nesting automatically.

Partial update of a nested object

var patch = new UpdatePerson
{
    Address = new UpdateAddress { City = "Shelbyville" }
};

// Apply: creates Address if null, updates only City
UpdatePersonPatchApplier.Apply(patch, person);

// ToPatchEntries: produces flattened path
var entries = UpdatePersonPatchApplier.ToPatchEntries(patch);
// entries[0]: Set(/address/city, Shelbyville)

Remove a nested object

var patch = new UpdatePerson
{
    Address = (UpdateAddress?)null
};

// Apply: sets person.Address = null
UpdatePersonPatchApplier.Apply(patch, person);

// ToPatchEntries: produces a Remove entry
var entries = UpdatePersonPatchApplier.ToPatchEntries(patch);
// entries[0]: Remove(/address)

Leave a nested object untouched

var patch = new UpdatePerson
{
    Name = "Bob"
    // Address not set at all — completely untouched
};

Cosmos DB Integration

PatchEntry is deliberately free of any Cosmos DB dependency. You write a thin adapter in your API layer:

using Microsoft.Azure.Cosmos;
using OptionPatch;

public static class CosmosPatchAdapter
{
    public static IReadOnlyList<PatchOperation> ToPatchOperations(
        IReadOnlyList<PatchEntry> entries)
    {
        var ops = new List<PatchOperation>(entries.Count);

        foreach (var entry in entries)
        {
            ops.Add(entry.IsRemoval
                ? PatchOperation.Remove(entry.Path)
                : PatchOperation.Set(entry.Path, entry.Value));
        }

        return ops;
    }
}

Usage:

var patch = new UpdatePerson
{
    Name = "Alice",
    Address = (UpdateAddress?)null
};

var entries = UpdatePersonPatchApplier.ToPatchEntries(patch);
var ops = CosmosPatchAdapter.ToPatchOperations(entries);

await container.PatchItemAsync<Person>(id, partitionKey, ops);
// Sends: Set(/name, "Alice"), Remove(/address)

This same pattern works for any patch target — JSON Patch (RFC 6902), MongoDB updates, or a custom format. Write one adapter per target.


JSON Property Name Support

The generator resolves JSON property names for ToPatchEntries paths using this order:

  1. [JsonPropertyName("...")] on the entity property — if present, uses the specified name
  2. camelCase fallback — converts the C# property name (e.g. ZipCodezipCode)

Example with explicit JSON names:

using System.Text.Json.Serialization;

public class Person
{
    [JsonPropertyName("full_name")]
    public string? Name { get; set; }

    public int? Age { get; set; }
}

Generated paths will be /full_name and /age.


Diagnostics

The generator emits build-time diagnostics when something doesn't map correctly:

ID Severity Description
PATCH001 Error Property types are incompatible (e.g. Option<string>int target)
PATCH002 Warning DTO property has no matching writable property on the target entity

These appear as regular build errors/warnings in Visual Studio, so you catch mapping mistakes at compile time instead of runtime.


How It Works

At build time, the incremental source generator:

  1. Scans for classes annotated with [GeneratePatchApplier(typeof(T))]
  2. Matches each Option<T> property by name to the target entity's properties
  3. Emits a {DtoName}PatchApplier.g.cs file with two methods:
    • Apply(patch, target) — direct property assignment on the entity
    • ToPatchEntries(patch, prefix) — JSON path + value list

You can inspect the generated files in your project's obj folder or via Visual Studio's Analyzers node in Solution Explorer.

The generated code is plain C# with no reflection, no expression trees, and no runtime compilation.


Limitations

This version does not yet handle:

  • Collections or dictionaries
  • Custom property name mappings on the DTO side
  • Immutable record targets
  • Constructor-based target creation
  • Cycle detection in nested DTOs
  • System.Text.Json converter for deserializing Option<T> from JSON

About

A source-generated PATCH semantics library for .NET. Define patch DTOs with Option<T> properties to distinguish between "not provided" and "set to null," then let the generator emit zero-reflection, zero-runtime-cost appliers and path-based patch entries (for Cosmos DB, JSON Patch, etc.) at build time.

Resources

License

MIT, MIT licenses found

Licenses found

MIT
LICENSE
MIT
LICENSE.txt

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages