Implementing multi-column filtering on the IBindingListView

A couple of years ago, I a whitepaper about how to implement searching and sorting on the generic BindingList for data binding in Windows Forms. About the same time my colleague, Karl, wrote a whitepaper about adding an auto-filter capability to the Windows Forms DataGridView control. Karl and I had talked about bridging the gap by me writing another whitepaper about how to implement filtering on a generic BindingList by implementing the IBindingListView. Well , it took a year or so but I did write the code and the whitepaper. During the process, I made one blog post about it, but continued to improve the code. Another few months have gone by, and the whitepaper is still not published, although I do have the paper and the code available on the Microsoft Download site. The download includes projects for Visual Studio 2005  and 2008 in both VB.NET and C#. The VS 2008 versions include some unit tests as well. I am a big fan of unit testing, even though I am by no means an expert. I found the unit tests very helpful and my editor suggested I publish them because you might find them helpful as well. In addition, I've included projects that demonstrate the filtered list working the the auto-filtered DataGridView.

When I started on this project I honestly did not understand how complex it was. When you implement filtering one of the first things you need to decide is what sort of filtering expressions your code will accept. I opted for a sub-set of the DataView.RowFilter format guidelines. I limited my operators to <,> and = to keep things simple. My idea was to give you a jumping off point for more complex filtering. In addition, I opted to only filter types that implement IComparable. Attempts to filter other types will result in an exception. A complication is that I started with a list that already supports single-column sorting. This means that the sorting and filtering implementations need to play nice together. For example, a sorted collection that is later filtered should remain in the same sorted order, or if a list is sorted and filtered, and the sort is removed, it should remain filtered.

Following is some of the code from the paper with a brief explanation. If you want to stop reading this post and go straight to the paper and code, you can find it here.

The are only three APIs exposed by IBindingListView that you need to implement for filtering:

  • SupportsFiltering property
  • Filter property
  • RemoveFilter method

SupportsFiltering is simple. For my implementation this always return true. The get for the Filter property is simple as well. I simply return the value of the backing private field. The filteret is where a lot of heavy lifting happens. When the Filter property is set, the filter is applied to the collection and only items meeting the filter criteria are returned. This means you need to do a lot of work in the set:

  • Cache the existing collection in case the filter is removed
  • Check for null or empty string; this means the filter has been removed and you need to restore the original collection
  • Parse the filter expression to determine if its in the correct format
  • Apply the filter, and return on list values that meet the filter criteria
  • Reapply the sort if necessary

These steps are further complicated by multi-column filtering.

Here's the code from my Filter set:

set
{
if (filterValue == value) return;

    // If the value is not null or empty, but doesn't
// match expected format, throw an exception.
if (!string.IsNullOrEmpty(value) &&
!Regex.IsMatch(value,
BuildRegExForFilterFormat(), RegexOptions.Singleline))
throw new ArgumentException("Filter is not in " +
"the format: propName[<>=]'value'.");

    //Turn off list-changed events.
RaiseListChangedEvents = false;

    // If the value is null or empty, reset list.
if (string.IsNullOrEmpty(value))
ResetList();
else
{
int count = 0;
string[] matches = value.Split(new string[] { " AND " },
StringSplitOptions.RemoveEmptyEntries);

        while (count < matches.Length)
{
string filterPart = matches[count].ToString();

            // Check to see if the filter was set previously.
// Also, check if current filter is a subset of
// the previous filter.
if (!String.IsNullOrEmpty(filterValue)
&& !value.Contains(filterValue))
ResetList();

            // Parse and apply the filter.
SingleFilterInfo filterInfo = ParseFilter(filterPart);
ApplyFilter(filterInfo);
count++;
}
}
// Set the filter value and turn on list changed events.
filterValue = value;
RaiseListChangedEvents = true;
OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1));
}

As I said previously, there's a lot happening here. First I check to see if the filter value has changed, if not, I simply return. Then I check for the correct filter format by calling BuildRegExForFilterFormat. Following is the code for this method:

// Build a regular expression to determine if
// filter is in correct format.
public static string BuildRegExForFilterFormat()
{
StringBuilder regex = new StringBuilder();

    // Look for optional literal brackets,
// followed by word characters or space.
regex.Append(@"\[?[\w\s]+\]?\s?");

    // Add the operators: > < or =.
regex.Append(@"[><=]");

    //Add optional space followed by optional quote and
// any character followed by the optional quote.
regex.Append(@"\s?'?.+'?");

   return regex.ToString();
}

If the filter is in the correct format I know I'm in business and turn off change notification so I can manipulate the contents of the list without notifying bound controls.

Next I check for null or an empty string. One of these values indicates the filter has been removed and I need to reset the contents of the list to the unfiltered content. Following is the code for the ResetList method. Once I restore the original list, I reapply the sort, if the list is sorted.

private void ResetList()
{
this.ClearItems();
foreach (T t in originalListValue)
this.Items.Add(t);
if (IsSortedCore)
ApplySortCore(SortPropertyCore, SortDirectionCore);
}

Next I split the filter at "AND" to separate the parts of a multi-column filter. I loop through each filter part and parse and apply the filter to the list. I do some checking to see if the current filter part is a subset of a previous filter indicating a portion of a multi-column filter is being removed. In this case, I reset the list before I apply the filter. I created the SingleFilterInfo and FilterOperator enums to aid in parsing and filtering. The actual parsing and filtering are done by the ParseFilter and ApplyFilter methods. Following is the code for  these methods along with the enums and supporting methods.

internal void ApplyFilter(SingleFilterInfo filterParts)
{
List<T> results;

    // Check to see if the property type we are filtering by implements
// the IComparable interface.
Type interfaceType =
TypeDescriptor.GetProperties(typeof(T))[filterParts.PropName]
.PropertyType.GetInterface("IComparable");

    if (interfaceType == null)
throw new InvalidOperationException("Filtered property" +
" must implement IComparable.");

    results = new List<T>();

    // Check each value and add to the results list.
foreach (T item in this)
{
if (filterParts.PropDesc.GetValue(item) != null)
{
IComparable compareValue =
filterParts.PropDesc.GetValue(item) as IComparable;
int result =
compareValue.CompareTo(filterParts.CompareValue);
if (filterParts.OperatorValue ==
FilterOperator.EqualTo && result == 0)
results.Add(item);
if (filterParts.OperatorValue ==
FilterOperator.GreaterThan && result > 0)
results.Add(item);
if (filterParts.OperatorValue ==
FilterOperator.LessThan && result < 0)
results.Add(item);
}
}
this.ClearItems();
foreach (T itemFound in results)
this.Add(itemFound);
}

internal SingleFilterInfo ParseFilter(string filterPart)
{
SingleFilterInfo filterInfo = new SingleFilterInfo();
filterInfo.OperatorValue = DetermineFilterOperator(filterPart);

    string[] filterStringParts =
filterPart.Split(new char[] { (char)filterInfo.OperatorValue });

    filterInfo.PropName =
filterStringParts[0].Replace("[", "").
Replace("]", "").Replace(" AND ", "").Trim();

    // Get the property descriptor for the filter property name.
PropertyDescriptor filterPropDesc =
TypeDescriptor.GetProperties(typeof(T))[filterInfo.PropName];

    // Convert the filter compare value to the property type.
if (filterPropDesc == null)
throw new InvalidOperationException("Specified property to " +
"filter " + filterInfo.PropName +
" on does not exist on type: " + typeof(T).Name);

    filterInfo.PropDesc = filterPropDesc;

    string comparePartNoQuotes = StripOffQuotes(filterStringParts[1]);
try
{
TypeConverter converter =
TypeDescriptor.GetConverter(filterPropDesc.PropertyType);
filterInfo.CompareValue =
converter.ConvertFromString(comparePartNoQuotes);
}
catch (NotSupportedException)
{
throw new InvalidOperationException("Specified filter" +
"value " + comparePartNoQuotes + " can not be converted" +
"from string. Implement a type converter for " +
filterPropDesc.PropertyType.ToString());
}
return filterInfo;
}

internal FilterOperator DetermineFilterOperator(string filterPart)
{
// Determine the filter's operator.
if (Regex.IsMatch(filterPart, "[^>^<]="))
return FilterOperator.EqualTo;
else if (Regex.IsMatch(filterPart, "<[^>^=]"))
return FilterOperator.LessThan;
else if (Regex.IsMatch(filterPart, "[^<]>[^=]"))
return FilterOperator.GreaterThan;
else
return FilterOperator.None;
}

internal static string StripOffQuotes(string filterPart)
{
// Strip off quotes in compare value if they are present.
if (Regex.IsMatch(filterPart, "'.+'"))
{
int quote = filterPart.IndexOf('\'');
filterPart = filterPart.Remove(quote, 1);
quote = filterPart.LastIndexOf('\'');
filterPart = filterPart.Remove(quote, 1);
filterPart = filterPart.Trim();
}
return filterPart;
}

      
public struct SingleFilterInfo
{
internal string PropName;
internal PropertyDescriptor PropDesc;
internal Object CompareValue;
internal FilterOperator OperatorValue;
}

// Enum to hold filter operators. The chars
// are converted to their integer values.
public enum FilterOperator
{
EqualTo = '=',
LessThan = '<',
GreaterThan = '>',
None = ' '
}

There's a lot in my paper that I am leaving out of this post. Hopefully, I've whet your appetite and you'll download the code and paper. The paper is included as part of the zip file. You can get it all here: Implementing Filtering for Windows Forms Data Binding. Enjoy!