SelmanMade.OptionPatch 1.0.0

dotnet add package SelmanMade.OptionPatch --version 1.0.0
                    
NuGet\Install-Package SelmanMade.OptionPatch -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SelmanMade.OptionPatch" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SelmanMade.OptionPatch" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="SelmanMade.OptionPatch" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SelmanMade.OptionPatch --version 1.0.0
                    
#r "nuget: SelmanMade.OptionPatch, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SelmanMade.OptionPatch@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SelmanMade.OptionPatch&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=SelmanMade.OptionPatch&version=1.0.0
                    
Install as a Cake Tool

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
Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 110 3/9/2026