devo6.SSC 0.5.0

dotnet add package devo6.SSC --version 0.5.0
                    
NuGet\Install-Package devo6.SSC -Version 0.5.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="devo6.SSC" Version="0.5.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="devo6.SSC" Version="0.5.0" />
                    
Directory.Packages.props
<PackageReference Include="devo6.SSC" />
                    
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 devo6.SSC --version 0.5.0
                    
#r "nuget: devo6.SSC, 0.5.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 devo6.SSC@0.5.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=devo6.SSC&version=0.5.0
                    
Install as a Cake Addin
#tool nuget:?package=devo6.SSC&version=0.5.0
                    
Install as a Cake Tool

SSC

PR xUnit Publish NuGet License

Runtime Package NuGet Version NuGet Downloads


Source Generator Package NuGet Version NuGet Downloads

SSC is a .NET library for structural comparison of multiple models.

It compares object graphs by aligning member values into per-model slots, normalizing container members (Dictionary/List/IEnumerable), and returning both comparison data and diagnostic issues.

Status

  • Target framework: .NET 8

NuGet Packages

  • Runtime package:
    • devo6.SSC
  • Source Generator package:
    • devo6.SSC.Generators

Install both packages when you want typed generated projection API (AsGeneratedView()).

Core Concepts

  • ParallelCompareApi.Compare<T>(IReadOnlyList<T> models, CompareConfiguration? configuration = null)
  • Result model:
    • CompareResult<T>.Root : comparison tree root
    • CompareResult<T>.Issues : accumulated diagnostics
    • CompareResult<T>.HasError : whether error-level issues exist
  • Slot model:
    • Parallel<T>[modelIndex] for value access
    • GetState(modelIndex) for slot state access
    • ValueState.Missing : this slot is missing, or comparison target does not exist
    • ValueState.Matched : there are comparison targets and all compared slots are equal
    • ValueState.Mismatched : this slot exists and at least one compared slot is different (including target-side missing)

Container Behavior

  • Dictionary:
    • Union keys across models
    • Build child nodes by key
    • Duplicate-equivalent keys in same model are recorded as DuplicateCompareKeyDetected
  • List / Array / IEnumerable:
    • Element type requires [CompareKey]
    • Missing compare key is recorded as issue (strict mode throws)
    • Duplicate compare key is recorded as issue (strict mode throws)

Strict Mode

  • StrictMode = false (default): collect issues and continue where possible
  • StrictMode = true: throw immediately on error
    • CompareInputException for input errors
    • CompareExecutionException for execution errors

Trace Logging

  • CompareConfiguration.TraceLog に callback を渡すと、compare 実行中の内部 trace を受け取れます
  • 特に container 判定では path, declaredType, container, runtimeType, materializedCount, compareKey などが出力されます
List<string> traceLines = [];

CompareConfiguration configuration = new()
{
    TraceLog = traceLines.Add,
};

CompareResult<ProductModel> result = ParallelCompareApi.Compare(models, configuration);

trace 例:

phase=metadata path=ProductModel.Items declaredType=System.Collections.Generic.List`1[ProductItem] container=List
phase=container path=ProductModel.Items container=List elementType=ProductItem compareKey=Id
phase=container path=ProductModel.Items modelIndex=0 container=List runtimeType=System.Collections.Generic.List`1[ProductItem] materializedCount=2

String Key Policy

  • Default string key comparison is StringComparison.Ordinal
  • StringKeyComparison = StringComparison.OrdinalIgnoreCase is supported
  • Under OrdinalIgnoreCase, diagnostic KeyText is canonicalized to the StringComparer.Ordinal minimum representation among equivalent candidates

Quick Start

dotnet build SSC.sln -c Release
dotnet test SSC.sln -c Release --verbosity minimal

Minimal Example

using System.Collections.Generic;
using SSC;

ProductModel[] models =
{
    new ProductModel
    {
        Items =
        [
            new ProductItem { Id = 1, Price = 100 },
            new ProductItem { Id = 2, Price = 200 },
        ],
    },
    new ProductModel
    {
        Items =
        [
            new ProductItem { Id = 2, Price = 250 },
            new ProductItem { Id = 3, Price = 300 },
        ],
    },
};

CompareResult<ProductModel> result = ParallelCompareApi.Compare(models);
dynamic root = result.AsDynamic()!;

// item keys are normalized as union: 1, 2, 3
decimal? leftPriceAtKey1 = root.Items[0].Price[0];   // 100
decimal? rightPriceAtKey1 = root.Items[0].Price[1];  // null (missing)
decimal? leftPriceAtKey2 = root.Items[1].Price[0];   // 200
decimal? rightPriceAtKey2 = root.Items[1].Price[1];  // 250
ValueState stateAtKey3Left = (ValueState)root.Items[2].GetState(0); // Missing

public sealed class ProductModel
{
    public List<ProductItem> Items { get; init; } = [];
}

public sealed class ProductItem
{
    [CompareKey]
    public int Id { get; init; }

    public decimal Price { get; init; }
}

Path and Diff APIs

SSC provides three related path APIs. Choose the API according to whether you need to address the comparison tree, enumerate differences, or assign consumer-defined labels.

Purpose API Path meaning
Read a comparison node, value, or state GetNodeByPath(), GetValueByPath(), GetStateByPath() Standard XPath-like node address
Enumerate structured differences GetDiffEntries() Standard diff-entry path generated by SSC
Relabel and search differences for reports or filters GetDiffEntryPathProjections() Consumer-defined projected path; not a node address

Standard XPath-like Path Access

Use standard XPath-like paths when you want to read a node, value, or state without dynamic or generated projections. Paths are root-relative by default, and a root type prefix is optional.

IParallelNode? priceNode = result.GetNodeByPath("Items[2].Price");

object? leftPrice = result.GetValueByPath("ProductModel.Items[2].Price", 0); // 200
object? rightPrice = result.GetValueByPath("Items[2].Price", 1);             // 250
ValueState rightState = result.GetStateByPath("Items[1].Price", 1);          // Missing

These APIs address the comparison tree. They do not accept a projected path created by IParallelDiffPathProjector.

Structured Diff Entries

GetDiffEntries() returns only detected differences as ParallelDiffEntry objects. Each entry contains its standard Path, optional ParentPath, Kind, associated node, and one ParallelDiffValue per model slot.

IReadOnlyList<ParallelDiffEntry> entries = result.GetDiffEntries();
ParallelDiffEntry priceDiff = entries.Single(entry => entry.Path == "Items[2].Price");

foreach (ParallelDiffValue value in priceDiff.Values)
{
    Console.WriteLine($"slot={value.ModelIndex}, value={value.Value}, state={value.State}");
}

Console.WriteLine(priceDiff);
// Items[2].Price: [0]=200(Mismatched), [1]=250(Mismatched)

For ParallelDiffEntryKind.Node, GetNodeByPath(entry.Path) normally resolves the same node. The legacy base-compatible empty compare-key selector ([]) preserves standard path text but is not parseable for lookup, so node and parent-path lookup are not guaranteed for that case.

For ParallelDiffEntryKind.ContainerPresence, Node is null. The path identifies the container member for display, while each slot state distinguishes an existing container from a missing container.

CompareResult<OptionalProductModel> emptyContainerResult = ParallelCompareApi.Compare(
[
    new OptionalProductModel { Items = [] },
    new OptionalProductModel { Items = null },
]);

ParallelDiffEntry containerDiff = emptyContainerResult
    .GetDiffEntries()
    .Single(entry => entry.Kind == ParallelDiffEntryKind.ContainerPresence);

IParallelNode? unresolved = emptyContainerResult.GetNodeByPath(containerDiff.Path); // null

public sealed class OptionalProductModel
{
    public List<ProductItem>? Items { get; init; }
}

Consumer-defined Projected Diff Paths

Use GetDiffEntryPathProjections() when reports, exports, or filters need a path vocabulary different from SSC's standard path. An IParallelDiffPathProjector receives each standard segment with traversal context and chooses to keep, replace, or omit it.

public sealed class ReportPathProjector : IParallelDiffPathProjector
{
    public ParallelDiffPathSegmentProjection Project(ParallelDiffPathProjectionContext context)
    {
        return context.Current.StandardSegment.MemberName == "Items"
            ? ParallelDiffPathSegmentProjection.Replace(
                context.Current.StandardSegment.WithMemberName("Product"))
            : ParallelDiffPathSegmentProjection.KeepStandard();
    }
}

ReportPathProjector projector = new();
IReadOnlyList<ParallelDiffEntryPathProjection> projections =
    result.GetDiffEntryPathProjections(projector);

A projection retains the original Entry and adds ProjectedPath and ProjectedParentPath. It also exposes model-slot values and states directly.

ParallelDiffEntryPathProjection projection = projections[0];

int modelCount = projection.Count;
object? before = projection[0];
object? after = projection[1];
ValueState afterState = projection.GetState(1);

string standardPath = projection.Entry.Path;
string reportPath = projection.ProjectedPath;

ProjectedPath is a display, classification, and filtering label. It is not accepted by GetNodeByPath() and is not converted back to a standard path.

Search projected paths

Use the string overload for ordinal exact matching and the ParallelDiffPathPattern overload for wildcard matching.

IReadOnlyList<ParallelDiffEntryPathProjection> exact =
    result.GetDiffEntryPathProjections(projector, "Product[2].Price");

ParallelDiffPathPattern pattern =
    ParallelDiffPathPattern.Parse("Product[*].Price");

IReadOnlyList<ParallelDiffEntryPathProjection> matched =
    result.GetDiffEntryPathProjections(projector, pattern);

Different standard entries may project to the same path. Search results therefore preserve duplicates and the original diff-entry order. Use projection.Entry.Path when the standard location is also required.

If a projector omits every segment of an entry, projection throws InvalidOperationException. Exceptions raised by the projector are propagated to the caller. A negative model index or an index greater than or equal to Count throws ArgumentOutOfRangeException.

Source Generator Example

using System.Collections.Generic;
using System.Linq;
using SSC;
using SSC.Generated;

[GenerateParallelView]
public sealed class Dataset
{
    public List<Group> Groups { get; init; } = [];
}

public sealed class Group
{
    [CompareKey]
    public int GroupId { get; init; }

    public List<Item> Items { get; init; } = [];
}

public sealed class Item
{
    [CompareKey]
    public int ItemId { get; init; }

    public double MetricA { get; init; }
}

Dataset[] models =
{
    new Dataset
    {
        Groups =
        [
            new Group
            {
                GroupId = 1,
                Items =
                [
                    new Item { ItemId = 100, MetricA = 1.0 },
                    new Item { ItemId = 200, MetricA = 2.0 },
                ],
            },
            new Group
            {
                GroupId = 2,
                Items =
                [
                    new Item { ItemId = 210, MetricA = 21.0 },
                    new Item { ItemId = 220, MetricA = 22.0 },
                ],
            },
        ],
    },
    new Dataset
    {
        Groups =
        [
            new Group
            {
                GroupId = 1,
                Items =
                [
                    new Item { ItemId = 100, MetricA = 10.0 },
                    new Item { ItemId = 300, MetricA = 30.0 },
                ],
            },
            new Group
            {
                GroupId = 2,
                Items =
                [
                    new Item { ItemId = 210, MetricA = 21.0 },
                    new Item { ItemId = 230, MetricA = 23.0 },
                ],
            },
        ],
    },
    new Dataset
    {
        Groups =
        [
            new Group
            {
                GroupId = 1,
                Items =
                [
                    new Item { ItemId = 100, MetricA = 100.0 },
                    new Item { ItemId = 400, MetricA = 40.0 },
                ],
            },
            new Group
            {
                GroupId = 2,
                Items =
                [
                    new Item { ItemId = 210, MetricA = 21.0 },
                    new Item { ItemId = 240, MetricA = 24.0 },
                ],
            },
        ],
    },
};

CompareResult<Dataset> result = ParallelCompareApi.Compare(models);
double? leftMetricAt100 = result.AsGeneratedView()!.Groups[0].Items[0].MetricA[0];
ValueState rightStateAt200 = result.AsGeneratedView()!.Groups[0].Items[1].MetricA.GetState(1);

int[] groupIds = result.AsGeneratedView()!.Groups
    .Select(group => group.GroupId[0] ?? group.GroupId[1] ?? group.GroupId[2] ?? -1)
    .ToArray();

int[] itemIds = result.AsGeneratedView()!.Groups
    .SelectMany(group => group.Items)
    .Select(item => item.ItemId[0] ?? item.ItemId[1] ?? item.ItemId[2] ?? -1)
    .ToArray();

int[] mismatchedItemIds = result.AsGeneratedView()!.Groups
    .SelectMany(group => group.Items)
    .Where(item => item.MetricA.GetState(0) == ValueState.Mismatched
        || item.MetricA.GetState(1) == ValueState.Mismatched
        || item.MetricA.GetState(2) == ValueState.Mismatched)
    .Select(item => item.ItemId[0] ?? item.ItemId[1] ?? item.ItemId[2] ?? -1)
    .ToArray();

Documentation

  • Design guide: doc/design/
  • Draft notes: doc/draft/
  • Progress tracking: tasks/
  • Work reports: reports/

License

MIT (LICENSE)

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net8.0

    • 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
0.5.0 0 8/1/2026
0.4.6-pre 0 8/1/2026
0.4.5-pre 0 8/1/2026
0.4.4-pre 195 7/19/2026
0.4.3-pre 159 7/12/2026
0.4.2-pre 86 7/11/2026
0.4.1-pre 91 7/11/2026
0.4.0 119 6/26/2026
0.3.9-pre 97 6/25/2026
0.3.8-pre 96 6/25/2026
0.3.7-pre 99 6/25/2026
0.3.6-pre 106 6/25/2026
0.3.5-pre 102 6/24/2026
0.3.4-pre 106 6/24/2026
0.3.3-pre 98 6/24/2026
0.3.2-pre 101 6/23/2026
0.3.1-pre 103 6/23/2026
0.3.0 112 4/21/2026
0.2.7-pre 93 4/21/2026
0.2.6-pre 96 4/21/2026
Loading failed