Построить дерево выражений реляционной группировки c # - PullRequest
3 голосов
/ 10 апреля 2019

Контекст:

Используя Ag-Grid, пользователи должны иметь возможность перетаскивать столбцы, по которым они хотят сгруппироваться.

enter image description here

Допустим, у меня есть следующая модель и сгруппированы по функциям:

List<OrderModel> orders = new List<OrderModel>()
{
    new OrderModel()
    {
        OrderId = 184214,
        Contact = new ContactModel()
        {
            ContactId = 1000
        }
    }
};

var queryOrders = orders.AsQueryable();

Редактировать: Таким образом, люди заставили меня осознать, что в приведенном ниже вопросе я на самом деле фокусировался на Select правильных предметах (что является одним из требований), я упустил фактическое выполнение группировка. Поэтому были внесены некоторые изменения, чтобы отразить обе проблемы: группировка и отбор, строго типизированный.

Типизированным образом:

Один столбец

IQueryable<OrderModel> resultQueryable = queryOrders
    .GroupBy(x => x.ExclPrice)
    .Select(x => new OrderModel() { ExclPrice = x.Key.ExclPrice});

Несколько столбцов

 IQueryable<OrderModel> resultQueryable = queryOrders
            .GroupBy(x => new OrderModel() { Contact = new ContactModel(){ ContactId = x.Contact.ContactId }, ExclPrice = x.ExclPrice})
            .Select(x => new OrderModel() {Contact = new ContactModel() {ContactId = x.Key.Contact.ContactId}, ExclPrice = x.Key.ExclPrice});

Однако последний не работает, определение OrderModel в GroupBy, очевидно, вызывает проблемы при переводе его в SQL.

Как мне построить это GroupBy / Select, используя выражения?

В настоящее время я до сих пор выбираю правильные элементы, но группировка пока не производится.

public static IQueryable<TModel> GroupByExpression(List<string> propertyNames, IQueryable<TModel> sequence)
{
    var param = Expression.Parameter(typeof(TModel), "item");
    var body = Expression.New(typeof(TModel).GetConstructors()[0]);
    var bindings = new List<MemberAssignment>();
    foreach (var property in propertyNames)
    {
        var fieldValue = typeof(TModel).GetProperty(property, BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.IgnoreCase);

        var fieldValueOriginal = Expression.Property(param, fieldValue ?? throw new InvalidOperationException());

        var memberAssignment = Expression.Bind(fieldValue, fieldValueOriginal);
        bindings.Add(memberAssignment);
    }
    var result = sequence.Select(Expression.Lambda<Func<TModel, TModel>>(Expression.MemberInit(body, bindings), param));
    return result;
}

Это работает нормально, пока я не хочу ввести отношения, поэтому в моем примере, item.Contact.ContactId.

Я пытался сделать это так:

public static IQueryable<TModel> GroupByExpression(List<string> propertyNames, IQueryable<TModel> sequence)
{
    var param = Expression.Parameter(typeof(TModel), "item");
    Expression propertyExp = param;
    var body = Expression.New(typeof(TModel).GetConstructors()[0]);
    var bindings = new List<MemberAssignment>();
    foreach (var property in propertyNames)
    {
        if (property.Contains("."))
        {
            //support nested, relation grouping
            string[] childProperties = property.Split('.');
            var prop = typeof(TModel).GetProperty(childProperties[0], BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.IgnoreCase);
            propertyExp = Expression.MakeMemberAccess(param, prop);
            //loop over the rest of the childs until we have reached the correct property
            for (int i = 1; i < childProperties.Length; i++)
            {
                prop = prop.PropertyType.GetProperty(childProperties[i],
                    BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.IgnoreCase);
                propertyExp = Expression.MakeMemberAccess(propertyExp, prop);

                if (i == childProperties.Length - 1)//last item, this would be the grouping field item
                {
                    var memberAssignment = Expression.Bind(prop, propertyExp);
                    bindings.Add(memberAssignment);
                }
            }
        }
        else
        {
            var fieldValue = typeof(TModel).GetProperty(property, BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.IgnoreCase);

            var fieldValueOriginal = Expression.Property(param, fieldValue ?? throw new InvalidOperationException());

            var memberAssignment = Expression.Bind(fieldValue, fieldValueOriginal);
            bindings.Add(memberAssignment);
        }


    }
    var memInitExpress = Expression.MemberInit(body, bindings);
    var result = sequence.Select(Expression.Lambda<Func<TModel, TModel>>(memInitExpress, param));
    return result;
}

Может показаться многообещающим, но, к сожалению, выдает ошибку на var memInitExpress = Expression.MemberInit(body, bindings);

ArgumentException '' ContactId 'не является членом типа' OrderModel ''

Вот так выглядит выражение при группировании по нескольким столбцам:

Результат Expression.MemberInit(body, bindings): {new OrderModel() {TotalInclPrice = item.TotalInclPrice, OrderId = item.OrderId}}

Итак, все выражение выглядит так: {item => new OrderModel() {TotalInclPrice = item.TotalInclPrice, OrderId = item.OrderId}}

Так что теперь не так сложно понять, почему я получил упомянутое исключение просто потому, что он использует OrderModel для выбора свойств, а ContactId отсутствует в этой модели. Однако я ограничен и должен придерживаться IQueryable<OrderModel>, поэтому теперь вопрос заключается в том, как создать выражение для группировки по ContactId, используя ту же модель. Я предполагаю, что на самом деле мне нужно выражение:

Результат Expression.MemberInit(body, bindings) должен быть: {new OrderModel() { Contact = new ContactModel() { ContactId = item.Contact.ContactId} , OrderId = item.OrderId}}. Как то так?

Итак, я подумал, что давайте вернемся к основам и сделаем это шаг за шагом. В конце концов цикл for создает следующее выражение. См. Мой ответ Как я решаю эту часть, Ответ Ивана , кажется, решил эту проблему в общих чертах, но я еще не тестировал этот код. Однако это еще не делает группировку, поэтому после применения группировки эти ответы могут больше не работать.

К вашему сведению: AgGrid может найти отношения свойств, просто указав поле столбца contact.contactId. Поэтому, когда данные загружаются, он просто пытается найти это свойство. Я думаю, что когда вышеприведенное выражение будет создано, оно будет работать в Grid. Сейчас я тоже пытаюсь создать подпункты MemberInit, потому что я думаю, что это решение для того, чтобы успешно это сделать.

Ответы [ 2 ]

2 голосов
/ 10 апреля 2019

Если идея состоит в том, чтобы динамически создать вложенный селектор MemberInit, это можно сделать следующим образом:

public static class QueryableExtensions
{
    public static IQueryable<T> SelectMembers<T>(this IQueryable<T> source, IEnumerable<string> memberPaths)
    {
        var parameter = Expression.Parameter(typeof(T), "item");
        var body = parameter.Select(memberPaths.Select(path => path.Split('.')));
        var selector = Expression.Lambda<Func<T, T>>(body, parameter);
        return source.Select(selector);
    }

    static Expression Select(this Expression source, IEnumerable<string[]> memberPaths, int depth = 0)
    {
        var bindings = memberPaths
            .Where(path => depth < path.Length)
            .GroupBy(path => path[depth], (name, items) =>
            {
                var item = Expression.PropertyOrField(source, name);
                return Expression.Bind(item.Member, item.Select(items, depth + 1));
            }).ToList();
        if (bindings.Count == 0) return source;
        return Expression.MemberInit(Expression.New(source.Type), bindings);
    }
}

В основном рекурсивно обрабатывают пути к элементам, группируют каждый уровень по имени элемента и привязывают элемент либо к исходному выражению, либо к MemberInit исходного выражения.

1 голос
/ 10 апреля 2019

Этот ответ состоит из двух частей:

  1. Создайте выражение GroupBy и убедитесь, что используется тот же тип возврата.
  2. Создайте выражение Select изрезультат выражения GroupBy

SELECT & GROUPING - неуниверсальный

Итак, полное решение приведено ниже, но чтобы дать вам представление окак это работает, смотрите этот фрагмент кода, это написано в неуниверсальной версии.Код для группировки практически одинаков, крошечная разница в том, что к началу добавлено свойство Key..

public static IQueryable<TModel> GroupByExpression(List<string> propertyNames, IQueryable<TModel> sequence)
{
    var param = Expression.Parameter(typeof(TModel), "item");
    Expression propertyExp = param;
    var body = Expression.New(typeof(TModel).GetConstructors()[0]);
    var bindings = new List<MemberAssignment>();
    var queryOrders = orders.AsQueryable();
    var orderBindings = new List<MemberAssignment>();

    //..more code was here, see question

    var orderParam = Expression.Parameter(typeof(OrderModel), "item");
    Expression orderPropertyExp = orderParam;
    var orderPropContact = typeof(OrderModel).GetProperty("Contact");
    orderPropertyExp = Expression.MakeMemberAccess(orderPropertyExp, orderPropContact);
    var orderPropContactId = orderPropContact.PropertyType.GetProperty("ContactId");
    orderPropertyExp = Expression.MakeMemberAccess(orderPropertyExp, orderPropContactId);

    var contactBody = Expression.New(typeof(ContactModel).GetConstructors()[0]);
    var contactMemerAssignment = Expression.Bind(orderPropContactId, propertyExp);
    orderBindings.Add(contactMemerAssignment);
    var contactMemberInit = Expression.MemberInit(Expression.New(contactBody, orderBindings);

    var orderContactMemberAssignment = Expression.Bind(orderPropContact, contactMemberInit);

    var orderMemberInit = Expression.MemberInit(Expression.New(typeof(OrderModel).GetConstructors()[0]), new List<MemberAssignment>() {orderContactMemberAssignment});

    //during debugging with the same model, I know TModel is OrderModel, so I can cast it
    //of course this is just a quick hack to verify it is working correctly in AgGrid, and it is!
    return (IQueryable<TModel>)queryOrders.Select(Expression.Lambda<Func<OrderModel, OrderModel>>(orderMemberInit, param));
}

Так что теперь нам нужно сделать это общим способом.

Группировка:

Чтобы сделать группировку в общем виде, я нашел этот удивительный пост , он заслуживает кучу кредитов для разработки этой части.Однако я должен был изменить это, чтобы удостовериться, что это также поддерживает подчиненные отношения.В моем примере: Order.Contact.ContactId.

Сначала я написал этот рекурсивный метод для правильного получения привязок MemberAssignment.

    /// <summary>
    /// Recursive get the MemberAssignment
    /// </summary>
    /// <param name="param">The initial paramter expression: var param =  Expression.Parameter(typeof(T), "item");</param>
    /// <param name="baseType">The type of the model that is being used</param>
    /// <param name="propEx">Can be equal to 'param' or when already started with the first property, use:  Expression.MakeMemberAccess(param, prop);</param>
    /// <param name="properties">The child properties, so not all the properties in the object, but the sub-properties of one property.</param>
    /// <param name="index">Index to start at</param>
    /// <returns></returns>
    public static MemberAssignment RecursiveSelectBindings(ParameterExpression param, Type baseType, Expression propEx, string[] properties, int index)
    {
        //Get the first property from the list.
        var prop = baseType.GetProperty(properties[index], BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.IgnoreCase);
        var leftProperty = prop;
        Expression selectPropEx = Expression.MakeMemberAccess(propEx, prop);
        //If this is the last property, then bind it and return that Member assignment
        if (properties.Length - 1 == index)
        {
            var memberAssignment = Expression.Bind(prop, selectPropEx);
            return memberAssignment;
        }

        //If we have more sub-properties, make sure the sub-properties are correctly generated.
        //Generate a "new Model() { }"
        NewExpression selectSubBody = Expression.New(leftProperty.PropertyType.GetConstructors()[0]);
        //Get the binding of the next property (recursive)
        var getBinding = RecursiveSelectBindings(param, prop.PropertyType, selectPropEx, properties, index + 1);

        MemberInitExpression selectSubMemberInit =
            Expression.MemberInit(selectSubBody, new List<MemberAssignment>() { getBinding });

        //Finish the binding by generating "new Model() { Property = item.Property.Property } 
        //During debugging the code, it will become clear what is what.
        MemberAssignment selectSubMemberAssignment = Expression.Bind(leftProperty, selectSubMemberInit);

        return selectSubMemberAssignment;
    }

Затем я мог бы изменить метод Select<T>пост , о котором я упоминал :

    static Expression Select<T>(this IQueryable<T> source, string[] fields)
    {
        var itemType = typeof(T);
        var groupType = itemType; //itemType.Derive();
        var itemParam = Expression.Parameter(itemType, "x");


        List<MemberAssignment> bindings = new List<MemberAssignment>();
        foreach (var property in fields)
        {
            Expression propertyExp;
            if (property.Contains("."))
            {
                string[] childProperties = property.Split('.');
                var binding = RecursiveSelectBindings(itemParam, itemType, itemParam, childProperties, 0);
                bindings.Add(binding);
            }
            else
            {
                var fieldValue = groupType.GetProperty(property, BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.IgnoreCase);
                var fieldValueOriginal = Expression.Property(itemParam, fieldValue ?? throw new InvalidOperationException());

                var memberAssignment = Expression.Bind(fieldValue, fieldValueOriginal);
                bindings.Add(memberAssignment);
            }
        }

        var selector = Expression.MemberInit(Expression.New(groupType), bindings.ToArray());
        return Expression.Lambda(selector, itemParam);
    }

Этот код выше вызывается кодом ниже (который я не модифицировал), но вы можете видеть, что он возвращает IQueryable<IGrouping<T,T>> тип.

    static IQueryable<IGrouping<T, T>> GroupEntitiesBy<T>(this IQueryable<T> source, string[] fields)
    {
        var itemType = typeof(T);
        var method = typeof(Queryable).GetMethods()
                     .Where(m => m.Name == "GroupBy")
                     .Single(m => m.GetParameters().Length == 2)
                     .MakeGenericMethod(itemType, itemType);

        var result = method.Invoke(null, new object[] { source, source.Select(fields) });
        return (IQueryable<IGrouping<T, T>>)result;
    }

SELECT

Итак, мы выполнили выражение GroupBy, теперь нам нужно сделать выражение Select.Как я уже говорил, он почти равен GroupBy, единственное отличие состоит в том, что мы должны добавить Key. перед каждым свойством.Это потому, что Key является результатом GroupBy, поэтому вам нужно начать с этого.

    public static IQueryable<TModel> GroupByExpression(List<string> propertyNames, IQueryable<TModel> sequence)
    {
       var grouping = sequence.GroupBy(propertyNames.ToArray());

        var selectParam = Expression.Parameter(grouping.ElementType, "item");
        Expression selectPropEx = selectParam;
        var selectBody = Expression.New(typeof(TModel).GetConstructors()[0]);
        var selectBindings = new List<MemberAssignment>();
        foreach (var property in propertyNames)
        {
            var keyProp = "Key." + property;
            //support nested, relation grouping
            string[] childProperties = keyProp.Split('.');
            var prop = grouping.ElementType.GetProperty(childProperties[0], BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.IgnoreCase);
            selectPropEx = Expression.MakeMemberAccess(selectParam, prop);

            var binding = PropertyGrouping.RecursiveSelectBindings(selectParam, prop.PropertyType, selectPropEx, childProperties, 1);
            selectBindings.Add(binding);
        }

        MemberInitExpression selectMemberInit = Expression.MemberInit(selectBody, selectBindings);

        var queryable = grouping
            .Select(Expression.Lambda<Func<IGrouping<TModel, TModel>, TModel>>(selectMemberInit, selectParam));
        return queryable;

    }

GetHashCode ()

К сожалению,это все еще не работало, пока я не начал внедрять GetHasCode() и Equals() в каждой используемой модели.Во время Count() или выполнения запроса с помощью .ToList() он будет сравнивать все объекты, чтобы убедиться, что объекты равны (или нет) друг другу.Если они равны: та же группа.Но поскольку мы сгенерировали эти модели на лету, у них нет способа правильно сравнить эти объекты в зависимости от места в памяти (по умолчанию).

К счастью, вы можете очень легко сгенерировать эти 2 метода:

https://docs.microsoft.com/en-us/visualstudio/ide/reference/generate-equals-gethashcode-methods?view=vs-2019

Убедитесь, что включены по крайней мере все свойства, которые вы будете использовать в таблице (и могут быть сгруппированы).

...