Sunday, January 3, 2010

Linq Queries against most collections including ListView, ListViewItemCollection, ControlCollection or anything IEnumerable

The Problem:

You can not run Linq queries against many Framework collections such as ListViewItems and Controls in an immediately obvious manner.

if (list.Items.All(item => item.Checked))
    return true;

The previous code snippet won't compile, won't work and won't shove you in the right direction via IntelliSense, Online Help, or by performing a super-quick Google for the hopelessly attention-deficit disordered such as myself. (more on the Googling later)

The Fix:

Introducing the Enumerable .Cast (TResult) Method

if (list.Items.Cast<ListViewItem>().All(item => item.Checked)
    return true;

Using this method you can rewrite the original code quite easily to this code and Linq query the collection to death with all of your favorite little Linq sledgehammers...

The Why:

Background story for those of you who didn't jump ship to go off using the solution...

I was trying to something I thought would be very straight-forward using Linq. I wanted to sync a tri-state "parent-relationship" CheckBox control with a ListView control that contained "child-relationship" items that had their own respective item specific checkboxes. The parent to child relationship here is conceptual and not baked into the controls, we're talking a simple CheckBox and ListView here.

You've all probably done something similar whether it be with a TreeView using checkboxes containing items with checkboxes. You've certainly seen this behavior if you've ever done a backup and selected what you want backed up on a hard drive. The concept is simple: if all child items are checked or unchecked, the parent CheckBox should be Checked or Unchecked accordingly. If some of the child objects are checked and some aren't, the parent CheckBox should be Indeterminate.

(The backup then flubs your selection, doesn't back up your nicely selected SQL database, and can't seem to adequately backup to your 1TB external HD popping up endless dialogs to let you know how inefficient it truly is but that's a whole different post altogether.)

All fine and dandy, this will be easy, right? Well, the first thing I wanted to do is say something like...

if (listEmployees.Items.All(λ => λ.Checked))
    checkEmployees.CheckState = CheckState.Checked;
else if (listEmployees.Items.All(λ => !λ.Checked))
    checkEmployees.CheckState = CheckState.Unchecked;
    checkEmployees.CheckState = CheckState.Indeterminate;

Obviously, my ListView is a list of Employee business objects and my CheckBoxes on my list items are per employee item that I'm displaying. Nothing fancy here.

Note: I use the λ character now for a lot of my Linq statements after reading this question about LINQ to SQL business object creation best practices. I agree that it's technically not the most accurate usage of the Lambda characer however it does clarify the code (to me) and reduces the chance of variable declaration conflicts with Linq queries.

So, I'm ready to compile, skip testing, deem this code ready for production and ship it to my hungry client when this little nastygram pops up during compile...

'System.Windows.Forms.ListView.ListViewItemCollection' does not contain a definition for 'All' and no extension method 'All' accepting a first argument of type 'System.Windows.Forms.ListView.ListViewItemCollection' could be found (are you missing a using directive or an assembly reference?)

Grr. OK, so off to Google I go (oh you do it too!)... I land on
LINQ on ListView.Items (ListViewItemCollection) telling me in no uncertain terms, with an accepted answer, that this just can't be done. Piffle! No way! I'm outraged! Linq can do anything!

So, I typically look at one Google answer like this and go back to the code to see what I, with all my omnipotent developer powers, can figure out. (Usually I'm humbled to admit the same defeat as the previous blog poster but not this time!

So, instinct and too much coffee tells me to look up 2 things...

1.) What are the requirements, more specifically, the "where" clause, if any, of the Linq method "All"
2.) If the ListView.Items property doesn't meet this requirement, then why doesn't it dangit!?!

So, doing a "Go to definition" on the All Linq Extension method I come up with the following in my sweet little meta data viewer.

public static bool All<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate);

The Linq All entension method requires an IEnumerable<TResult> as the source of the extension meaning that only objects that expose IEnumerable<TResult> as part of their class definition will get picked up, and be extensible using the Linq system. There's no where clause, it must be an IEnumerable<TResult> supported object whether by inheritance or interface support.

OK, I was thinking just IEnumerable personally but actually IEnumerable<TResult> makes sense since Linq has to perform anonymous queries using properties of the object that's being extended. Meaning if Linq extended IEnumerable (pure) that's great but when I went to do my neat little .Checked (is true) statement Linq wouldn't know what the heck a .Checked was because IEnumerable would be based on an enumerable object collection.

Fine, makes sense, so what the heck is a ListView.Items collection then? Back over to the "Go to definition" meta lookup for that guy...

[Editor("System.Windows.Forms.Design.ListViewItemCollectionEditor, System.Design, Version=, 
Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a", typeof (UITypeEditor))]
public ListViewItemCollection Items { get; }

Uh...OK, so the Items property is this ListViewItemCollection class, but what is that? AGAIN with the lookup I come to...

public class ListViewItemCollection : IList, ICollection, IEnumerable

Aha! It's the problem I just defined in my mind. ListViewItemCollection is just an IEnumerable collection (of objects). The indexer...

public virtual ListViewItem this[int index] { get; set; }

.. is the reason we can easily deal with ListViewItems when we're doing foreach loops on the ListView.Items property. It does the boxing for us. Interesting performance hit, will have to check that out later as Microsoft is probably (hopefully) doing something to defer the boxing cost of each item in the List.

OK, so problem identified but what could I do about it. I'd love to say it was some methodical deduction that brought me to the Cast method but I just did an IntelliSense on the ListView.Items property to see what Linq extensions were available to me.

I started looking into AsQueryable() but I couldn't quite achieve what I wanted. Then, I noticed the little Cast beneath it (in IntelliSense). Poof, that worked.

Voila, now you can run your handy Linq queries against most anything IEnumerable as long as you can, with some accuracy, cast each member to a specific type.

On the Googling: I thought to myself cool! I figured out a problem everyone can use everywhere at all times. Then that creeping feeling overcame me that, "Nah, that was too easy" so I went back to Google. I wasn't a pioneer after all, drat. A Good, practical LINQ example shows you the same technique with a bit more detail and insight (but less comedy) than I came up with. I didn't realize that Linq works on Sequences so you should check this post out as well for a better understanding. Our similarities in deducing the same conclusion was a bit eerie however but I'll let that slide and drink more java (coffee not the language).

I will post my final solution to the checkbox issue itself because it screams reusable code to me. I will refactor my solution into something that isn't bound to specific controls, maybe not even Windows.Forms controls and post it here later.

I made a New Years Resolution to blog at least 4 times a week and mean to keep it up so this is the first installment (late already).

That's all for today and happy Linq'ing...


René said...

Thanks for this, it helped me in the right direction. Just wanted to let you know that the code you present as a fix has a syntax error: it misses a closing ) in the first line.

Dave said...

Rene - glad it helped and thanks for the point-out on the typo. I have no idea why I didn't get notice of your comment.