Skip to content

Latest commit

 

History

History
305 lines (246 loc) · 12.3 KB

File metadata and controls

305 lines (246 loc) · 12.3 KB

Model Pipe

Motivation

The Model Pipe is a variation of the Compositing Model approach. It introduces an additional Model class, called Pipe, to intercept the data flow between Model and View and add flexibility for data manipulation while in transit. Its concept is similar to a UNIX pipe, and its most common use is for filtering and sorting.

The Pipe class encapsulates the transformation logic in a dedicated, potentially reusable Model class. Different Pipe classes can be created, each with specific capabilities. To be compatible with the View, a Pipe should implement the same interface of the submodel, eventually extending it for the additional state it might contain. Pipes can also be chained together to perform sequential reduction of data.

Design

The Pipe class encapsulates the transformation logic in a dedicated, potentially reusable Model class. Different Pipe classes can be created, each with specific capabilities. Pipes can also be chained together to perform sequential reduction of data.

To be compatible with the View, a Pipe should implement the same interface of the SubModel, eventually extending it for the additional state it might contain. For example, a filtering Pipe may host data about the current filter. Controllers acting on the Pipe class act on this state. while the manipulation of the SubModel's data is performed directly on the SubModel itself.

The Pipe generates notification events either when the Submodel contents change, or when its internal state changes in a way that modifies the resulting data.

Practical Example

An additional need that may emerge from our addressbook application is to filter out names and sort them alphabetically. A possible design approach would be to include this logic directly into the AddressBook class, but this approach would not work if we required two Views to observe the Model, maybe with different search criteria for the filter.

The next plausible candidate for hosting this logic is the View, but this can also lead to problems. The View might have a visual understanding of the semantic of the data, for example it knows how to extract a name from the Model and knows where it should go in the GUI, but does not necessarily possess enough logical understanding of the Model or be the most appropriate place to perform extravagant manipulations. Despite the shortcomings, both approaches may be a good compromise depending on the circumstances.

It is therefore a better strategy to use a Model Pipe design. We add two new Pipe classes to the Model layer introduced in the earlier section: one for filtering (AddressBookFilter) and for sorting (AddressBookSorter), as represented in figure

The implementation will also require two separated Views, both contained in the same window: the AddressBookView was introduced in the previous section and will be connected to the Sorter Model as the end point of the Model chain; The FilterView will instead display and modify the filter string, and will connect to the AddressBookFilter Model. We will explain the motivations for this design later in the explanation.

The AddressBookFilter registers on the filtered Model and holds the current filter string

class AddressBookFilter(BaseModel):
   def __init__(self, model):
       super(AddressBookFilter, self).__init__()
       self._filter_string = ""
       self._model = model
       self._model.register(self)

To modify the filter string, we need a setFilter method. When a new string is set, the product of the AddressBookFilter Model is expected to change, so _notifyListeners is called.

    def setFilter(self, string):
        self._filter_string = string
        self._notifyListeners()

The actual filtering is performed on the fly on the underlying data in the numEntries and getEntry methods, which is the usual interface for the Model in the address book application

    def numEntries(self):
        entries = 0
        for i in xrange(self._model.numEntries()):
            entry = self._model.getEntry(i)
            if self._filter_string in entry["name"]:
                entries += 1
    
        return entries
    
    def getEntry(self, entry_number):
        entries = 0
        for i in xrange(self._model.numEntries()):
            entry = self._model.getEntry(i)
            if self._filter_string in entry["name"]:
                if entries == entry_number:
                    return entry
                entries += 1
    
        raise IndexError("Invalid entry %d" % entry_number)

Finally, the Filter forwards notifications from its submodel to its listeners

    def notify(self):
        self._notifyListeners()

Similarly, the AddressBookSorter is defined to register on a Model for notifications. The current implementation supports only a simple A-z alphabetical sorting, and as such does not need to expose state for changes. Typical examples of possible state would be ascending vs. descending or the sorting key. The Sorter would then expose setters for all these values, and the View would have to provide supporting widgets to modify them

class AddressBookSorter(BaseModel):
   def __init__(self, model):
       super(AddressBookSorter, self).__init__()
       self._model = model
       self._model.register(self)
       self._rebuildOrderMap()

   def numEntries(self):
       return self._model.numEntries()

We implement the sorting naively, by walking through the underlying data and building an index-to-index mapping

    def _rebuildOrderMap(self):
        values = []

        for i in range(self._model.numEntries()):
            values.append( (i, self._model.getEntry(i)["name"]) )

        self._order_map = map(lambda x: x[0], 
                              sorted(values, key=operator.itemgetter(1))
                             )

The mapping is internal state that does not need to be exposed to the View, but must stay synchronized at all times with the underlying Model. Consequently, it must be recomputed every time the underlying Model reports a change

    def notify(self):
        self._rebuildOrderMap()
        self._notifyListeners()

We will then use the order map to extract entries in the appropriate order from the underlying Model

    def getEntry(self, entry_number):
        try:
            return self._model.getEntry(self._order_map[entry_number])
        except:
            raise IndexError("Invalid entry %d" % entry_number)

Finally, we need a View and Controller to modify the filter string. The View is a QLineEdit with some layouting and labeling. Its signal textChanged triggers the Controller's applyFilter method, so that as new characters are typed in, the Controller will change the filter string. Note how FilterView does not need a notify method: we don't expect the filter string to change from external sources, and QLineEdit is an autonomous widget which keeps its own state and representation synchronized

class FilterView(QtGui.QWidget):
   def __init__(self, *args, **kwargs):
       super(QtGui.QWidget, self).__init__(*args, **kwargs)
       self._initGUI()
       self._model = None
       self._controller = FilterController(self._model)
       self.connect(self._filter_lineedit,
                    QtCore.SIGNAL("textChanged(QString)"),
                    self._controller.applyFilter
                    )
   def _initGUI(self):
       self._hlayout = QtGui.QHBoxLayout()
       self.setLayout(self._hlayout)
       self._filter_label = QtGui.QLabel("Filter", parent=self)
       self._hlayout.addWidget(self._filter_label)
       self._filter_lineedit = QtGui.QLineEdit(parent=self)
       self._hlayout.addWidget(self._filter_lineedit)

We want to delay the setting of the Model after instantiation, so we need a setter method and design View and Controller to nicely handle None as a Model, always a good practice [#]_. The reason for this delayed initialization is that both FilterView and AddressBookView are visually contained into a dumb container. We will detail this point when analyzing the container

    def setModel(self, model):
        self._model = model
        self._controller.setModel(model)

The FilterController needs only the Model, initially set to None by the View

class FilterController(object):
   def __init__(self, model):
       self._model = model

   def setModel(self, model):
       self._model = model

The applyFilter method simply invokes setFilter on the associated Model, which must be the AddressBookFilter instance. Due to Qt Signal/Slot mechanism, this method receives a QString as argument, so we need to convert it into a python string before setting it into the Model

    def applyFilter(self, filter_string):
        if self._model:
            self._model.setFilter(str(filter_string))

As described early, the final application will have two Views in the same window, one above the other. To achieve this, we need a container widget to layout the two Views. We don't want to convey any misdirection about this container being anything else but a dumb container, so its initializer does not accept the Models. We will instead set the Model on each individual View from the outside through their setModel methods described earlier

class ContainerWidget(QtGui.QWidget):
   def __init__(self, *args, **kwargs):
       super(ContainerWidget, self).__init__(*args, **kwargs)
       self.filterview = FilterView(parent=self)
       self.addressbookview = AddressBookView(parent=self)
       self._vlayout = QtGui.QVBoxLayout()
       self.setLayout(self._vlayout)
       self._vlayout.addWidget(self.filterview)
       self._vlayout.addWidget(self.addressbookview)

To set up the application, there is little variation from the Compositing Model example: we set up the AddressBook Model from the individual submodels.

csv1_model = AddressBookCSV("../Common/file1.csv")
xml_model = AddressBookXML("../Common/file.xml")
csv2_model = AddressBookCSV("../Common/file2.csv")
address_book = AddressBook([csv1_model, xml_model, csv2_model])

The Pipes are then created and chained one after another

address_book_filter = AddressBookFilter(address_book)
address_book_sorter = AddressBookSorter(address_book_filter)

AddressBookSorter will then be passed to AddressBookView to display the data at the end of the process, and AddressBookFilter will be passed as a Model for FilterView/FilterController to modify the search string

widget = ContainerWidget()
widget.addressbookview.setModel(address_book_sorter)
widget.filterview.setModel(address_book_filter)
widget.show()

Why did we partition the GUI into two Views, instead of having a unified View attached to the last Model in the chain and containing both the List and the Filter line edit? This unified View would have to install its Controller to modify the Filter string on an AddressBookFilter, but the passed Model is an AddressBookSorter. The Sorter would therefore have to provide a method to extract its submodel. The unified View would then invoke this method, hope that the returned Model is a Filter, and finally pass it to the FilterController. This would fail if the Sorter is removed from the schema, or another Pipe object is added on either side of the Sorter. Such design is therefore rather brittle.

A solution with two separated Views give a more flexible, resilient and cleaner design: the List does not need to know about the nature of its Model, it just asks for its data; the Pipe chain can be modified without affecting the View; The FilterView is attached to its natural Model, the AddressBookFilter, and its Controller can be installed safely without any fragile traversal of the Pipe chain.

.. [#] Additionally, when a View or Controller allows to change the Model after initialization, it is important that setModel unregisters the View from the old Model, or it will keep sending change notifications. We skip this step because we never register for notifications in the first place.

FIXME

The Pipe monitors the real data source, forwarding the events.

FIXME: Index remapping