Reputation: 11
I have a QComboBox populated from a relationModel of a QSqlRelationalTableModel and connected to a QDataWidgetMapper.
I select a row in the QTableView, this row (record) mapped to the QLineEdit and QComboBox widgets then I make some changes and save.
If I select another row and save without changing the QComboBox value, the value changes and submitted to the model.
I use the editable combobox not for adding items to the list, but to use the completer feature when I have a large list instead of dropping down the combobox view
Creating the db:
import sqlite3
conn = sqlite3.connect('customers.db')
c = conn.cursor()
c.execute("PRAGMA foreign_keys=on;")
c.execute("""CREATE TABLE IF NOT EXISTS provinces (
ProvinceId TEXT PRIMARY KEY,
Name TEXT NOT NULL
)""")
c.execute("""CREATE TABLE IF NOT EXISTS customers (
CustomerId TEXT PRIMARY KEY,
Name TEXT NOT NULL,
ProvinceId TEXT,
FOREIGN KEY (ProvinceId) REFERENCES provinces (ProvinceId)
ON UPDATE CASCADE
ON DELETE RESTRICT
)""")
c.execute("INSERT INTO provinces VALUES ('N', 'Northern')")
c.execute("INSERT INTO provinces VALUES ('E', 'Eastern')")
c.execute("INSERT INTO provinces VALUES ('W', 'Western')")
c.execute("INSERT INTO provinces VALUES ('S', 'Southern')")
c.execute("INSERT INTO provinces VALUES ('C', 'Central')")
c.execute("INSERT INTO customers VALUES ('1', 'customer1', 'N')")
c.execute("INSERT INTO customers VALUES ('2', 'customer2', 'E')")
c.execute("INSERT INTO customers VALUES ('3', 'customer3', 'W')")
c.execute("INSERT INTO customers VALUES ('4', 'customer4', 'S')")
c.execute("INSERT INTO customers VALUES ('5', 'customer5', 'C')")
conn.commit()
conn.close()
and here is the window:
from PyQt5.QtWidgets import *
from PyQt5.QtSql import *
class Window(QWidget):
def __init__(self):
super().__init__()
self.db = QSqlDatabase.addDatabase("QSQLITE")
self.db.setDatabaseName("customers.db")
self.db.open()
self.model = QSqlRelationalTableModel(self, self.db)
self.model.setTable("customers")
self.model.setRelation(2, QSqlRelation("provinces", "ProvinceId", "Name"))
self.model.setEditStrategy(QSqlTableModel.EditStrategy.OnManualSubmit)
self.model.select()
self.id = QLineEdit()
self.name = QLineEdit()
self.province = QComboBox()
# stuck here
self.province.setEditable(True)
self.province.setModel(self.model.relationModel(2))
self.province.setModelColumn(1)
self.province.setView(QTableView())
self.mapper = QDataWidgetMapper()
self.mapper.setItemDelegate(QSqlRelationalDelegate())
self.mapper.setModel(self.model)
self.mapper.addMapping(self.id, 0)
self.mapper.addMapping(self.name, 1)
self.mapper.addMapping(self.province, 2)
save = QPushButton("Save")
save.clicked.connect(self.submit)
self.tableView = QTableView()
self.tableView.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.tableView.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
self.tableView.setModel(self.model)
self.tableView.clicked.connect(lambda: self.mapper.setCurrentModelIndex(self.tableView.currentIndex()))
vBox = QVBoxLayout()
vBox.addWidget(self.id)
vBox.addWidget(self.name)
vBox.addWidget(self.province)
vBox.addSpacing(20)
vBox.addWidget(save)
vBox.addWidget(self.tableView)
self.setLayout(vBox)
self.mapper.toFirst()
def submit(self):
self.mapper.submit()
self.model.submitAll()
def main():
import sys
App = QApplication(sys.argv)
window = Window()
window.show()
sys.exit(App.exec_())
if __name__ == '__main__':
main()
Upvotes: 0
Views: 707
Reputation: 48509
An important thing to consider is that item delegates (and, specifically, QSqlRelationalDelegate) use the widget's user property to read and write data from and to the widget.
The user property of QComboBox is currentText
; if it's not editable, its value is an empty string (for -1 index) or the current item's text, and setting that property results in the combo trying to look for the first item that fully matches that text, and changes the current index if a match is found.
When the combo is editable, though, only the text is changed, not the current index, and it's also possible to set
Now, after some digging, I found various "culprits" to the issue you're facing.
QDataWidgetMapper uses the EditRole
to both commit data and populate widgets. This clearly represents a problem since the edit role is what the relational model uses for the actual data set on the model (eg. "S" for Southern), while the display role is what is used to display the related value.
The result of all the above aspects is that, assuming the combo is not changed by the user:
setModelData()
;setEditorData()
;Also note that, until Qt 5.12 (see QTBUG-59632), the above caused a further issue as the default implementation of setEditorData
uses the edit role, so the editable combo would also get the related letter instead of the actual value display.
Considering the above, there are two options:
setModelData()
by matching the current text and using the relation modelfrom PyQt5.QtCore import *
_version = tuple(map(int, QT_VERSION_STR.split('.')))
class Delegate(QSqlRelationalDelegate):
def setModelData(self, editor, model, index):
if isinstance(editor, QComboBox):
value = editor.currentText()
if not value:
return
childModel = model.relationModel(index.column())
for column in range(2):
match = childModel.match(childModel.index(0, column),
Qt.DisplayRole, value, Qt.MatchStartsWith)
if match:
match = match[0]
displayValue = match.sibling(match.row(), 1).data()
editValue = match.sibling(match.row(), 0).data()
model.setData(index, displayValue, Qt.DisplayRole)
model.setData(index, editValue, Qt.EditRole)
return
super().setModelData(editor, model, index)
if _version[1] < 12:
# fix for old Qt versions that don't properly update the QComboBox
def setEditorData(self, editor, index):
if isinstance(editor, QComboBox):
value = index.data()
if isinstance(value, str):
propName = editor.metaObject().userProperty().name()
editor.setProperty(propName, value)
else:
super().setEditorData(editor, index)
setModelData
to override the default behavior for QComboBoxclass MapperCombo(QComboBox):
@pyqtProperty(str, user=True)
def mapperText(self):
text = self.currentText()
if text == self.currentData(Qt.DisplayRole):
return text
model = self.model()
for column in range(2):
match = model.match(model.index(0, column),
Qt.DisplayRole, text, Qt.MatchStartsWith)
if match:
self.setCurrentIndex(match[0].row())
return self.currentText()
return self.itemText(self.currentIndex())
@mapperText.setter
def mapperText(self, text):
model = self.model()
for column in range(2):
match = model.match(model.index(0, column),
Qt.DisplayRole, text, Qt.MatchStartsWith)
if match:
index = match[0].row()
break
else:
index = 0
if index != self.currentIndex():
self.setCurrentIndex(index)
else:
self.setCurrentText(self.currentData(Qt.DisplayRole))
@property
def mapperValue(self):
return self.model().data(self.model().index(
self.currentIndex(), 0), Qt.DisplayRole)
class Delegate(QSqlRelationalDelegate):
def setModelData(self, editor, model, index):
if isinstance(editor, MapperCombo):
model.setData(index, editor.mapperText, Qt.DisplayRole)
model.setData(index, editor.mapperValue, Qt.EditRole)
else:
super().setModelData(editor, model, index)
Finally, a QLineEdit with a proper QCompleter could be used, but this still requires subclassing the delegate, as setModelData
needs to use the proper string.
class Delegate(QSqlRelationalDelegate):
def setModelData(self, editor, model, index):
if model.relation(index.column()).isValid():
value = editor.text()
if value:
childModel = model.relationModel(index.column())
match = childModel.match(childModel.index(0, 1),
Qt.DisplayRole, value, Qt.MatchStartsWith)
if match:
childIndex = match[0]
model.setData(index, childIndex.data(), Qt.DisplayRole)
model.setData(index,
childIndex.sibling(childIndex.row(), 0).data(), Qt.EditRole)
editor.setText(childIndex.data())
else:
super().setModelData(editor, model, index)
Some further notes and suggestions:
ManualSubmit
policy (self.mapper.setSubmitPolicy(self.mapper.ManualSubmit)
), alternatively, you could subclass the model and find ways to visually display modified cells until the changes are submitted;clicked
already provides the new index: self.tableView.clicked.connect(self.mapper.setCurrentModelIndex)
def submit(self):
current = self.mapper.currentIndex()
self.mapper.submit()
self.model.submitAll()
self.mapper.setCurrentIndex(current)
Upvotes: 0