【问题标题】:Inconsistent behavior of QSqlTableModel with OnRowSubmitQSqlTableModel 与 OnRowSubmit 的行为不一致
【发布时间】:2022-01-24 02:03:19
【问题描述】:

前提:这个问题可能指的是两个不同的问题,但我相信它们可能是有联系的。如果在 cmets 和进一步研究之后我们会发现它们实际上是不相关的,我将打开一个单独的问题。

我在 QSqlTableModel 的某些方面遇到了一些意外和奇怪的行为,并且至少在一种情况下使用子类化。我不是 Sql 专家,但其中一个问题似乎不是预期的行为。

我只能为 SQLite 确认这一点,因为我不使用其他数据库系统。
我还可以使用 [Py]Qt 5.15.2 和 6.2.2 重现这些问题。

1。忽略编辑器更改后,新行被“删除”

使用默认的OnRowChange编辑策略,如果添加一行,则在字段中插入一些数据,使用Escanother字段的编辑/kbd>,然后从视图中删除 整个行。

不过,实际的数据库仍在更新,再次打开程序会显示之前“隐藏”的行,但已取消的字段除外。

from PyQt5 import QtWidgets, QtSql

class TestModel(QtSql.QSqlTableModel):
    def __init__(self):
        super().__init__()
        QtSql.QSqlQuery().exec(
            'CREATE TABLE IF NOT EXISTS test (name, value, data);')
        self.setTable('test')
        self.select()


app = QtWidgets.QApplication([])

db = QtSql.QSqlDatabase.addDatabase('QSQLITE')
db.setDatabaseName('test.db')
db.open()

win = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(win)
addButton = QtWidgets.QPushButton('Add row')
layout.addWidget(addButton)
table = QtWidgets.QTableView()
layout.addWidget(table)
model = TestModel()
table.setModel(model)

addButton.clicked.connect(lambda: model.insertRow(model.rowCount()))
app.aboutToQuit.connect(model.submitAll)

win.resize(640, 480)
win.show()
app.exec()

这些是重现问题的步骤:

  1. 用按钮添加一行;
  2. 编辑至少一个字段,但不是所有字段;
  3. 开始编辑空白字段;
  4. Esc;
  5. 关闭并重新启动程序;

在第 4 步之后,您会看到添加的行已从视图中删除,这并不完全出乎意料:由于策略是 OnRowChange,因此取消会还原所有缓存的更改(包括 insertRow());我不完全同意这种行为(想象一下填写几十个字段然后错误地按 Esc),但这不是重点。
出乎意料的是,模型实际上已经更新了新的行和在点击Esc之前已经提交的所有字段,并且重新启动程序会显示这一点。

2。实现data() 将不完整的记录恢复到以前的数据

编辑其行中包含空 (NULL) 字段的索引会带来不同的结果,无论 data() 是否已在子类中实现,即使覆盖只是调用基本实现 .

将以下内容添加到上面的TestModel 类中:

    def data(self, index, role=QtCore.Qt.DisplayRole):
        return super().data(index, role)

还有app.exec()之前的提交按钮:

submitButton = QtWidgets.QPushButton('Submit')
layout.addWidget(submitButton)
submitButton.clicked.connect(model.submitAll)

要重现问题,请按以下步骤操作:

  1. 打开一个数据库,其中至少有一行底部有一个空字段,类似于上面所做的(注意:“空字段”是指一个从未被编辑过的项目);
  2. 编辑该行中的任何字段并按Enter;

使用OnRowChangeOnFieldChange 策略,结果是整行无效:垂直标题显示“!” (无效记录的提示)和所有字段被清除,包括那些具有数据库先前值的字段。

当编辑策略设置为OnManualSubmit 时,调用submitAll() 将恢复为数据库的原始值,就像更改已恢复一样。

如果底部为空字段的行不是,则行为略有不同;执行上面的前两个步骤,然后:

  1. 按下提交按钮;
  2. 关闭并重新启动程序;

在这种情况下,在第 3 步之后视图似乎已经接受了更改,但重新启动程序显示没有应用任何修改。


根据编辑策略和情况,行为会发生变化。通常,如果一条具有空字段的记录后跟至少一条设置了所有字段的记录,则在取消对该字段的编辑时,视图和模型会按预期运行。
至少在一种情况下,甚至根本不可能编辑一个空字段(我不得不承认,我做了很多随机/速度测试,当我发现我无法编辑一个我不记得的字段时重现它的步骤)。

同样奇怪的是setData()submitAll()都返回True,而没有明确的lastError()。尽管如此,显示的(和存储的)数据会恢复到以前的数据库内容。

我相信这两个问题都可能是由一个常见的错误引起的,但是,在向 Qt 错误报告系统提交一些东西之前,我想得到一些反馈,特别是来自那些在 SQL 和其他数据库驱动程序方面更有经验的人,在为了提供更好的报告(并最终知道这些问题是否实际上相关)。

【问题讨论】:

  • 再次试验后,我发现行为发生了很大变化。这发生在我对我的 arch linux 系统进行了广泛更新之后,其中包括对 qt5 和 pyqt5 的一些升级(以及许多其他内容)。我现在可以重现您问题中提到的许多问题。作为记录,我之前使用的是 qt5-base 5.15.2+kde+r263-1,现在使用的是 qt5-base 5.15.2+kde+r268-1。由于这使我早期的大部分 cmets 都过时了,因此我已将其删除。
  • @ekhumoro 非常感谢你之前的 cmets(我读过,但我没有足够的时间或精力来回复你[抱歉] 我不想添加不必要的 cmets,直到我能够提供有效的响应)。从您的最后评论看来,行为并没有因为实际的 Qt 版本而改变,而是来自配置的“混合”,这让我更加困惑:AFAIK Qt 使用自己的驱动程序,但我一直认为那些是直接“捆绑”在发行版中,并且它们的开发与框架的其余部分一起集中。
  • 明确一点:我说的是this patch collection,在LTS version of Qt5 became commercial only 之后变得必要。
  • 好的,这可能解释了为什么我们并不总是看到完全相同的东西。明天我会尝试做更多的实验并确认我现在看到的行为。
  • @ekhumoro 非常感谢!顺便说一句,我并不着急,而且我知道这类问题并不容易处理:如果您正在度假,请花点时间享受您的圣日......并致以最良好的祝愿! :-)

标签: python sqlite qt pyqt qsqltablemodel


【解决方案1】:

这两个问题都是由 Qt 中的错误引起的,但它们并不相关。

在解释这些问题之前,对垂直标题中使用的符号进行一些澄清可能会有所帮助,因为它们提供了有关问题根源的一些重要线索。符号是documented thus:

如果您以编程方式使用 QSqlTableModel::insertRows(),新行将被标记为 星号 (*) 直到使用 submitAll() 提交或 当用户移动到另一条记录时自动(假设编辑 策略是 QSqlTableModel::OnRowChange)。同样,如果您删除行 使用 removeRows(),行将用感叹号标记 (!) 直到提交更改。

第一个问题是由这一系列事件引起的:

在编辑新行的同时按 Esc 后(即 * 显示在垂直标题中),代理将发出 closeEditorRevertModelCache 提示。这会调用视图的closeEditor 插槽,而后者又会调用表模型上的revert() - 最终还会调用私有revertCachedRow 函数。这个函数调用beginRemoveRows——但关键是清除缓存之前。接下来,发出rowsAboutToBeRemoved,从视图中删除行,导致发出currentRowChanged,然后调用表模型的submit()槽。哎呀!仍然未清除的缓存数据现在无意中提交到数据库,在最终删除缓存数据后调用 endRemoveRows 之前。所以,简而言之,这里的 bug 是在 revert() 的执行过程中没有警卫来阻止 submit() 被调用。

第二个问题要微妙得多。出现此问题的原因是 SQL 表是在没有主键的情况下创建的,并且列没有显式类型。这一切都完全有效,但它在构建 SQL 语句的一小部分 Qt 代码中暴露了一个严重错误。

这发生在QSqlTableModel::selectRow 中,它需要从primaryValues 返回的QSqlRecord 构建一个where 子句。数据库驱动程序的sqlStatement 函数用于此目的,但需要知道字段值的确切类型才能正确引用它们。但是,表模型缓存不能确保对没有显式类型的列使用合理的默认类型。这意味着无类型的值将通过无引号传递,允许在编辑表时评估任意 SQL 表达式。糟糕!

这有时会使错误难以重现,因为确切的行为取决于输入的精确值。 foo 之类的值会导致 SQL 错误,因为它是一个不存在的有效列名;然而像6 这样的值不会引发错误,但由于类型不匹配而错误地无法返回任何行(即INT vs @ 987654343@)。如果selectRow 找不到相关行,它可能会调用cache.refresh(),这将清除值并标记要删除的行(因此在垂直标题中显示!)。另请注意,QSqlQuery 用于执行有问题的语句,因此任何错误都会静默传递,并且无法通过数据库或驱动程序获得。

我在原始示例的下方提供了重写,其中包含一些可以通过命令行打开的修复程序(1 修复第一个问题,2 修复第二个问题,3修复两者)。这些主要用于调试,但如果需要,也可以作为变通方法进行调整。第二个修复相当hackish(因为primaryValues 不能在PyQt 中重新实现) - 但只有在您无法控制数据库模式时才需要它。如果表具有类型化的主键和/或所有列都具有显式类型,则根本不会发生第二个问题。希望脚本的输出能够清楚地说明发生了什么。

PyQt5

import sys
from PyQt5 import QtCore, QtWidgets, QtSql

BUGFIX = int(sys.argv[1]) if len(sys.argv) > 1 else 0

class TestModel(QtSql.QSqlTableModel):
    def __init__(self):
        super().__init__()
        self._select_row = None
        self._reverting = False
        QtSql.QSqlQuery().exec(
            'CREATE TABLE IF NOT EXISTS test (name, value, data);')
        self.setTable('test')
        self.select()

    def selectRow(self, row):
        if BUGFIX & 2:
            self._select_row = row
        result = super().selectRow(row)
        print(f'selectRow: {result}')
        return result

    def select(self):
        return super().select() if self._select_row is None else False

    def selectStatement(self):
        if self._select_row is not None:
            record = self.primaryValues(self._select_row)
            for index in range(record.count()):
                field = record.field(index)
                if (not field.isNull() and
                    field.type() == QtCore.QVariant.Invalid):
                    field.setType(QtCore.QVariant.String)
                    record.replace(index, field)
            where = self.database().driver().sqlStatement(
                QtSql.QSqlDriver.WhereStatement,
                self.tableName(), record, False)
            if where[:6].upper() == 'WHERE ':
                where = where[6:]
            self.setFilter(where)
            self._select_row = None
        statement = super().selectStatement()
        print(f'selectStatement: {statement!r}')
        query = self.database().exec(statement)
        if query.lastError().isValid():
            print(f'  query-lastError: {query.lastError().text()!r}')
        else:
            print(f'  query-next: {query.next()}')
        return statement

    def revert(self):
        if BUGFIX & 1:
            self._reverting = True
        print('reverting ...')
        super().revert()
        self._reverting = False
        print('reverted')

    def submit(self):
        print('submitting ...')
        result = False if self._reverting else super().submit()
        print(f'submitted: {result}')
        return result

app = QtWidgets.QApplication(['Test'])

db = QtSql.QSqlDatabase.addDatabase('QSQLITE')
db.setDatabaseName('test.db')
db.open()

win = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(win)
addButton = QtWidgets.QPushButton('Add row')
layout.addWidget(addButton)
table = QtWidgets.QTableView()
layout.addWidget(table)
model = TestModel()
table.setModel(model)
submitButton = QtWidgets.QPushButton('Submit')
layout.addWidget(submitButton)
submitButton.clicked.connect(model.submitAll)
addButton.clicked.connect(lambda: model.insertRow(model.rowCount()))
app.aboutToQuit.connect(model.submitAll)

win.setGeometry(1000, 50, 640, 480)
win.show()
app.exec()

PyQt6

import sys
from PyQt6 import QtCore, QtWidgets, QtSql

BUGFIX = int(sys.argv[1]) if len(sys.argv) > 1 else 0

class TestModel(QtSql.QSqlTableModel):
    def __init__(self):
        super().__init__()
        self._select_row = None
        self._reverting = False
        QtSql.QSqlQuery().exec(
            'CREATE TABLE IF NOT EXISTS test (name, value, data);')
        self.setTable('test')
        self.select()

    def selectRow(self, row):
        if BUGFIX & 2:
            self._select_row = row
        result = super().selectRow(row)
        print(f'selectRow: {result}')
        return result

    def select(self):
        return super().select() if self._select_row is None else False

    def selectStatement(self):
        if self._select_row is not None:
            record = self.primaryValues(self._select_row)
            MetaType = QtCore.QMetaType.Type
            MetaString = QtCore.QMetaType(MetaType.QString.value)
            for index in range(record.count()):
                field = record.field(index)
                if (not field.isNull() and
                    field.metaType().id() == MetaType.UnknownType.value):
                    field.setMetaType(MetaString)
                    record.replace(index, field)
            where = self.database().driver().sqlStatement(
                QtSql.QSqlDriver.StatementType.WhereStatement,
                self.tableName(), record, False)
            if where[:6].upper() == 'WHERE ':
                where = where[6:]
            self.setFilter(where)
            self._select_row = None
        statement = super().selectStatement()
        print(f'selectStatement: {statement!r}')
        query = self.database().exec(statement)
        if query.lastError().isValid():
            print(f'  query-lastError: {query.lastError().text()!r}')
        else:
            print(f'  query-next: {query.next()}')
        return statement

    def revert(self):
        if BUGFIX & 1:
            self._reverting = True
        print('reverting ...')
        super().revert()
        self._reverting = False
        print('reverted')

    def submit(self):
        print('submitting ...')
        result = False if self._reverting else super().submit()
        print(f'submitted: {result}')
        return result

app = QtWidgets.QApplication(['Test'])

db = QtSql.QSqlDatabase.addDatabase('QSQLITE')
db.setDatabaseName('test.db')
db.open()

win = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(win)
addButton = QtWidgets.QPushButton('Add row')
layout.addWidget(addButton)
table = QtWidgets.QTableView()
layout.addWidget(table)
model = TestModel()
table.setModel(model)
submitButton = QtWidgets.QPushButton('Submit')
layout.addWidget(submitButton)
submitButton.clicked.connect(model.submitAll)
addButton.clicked.connect(lambda: model.insertRow(model.rowCount()))
app.aboutToQuit.connect(model.submitAll)

win.setGeometry(1000, 50, 640, 480)
win.show()
app.exec()

【讨论】:

  • 非常感谢您的彻底回答和测试。我会花时间自己仔细研究和测试它,因为我也想用 Qt6 检查它(考虑到可能不会再有 Qt5 的操作系统版本,如果这确实在 Qt6 上得到修复,我怀疑这种行为无论如何都会被修复)。
  • @musicamante Bah,我忘了 Qt6!我添加了一个单独的 PyQt6 测试脚本,由于对QSqlField API 的重大更改而需要该脚本。但是,底层源代码的相关部分没有改变(从 Qt-6.2.2 开始),所以我的答案的其余部分应该不受影响。请注意,Qt5 LTS 支持运行至 2023 年 6 月,因此仍有可能向后移植修复程序 - 尤其是。因为第二个问题可以被视为一个安全漏洞(尽管实际上我不确定它的可利用性)。
猜你喜欢
  • 2010-12-23
  • 2013-09-02
  • 2020-02-28
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2016-05-20
相关资源
最近更新 更多