Использование JSONPath для фильтрации свойств в документах JSON

У меня есть произвольно определенный документ JSON, и я хочу иметь возможность применить выражение JSONPath в виде белого списка фильтр для свойств: все выбранные узлы и их предки до корневого узла остаются, все остальные узлы удаляются. Если узлов не существует, я должен получить пустой документ.

Кажется, что в JSON.Net не было ничего похожего на это, и я нигде не мог найти подобных примеров, поэтому я создал свой собственный. Я решил скопировать выбранные узлы во вновь созданный документ, а не пытаться удалить все узлы, которые не совпадают. Учитывая, что может быть несколько совпадений, а документы могут быть большими, необходимо иметь возможность эффективно обрабатывать объединение результатов множественного выбора в единый документ дерева / JSON.

Моя попытка вроде работает, но я получаю странные результаты. Процесс включает MergedAncestry метод, который выполняет итерацию по SelectTokens результатам, вызывает GetFullAncestry (который рекурсивно строит дерево до этого узла), а затем объединяет результаты. Однако похоже, что слияние JArrays происходит на неправильном уровне, как вы можете видеть в разделе «Фактические результаты» ниже.

Мои вопросы:

  1. Есть ли лучший / более быстрый / встроенный способ добиться этого?
  2. Если нет, то что я делаю не так?

Код:

public static void Main()
{
    string json = @"..."; // snipped for brevity - see DotNetFiddle: https://dotnetfiddle.net/wKN1Hj
    var root = (JContainer)JToken.Parse(json);
    var t3 = root.SelectTokens("$.Array3B.[*].Array3B1.[*].*");

    // See DotNetFiddle for simpler examples that work
    Console.WriteLine($"{MergedAncestry(t3).ToString()}");  // Wrong output!

    Console.ReadKey();
}

// Returns a single document merged using the full ancestry of each of the input tokens
static JToken MergedAncestry(IEnumerable<JToken> tokens)
{
    JObject merged = null;
    foreach(var token in tokens)
    {
        if (merged == null)
        {
            // First object
            merged = (JObject)GetFullAncestry(token);
        }
        else
        {
            // Subsequent objects merged
            merged.Merge((JObject)GetFullAncestry(token), new JsonMergeSettings
            {
                // union array values together to avoid duplicates
                MergeArrayHandling = MergeArrayHandling.Union
            });
        }
    }
    return merged ?? new JObject();
}

// Recursively builds a new tree to the node matching the ancestry of the original node
static JToken GetFullAncestry(JToken node, JToken tree = null)
{
    if (tree == null)
    {
        // First level: start by cloning the current node
        tree = node?.DeepClone();
    }

    if (node?.Parent == null)
    {
        // No parents left, return the tree we've built
        return tree;
    }

    // Rebuild the parent node in our tree based on the type of node
    JToken a;
    switch (node.Parent)
    {
        case JArray _:
            return GetFullAncestry(node.Parent, new JArray(tree));
        case JProperty _:
            return GetFullAncestry(node.Parent, new JProperty(((JProperty)node.Parent).Name, tree));
        case JObject _:
            return GetFullAncestry(node.Parent, new JObject(tree));
        default:
            return tree;
    }
}

Пример JSON:

{
  "Array3A": [
    { "Item_3A1": "Desc_3A1" }
  ],
  "Array3B": [
    { "Item_3B1": "Desc_3B1" },
    {
      "Array3B1": [
        { "Item_1": "Desc_3B11" },
        { "Item_2": "Desc_3B12" },
        { "Item_3": "Desc_3B13" }
      ]
    },
    {
      "Array3B2": [
        { "Item_1": "Desc_3B21" },
        { "Item_2": "Desc_3B22" },
        { "Item_3": "Desc_3B23" }
      ]
    }
  ]
}

Полный код и тесты см. В DotNetFiddle.

"Фильтр" JSONPath:

$.Array3B.[*].Array3B1.[*].*

Ожидаемые результаты:

{
    "Array3B": [
    {
        "Array3B1": [
        { "Item_1": "Desc_3B11" },
        { "Item_2": "Desc_3B12" },
        { "Item_3": "Desc_3B13" }
        ]
    }
    ]
}

Фактические результаты:

{
    "Array3B": [
    {
        "Array3B1": [ { "Item_1": "Desc_3B11" } ]
    },
    {
        "Array3B1": [ { "Item_2": "Desc_3B12" } ]
    },
    {
        "Array3B1": [ { "Item_3": "Desc_3B13" } ]
    }
    ]
}

person pcdev    schedule 14.08.2019    source источник
comment
Поразмыслив немного над этим, я могу понять, почему возникает результат слияния: технически это правильное слияние сверху вниз. Однако мне нужно слияние снизу вверх в контексте исходного документа, т.е. сохранение общих предков. Я работаю над решением, которое включает рекурсивное построение дерева от листовых узлов вверх, при этом распознавая общих предков, но был бы признателен, если бы у кого-нибудь были лучшие предложения!   -  person pcdev    schedule 14.08.2019
comment
Вы ищете JsonExtensions.RemoveAllExcept<TJToken>(this TJToken obj, IEnumerable<string> paths) из этого ответа на Как выполнить частичную сериализацию объекта с указанием« путей »с помощью Newtonsoft JSON.NET?   -  person dbc    schedule 17.08.2019
comment
Ух ты. Спасибо @dbc! Это именно то, что я искал! Я еще не пробовал проверить скорость, но думаю, что в большинстве случаев это будет работать намного лучше, чем восстановление копии дерева, как я сделал ниже. Посмотрю на него через день или два и посоветую, но я почти уверен, что он сделает то, что мне нужно. Спасибо еще раз!   -  person pcdev    schedule 18.08.2019
comment
@dbc: Ваш метод работает очень хорошо, спасибо. Я провел некоторое тестирование и, к моему большому удивлению, после исправления небольшой ошибки в моем коде кажется, что мой метод работает примерно так же, как ваш метод RemoveAllExcept, когда входные строки JSONPath соответствуют всем узлам, примерно на 50-100% быстрее, когда он соответствует половине узлов и на порядок быстрее при небольшом количестве совпадений (документ 8 МБ, 100 итераций). Не уничтожать оригинал будет полезно для моих целей, поэтому я буду придерживаться своего ответа, но я буду рад проголосовать за, если вы добавите свой ответ. Еще раз спасибо за ваш вклад!   -  person pcdev    schedule 19.08.2019
comment
@dbc DotNetFiddle Сравнение: dotnetfiddle.net/i5Qlam   -  person pcdev    schedule 19.08.2019


Ответы (1)


Хорошо, я нашел способ это сделать. Спасибо @dbc за предложения, улучшения и указание на проблемы.

Рекурсия в конечном итоге не должна была работать так хорошо, поскольку мне нужно было обеспечить соответствие всех узлов на одном уровне в дереве с общим родителем, тогда как потенциально могут быть входные узлы на любом уровне.

Я добавил метод фильтрации по нескольким путям JSONPath для вывода одного результирующего документа, так как это была первоначальная цель.

static JToken FilterByJSONPath(JToken document, IEnumerable<string> jPaths)
{
    var matches = jPaths.SelectMany(path => document.SelectTokens(path, false));
    return MergeAncestry(matches);
}

static JToken MergeAncestry(IEnumerable<JToken> tokens)
{
    if (tokens == null || !tokens.Any())
    {
        return new JObject();
    }

    // Get a dictionary of tokens indexed by their depth
    var tokensByDepth = tokens
        .Distinct(ObjectReferenceEqualityComparer<JToken>.Default)
        .GroupBy(t => t.Ancestors().Count())
        .ToDictionary(
            g => g.Key, 
            g => g.Select(node => new CarbonCopyToken { Original = node, CarbonCopy = node.DeepClone() })
                    .ToList());

    // start at the deepest level working up
    int depth = tokensByDepth.Keys.Max();
    for (int i = depth; i > 0; i--)
    {
        // If there's nothing at the next level up, create a list to hold parents of children at this level
        if (!tokensByDepth.ContainsKey(i - 1))
        {
            tokensByDepth.Add(i - 1, new List<CarbonCopyToken>());
        }

        // Merge all tokens at this level into families by common parent
        foreach (var parent in MergeCommonParents(tokensByDepth[i]))
        {
            tokensByDepth[i - 1].Add(parent);
        }
    }

    // we should be left with a list containing a single CarbonCopyToken - contining the root of our copied document and the root of the source
    var cc = tokensByDepth[0].FirstOrDefault();
    return cc?.CarbonCopy ?? new JObject();
}

static IEnumerable<CarbonCopyToken> MergeCommonParents(IEnumerable<CarbonCopyToken> tokens)
{
    var newParents = tokens.GroupBy(t => t.Original.Parent).Select(g => new CarbonCopyToken {
        Original = g.First().Original.Parent,
        CarbonCopy = CopyCommonParent(g.First().Original.Parent, g.AsEnumerable())
        });
    return newParents;
}

static JToken CopyCommonParent(JToken parent, IEnumerable<CarbonCopyToken> children)
{
    switch (parent)
    {
        case JProperty _:
            return new JProperty(((JProperty)parent).Name, children.First().CarbonCopy);
        case JArray _:
            var newParentArray = new JArray();
            foreach (var child in children)
            {
                newParentArray.Add(child.CarbonCopy);
            }
            return newParentArray;
        default: // JObject, or any other type we don't recognise
            var newParentObject = new JObject();
            foreach (var child in children)
            {
                newParentObject.Add(child.CarbonCopy);
            }
            return newParentObject;
    }

}

Обратите внимание, что он использует пару новых классов: CarbonCopyToken позволяет нам отслеживать узлы и их копии по мере того, как мы поднимаемся вверх по уровню дерева, и ObjectReferenceEqualityComparer<T>, который предотвращает дублирование с помощью метода Distinct (еще раз спасибо @dbc за указание на это):

public class CarbonCopyToken
{
    public JToken Original { get; set; }
    public JToken CarbonCopy { get; set; }
}

/// <summary>
/// A generic object comparerer that would only use object's reference, 
/// ignoring any <see cref="IEquatable{T}"/> or <see cref="object.Equals(object)"/>  overrides.
/// </summary>
public class ObjectReferenceEqualityComparer<T> : IEqualityComparer<T> where T : class
{
    // Adapted from this answer https://stackoverflow.com/a/1890230
    // to https://stackoverflow.com/questions/1890058/iequalitycomparert-that-uses-referenceequals
    // By https://stackoverflow.com/users/177275/yurik
    private static readonly IEqualityComparer<T> _defaultComparer;

    static ObjectReferenceEqualityComparer() { _defaultComparer = new ObjectReferenceEqualityComparer<T>(); }

    public static IEqualityComparer<T> Default { get { return _defaultComparer; } }

    #region IEqualityComparer<T> Members

    public bool Equals(T x, T y)
    {
        return ReferenceEquals(x, y);
    }

    public int GetHashCode(T obj)
    {
        return System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj);
    }

    #endregion
}

Пример использования:

List<string> filters = new {
    "$..Test1",
    "$.Path.To.[*].Some.Nodes",
    "$.Other.*.Nodes"
}
var result = FilterByJSONPath(inputDocument, filters);

DotNetFiddle показывает предыдущие тесты плюс один дополнительный: https://dotnetfiddle.net/ekABRI

person pcdev    schedule 15.08.2019
comment
Мне нравится тот факт, что ваш метод создает новое JToken дерево, а не обрезает входящее дерево. Но похоже, что это ошибка. Если я использую JSON и фильтры из Создание уменьшенного json из большего json в C #, тогда этот алгоритм дает другой результат, чем RemoveAllExcept() . См .: dotnetfiddle.net/JwwXjP - person dbc; 19.08.2019
comment
Проблема может заключаться в том, что JValue переопределяет Equals(), поэтому, если два разных JValue объекта имеют одинаковые значения, они могут быть объединены при добавлении в хеш-таблицу (напрямую или через .Distinct()). - person dbc; 19.08.2019
comment
@dbc спасибо за это, очень интересно, после прочтения вашего комментария я воспроизвел это поведение, но в моем случае это не будет проблемой. Я добавлю комментарий к этому ответу, чтобы прояснить это для других. - person pcdev; 19.08.2019
comment
@dbc, если подумать, это может быть проблемой. Теперь я также понимаю, почему ваше решение использовало ObjectReferenceEqualityComparer - отличное решение Equals проблемы. Я добавил тот же IEqualityComparer в Distinct, и теперь он дает те же результаты, что и ваше решение. Еще раз спасибо! - person pcdev; 20.08.2019