Search code examples
qt5pyside2

QIdentityProxyModel requires overriding rowCount when used with QCompleter?


I've a QSqlQueryModel which fetches IDs like 1, 2,, etc. In the UI we display this field as a 4-digit int: 0001, 0002, etc.

Here's my proxy subclass of QIdentityProxyModel to add the zero prefix:

class ZeroPrefixProxy(QIdentityProxyModel):

    def __init__(self, parent=None):
        super().__init__(parent)

    def data(self, index, role):
        d = self.sourceModel().data(index, role)
        return f'{d:04}' if role in (Qt.DisplayRole, Qt.EditRole) else d

# Uncommenting this works
#    def rowCount(self, parent=QModelIndex()):
#        return self.sourceModel().rowCount(parent)

Here's how I set it to a QLineEdits completer:

def setIdCompleterModel(self):
    # model is a loaded QSqlQueryModel
    proxy = ZeroPrefixProxy(self.ui.txtId)
    proxy.setSourceModel(model)
    self.ui.txtId.setCompleter(QCompleter(proxy, self.ui.txtId))
    # Uncommenting this works
    # proxy.data(proxy.index(0, 0), Qt.DisplayRole)

No suggestions are displayed irrespective of what I type (1 or 0001). However, when I uncomment either snippets above things work great.

I do not want to do either as they seem pointless:

  • QIdentityProxyModel already implements columCount (it works correctly)
  • I've no reason to call data (I originally wrote it just to test)

What am I missing? Why is the simple subclass implementation not working?

Setup: ArchLinux, Qt 5.15.10, PySide2 5.15.2.1

MCVE

This code works on my setup only if I comment out ZeroPrefixProxy.data:

import sys

from PySide2.QtCore import Qt, QIdentityProxyModel, QModelIndex
from PySide2.QtWidgets import QApplication, QMainWindow, QLineEdit, QCompleter
from PySide2.QtGui import QStandardItem, QStandardItemModel

class ZeroPrefixProxy(QIdentityProxyModel):

    def __init__(self, parent=None):
        super().__init__(parent)

    # Commenting this method out makes things work
    def data(self, index, role=Qt.DisplayRole):
        return self.sourceModel().data(index, role)

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        useStdModel = False
        if useStdModel:
            model = QStandardItemModel(5, 1, self)
            for i in range(1, 6):
                item = QStandardItem()
                # setData as ctor only takes an str, we need an int
                item.setData(i, Qt.EditRole)
                model.setItem(i-1, 0, item)
        else:
            db = QSqlDatabase.addDatabase('QPSQL')
            host = 'localhost'
            dbname = 'test'
            db.setHostName(host)
            db.setDatabaseName(dbname)
            db.setUserName('pysider')
            if not db.open():
                print('DB not open')
            model = QSqlQueryModel()
            model.setQuery('SELECT id FROM items')

        proxy = ZeroPrefixProxy(self)
        proxy.setSourceModel(model)
        lineEdit = QLineEdit()
        comp = QCompleter(proxy, lineEdit)
        lineEdit.setCompleter(comp)
        self.setCentralWidget(lineEdit)

app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

Solution

  • Elementary mono and bi-dimensional models often rely just on the row and column of the given QModelIndex.

    A proper, accurate and safety reliable model should theoretically ensure that the QModelIndex's model() is actually the same, otherwise return an invalid result (None in Python).

    Your example works for QSqlQueryModel because SQL models are 2D by their nature, so the assumption is that the given index actually belongs to the same model and then it will try to return the model data based on those row/column coordinates.

    This is theoretically a bug, but if we also consider that SQL models often contain thousands of records, it has probably been done for optimization reasons: in my opinion, this is a clear case in which "ask forgiveness, not permission" is better than the opposite.

    Your example would have also worked if you used a basic QAbstractTableModel that uses a list of lists as data model and a basic row/column combination to get its values:

    class MyModel(QAbstractTableModel):
        def __init__(self, data):
            super().__init__()
            self._data = data
    
        def rowCount(self, parent=None):
            return len(self._data)
    
        def columnCount(self, parent=None):
            if self._data:
                return len(self._data[0])
            return 0
    
        def data(self, index, role=Qt.DisplayRole):
            if role == Qt.DisplayRole:
                return self._data[index.row()][index.column()]
    

    Since the QIdentityProxyModel leaves the original model layout unchanged, the row/column combination will always be consistent with the returned data, even if the model of the QModelIndex is not really the same.

    If you had used a QSortFilterProxyModel with filtering active instead, that would have probably returned unexpected results.

    A proper and extensible model should always ensure that the model of the QModelIndex actually is the same: QStandardItemModel actually enforces that because it uses internal pointers to reference any item within its structure, and that's necessary since it also allows the possibility of tree structures, which expect a parent item (index) in order to properly map a row and column combination. It retrieves the data not just based on the row/column combination, but based on the parent and the actual model-index ownership.

    That's the whole purpose of mapToSource() of proxy models: it returns a (possibly valid) index that actually maps to the source model with the correct index belonging to that model only.

    So, the correct implementation of data() (or any index-related function, such as flags()) in a proxy model requires a call to that function, no matter any assumption made on the source model implementation:

    class ZeroPrefixProxy(QIdentityProxyModel):
    
        def __init__(self, parent=None):
            super().__init__(parent)
    
        # Commenting this method out makes things work
        def data(self, index, role=Qt.DisplayRole):
            return self.sourceModel().data(self.mapToSource(index), role)