Construye una consulta de LINQ GroupBy usando árboles de expresiones

c# expression-trees linq

Pregunta

Me he quedado en este problema durante una semana y no se ha encontrado ninguna solución.

Tengo un POCO como abajo:

public class Journal {
    public int Id { get; set; }
    public string AuthorName { get; set; }
    public string Category { get; set; }
    public DateTime CreatedAt { get; set; }
}

Quiero saber, durante un intervalo de fechas específico (agrupado por meses o años), la cantidad de revistas contadas por un Nombre de autor o una Categoría.

Después de enviar el objeto consultado al JSON, el serializador generó los datos JSON como se muestra a continuación (solo uso de JSON para demostrar los datos que quiero obtener, cómo serializar un objeto a un JSON no es mi problema)

data: {
    '201301': {
        'Alex': 10,
        'James': 20
    },
    '201302': {
        'Alex': 1,
        'Jessica': 9
    }
}

O

data: {
    '2012': {
         'C#': 230
         'VB.NET': 120,
         'LINQ': 97
     },
     '2013': {
         'C#': 115
         'VB.NET': 29,
         'LINQ': 36
     }
}

Lo que sé es escribir una consulta LINQ en "método" como:

IQueryable<Journal> query = db.GroupBy(x=> new 
    {
        Year = key.CreatedAt.Year,
        Month = key.CreatedAt.Month
    }, prj => prj.AuthorName)
    .Select(data => new {
        Key = data.Key.Year * 100 + data.Key.Month, // very ugly code, I know
        Details = data.GroupBy(y => y).Select(z => new { z.Key, Count = z.Count() })
    });

Las condiciones agrupadas por meses o años, AuthorName o Category se pasarán por dos parámetros de método de tipo de cadena. Lo que no sé es cómo usar los parámetros de "Cadena Mágica" en un método GroupBy (). Después de algunas búsquedas en Google, parece que no puedo agrupar datos al pasar una cadena mágica como "AuthorName". Lo que debo hacer es crear un árbol de expresiones y pasarlo al método GroupBy ().

Cualquier solución o sugerencia es de agradecer.

Respuesta aceptada

Ooh, esto parece un problema divertido :)

Entonces, primero configuremos nuestra fuente falsa, ya que no tengo su base de datos a mano:

// SETUP: fake up a data source
var folks = new[]{"Alex", "James", "Jessica"};
var cats = new[]{"C#", "VB.NET", "LINQ"};
var r = new Random();
var entryCount = 100;
var entries = 
    from i in Enumerable.Range(0, entryCount)
    let id = r.Next(0, 999999)
    let person = folks[r.Next(0, folks.Length)]
    let category = cats[r.Next(0, cats.Length)]
    let date = DateTime.Now.AddDays(r.Next(0, 100) - 50)
    select new Journal() { 
        Id = id, 
        AuthorName = person, 
        Category = category, 
        CreatedAt = date };    

Ok, ahora tenemos un conjunto de datos con los que trabajar, veamos lo que queremos ... queremos algo con una "forma" como:

public Expression<Func<Journal, ????>> GetThingToGroupByWith(
    string[] someMagicStringNames, 
    ????)

Que tiene aproximadamente la misma funcionalidad que (en pseudo código):

GroupBy(x => new { x.magicStringNames })

Vamos a analizarlo una pieza a la vez. Primero, ¿cómo diablos hacemos esto dinámicamente?

x => new { ... }

El compilador hace la magia por nosotros normalmente; lo que hace es definir un nuevo Type , y podemos hacer lo mismo:

    var sourceType = typeof(Journal);

    // define a dynamic type (read: anonymous type) for our needs
    var dynAsm = AppDomain
        .CurrentDomain
        .DefineDynamicAssembly(
            new AssemblyName(Guid.NewGuid().ToString()), 
            AssemblyBuilderAccess.Run);
    var dynMod = dynAsm
         .DefineDynamicModule(Guid.NewGuid().ToString());
    var typeBuilder = dynMod
         .DefineType(Guid.NewGuid().ToString());
    var properties = groupByNames
        .Select(name => sourceType.GetProperty(name))
        .Cast<MemberInfo>();
    var fields = groupByNames
        .Select(name => sourceType.GetField(name))
        .Cast<MemberInfo>();
    var propFields = properties
        .Concat(fields)
        .Where(pf => pf != null);
    foreach (var propField in propFields)
    {        
        typeBuilder.DefineField(
            propField.Name, 
            propField.MemberType == MemberTypes.Field 
                ? (propField as FieldInfo).FieldType 
                : (propField as PropertyInfo).PropertyType, 
            FieldAttributes.Public);
    }
    var dynamicType = typeBuilder.CreateType();

Entonces, lo que hemos hecho aquí es definir un tipo desechable personalizado que tenga un campo para cada nombre que pasamos, que es el mismo tipo que (propiedad o campo) en el tipo de fuente. ¡Bonito!

Ahora, ¿cómo le damos a LINQ lo que quiere?

Primero, configuremos una "entrada" para la función que devolveremos:

// Create and return an expression that maps T => dynamic type
var sourceItem = Expression.Parameter(sourceType, "item");

Sabemos que tendremos que "renovar" uno de nuestros nuevos tipos dinámicos ...

Expression.New(dynamicType.GetConstructor(Type.EmptyTypes))

Y tendremos que inicializarlo con los valores provenientes de ese parámetro ...

Expression.MemberInit(
    Expression.New(dynamicType.GetConstructor(Type.EmptyTypes)),
    bindings), 

Pero, ¿qué diablos vamos a utilizar para los bindings ? Hmm ... bueno, queremos algo que se enlaza a las propiedades / campos correspondientes en el tipo de fuente, pero los vuelve a correlacionar con nuestros campos de tipo dynamicType ...

    var bindings = dynamicType
        .GetFields()
        .Select(p => 
            Expression.Bind(
                 p, 
                 Expression.PropertyOrField(
                     sourceItem, 
                     p.Name)))
        .OfType<MemberBinding>()
        .ToArray();

Oof ... de aspecto desagradable, pero aún no hemos terminado, por lo que debemos declarar un tipo de retorno para el Func que estamos creando a través de los árboles de Expresión ... cuando tenga dudas, ¡use el object !

Expression.Convert( expr, typeof(object))

Y finalmente, enlazaremos esto con nuestro "parámetro de entrada" a través de Lambda , haciendo que toda la pila:

    // Create and return an expression that maps T => dynamic type
    var sourceItem = Expression.Parameter(sourceType, "item");
    var bindings = dynamicType
        .GetFields()
        .Select(p => Expression.Bind(p, Expression.PropertyOrField(sourceItem, p.Name)))
        .OfType<MemberBinding>()
        .ToArray();

    var fetcher = Expression.Lambda<Func<T, object>>(
        Expression.Convert(
            Expression.MemberInit(
                Expression.New(dynamicType.GetConstructor(Type.EmptyTypes)),
                bindings), 
            typeof(object)),
        sourceItem);                

Para facilitar su uso, vamos a envolver todo el desastre como un método de extensión, así que ahora tenemos:

public static class Ext
{
    // Science Fact: the "Grouper" (as in the Fish) is classified as:
    //   Perciformes Serranidae Epinephelinae
    public static Expression<Func<T, object>> Epinephelinae<T>(
         this IEnumerable<T> source, 
         string [] groupByNames)
    {
        var sourceType = typeof(T);
    // define a dynamic type (read: anonymous type) for our needs
    var dynAsm = AppDomain
        .CurrentDomain
        .DefineDynamicAssembly(
            new AssemblyName(Guid.NewGuid().ToString()), 
            AssemblyBuilderAccess.Run);
    var dynMod = dynAsm
         .DefineDynamicModule(Guid.NewGuid().ToString());
    var typeBuilder = dynMod
         .DefineType(Guid.NewGuid().ToString());
    var properties = groupByNames
        .Select(name => sourceType.GetProperty(name))
        .Cast<MemberInfo>();
    var fields = groupByNames
        .Select(name => sourceType.GetField(name))
        .Cast<MemberInfo>();
    var propFields = properties
        .Concat(fields)
        .Where(pf => pf != null);
    foreach (var propField in propFields)
    {        
        typeBuilder.DefineField(
            propField.Name, 
            propField.MemberType == MemberTypes.Field 
                ? (propField as FieldInfo).FieldType 
                : (propField as PropertyInfo).PropertyType, 
            FieldAttributes.Public);
    }
    var dynamicType = typeBuilder.CreateType();

        // Create and return an expression that maps T => dynamic type
        var sourceItem = Expression.Parameter(sourceType, "item");
        var bindings = dynamicType
            .GetFields()
            .Select(p => Expression.Bind(
                    p, 
                    Expression.PropertyOrField(sourceItem, p.Name)))
            .OfType<MemberBinding>()
            .ToArray();

        var fetcher = Expression.Lambda<Func<T, object>>(
            Expression.Convert(
                Expression.MemberInit(
                    Expression.New(dynamicType.GetConstructor(Type.EmptyTypes)),
                    bindings), 
                typeof(object)),
            sourceItem);                
        return fetcher;
    }
}

Ahora, para usarlo:

// What you had originally (hand-tooled query)
var db = entries.AsQueryable();
var query = db.GroupBy(x => new 
    {
        Year = x.CreatedAt.Year,
        Month = x.CreatedAt.Month
    }, prj => prj.AuthorName)
    .Select(data => new {
        Key = data.Key.Year * 100 + data.Key.Month, // very ugly code, I know
        Details = data.GroupBy(y => y).Select(z => new { z.Key, Count = z.Count() })
    });    

var func = db.Epinephelinae(new[]{"CreatedAt", "AuthorName"});
var dquery = db.GroupBy(func, prj => prj.AuthorName);

Esta solución carece de la flexibilidad de las "declaraciones anidadas", como "CreatedDate.Month", pero con un poco de imaginación, posiblemente pueda extender esta idea para trabajar con cualquier consulta de forma libre.



Licencia bajo: CC-BY-SA with attribution
No afiliado con Stack Overflow
Licencia bajo: CC-BY-SA with attribution
No afiliado con Stack Overflow