Linq: GroupBy, Sum y Count

133

Tengo una coleccion de productos

public class Product {

   public Product() { }

   public string ProductCode {get; set;}
   public decimal Price {get; set; }
   public string Name {get; set;}
}

Ahora quiero agrupar la colección en función del código del producto y devolver un objeto que contenga el nombre, el número o los productos de cada código y el precio total de cada producto.

public class ResultLine{

   public ResultLine() { }

   public string ProductName {get; set;}
   public string Price {get; set; }
   public string Quantity {get; set;}
}

Entonces uso un GroupBy para agrupar por ProductCode, luego calculo la suma y también cuento el número de registros para cada código de producto.

Esto es lo que tengo hasta ahora:

List<Product> Lines = LoadProducts();    
List<ResultLine> result = Lines
                .GroupBy(l => l.ProductCode)
                .SelectMany(cl => cl.Select(
                    csLine => new ResultLine
                    {
                        ProductName =csLine.Name,
                        Quantity = cl.Count().ToString(),
                        Price = cl.Sum(c => c.Price).ToString(),
                    })).ToList<ResultLine>();

Por alguna razón, la suma se realiza correctamente pero el recuento siempre es 1.

Datos de Sampe:

List<CartLine> Lines = new List<CartLine>();
            Lines.Add(new CartLine() { ProductCode = "p1", Price = 6.5M, Name = "Product1" });
            Lines.Add(new CartLine() { ProductCode = "p1", Price = 6.5M, Name = "Product1" });
            Lines.Add(new CartLine() { ProductCode = "p2", Price = 12M, Name = "Product2" });

Resultado con datos de muestra:

Product1: count 1   - Price:13 (2x6.5)
Product2: count 1   - Price:12 (1x12)

¡El producto 1 debería tener count = 2!

Intenté simular esto en una aplicación de consola simple, pero allí obtuve el siguiente resultado:

Product1: count 2   - Price:13 (2x6.5)
Product1: count 2   - Price:13 (2x6.5)
Product2: count 1   - Price:12 (1x12)

Producto1: solo debe aparecer una vez ... El código de lo anterior se puede encontrar en pastebin: http://pastebin.com/cNHTBSie

ThdK
fuente

Respuestas:

285

No entiendo de dónde proviene el primer "resultado con datos de muestra", pero el problema en la aplicación de consola es que está utilizando SelectManypara ver cada elemento en cada grupo .

Creo que solo quieres:

List<ResultLine> result = Lines
    .GroupBy(l => l.ProductCode)
    .Select(cl => new ResultLine
            {
                ProductName = cl.First().Name,
                Quantity = cl.Count().ToString(),
                Price = cl.Sum(c => c.Price).ToString(),
            }).ToList();

El uso de First()aquí para obtener el nombre del producto supone que cada producto con el mismo código de producto tiene el mismo nombre de producto. Como se señaló en los comentarios, puede agrupar por nombre de producto y código de producto, lo que dará los mismos resultados si el nombre es siempre el mismo para cualquier código dado, pero aparentemente genera un mejor SQL en EF.

También le sugiero que cambie las propiedades Quantityy Pricepara que sean inty decimaltipos respectivamente, ¿por qué usar una propiedad de cadena para datos que claramente no es textual?

Jon Skeet
fuente
Ok, mi aplicación de consola está funcionando. Gracias por indicarme que use First () y omita SelectMany. ResultLine es en realidad un ViewModel. El precio se formateará con el signo de moneda. Es por eso que necesito que sea una cuerda. Pero podría cambiar la cantidad a int .. Veré ahora si esto también puede ayudar a mi sitio web. Yo lo haré saber.
ThdK
66
@ThdK: No, también debe mantenerlo Pricecomo decimal y luego cambiar la forma en que lo formatea. Mantenga limpia la representación de datos y solo cambie a una vista de presentación en el último momento posible.
Jon Skeet
44
¿Por qué no agrupar por código de producto y nombre? Algo así: .GroupBy (l => new {l.ProductCode, l.Name}) y use ProductName = c.Key.Name,
Kirill Bestemyanov
@KirillBestemyanov: Sí, esa es otra opción, ciertamente.
Jon Skeet
Ok, parece que mi colección fue en realidad una envoltura alrededor de la colección real ... Dispárame ... Lo bueno es que practiqué algo de Linq hoy :)
ThdK
27

La siguiente consulta funciona. Utiliza cada grupo para hacer la selección en lugar de SelectMany. SelectManytrabaja en cada elemento de cada colección. Por ejemplo, en su consulta tiene un resultado de 2 colecciones. SelectManyobtiene todos los resultados, un total de 3, en lugar de cada colección. El siguiente código funciona en cada uno IGroupingen la parte seleccionada para que sus operaciones agregadas funcionen correctamente.

var results = from line in Lines
              group line by line.ProductCode into g
              select new ResultLine {
                ProductName = g.First().Name,
                Price = g.Sum(pc => pc.Price).ToString(),
                Quantity = g.Count().ToString(),
              };
Charles Lambert
fuente
2

a veces necesita seleccionar algunos campos por FirstOrDefault()o singleOrDefault()puede usar la siguiente consulta:

List<ResultLine> result = Lines
    .GroupBy(l => l.ProductCode)
    .Select(cl => new Models.ResultLine
            {
                ProductName = cl.select(x=>x.Name).FirstOrDefault(),
                Quantity = cl.Count().ToString(),
                Price = cl.Sum(c => c.Price).ToString(),
            }).ToList();
Mahdi Jalali
fuente
1
¿puede explicar por qué a veces necesito usar FirstOrDefault() or singleOrDefault () `?
Shanteshwar Inde
@ShanteshwarInde First () y FirstOrDefault () obtienen el primer objeto de una serie, mientras que Single () y SingleOrDefault () esperan solo 1 del resultado. Si Single () y SingleOrDefault () ven que hay más de 1 objeto en un conjunto de resultados o como resultado del argumento proporcionado, arrojará una excepción. En el uso, usa el primero cuando solo desea, tal vez una muestra de una serie y los otros objetos no son importantes para usted, mientras que usa el último si solo espera un objeto y hace algo si hay más de un resultado , como registrar el error.
Kristianne Nerona