BornToCode BornToCode - 3 months ago 14
C# Question

How to group an IEnumerable<T> using a composite key selector?

I'm looking for the simplest way to group list's items by an IEnumerable property of the list's items.
For example list contains products, each product contains list of upgrades. Two products should be grouped if they have the same id and also their upgrades property contains the same ids. I managed to do it with a custom EqualityComparer but I wonder if there's any simpler way to do this?

My current working code:

public class Product
{
public int ProductId { get; set; }
public string Name { get; set; }
public IEnumerable<Upgrade> Upgrades { get; set; }
}

public class Upgrade
{
public int Id { get; set; }
public string Name { get; set; }
}

public class ProductsEqualityComparer : IEqualityComparer<Product>
{
public bool Equals(Product x, Product y)
{
bool areProductsIdsEqual = x.ProductId == y.ProductId;
bool areUpgradesEqual = new HashSet<int>(x.Upgrades.Select(u => u.Id)).SetEquals(y.Upgrades.Select(u2 => u2.Id));
return areProductsIdsEqual && areUpgradesEqual;
}

public int GetHashCode(Product obj)
{
int hash = obj.ProductId.GetHashCode();
foreach (var upgrade in obj.Upgrades)
{
hash ^= upgrade.Id.GetHashCode();
}
return hash;
}
}


Then I just call it like this:

var grouped = productList.GroupBy(l => l, new ProductsEqualityComparer());


To all Linq gurus out there - is there any simpler/better way to do this?

Answer

Your own solution is perhaps the most efficient one. However, there is an alternative if you can afford creating an additional wrapper over the products and group those wrappers, e.g.:

var grouped = productList.Select(p => new {
        GroupId = p.ProductId.ToString() + ";" + String.Join(",", p.Upgrades.Distinct().OrderBy(u => u)),
        Product = p
    }.GroupBy(
        p => p.GroupId, 
        p => p.Product
    );

However, notice that this solution incurs a cost of calculating a GroupId string and wrapping all the products into intermediary objects. The example above uses a string as GroupId for simplicity, perhaps there is a room for optimization if neccesssary.