Custom object models

edit

To query a tree that is not a System.Text.Json type, such as the model of a YAML or TOML parser or your own configuration objects, implement a JsonPathNavigator<TValue>. The query runs on your values directly: there is no conversion to JSON first.

Evaluate a query on your model

Pass the root value and the navigator to Evaluate or EvaluateValue:

var myRoot = MyNode.Object(new()
{
    ["items"] = MyNode.Array(
        MyNode.Object(new() { ["name"] = MyNode.String("a"), ["enabled"] = MyNode.Boolean(true) }),
        MyNode.Object(new() { ["name"] = MyNode.String("b"), ["enabled"] = MyNode.Boolean(false) })),
});

var path = JsonPath.Parse("$.items[?@.enabled == true]");
var result = path.Evaluate(root: myRoot, navigator: MyNodeNavigator.Instance);

var matches = new List<string>();
foreach (var match in result)
{
    MyNode? node = match.Value;
    matches.Add($"{match.Path}: {node?.Properties["name"]?.Value}");
}

Assert.Equal(["$['items'][0]: a"], matches);

The result is a JsonPathResult<TValue>: each match has the Value of your type and its normalized Path. Every part of the syntax works, filters and functions included, and the evaluation modes apply as for JSON.

Write the navigator

Here, MyNode is a small object model and MyNodeNavigator exposes it to JSONPath:

internal sealed class MyNode
{
    private MyNode(JsonPathNodeKind kind) => Kind = kind;

    public JsonPathNodeKind Kind { get; }
    public object? Value { get; private init; } // string, double or bool
    public Dictionary<string, MyNode?> Properties { get; private init; } = [];
    public List<MyNode?> Items { get; private init; } = [];

    public static MyNode Object(Dictionary<string, MyNode?> properties) => new(JsonPathNodeKind.Object) { Properties = properties };
    public static MyNode Array(params MyNode?[] items) => new(JsonPathNodeKind.Array) { Items = [.. items] };
    public static MyNode String(string value) => new(JsonPathNodeKind.String) { Value = value };
    public static MyNode Number(double value) => new(JsonPathNodeKind.Number) { Value = value };
    public static MyNode Boolean(bool value) => new(JsonPathNodeKind.Boolean) { Value = value };
}

internal sealed class MyNodeNavigator : JsonPathNavigator<MyNode>
{
    public static MyNodeNavigator Instance { get; } = new();

    // A null node is JSON null
    public override JsonPathNodeKind GetKind(MyNode? value) => value?.Kind ?? JsonPathNodeKind.Null;

    public override IEnumerable<JsonPathProperty<MyNode>> GetProperties(MyNode? value)
        => value?.Properties.Select(property => new JsonPathProperty<MyNode>(property.Key, property.Value)) ?? [];

    public override bool TryGetPropertyValue(MyNode? value, string name, out MyNode? result)
    {
        result = null;
        return value is not null && value.Properties.TryGetValue(name, out result);
    }

    public override int GetArrayLength(MyNode? value) => value?.Items.Count ?? 0;

    public override bool TryGetElement(MyNode? value, int index, out MyNode? result)
    {
        if (value is not null && index >= 0 && index < value.Items.Count)
        {
            result = value.Items[index];
            return true;
        }

        result = null;
        return false;
    }

    public override bool TryGetString(MyNode? value, out string? result)
    {
        result = value?.Value as string;
        return result is not null;
    }

    public override bool TryGetNumber(MyNode? value, out double result)
    {
        if (value?.Value is double number)
        {
            result = number;
            return true;
        }

        result = 0;
        return false;
    }

    public override bool TryGetBoolean(MyNode? value, out bool result)
    {
        if (value?.Value is bool boolean)
        {
            result = boolean;
            return true;
        }

        result = false;
        return false;
    }
}

A navigator answers the questions the evaluator asks about a value:

MethodReturns
GetKindWhether the value is an object, an array, a string, a number, a Boolean or null (JsonPathNodeKind)
GetPropertiesThe members of an object, as JsonPathProperty<TValue> (name and value), in their order
TryGetPropertyValueThe value of one member of an object, and whether the member exists
GetArrayLengthThe number of elements of an array
TryGetElementThe element at a zero-based index, and whether it exists
HasConstantTimeElementAccessWhether reading an element by index takes constant time
CopyElementsCopies array elements in index order when indexed access is not constant time
HasConstantTimePropertyLookupWhether looking up an object member by name takes constant time
TryGetString, TryGetNumber, TryGetBooleanThe scalar value, and whether the value has that type

Rules for a navigator

  • Represent JSON null your way. null or a dedicated value of your model can stand for JSON null: GetKind says which values are null. The matches then hold that value.
  • Return false for what does not exist. TryGetPropertyValue and TryGetElement return false for a missing member or an index outside the array; this is what lax mode turns into "no match" and strict mode into an exception.
  • Keep the order stable. The order of GetProperties is the order of the wildcard, descendant and filter results.
  • Numbers are double. TryGetNumber returns a double, so filters compare your numbers as double values; see Number precision.
  • Expose a tree, not a graph. A cycle, such as a back-reference to the parent, is reported as a depth error (JsonPathEvaluationException) when a descendant segment or a comparison walks it, instead of looping forever.
  • Keep array traversal linear. If TryGetElement is not constant-time, override HasConstantTimeElementAccess to return false and implement CopyElements to copy the array in index order. The default copies by calling TryGetElement for every index.
  • Keep object equality linear. If TryGetPropertyValue is not constant-time, override HasConstantTimePropertyLookup to return false. The evaluator then builds a lookup table from the object's members when comparing objects.