pythonpyqt5qtreeviewqsortfilterproxymodelqsqlrelationaltablemodel

Endless loop on QTreeView when filtering via QSortFilterProxyModel in a QSqlRelationalTableModel


Whenever I enable a filter with QSortFilterProxyModel() and insert a new record in my QSqlRelationalTableModel() which is linked to a QTreeView I get the error:

RecursionError: maximum recursion depth exceeded

Standard case is to create a new datarecord with CTRL + N - OK.

Also filtering works - OK.

But if I set the filter and create a new record, python fails with:

RecursionError: maximum recursion depth exceeded
Backend terminated (returncode: 3)
Fatal Python error: Aborted

How to reproduce:

  1. Set a filter, e.g. lastName to Smith.
  2. Hit CTRL + N to create a new record.

=> Result: Python falls into endless loop until mentioned error message occurrs.

=> Expected result: The row should be created and not hit by the filter. When the filter is deleted, all rows should appear, also the newly created row.

Full working code example:

import sys
import re
from PyQt5 import QtWidgets, QtGui, QtCore, QtSql

db = QtSql.QSqlDatabase.addDatabase("QSQLITE")
db.setDatabaseName(":memory:");
modelQuery = QtSql.QSqlQueryModel()
modelTable = QtSql.QSqlRelationalTableModel()

def _human_key(key):
    parts = re.split(r'(\d*\.\d+|\d+)', key)
    return tuple((e.swapcase() if i % 2 == 0 else float(e))
            for i, e in enumerate(parts))

class FilterHeader(QtWidgets.QHeaderView):
    filterActivated = QtCore.pyqtSignal()

    def __init__(self, parent):
        super().__init__(QtCore.Qt.Horizontal, parent)
        self._editors = []
        self._padding = 4
        self.setStretchLastSection(True)        
        self.setDefaultAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)
        self.setSortIndicatorShown(False)
        self.sectionResized.connect(self.adjustPositions)
        parent.horizontalScrollBar().valueChanged.connect(self.adjustPositions)

    def setFilterBoxes(self, count):
        while self._editors:
            editor = self._editors.pop()
            editor.deleteLater()
        for index in range(count):
            editor = QtWidgets.QLineEdit(self.parent())            
            editor.setPlaceholderText('Filter')
            editor.setClearButtonEnabled(True)            
            editor.textChanged.connect(self.textChanged)

            self._editors.append(editor)
        self.adjustPositions()

    def textChanged(self):        
        self.filterActivated.emit()

    def sizeHint(self):
        size = super().sizeHint()
        if self._editors:
            height = self._editors[0].sizeHint().height()
            size.setHeight(size.height() + height + self._padding)
        return size

    def updateGeometries(self):
        if self._editors:
            height = self._editors[0].sizeHint().height()
            self.setViewportMargins(0, 0, 0, height + self._padding)
        else:
            self.setViewportMargins(0, 0, 0, 0)
        super().updateGeometries()
        self.adjustPositions()

    def adjustPositions(self):
        for index, editor in enumerate(self._editors):
            height = editor.sizeHint().height()
            editor.move(
                self.sectionPosition(index) - self.offset() + 2,
                height + (self._padding // 2))
            editor.resize(self.sectionSize(index), height)

    def filterText(self, index):        
        if 0 <= index < len(self._editors):
            return self._editors[index].text()
        return ''

    def setFilterText(self, index, text):
        if 0 <= index < len(self._editors):
            self._editors[index].setText(text)

    def clearFilters(self):        
        for editor in self._editors:
            editor.clear()


class HumanProxyModel(QtCore.QSortFilterProxyModel):
    def lessThan(self, source_left, source_right):
        data_left = source_left.data()
        data_right = source_right.data()
        if type(data_left) == type(data_right) == str:
            return _human_key(data_left) < _human_key(data_right)
        return super(HumanProxyModel, self).lessThan(source_left, source_right)

    @property
    def filters(self):        
        if not hasattr(self, "_filters"):
            self._filters = []        
        return self._filters

    @filters.setter
    def filters(self, filters):
        self._filters = filters
        self.invalidateFilter()

    def filterAcceptsRow(self, sourceRow, sourceParent):            
        for i, text in self.filters:
            if 0 <= i < self.columnCount():            
                ix = self.sourceModel().index(sourceRow, i, sourceParent)
                data = ix.data()
                if text not in data:                    
                    return False        
        return True                

class winMain(QtWidgets.QMainWindow):
    cur_row = -1
    row_id = -1

    def __init__(self, parent=None):        
        super().__init__(parent)                
        self.setupUi()
        self.setGeometry(300,200,700,500)

        self.treeView.selectionModel().selectionChanged.connect(self.item_selection_changed_slot)        
        self.center()
        self.show()                

    def new_dataset(self):
        print("new_dataset() called.")            

        # get new row
        row = modelTable.rowCount()
        new_row = row+1
        self.cur_row = new_row        

        # get next free row id
        model = QtSql.QSqlQueryModel()
        model.setQuery("SELECT max(id)+1 FROM person")
        self.row_id = model.data(model.index(0, 0))        

        # insert a new row with dummy data
        modelTable.insertRow(row)
        modelTable.setData(modelTable.index(row,0), self.row_id, QtCore.Qt.EditRole)
        modelTable.setData(modelTable.index(row,1), "new" + str(self.row_id), QtCore.Qt.EditRole)
        modelTable.setData(modelTable.index(row,2), "new" + str(self.row_id), QtCore.Qt.EditRole)
        modelTable.setData(modelTable.index(row,3), "new" + str(self.row_id), QtCore.Qt.EditRole)
        modelTable.setData(modelTable.index(row,4), 2, QtCore.Qt.EditRole)

        modelTable.submitAll()        

    def handleFilterActivated(self):        
        header = self.treeView.header()
        filters = []
        for i in range(header.count()):
            text = header.filterText(i)
            if text:
                filters.append((i, text))
        proxy = self.treeView.model()        
        proxy.filters = filters

    QtCore.pyqtSlot()
    def item_selection_changed_slot(self):
        selected = self.treeView.selectionModel()
        indexes = selected.selectedIndexes()        

        sourceIdx = self.treeView.currentIndex()
        ix = self.treeView.model().index(sourceIdx.row(), 0)  # column which contains the id

        self.cur_row = sourceIdx.row()
        self.row_id = ix.data()

        record = modelTable.record(self.cur_row)

        persId = record.value("persId")
        lastName = record.value("lastName")
        firstName = record.value("firstName")
        country = record.value("name")
        print(f"{persId} - {lastName}, {firstName} from {country} selected.")

    def keyReleaseEvent(self, eventQKeyEvent):                
        key = eventQKeyEvent.key()
        modifiers = QtWidgets.QApplication.keyboardModifiers()
        if modifiers == QtCore.Qt.ShiftModifier and key == QtCore.Qt.Key_Escape:            
                self.clear_all_filters()


    def center(self):
        frameGm = self.frameGeometry()
        screen = QtWidgets.QApplication.desktop().screenNumber(QtWidgets.QApplication.desktop().cursor().pos())
        centerPoint = QtWidgets.QApplication.desktop().screenGeometry(screen).center()
        frameGm.moveCenter(centerPoint)
        self.move(frameGm.topLeft())

    def setupUi(self):
        self.centralwidget = QtWidgets.QWidget(self)
        self.horizontalLayout = QtWidgets.QHBoxLayout(self.centralwidget)

        self.treeView = QtWidgets.QTreeView(self.centralwidget)      

        self.treeView.setRootIsDecorated(False)                      
        self.treeView.setSortingEnabled(True)
        self.treeView.setAlternatingRowColors(True)        
        self.treeView.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
        self.treeView.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
        self.treeView.header().setStretchLastSection(True)           

        self.horizontalLayout.addWidget(self.treeView)
        self.setCentralWidget(self.centralwidget)

        header = FilterHeader(self.treeView)        
        self.treeView.setHeader(header)         

        # ToolBar        
        newDatasetAct = QtWidgets.QAction(QtGui.QIcon('img/icons8-new-file-50.png'), 'New dataset (CTRL+N)', self)
        newDatasetAct.setShortcut('Ctrl+N')
        newDatasetAct.triggered.connect(self.new_dataset)

        self.toolbar = self.addToolBar('Main')        
        self.toolbar.addAction(newDatasetAct)

        modelTable.setTable("person")

        modelTable.setRelation(4, QtSql.QSqlRelation("country", "id", "name"));

        modelTable.setEditStrategy(QtSql.QSqlTableModel.OnManualSubmit)

        self.treeView.setModel(modelTable) # display data of the SQLTableModel into the QTreeView      

        # enable human sorting                
        proxy = HumanProxyModel(self)
        proxy.setSourceModel(modelTable)
        self.treeView.setModel(proxy)

        # enable filtering
        header.setFilterBoxes(modelTable.columnCount())
        header.filterActivated.connect(self.handleFilterActivated)        

def create_sample_data():     
    modelQuery.setQuery("""CREATE TABLE IF NOT EXISTS country (                                    
                                    id   INTEGER PRIMARY KEY UNIQUE NOT NULL,
                                    name TEXT
                                    )""")

    # id         INTEGER PRIMARY KEY UNIQUE,
    modelQuery.setQuery("""CREATE TABLE IF NOT EXISTS person (
                                   id         INTEGER PRIMARY KEY UNIQUE NOT NULL,
                                   persId     TEXT,
                                   lastName   TEXT,
                                   firstName  TEXT,
                                   country_id INTEGER NOT NULL DEFAULT 3,
              FOREIGN KEY (country_id) REFERENCES country(id)
                                   )""")

    # create some sample data for our model
    modelQuery.setQuery("INSERT INTO country (id, name) VALUES (0, 'None')")    
    modelQuery.setQuery("INSERT INTO country (id, name) VALUES (1, 'Angola')")    
    modelQuery.setQuery("INSERT INTO country (id, name) VALUES (2, 'Serbia')")
    modelQuery.setQuery("INSERT INTO country (id, name) VALUES (3, 'Georgia')")

    modelQuery.setQuery("INSERT INTO person (id, persId, lastName, firstName, country_id) VALUES (1, '1001', 'Martin', 'Robert', 1)")
    modelQuery.setQuery("INSERT INTO person (id, persId, lastName, firstName, country_id) VALUES (2, '1002', 'Smith', 'Brad', 2)")
    modelQuery.setQuery("INSERT INTO person (id, persId, lastName, firstName, country_id) VALUES (3, '1003', 'Smith', 'Angelina', 3)")

if __name__ == '__main__':                         
    app = QtWidgets.QApplication(sys.argv)             

    create_sample_data()

    window = winMain()    
    sys.exit(app.exec_())    

Solution

  • The problem comes from the call to self.columnCount() in filterAcceptsRow. columnCount of a QSortFilterProxyModel requires a mapping of the current proxy model, which in turn calls filterAcceptsRow again, making the function recursive.

    Use if 0 <= i < self.sourceModel().columnCount() and the problem is solved.