A Custom QSortFilterProxyModel for Multicolumn Filtering

February 09, 2013

As I have continued my adventures into PySide, the Qt library for Python, I have been really impressed with the quality of the Qt framework. It offers an abundance of useful objects to be used in projects. I find myself utilizing table views constantly, and when dealing with table views one often wants to filter the displayed table by some criteria. The builtin QSortFilterProxyModel is designed just for this use, but has one serious oversight—you can only filter on a single column. Typically one would use the QSortFilterProxyModel’s setFilterRegExp() method in combination with setFilterKeyColumn(), but what if you want to filter on multiple columns, or better yet implement any sort of custom filtering?

The solution is to subclass QSortFilterProxyModel and reimplement its filterAcceptsRow(). If we wanted just multicolumn sorting we could write a filterAcceptsRow() like this:

def filterAcceptsRow(self, row_num, parent):
    model refers to our source model which is subclassed from
    QAbstractTableModel. It has a method called row(row_num)
    that returns the row in the table as a python list.
    filterString is a string to filter against which has
    been previously set. For illustration purposes we
    don't bother with regular expression support, but
    this could easily be added.

    filterColumns is a list of columns to test against,
    it should have the form [3, 4] to test against columns
    3 and 4.
    model = self.sourceModel()  # the underlying model, 
                                # implmented as a python array
    row = model.row(row_num)
    tests = [self.filterString in row[col]
             for col in self.filterColumns]

    return True in tests        # accepts row if any column
                                # contains filterString

This works how we want it, but is pretty specific to just multicolumn filtering. Lets see if we can provide a more general solution. Our first step is to allow our subclass of QSortFilterProxyModel to accept functions which it can use to filter the data.

class CustomSortFilterProxyModel(QtGui.QSortFilterProxyModel):
    def __init__(self, parent=None):
        super(CustomSortFilterProxyModel, self).__init__(parent)
        self.filterString = ''
        self.filterFunctions = {} 

    def addFilterFunction(self, name, new_func):
        name is a hashable identifier for new_func, so 
        we can remove it later if needed.

        new_func is a function which accepts two arguments,
        the currently set filterString, and the row to
        be tested.
        self.filterFunctions[name] = new_func

Now our filterAcceptsRow() looks like this:

    def filterAcceptsRow(self, row, parent):
        Reimplemented from base class.
        model = self.sourceModel()
        tests = [func(model.row(row), self.filterString)
                 for func in self.filterFunctions.values()]
        return not False in tests

Notice how this is now mutually exclusive, every test needs to be passed for the row to accepted. This means that if we want to implement OR like behavior (as we would typically want in multicolumn filtering), all those tests need to be part of a single filter function. The reason we do this is so we can add tests which exclude elements, for instance we may want to filter rows whose 1st column is greater than its 2nd column and whose name is like some search string.

In our main program we set a filter function to filter on all columns like so:

# in the lambda function, r is a python list
# representing the row, s is the filterString
# to test against.
    lambda r,s: (True in [s in unicode(col).lower()
                          for col in r]))

Or maybe we are making an inventory program and only want to show items whose stock is less than 20%. Assuming column 1 is qty in stock and column 2 is qty needed we could implement it like this:

    lambda r,s: ((float(r[1])/float(r[2]) < 0.2)
                  if r[2] > 0 else False))

The entire class is available as a gist.




← Raspberry Pi Laser Cut Case tlassemble now on Homebrew →