From eec1d8c0ad54bd0b70dfd1d2eaaa32e09722ea31 Mon Sep 17 00:00:00 2001 From: Antti Soininen Date: Thu, 18 Apr 2024 13:11:28 +0300 Subject: [PATCH] Redesign Commit viewer to scale better with large databases - Affected items are now listed in tables and the tables are tabbed by item type. The previous tree view could not really handle large number of items usability or performance wise. - Affected items are now fetched in a worker thread to keep the window responsive. - Parameter values are now shown like in the Parameter value table in DB editor proper instead of showing the JSON representation. This seems to give a huge performance boost: apparently Qt is not great when it needs to render large amounts of text in a tiny table cell. Re #26062 --- CHANGELOG.md | 1 + benchmarks/db_mngr_get_value.py | 17 +- .../mvcmodels/parameter_value_list_item.py | 3 +- .../mvcmodels/pivot_table_models.py | 3 +- .../mvcmodels/single_models.py | 3 +- .../ui/commit_viewer_affected_item_info.py | 60 ++++ .../ui/commit_viewer_affected_item_info.ui | 56 ++++ .../spine_db_editor/ui/db_commit_viewer.py | 95 ++++-- .../spine_db_editor/ui/db_commit_viewer.ui | 246 ++++++++++---- .../spine_db_editor/widgets/commit_viewer.py | 315 ++++++++++++++---- .../spine_db_editor/widgets/custom_menus.py | 2 +- spinetoolbox/spine_db_manager.py | 149 +++++---- .../widgets/test_commit_viewer.py | 19 +- tests/test_SpineDBManager.py | 143 +++++--- 14 files changed, 813 insertions(+), 299 deletions(-) create mode 100644 spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.py create mode 100644 spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.ui diff --git a/CHANGELOG.md b/CHANGELOG.md index cf457c700..5a01fee99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Many parts of the Spine data structure have been redesigned. - You can now select a different Julia executable & project or Julia kernel for each Tool spec. This overrides the global setting from Toolbox Settings. - Headless mode now supports remote execution (see 'python -m spinetoolbox --help') +- Commit Viewer's UI has undergone some redesigning and can now handle large databases. ### Deprecated diff --git a/benchmarks/db_mngr_get_value.py b/benchmarks/db_mngr_get_value.py index c3d020c29..15e23bc57 100644 --- a/benchmarks/db_mngr_get_value.py +++ b/benchmarks/db_mngr_get_value.py @@ -4,6 +4,8 @@ import os import sys +from spinedb_api.db_mapping_base import PublicItem + if sys.platform == "win32" and "HOMEPATH" not in os.environ: import pathlib os.environ["HOMEPATH"] = str(pathlib.Path(sys.executable).parent) @@ -15,18 +17,17 @@ from PySide6.QtWidgets import QApplication from spinetoolbox.spine_db_manager import SpineDBManager from spinedb_api import DatabaseMapping, to_database -from spinedb_api.temp_id import TempId from benchmarks.utils import StdOutLogger def db_mngr_get_value( - loops: int, db_mngr: SpineDBManager, db_map: DatabaseMapping, ids: Sequence[TempId], role: Qt.ItemDataRole + loops: int, db_mngr: SpineDBManager, db_map: DatabaseMapping, items: Sequence[PublicItem], role: Qt.ItemDataRole ) -> float: duration = 0.0 for _ in range(loops): - for id_ in ids: + for item in items: start = time.perf_counter() - db_mngr.get_value(db_map, "parameter_value", id_, role) + db_mngr.get_value(db_map, item, role) duration += time.perf_counter() - start return duration @@ -40,7 +41,7 @@ def run_benchmark(output_file: Optional[str]): db_map.add_entity_class_item(name="Object") db_map.add_parameter_definition_item(name="x", entity_class_name="Object") db_map.add_entity_item(name="object", entity_class_name="Object") - value_ids = [] + value_items = [] for i in range(100): alternative_name = str(i) db_map.add_alternative_item(name=str(i)) @@ -54,16 +55,16 @@ def run_benchmark(output_file: Optional[str]): type=value_type, ) assert error is None - value_ids.append(item["id"]) + value_items.append(item) runner = pyperf.Runner() benchmark = runner.bench_time_func( "SpineDatabaseManager.get_value[parameter_value, DisplayRole]", db_mngr_get_value, db_mngr, db_map, - value_ids, + value_items, Qt.ItemDataRole.DisplayRole, - inner_loops=len(value_ids), + inner_loops=len(value_items), ) if output_file: pyperf.add_runs(output_file, benchmark) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_item.py b/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_item.py index d6fa065a2..03ae1dbfc 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_item.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_item.py @@ -104,7 +104,8 @@ def data(self, column, role=Qt.ItemDataRole.DisplayRole): if role == Qt.ItemDataRole.DisplayRole and not self.id: return "Enter new list value here..." if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole, Qt.ItemDataRole.ToolTipRole, PARSED_ROLE): - return self.db_mngr.get_value(self.db_map, self.item_type, self.id, role=role) + item = self.db_mngr.get_item(self.db_map, self.item_type, self.id) + return self.db_mngr.get_value(self.db_map, item, role=role) return super().data(column, role) def list_index(self): diff --git a/spinetoolbox/spine_db_editor/mvcmodels/pivot_table_models.py b/spinetoolbox/spine_db_editor/mvcmodels/pivot_table_models.py index eeb0915a8..37e191e03 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/pivot_table_models.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/pivot_table_models.py @@ -1270,7 +1270,8 @@ def _data(self, index, role): if data[0][0] is None: return None db_map, id_ = data[0][0] - return self.db_mngr.get_value(db_map, "parameter_value", id_, role) + item = self.db_mngr.get_item(db_map, "parameter_value", id_) + return self.db_mngr.get_value(db_map, item, role) def _do_batch_set_inner_data(self, row_map, column_map, data, values): return self._batch_set_parameter_value_data(row_map, column_map, data, values) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/single_models.py b/spinetoolbox/spine_db_editor/mvcmodels/single_models.py index e009fe6ad..241527bd8 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/single_models.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/single_models.py @@ -329,7 +329,8 @@ def data(self, index, role=Qt.ItemDataRole.DisplayRole): PARSED_ROLE, ): id_ = self._main_data[index.row()] - return self.db_mngr.get_value(self.db_map, self.item_type, id_, role) + item = self.db_mngr.get_item(self.db_map, self.item_type, id_) + return self.db_mngr.get_value(self.db_map, item, role) return super().data(index, role) diff --git a/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.py b/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.py new file mode 100644 index 000000000..cd6b79a97 --- /dev/null +++ b/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# This file is part of Spine Toolbox. +# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) +# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General +# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with +# this program. If not, see . +###################################################################################################################### + +################################################################################ +## Form generated from reading UI file 'commit_viewer_affected_item_info.ui' +## +## Created by: Qt User Interface Compiler version 6.5.2 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, + QMetaObject, QObject, QPoint, QRect, + QSize, QTime, QUrl, Qt) +from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, + QFont, QFontDatabase, QGradient, QIcon, + QImage, QKeySequence, QLinearGradient, QPainter, + QPalette, QPixmap, QRadialGradient, QTransform) +from PySide6.QtWidgets import (QApplication, QHeaderView, QLabel, QSizePolicy, + QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) + +class Ui_Form(object): + def setupUi(self, Form): + if not Form.objectName(): + Form.setObjectName(u"Form") + Form.resize(400, 300) + self.verticalLayout = QVBoxLayout(Form) + self.verticalLayout.setObjectName(u"verticalLayout") + self.verticalLayout.setContentsMargins(0, 0, 0, 0) + self.affected_items_table = QTableWidget(Form) + self.affected_items_table.setObjectName(u"affected_items_table") + + self.verticalLayout.addWidget(self.affected_items_table) + + self.fetch_status_label = QLabel(Form) + self.fetch_status_label.setObjectName(u"fetch_status_label") + + self.verticalLayout.addWidget(self.fetch_status_label) + + + self.retranslateUi(Form) + + QMetaObject.connectSlotsByName(Form) + # setupUi + + def retranslateUi(self, Form): + Form.setWindowTitle(QCoreApplication.translate("Form", u"Form", None)) + self.fetch_status_label.setText(QCoreApplication.translate("Form", u"TextLabel", None)) + # retranslateUi + diff --git a/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.ui b/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.ui new file mode 100644 index 000000000..fe6cbacdc --- /dev/null +++ b/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.ui @@ -0,0 +1,56 @@ + + + + Form + + + + 0 + 0 + 400 + 300 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + TextLabel + + + + + + + + diff --git a/spinetoolbox/spine_db_editor/ui/db_commit_viewer.py b/spinetoolbox/spine_db_editor/ui/db_commit_viewer.py index b9d750e60..e3bdbbe7d 100644 --- a/spinetoolbox/spine_db_editor/ui/db_commit_viewer.py +++ b/spinetoolbox/spine_db_editor/ui/db_commit_viewer.py @@ -26,9 +26,10 @@ QFont, QFontDatabase, QGradient, QIcon, QImage, QKeySequence, QLinearGradient, QPainter, QPalette, QPixmap, QRadialGradient, QTransform) -from PySide6.QtWidgets import (QApplication, QHBoxLayout, QHeaderView, QSizePolicy, - QSplitter, QStackedWidget, QTextBrowser, QTreeWidget, - QTreeWidgetItem, QWidget) +from PySide6.QtWidgets import (QApplication, QFrame, QHBoxLayout, QHeaderView, + QLabel, QSizePolicy, QSplitter, QStackedWidget, + QTabWidget, QTextBrowser, QTreeWidget, QTreeWidgetItem, + QVBoxLayout, QWidget) class Ui_DBCommitViewer(object): def setupUi(self, DBCommitViewer): @@ -36,55 +37,107 @@ def setupUi(self, DBCommitViewer): DBCommitViewer.setObjectName(u"DBCommitViewer") DBCommitViewer.resize(716, 218) self.horizontalLayout_2 = QHBoxLayout(DBCommitViewer) - self.horizontalLayout_2.setSpacing(0) self.horizontalLayout_2.setObjectName(u"horizontalLayout_2") - self.horizontalLayout_2.setContentsMargins(0, 0, 0, 0) self.splitter = QSplitter(DBCommitViewer) self.splitter.setObjectName(u"splitter") self.splitter.setOrientation(Qt.Horizontal) self.commit_list = QTreeWidget(self.splitter) + __qtreewidgetitem = QTreeWidgetItem() + __qtreewidgetitem.setText(0, u"1"); + self.commit_list.setHeaderItem(__qtreewidgetitem) self.commit_list.setObjectName(u"commit_list") self.splitter.addWidget(self.commit_list) - self.affected_items_widget_stack = QStackedWidget(self.splitter) + self.verticalFrame = QFrame(self.splitter) + self.verticalFrame.setObjectName(u"verticalFrame") + self.verticalFrame.setStyleSheet(u"QFrame {\n" +" background-color: white;\n" +"}") + self.verticalFrame.setFrameShape(QFrame.Box) + self.verticalLayout = QVBoxLayout(self.verticalFrame) + self.verticalLayout.setSpacing(3) + self.verticalLayout.setObjectName(u"verticalLayout") + self.verticalLayout.setContentsMargins(5, 5, 5, 0) + self.label = QLabel(self.verticalFrame) + self.label.setObjectName(u"label") + + self.verticalLayout.addWidget(self.label) + + self.affected_items_widget_stack = QStackedWidget(self.verticalFrame) self.affected_items_widget_stack.setObjectName(u"affected_items_widget_stack") - self.page = QWidget() - self.page.setObjectName(u"page") - self.horizontalLayout_3 = QHBoxLayout(self.page) + self.items_page = QWidget() + self.items_page.setObjectName(u"items_page") + self.horizontalLayout_3 = QHBoxLayout(self.items_page) self.horizontalLayout_3.setSpacing(0) self.horizontalLayout_3.setObjectName(u"horizontalLayout_3") self.horizontalLayout_3.setContentsMargins(0, 0, 0, 0) - self.affected_items = QTreeWidget(self.page) - self.affected_items.setObjectName(u"affected_items") + self.affected_item_tab_widget = QTabWidget(self.items_page) + self.affected_item_tab_widget.setObjectName(u"affected_item_tab_widget") + self.affected_item_tab_widget.setDocumentMode(True) - self.horizontalLayout_3.addWidget(self.affected_items) + self.horizontalLayout_3.addWidget(self.affected_item_tab_widget) - self.affected_items_widget_stack.addWidget(self.page) - self.page_2 = QWidget() - self.page_2.setObjectName(u"page_2") - self.horizontalLayout = QHBoxLayout(self.page_2) + self.affected_items_widget_stack.addWidget(self.items_page) + self.no_items_page = QWidget() + self.no_items_page.setObjectName(u"no_items_page") + self.horizontalLayout = QHBoxLayout(self.no_items_page) self.horizontalLayout.setSpacing(0) self.horizontalLayout.setObjectName(u"horizontalLayout") self.horizontalLayout.setContentsMargins(0, 0, 0, 0) - self.no_affected_items_notice = QTextBrowser(self.page_2) + self.no_affected_items_notice = QTextBrowser(self.no_items_page) self.no_affected_items_notice.setObjectName(u"no_affected_items_notice") + self.no_affected_items_notice.setFocusPolicy(Qt.NoFocus) self.no_affected_items_notice.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.no_affected_items_notice.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.no_affected_items_notice.setOpenLinks(False) self.horizontalLayout.addWidget(self.no_affected_items_notice) - self.affected_items_widget_stack.addWidget(self.page_2) - self.splitter.addWidget(self.affected_items_widget_stack) + self.affected_items_widget_stack.addWidget(self.no_items_page) + self.loading_page = QWidget() + self.loading_page.setObjectName(u"loading_page") + self.horizontalLayout_4 = QHBoxLayout(self.loading_page) + self.horizontalLayout_4.setSpacing(0) + self.horizontalLayout_4.setObjectName(u"horizontalLayout_4") + self.horizontalLayout_4.setContentsMargins(0, 0, 0, 0) + self.loading_label = QLabel(self.loading_page) + self.loading_label.setObjectName(u"loading_label") + self.loading_label.setAlignment(Qt.AlignLeading|Qt.AlignLeft|Qt.AlignTop) + + self.horizontalLayout_4.addWidget(self.loading_label) + + self.affected_items_widget_stack.addWidget(self.loading_page) + self.no_commit_selected_page = QWidget() + self.no_commit_selected_page.setObjectName(u"no_commit_selected_page") + self.verticalLayout_2 = QVBoxLayout(self.no_commit_selected_page) + self.verticalLayout_2.setSpacing(0) + self.verticalLayout_2.setObjectName(u"verticalLayout_2") + self.verticalLayout_2.setContentsMargins(0, 0, 0, 0) + self.label_2 = QLabel(self.no_commit_selected_page) + self.label_2.setObjectName(u"label_2") + self.label_2.setAlignment(Qt.AlignLeading|Qt.AlignLeft|Qt.AlignTop) + + self.verticalLayout_2.addWidget(self.label_2) + + self.affected_items_widget_stack.addWidget(self.no_commit_selected_page) + + self.verticalLayout.addWidget(self.affected_items_widget_stack) + + self.splitter.addWidget(self.verticalFrame) self.horizontalLayout_2.addWidget(self.splitter) self.retranslateUi(DBCommitViewer) + self.affected_items_widget_stack.setCurrentIndex(3) + self.affected_item_tab_widget.setCurrentIndex(-1) + + QMetaObject.connectSlotsByName(DBCommitViewer) # setupUi def retranslateUi(self, DBCommitViewer): + self.label.setText(QCoreApplication.translate("DBCommitViewer", u"Affected items", None)) self.no_affected_items_notice.setHtml(QCoreApplication.translate("DBCommitViewer", u"\n" "\n" "

No affected items found for selected commit.

\n" -"

Note that we cannot show items that have been removed by this or a later commit.

", None)) +"

Note, that it is not possible to show items that have been removed by this or a later commit.

", None)) + self.loading_label.setText(QCoreApplication.translate("DBCommitViewer", u"Loading...", None)) + self.label_2.setText(QCoreApplication.translate("DBCommitViewer", u"Select a commit from the list on the left.", None)) pass # retranslateUi diff --git a/spinetoolbox/spine_db_editor/ui/db_commit_viewer.ui b/spinetoolbox/spine_db_editor/ui/db_commit_viewer.ui index 9a09ab605..e752a981d 100644 --- a/spinetoolbox/spine_db_editor/ui/db_commit_viewer.ui +++ b/spinetoolbox/spine_db_editor/ui/db_commit_viewer.ui @@ -24,77 +24,114 @@ - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - Qt::Horizontal - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::ScrollBarAlwaysOff - - - Qt::ScrollBarAlwaysOff - - - <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> + + + + 1 + + + + + + QFrame { + background-color: white; +} + + + QFrame::Box + + + + 3 + + + 5 + + + 5 + + + 5 + + + 0 + + + + + Affected items + + + + + + + 3 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + -1 + + + true + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::NoFocus + + + Qt::ScrollBarAlwaysOff + + + Qt::ScrollBarAlwaysOff + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> <html><head><meta name="qrichtext" content="1" /><meta charset="utf-8" /><style type="text/css"> p, li { white-space: pre-wrap; } hr { height: 1px; border-width: 0; } @@ -102,15 +139,76 @@ li.unchecked::marker { content: "\2610"; } li.checked::marker { content: "\2612"; } </style></head><body style=" font-family:'Segoe UI'; font-size:9pt; font-weight:400; font-style:normal;"> <p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">No affected items found for selected commit.</p> -<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Note that we cannot show items that have been removed by this or a later commit.</p></body></html> - - - false - +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Note, that it is not possible to show items that have been removed by this or a later commit.</p></body></html> + + + false + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Loading... + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Select a commit from the list on the left. + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + - - - + + + diff --git a/spinetoolbox/spine_db_editor/widgets/commit_viewer.py b/spinetoolbox/spine_db_editor/widgets/commit_viewer.py index 4576f9d63..431a632aa 100644 --- a/spinetoolbox/spine_db_editor/widgets/commit_viewer.py +++ b/spinetoolbox/spine_db_editor/widgets/commit_viewer.py @@ -10,23 +10,32 @@ # this program. If not, see . ###################################################################################################################### -"""Contains the CommitViewer class.""" +"""Contains Database editor's Commit viewer.""" from PySide6.QtWidgets import ( QMainWindow, + QTableWidget, + QTableWidgetItem, QTabWidget, QWidget, QGridLayout, - QTreeWidget, QTreeWidgetItem, QSplitter, QLabel, ) -from PySide6.QtCore import Qt, Slot -from spinetoolbox.helpers import restore_ui, save_ui, busy_effect, DB_ITEM_SEPARATOR +from PySide6.QtCore import QEventLoop, QObject, Qt, QThread, Signal, Slot +from spinetoolbox.helpers import restore_ui, save_ui, DB_ITEM_SEPARATOR class _DBCommitViewer(QWidget): + """Commit viewer's central widget.""" + def __init__(self, db_mngr, db_map, parent=None): + """ + Args: + db_mngr (SpineDBManager): database manager + db_map (DatabaseMapping): database mapping + parent (QWidget, optional): parent widget + """ from ..ui.db_commit_viewer import Ui_DBCommitViewer super().__init__(parent=parent) @@ -36,10 +45,10 @@ def __init__(self, db_mngr, db_map, parent=None): self._db_map = db_map self._ui.commit_list.setHeaderLabel("Commits") self._ui.commit_list.setIndentation(0) - self._ui.affected_items.setHeaderLabel("Affected items") self._ui.splitter.setSizes([0.3, 0.7]) self._ui.splitter.setStretchFactor(0, 0) self._ui.splitter.setStretchFactor(1, 1) + self._ui.affected_items_widget_stack.setCurrentIndex(3) for commit in reversed(db_map.get_items("commit")): tree_item = QTreeWidgetItem(self._ui.commit_list) tree_item.setData(0, Qt.ItemDataRole.UserRole + 1, commit["id"]) @@ -48,6 +57,11 @@ def __init__(self, db_mngr, db_map, parent=None): widget = _CommitItem(commit) self._ui.commit_list.setIndexWidget(index, widget) self._ui.commit_list.currentItemChanged.connect(self._select_commit) + self._ui.affected_item_tab_widget.tabBarClicked.connect(self._set_preferred_item_type) + self._affected_item_widgets = {} + self._preferred_affected_item_type = None + self._thread = None + self._worker = None @property def splitter(self) -> QSplitter: @@ -55,33 +69,132 @@ def splitter(self) -> QSplitter: @Slot(QTreeWidgetItem, QTreeWidgetItem) def _select_commit(self, current, previous): - self._ui.commit_list.setDisabled(True) - self._do_select_commit(current) - self._ui.commit_list.setEnabled(True) + """Start a worker thread that fetches affected items for the selected commit. - @busy_effect - def _do_select_commit(self, current): + Args: + current (QTreeWidgetItem): currently selected commit item + previous (QTreeWidgetItem): previously selected commit item + """ commit_id = current.data(0, Qt.ItemDataRole.UserRole + 1) - self._ui.affected_items_widget_stack.setCurrentIndex(0) - self._ui.affected_items.clear() - for item_type, ids in self._db_mngr.get_items_for_commit(self._db_map, commit_id).items(): - top_level_item = QTreeWidgetItem([item_type]) - self._ui.affected_items.addTopLevelItem(top_level_item) - bottom_level_item = QTreeWidgetItem(top_level_item) - bottom_level_item.setFlags(bottom_level_item.flags() & ~Qt.ItemIsSelectable) - index = self._ui.affected_items.indexFromItem(bottom_level_item) - items = [self._db_mngr.get_item(self._db_map, item_type, id_) for id_ in ids] - widget = _AffectedItemsFromOneTable(items, parent=self._ui.affected_items) - self._ui.affected_items.setIndexWidget(index, widget) - top_level_item.setExpanded(True) - if self._ui.affected_items.topLevelItemCount() == 0: - self._ui.affected_items_widget_stack.setCurrentIndex(1) + self._ui.affected_items_widget_stack.setCurrentIndex(2) + self._ui.affected_item_tab_widget.clear() + for widget in self._affected_item_widgets.values(): + widget.table.setRowCount(0) + self._launch_new_worker(commit_id) + + def _launch_new_worker(self, commit_id): + """Starts a new worker thread. + + If a thread is already running, it is quite before starting a new one. + + Args: + commit_id (TempId): commit id + """ + if self._thread is not None: + self._thread.quit() + self._thread.wait() + self._thread = QThread(self) + self._worker = Worker(self._db_mngr, self._db_map, commit_id) + self._worker.moveToThread(self._thread) + self._thread.started.connect(self._worker.run) + self._worker.chunk_ready.connect(self._process_affected_items) + self._worker.max_ids_reached.connect(self._max_affected_items_fetched) + self._worker.all_ids_fetched.connect(self._all_affected_items_fetched) + self._worker.finished.connect(self._finish_work) + self._thread.start() + + @Slot(str, list, list) + def _process_affected_items(self, item_type, keys, items): + """Adds a fetched chunk of affected items to appropriate table view. + + Args: + item_type (str): fethced item type + keys (Sequence of str): item keys + items (Sequence of Sequence): list of items, each item being a list of labels; + items must have the same length as keys + """ + affected_items_widget = self._affected_item_widgets.get(item_type) + if affected_items_widget is None: + affected_items_widget = _AffectedItemsWidget() + self._affected_item_widgets[item_type] = affected_items_widget + item_table = affected_items_widget.table + item_table.setColumnCount(len(keys)) + item_table.setHorizontalHeaderLabels(keys) + else: + item_table = affected_items_widget.table + if self._ui.affected_item_tab_widget.indexOf(affected_items_widget) == -1: + self._ui.affected_item_tab_widget.addTab(affected_items_widget, item_type) + if self._preferred_affected_item_type is None: + self._preferred_affected_item_type = item_type + if item_type == self._preferred_affected_item_type: + self._ui.affected_item_tab_widget.setCurrentWidget(affected_items_widget) + if self._ui.affected_items_widget_stack.currentIndex() != 0: + self._ui.affected_items_widget_stack.setCurrentIndex(0) + for item in items: + row = item_table.rowCount() + item_table.insertRow(row) + for column, label in enumerate(item): + cell = QTableWidgetItem(label) + cell.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable) + item_table.setItem(row, column, cell) + + @Slot(str, int) + def _max_affected_items_fetched(self, item_type, still_available): + """Updates the fetch status label. + + Args: + item_type (str): item type + still_available (int): number of items left unfetched + """ + label = self._affected_item_widgets[item_type].label + label.setVisible(True) + label.setText(f"...and {still_available} {item_type} items more.") + + @Slot(str) + def _all_affected_items_fetched(self, item_type): + """Hides the fetch status label. + + Args: + item_type (str): item type + """ + label = self._affected_item_widgets[item_type].label.setVisible(False) + + @Slot() + def _finish_work(self): + """Quits the worker thread if it is running.""" + if self._thread is not None: + self._thread.quit() + self._thread.wait() + self._worker = None + self._thread = None + if self._ui.affected_item_tab_widget.count() == 0: + self._ui.affected_items_widget_stack.setCurrentIndex(1) + + def tear_down(self): + """Tears down the widget.""" + self._finish_work() + + @Slot(int) + def _set_preferred_item_type(self, preferred_tab_index): + """Sets the preferred item type for affected items. + + The tab showing the preferred type is selected automatically as the current tab when/if it gets fetched. + + Args: + preferred_tab_index (int): index of the preferred tab + """ + self._preferred_affected_item_type = self._ui.affected_item_tab_widget.tabText(preferred_tab_index) class _CommitItem(QWidget): """A widget to show commit message, author and data on a QTreeWidget.""" def __init__(self, commit, parent=None): + """ + Args: + commit (dict): commit database item + parent (QWidget, optional): parent widget + """ super().__init__(parent=parent) comment = QLabel(str(commit["comment"]) or "") user = QLabel(str(commit["user"])) @@ -96,54 +209,29 @@ def __init__(self, commit, parent=None): layout.addWidget(date, 1, 1) -class _AffectedItemsFromOneTable(QTreeWidget): - """A widget to show all the items from one table that are affected by a commit.""" +class _AffectedItemsWidget(QWidget): + """A composite widget that contains a table and a label.""" - def __init__(self, items, parent=None): - super().__init__(parent=parent) - self.setIndentation(0) - first = next(iter(items), None) - if first is None: - return - self._margin = 6 - keys = [key for key in first._extended() if not any(word in key for word in ("id", "parsed"))] - self.setHeaderLabels(keys) - tree_items = [QTreeWidgetItem([self._parse_value(item[key]) for key in keys]) for item in items] - self.addTopLevelItems(tree_items) - last = tree_items[-1] - rect = self.visualItemRect(last) - self._height = rect.bottom() - for k, _ in enumerate(keys): - self.resizeColumnToContents(k) + def __init__(self): + from ..ui.commit_viewer_affected_item_info import Ui_Form - @staticmethod - def _parse_value(value): - if isinstance(value, bytes): - return value.decode("utf-8") - if isinstance(value, (tuple, list)): - return DB_ITEM_SEPARATOR.join(value) - return value + super().__init__() + self._ui = Ui_Form() + self._ui.setupUi(self) + self._ui.fetch_status_label.setVisible(False) - def moveEvent(self, ev): - if ev.pos().x() > 0: - self.move(self._margin, ev.pos().y()) - offset = ev.pos().x() - self._margin - self.resize(self.size().width() + offset - 2, self.size().height()) - return - super().moveEvent(ev) - - def sizeHint(self): - size = super().sizeHint() - height = self._height + self.frameWidth() * 2 + self.header().height() + self._margin - scroll_bar = self.horizontalScrollBar() - if scroll_bar.isVisible(): - height += scroll_bar.height() - height = min(size.height(), height) - size.setHeight(height) - return size + @property + def table(self) -> QTableWidget: + return self._ui.affected_items_table + + @property + def label(self) -> QLabel: + return self._ui.fetch_status_label class CommitViewer(QMainWindow): + """Commit viewer window.""" + def __init__(self, qsettings, db_mngr, *db_maps, parent=None): """ Args: @@ -173,6 +261,11 @@ def __init__(self, qsettings, db_mngr, *db_maps, parent=None): @Slot(int) def _carry_splitter_state(self, index): + """Ensures that splitters have the same state in all tabs. + + Args: + index (int): current database tab index + """ previous = self.centralWidget().widget(self._current_index) current = self.centralWidget().widget(index) self._current_index = index @@ -182,7 +275,95 @@ def _carry_splitter_state(self, index): def closeEvent(self, ev): super().closeEvent(ev) save_ui(self, self._qsettings, "commitViewer") - current = self.centralWidget().widget(self._current_index) + tab_view: QTabWidget = self.centralWidget() + current = tab_view.widget(self._current_index) self._qsettings.beginGroup("commitViewer") self._qsettings.setValue("splitterState", current.splitter.saveState()) self._qsettings.endGroup() + for tab_index in range(tab_view.count()): + commit_widget = tab_view.widget(tab_index) + commit_widget.tear_down() + + +class Worker(QObject): + """Worker that fetches affected items. + + The items are fetched in chunks which makes it possible to quit the thread mid-execution. + There is also a hard limit to how many items are fetched. + """ + + SOFT_MAX_IDS = 400 + HARD_EXTRA_ID_LIMIT = 100 + CHUNK_SIZE = 50 + + max_ids_reached = Signal(str, int) + all_ids_fetched = Signal(str) + chunk_ready = Signal(str, list, list) + finished = Signal() + + def __init__(self, db_mngr, db_map, commit_id): + """ + Args: + db_mngr (SpineDBManager): database manager + db_map (DatabaseMapping): database mapping + commit_id (TempId): commit id + """ + super().__init__() + self._db_mngr = db_mngr + self._db_map = db_map + self._commit_id = commit_id + + @Slot() + def run(self): + """Fetches affected items.""" + try: + for item_type, ids in self._db_mngr.get_items_for_commit(self._db_map, self._commit_id).items(): + items = [] + keys = None + max_reached = False + id_count = 0 + for id_count, id_ in enumerate(ids): + db_item = self._db_mngr.get_item(self._db_map, item_type, id_) + if keys is None: + keys = [key for key in db_item._extended() if not any(word in key for word in ("id", "parsed"))] + items.append([self._parse_value(self._db_mngr, self._db_map, db_item, key) for key in keys]) + if id_count % self.CHUNK_SIZE == 0: + self.thread().eventDispatcher().processEvents(QEventLoop.ProcessEventsFlag.AllEvents) + QThread.yieldCurrentThread() + if id_count != 0: + self.chunk_ready.emit(item_type, keys, items) + items = [] + if id_count + 1 >= self.SOFT_MAX_IDS and len(ids) - id_count > self.HARD_EXTRA_ID_LIMIT: + max_reached = True + break + if items: + self.chunk_ready.emit(item_type, keys, items) + if max_reached: + self.max_ids_reached.emit(item_type, len(ids) - id_count - 1) + else: + self.all_ids_fetched.emit(item_type) + finally: + self.finished.emit() + + @staticmethod + def _parse_value(db_mngr, db_map, item, key): + """Converts item field values to something more displayable. + + Args: + db_mngr (SpineDBManager): database manager + db_map (DatabaseMapping): database mapping + item (PublicItem): database item + key (str): value's key + + Returns: + str: displayable presentation of the value + """ + if item.item_type in ("parameter_definition", "parameter_value", "list_value") and key in ( + "value", + "default_value", + ): + return db_mngr.get_value(db_map, item, role=Qt.ItemDataRole.DisplayRole) + value = item[key] + if isinstance(value, (tuple, list)): + return DB_ITEM_SEPARATOR.join(value) + return value diff --git a/spinetoolbox/spine_db_editor/widgets/custom_menus.py b/spinetoolbox/spine_db_editor/widgets/custom_menus.py index 68e8e31db..e5c4347c0 100644 --- a/spinetoolbox/spine_db_editor/widgets/custom_menus.py +++ b/spinetoolbox/spine_db_editor/widgets/custom_menus.py @@ -90,7 +90,7 @@ def _get_value(self, item, db_map): def _get_display_value(self, item, db_map): if self._field in ("value", "default_value"): - return self._db_mngr.get_value(db_map, self._item_type, item["id"], role=Qt.DisplayRole) + return self._db_mngr.get_value(db_map, item, role=Qt.DisplayRole) if self._field == "entity_byname": return DB_ITEM_SEPARATOR.join(item[self._field]) return self._get_value(item, db_map) or "(empty)" diff --git a/spinetoolbox/spine_db_manager.py b/spinetoolbox/spine_db_manager.py index 294bdd796..fbae77047 100644 --- a/spinetoolbox/spine_db_manager.py +++ b/spinetoolbox/spine_db_manager.py @@ -73,29 +73,29 @@ class SpineDBManager(QObject): Args: str: item type, such as "object_class" - dict: mapping DiffDatabaseMapping to list of added dict-items. + dict: mapping DatabaseMapping to list of added dict-items. """ items_updated = Signal(str, dict) """Emitted whenever items are updated in a DB. Args: str: item type, such as "object_class" - dict: mapping DiffDatabaseMapping to list of updated dict-items. + dict: mapping DatabaseMapping to list of updated dict-items. """ items_removed = Signal(str, dict) """Emitted whenever items are removed from a DB. Args: str: item type, such as "object_class" - dict: mapping DiffDatabaseMapping to list of updated dict-items. + dict: mapping DatabaseMapping to list of updated dict-items. """ def __init__(self, settings, parent, synchronous=False): - """Initializes the instance. - + """ Args: settings (QSettings): Toolbox settings parent (QObject, optional): parent object + synchronous (bool): If True, fetch database synchronously """ super().__init__(parent) self.qsettings = settings @@ -197,7 +197,7 @@ def fetch_more(self, db_map, parent): """Fetches more items of given type from given db. Args: - db_map (DiffDatabaseMapping) + db_map (DatabaseMapping) parent (FetchParent): The object that requests the fetching. """ if db_map.closed: @@ -212,7 +212,7 @@ def get_icon_mngr(self, db_map): """Returns an icon manager for given db_map. Args: - db_map (DiffDatabaseMapping) + db_map (DatabaseMapping) Returns: SpineDBIconManager @@ -235,7 +235,7 @@ def db_map_key(db_map): """Creates an identifier for given db_map. Args: - db_map (DiffDatabaseMapping): database mapping + db_map (DatabaseMapping): database mapping Returns: int: identification key @@ -249,7 +249,7 @@ def db_map_from_key(self, key): key (int): identification key Returns: - DiffDatabaseMapping: database mapping + DatabaseMapping: database mapping Raises: KeyError: raised if database map is not found @@ -268,7 +268,7 @@ def db_map(self, url): url (str): a database URL Returns: - DiffDatabaseMapping: a database map or None if not found + DatabaseMapping: a database map or None if not found """ url = str(url) return self._db_maps.get(url) @@ -319,7 +319,7 @@ def close_all_sessions(self): self.close_session(url) def get_db_map(self, url, logger, ignore_version_error=False, window=False, codename=None, create=False): - """Returns a DiffDatabaseMapping instance from url if possible, None otherwise. + """Returns a DatabaseMapping instance from url if possible, None otherwise. If needed, asks the user to upgrade to the latest db version. Args: @@ -331,7 +331,7 @@ def get_db_map(self, url, logger, ignore_version_error=False, window=False, code create (bool, optional) Returns: - DiffDatabaseMapping, NoneType + DatabaseMapping, NoneType """ url = str(url) db_map = self._db_maps.get(url) @@ -364,7 +364,7 @@ def get_db_map(self, url, logger, ignore_version_error=False, window=False, code @busy_effect def _do_get_db_map(self, url, **kwargs): - """Returns a memorized DiffDatabaseMapping instance from url. + """Returns a memorized DatabaseMapping instance from url. Called by `get_db_map`. Args: @@ -374,7 +374,7 @@ def _do_get_db_map(self, url, **kwargs): create (bool) Returns: - DiffDatabaseMapping + DatabaseMapping """ worker = SpineDBWorker(self, url, synchronous=self._synchronous) try: @@ -412,7 +412,7 @@ def register_listener(self, listener, *db_maps): Args: listener (object) - db_maps (DiffDatabaseMapping) + *db_maps """ self.update_data_store_db_maps() for db_map in db_maps: @@ -436,7 +436,7 @@ def unregister_listener(self, listener, *db_maps, dirty_db_maps=None, commit_dir Args: listener (object) - *db_maps (DiffDatabaseMapping) + *db_maps commit_dirty (bool): True to commit dirty database mapping, False to roll back commit_msg (str): commit message @@ -517,7 +517,7 @@ def is_dirty(self, db_map): """Returns True if mapping has pending changes. Args: - db_map (DiffDatabaseMapping): database mapping + db_map (DatabaseMapping): database mapping Returns: bool: True if db_map has pending changes, False otherwise @@ -531,7 +531,7 @@ def dirty(self, *db_maps): *db_maps: mappings to check Return: - list of DiffDatabaseMapping: dirty mappings + list of DatabaseMapping: dirty mappings """ return [db_map for db_map in db_maps if self.is_dirty(db_map)] @@ -543,7 +543,7 @@ def dirty_and_without_editors(self, listener, *db_maps): *db_maps: mappings to check Return: - list of DiffDatabaseMapping: mappings that are dirty and don't have editors + list of DatabaseMapping: mappings that are dirty and don't have editors """ def has_editors(db_map): @@ -670,7 +670,7 @@ def entity_class_renderer(self, db_map, entity_class_id, for_group=False, color= """Returns an icon renderer for a given entity class. Args: - db_map (DiffDatabaseMapping): database map + db_map (DatabaseMapping): database map entity_class_id (int): entity class id for_group (bool): if True, return the group object icon instead @@ -691,7 +691,7 @@ def entity_class_icon(self, db_map, entity_class_id, for_group=False): """Returns an appropriate icon for a given entity class. Args: - db_map (DiffDatabaseMapping): database map + db_map (DatabaseMapping): database map entity_class_id (int): entity class' id for_group (bool): if True, return the group object icon instead @@ -707,7 +707,7 @@ def get_item(db_map, item_type, id_): or an empty dict if not found. Args: - db_map (DiffDatabaseMapping) + db_map (DatabaseMapping) item_type (str) id_ (int) @@ -724,7 +724,7 @@ def get_items(db_map, item_type): """Returns a list of the items of the given type in the given db map. Args: - db_map (DiffDatabaseMapping) + db_map (DatabaseMapping) item_type (str) Returns: @@ -737,7 +737,7 @@ def get_items_by_field(self, db_map, item_type, field, value): for the given field. Args: - db_map (DiffDatabaseMapping) + db_map (DatabaseMapping) item_type (str) field (str) value @@ -753,7 +753,7 @@ def get_item_by_field(self, db_map, item_type, field, value): Returns an empty dictionary if none found. Args: - db_map (DiffDatabaseMapping) + db_map (DatabaseMapping) item_type (str) field (str) value @@ -812,30 +812,28 @@ def _format_list_value(self, db_map, item_type, value, list_value_id): formatted_value = value_list_name + formatted_value return formatted_value - def get_value(self, db_map, item_type, id_, role=Qt.ItemDataRole.DisplayRole): + def get_value(self, db_map, item, role=Qt.ItemDataRole.DisplayRole): """Returns the value or default value of a parameter. Args: - db_map (DatabaseMapping) - item_type (str): either "parameter_definition", "parameter_value", or "list_value" - id_ (int): The parameter_value or definition id - role (int, optional) + db_map (DatabaseMapping): database mapping + item (PublicItem): parameter value item, parameter definition item, or list value item + role (Qt.ItemDataRole): data role Returns: any """ - item = self.get_item(db_map, item_type, id_) if not item: return None value_field, type_field = { "parameter_value": ("value", "type"), "list_value": ("value", "type"), "parameter_definition": ("default_value", "default_type"), - }[item_type] - list_value_id = id_ if item_type == "list_value" else item["list_value_id"] + }[item.item_type] + list_value_id = item["id"] if item.item_type == "list_value" else item["list_value_id"] complex_types = {"array": "Array", "time_series": "Time series", "time_pattern": "Time pattern", "map": "Map"} if role == Qt.ItemDataRole.DisplayRole and item[type_field] in complex_types: - return self._format_list_value(db_map, item_type, complex_types[item[type_field]], list_value_id) + return self._format_list_value(db_map, item.item_type, complex_types[item[type_field]], list_value_id) if role == Qt.ItemDataRole.EditRole: return join_value_and_type(item[value_field], item[type_field]) return self._format_value(item["parsed_value"], role=role) @@ -886,11 +884,12 @@ def get_value_indexes(self, db_map, item_type, id_): """Returns the value or default value indexes of a parameter. Args: - db_map (DiffDatabaseMapping) + db_map (DatabaseMapping) item_type (str): either "parameter_definition" or "parameter_value" id_ (int): The parameter_value or definition id """ - parsed_value = self.get_value(db_map, item_type, id_, role=PARSED_ROLE) + item = self.get_item(db_map, item_type, id_) + parsed_value = self.get_value(db_map, item, role=PARSED_ROLE) if isinstance(parsed_value, IndexedValue): return parsed_value.indexes return [""] @@ -899,13 +898,14 @@ def get_value_index(self, db_map, item_type, id_, index, role=Qt.ItemDataRole.Di """Returns the value or default value of a parameter for a given index. Args: - db_map (DiffDatabaseMapping) + db_map (DatabaseMapping) item_type (str): either "parameter_definition" or "parameter_value" id_ (int): The parameter_value or definition id index: The index to retrieve role (int, optional) """ - parsed_value = self.get_value(db_map, item_type, id_, role=PARSED_ROLE) + item = self.get_item(db_map, item_type, id_) + parsed_value = self.get_value(db_map, item, role=PARSED_ROLE) if isinstance(parsed_value, IndexedValue): parsed_value = parsed_value.get_value(index) if role == Qt.ItemDataRole.EditRole: @@ -922,7 +922,7 @@ def get_value_list_item(self, db_map, id_, index, role=Qt.ItemDataRole.DisplayRo """Returns one value item of a parameter_value_list. Args: - db_map (DiffDatabaseMapping) + db_map (DatabaseMapping) id_ (int): The parameter_value_list id index (int): The value item index role (int, optional) @@ -936,12 +936,12 @@ def get_parameter_value_list(self, db_map, id_, role=Qt.ItemDataRole.DisplayRole """Returns a parameter_value_list formatted for the given role. Args: - db_map (DiffDatabaseMapping) + db_map (DatabaseMapping) id_ (int): The parameter_value_list id role (int, optional) """ return [ - self.get_value(db_map, "list_value", item["id"], role=role) + self.get_value(db_map, item, role=role) for item in self.get_items_by_field(db_map, "list_value", "parameter_value_list_id", id_) ] @@ -954,7 +954,7 @@ def import_data(self, db_map_data, command_text="Import data"): Condenses all in a single command for undo/redo. Args: - db_map_data (dict(DiffDatabaseMapping, dict())): Maps dbs to data to be passed as keyword arguments + db_map_data (dict(DatabaseMapping, dict())): Maps dbs to data to be passed as keyword arguments to `get_data_for_import` command_text (str, optional): What to call the command that condenses the operation. """ @@ -981,7 +981,7 @@ def add_alternatives(self, db_map_data): """Adds alternatives to db. Args: - db_map_data (dict): lists of items to add keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to add keyed by DatabaseMapping """ self.add_items("alternative", db_map_data) @@ -989,7 +989,7 @@ def add_scenarios(self, db_map_data): """Adds scenarios to db. Args: - db_map_data (dict): lists of items to add keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to add keyed by DatabaseMapping """ self.add_items("scenario", db_map_data) @@ -997,7 +997,7 @@ def add_entity_classes(self, db_map_data): """Adds entity classes to db. Args: - db_map_data (dict): lists of items to add keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to add keyed by DatabaseMapping """ self.add_items("entity_class", db_map_data) @@ -1005,7 +1005,7 @@ def add_entities(self, db_map_data): """Adds entities to db. Args: - db_map_data (dict): lists of items to add keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to add keyed by DatabaseMapping """ self.add_items("entity", db_map_data) @@ -1013,7 +1013,7 @@ def add_entity_groups(self, db_map_data): """Adds entity groups to db. Args: - db_map_data (dict): lists of items to add keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to add keyed by DatabaseMapping """ self.add_items("entity_group", db_map_data) @@ -1021,7 +1021,7 @@ def add_entity_alternatives(self, db_map_data): """Adds entity alternatives to db. Args: - db_map_data (dict): lists of items to add keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to add keyed by DatabaseMapping """ self.add_items("entity_alternative", db_map_data) @@ -1029,7 +1029,7 @@ def add_parameter_definitions(self, db_map_data): """Adds parameter definitions to db. Args: - db_map_data (dict): lists of items to add keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to add keyed by DatabaseMapping """ self.add_items("parameter_definition", db_map_data) @@ -1037,7 +1037,7 @@ def add_parameter_values(self, db_map_data): """Adds parameter values to db without checking integrity. Args: - db_map_data (dict): lists of items to add keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to add keyed by DatabaseMapping """ self.add_items("parameter_value", db_map_data) @@ -1045,7 +1045,7 @@ def add_parameter_value_lists(self, db_map_data): """Adds parameter_value lists to db. Args: - db_map_data (dict): lists of items to add keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to add keyed by DatabaseMapping """ self.add_items("parameter_value_list", db_map_data) @@ -1053,7 +1053,7 @@ def add_list_values(self, db_map_data): """Adds parameter_value list values to db. Args: - db_map_data (dict): lists of items to add keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to add keyed by DatabaseMapping """ self.add_items("list_value", db_map_data) @@ -1061,7 +1061,7 @@ def add_metadata(self, db_map_data): """Adds metadata to db. Args: - db_map_data (dict): lists of items to add keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to add keyed by DatabaseMapping """ self.add_items("metadata", db_map_data) @@ -1069,7 +1069,7 @@ def add_entity_metadata(self, db_map_data): """Adds entity metadata to db. Args: - db_map_data (dict): lists of items to add keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to add keyed by DatabaseMapping """ self.add_items("entity_metadata", db_map_data) @@ -1077,7 +1077,7 @@ def add_parameter_value_metadata(self, db_map_data): """Adds parameter value metadata to db. Args: - db_map_data (dict): lists of items to add keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to add keyed by DatabaseMapping """ self.add_items("parameter_value_metadata", db_map_data) @@ -1093,7 +1093,7 @@ def add_ext_entity_metadata(self, db_map_data): """Adds entity metadata together with all necessary metadata to db. Args: - db_map_data (dict): lists of items to add keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to add keyed by DatabaseMapping """ self._add_ext_item_metadata(db_map_data, "entity_metadata") @@ -1101,7 +1101,7 @@ def add_ext_parameter_value_metadata(self, db_map_data): """Adds parameter value metadata together with all necessary metadata to db. Args: - db_map_data (dict): lists of items to add keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to add keyed by DatabaseMapping """ self._add_ext_item_metadata(db_map_data, "parameter_value_metadata") @@ -1109,7 +1109,7 @@ def update_alternatives(self, db_map_data): """Updates alternatives in db. Args: - db_map_data (dict): lists of items to update keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to update keyed by DatabaseMapping """ self.update_items("alternative", db_map_data) @@ -1117,7 +1117,7 @@ def update_scenarios(self, db_map_data): """Updates scenarios in db. Args: - db_map_data (dict): lists of items to update keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to update keyed by DatabaseMapping """ self.update_items("scenario", db_map_data) @@ -1125,7 +1125,7 @@ def update_entity_classes(self, db_map_data): """Updates entity classes in db. Args: - db_map_data (dict): lists of items to update keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to update keyed by DatabaseMapping """ self.update_items("entity_class", db_map_data) @@ -1133,7 +1133,7 @@ def update_entities(self, db_map_data): """Updates entities in db. Args: - db_map_data (dict): lists of items to update keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to update keyed by DatabaseMapping """ self.update_items("entity", db_map_data) @@ -1141,7 +1141,7 @@ def update_entity_alternatives(self, db_map_data): """Updates entity alternatives in db. Args: - db_map_data (dict): lists of items to update keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to update keyed by DatabaseMapping """ self.update_items("entity_alternative", db_map_data) @@ -1149,7 +1149,7 @@ def update_parameter_definitions(self, db_map_data): """Updates parameter definitions in db. Args: - db_map_data (dict): lists of items to update keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to update keyed by DatabaseMapping """ self.update_items("parameter_definition", db_map_data) @@ -1157,7 +1157,7 @@ def update_parameter_values(self, db_map_data): """Updates parameter values in db. Args: - db_map_data (dict): lists of items to update keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to update keyed by DatabaseMapping """ self.update_items("parameter_value", db_map_data) @@ -1165,7 +1165,7 @@ def update_expanded_parameter_values(self, db_map_data): """Updates expanded parameter values in db. Args: - db_map_data (dict): lists of expanded items to update keyed by DiffDatabaseMapping + db_map_data (dict): lists of expanded items to update keyed by DatabaseMapping """ for db_map, expanded_data in db_map_data.items(): packed_data = {} @@ -1173,7 +1173,8 @@ def update_expanded_parameter_values(self, db_map_data): packed_data.setdefault(item["id"], {})[item["index"]] = (item["value"], item["type"]) items = [] for id_, indexed_values in packed_data.items(): - parsed_value = self.get_value(db_map, "parameter_value", id_, role=PARSED_ROLE) + item = self.get_item(db_map, "parameter_value", id_) + parsed_value = self.get_value(db_map, item, role=PARSED_ROLE) if isinstance(parsed_value, IndexedValue): parsed_value = deep_copy_value(parsed_value) for index, (val, typ) in indexed_values.items(): @@ -1190,7 +1191,7 @@ def update_parameter_value_lists(self, db_map_data): """Updates parameter_value lists in db. Args: - db_map_data (dict): lists of items to update keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to update keyed by DatabaseMapping """ self.update_items("parameter_value_list", db_map_data) @@ -1198,7 +1199,7 @@ def update_list_values(self, db_map_data): """Updates parameter_value list values in db. Args: - db_map_data (dict): lists of items to update keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to update keyed by DatabaseMapping """ self.update_items("list_value", db_map_data) @@ -1206,7 +1207,7 @@ def update_metadata(self, db_map_data): """Updates metadata in db. Args: - db_map_data (dict): lists of items to update keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to update keyed by DatabaseMapping """ self.update_items("metadata", db_map_data) @@ -1214,7 +1215,7 @@ def update_entity_metadata(self, db_map_data): """Updates entity metadata in db. Args: - db_map_data (dict): lists of items to update keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to update keyed by DatabaseMapping """ self.update_items("entity_metadata", db_map_data) @@ -1222,7 +1223,7 @@ def update_parameter_value_metadata(self, db_map_data): """Updates parameter value metadata in db. Args: - db_map_data (dict): lists of items to update keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to update keyed by DatabaseMapping """ self.update_items("parameter_value_metadata", db_map_data) @@ -1238,7 +1239,7 @@ def update_ext_entity_metadata(self, db_map_data): """Updates entity metadata in db. Args: - db_map_data (dict): lists of items to update keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to update keyed by DatabaseMapping """ self._update_ext_item_metadata(db_map_data, "entity_metadata") @@ -1246,7 +1247,7 @@ def update_ext_parameter_value_metadata(self, db_map_data): """Updates parameter value metadata in db. Args: - db_map_data (dict): lists of items to update keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to update keyed by DatabaseMapping """ self._update_ext_item_metadata(db_map_data, "parameter_value_metadata") @@ -1254,7 +1255,7 @@ def set_scenario_alternatives(self, db_map_data): """Sets scenario alternatives in db. Args: - db_map_data (dict): lists of items to set keyed by DiffDatabaseMapping + db_map_data (dict): lists of items to set keyed by DatabaseMapping """ db_map_error_log = {} for db_map, data in db_map_data.items(): diff --git a/tests/spine_db_editor/widgets/test_commit_viewer.py b/tests/spine_db_editor/widgets/test_commit_viewer.py index 1e746bf53..1baa4bda0 100644 --- a/tests/spine_db_editor/widgets/test_commit_viewer.py +++ b/tests/spine_db_editor/widgets/test_commit_viewer.py @@ -69,10 +69,21 @@ def test_selecting_initial_commit_shows_base_alternative(self): commit_list = current_tab._ui.commit_list initial_commit_item = commit_list.topLevelItem(0) commit_list.setCurrentItem(initial_commit_item) - affected_list = current_tab._ui.affected_items - self.assertEqual(affected_list.topLevelItemCount(), 1) - affected_item = affected_list.topLevelItem(0) - self.assertEqual(affected_item.data(0, Qt.ItemDataRole.DisplayRole), "alternative") + affected_item_tab_widget = current_tab._ui.affected_item_tab_widget + while affected_item_tab_widget.count() != 1: + QApplication.processEvents() + affected_items_table = affected_item_tab_widget.widget(0).table + while affected_items_table.rowCount() != 1: + QApplication.processEvents() + self.assertEqual(affected_items_table.columnCount(), 2) + self.assertEqual(affected_items_table.horizontalHeaderItem(0).text(), "name") + self.assertEqual(affected_items_table.horizontalHeaderItem(1).text(), "description") + expected = [["Base", "Base alternative"]] + for row in range(affected_items_table.rowCount()): + expected_row = expected[row] + for column in range(affected_items_table.columnCount()): + with self.subTest(row=row, column=column): + self.assertEqual(affected_items_table.item(row, column).text(), expected_row[column]) if __name__ == "__main__": diff --git a/tests/test_SpineDBManager.py b/tests/test_SpineDBManager.py index e8784c008..abaf35b98 100644 --- a/tests/test_SpineDBManager.py +++ b/tests/test_SpineDBManager.py @@ -57,8 +57,13 @@ def setUpClass(cls): QApplication() def setUp(self): - self.db_mngr = SpineDBManager(None, None) - self.db_mngr.get_item = MagicMock() + app_settings = MagicMock() + self.db_mngr = SpineDBManager(app_settings, None, synchronous=True) + logger = MagicMock() + self._db_map = self.db_mngr.get_db_map("sqlite://", logger, create=True) + self._db_map.add_entity_class_item(name="Object") + self._db_map.add_parameter_definition_item(name="x", entity_class_name="Object") + self._db_map.add_entity_item(name="thing", entity_class_name="Object") def tearDown(self): self.db_mngr.close_all_sessions() @@ -66,125 +71,167 @@ def tearDown(self): self.db_mngr.deleteLater() QApplication.processEvents() - def get_value(self, role): - mock_db_map = MagicMock() - id_ = 0 - return self.db_mngr.get_value(mock_db_map, "parameter_value", id_, role) + def _add_value(self, value, alternative="Base"): + db_value, value_type = to_database(value) + item, error = self._db_map.add_parameter_value_item( + entity_class_name="Object", + entity_byname=("thing",), + parameter_definition_name="x", + alternative_name=alternative, + value=db_value, + type=value_type, + ) + self.assertIsNone(error) + return item def test_plain_number_in_display_role(self): value = 2.3 - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.DisplayRole) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.DisplayRole) self.assertEqual(formatted, "2.3") def test_plain_number_in_edit_role(self): value = 2.3 - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.EditRole) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.EditRole) self.assertEqual(formatted, join_value_and_type(b"2.3", None)) def test_plain_number_in_tool_tip_role(self): value = 2.3 - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - self.assertIsNone(self.get_value(Qt.ItemDataRole.ToolTipRole)) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.ToolTipRole) + self.assertIsNone(formatted) def test_date_time_in_display_role(self): value = DateTime("2019-07-12T16:00") - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.DisplayRole) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.DisplayRole) self.assertEqual(formatted, "2019-07-12T16:00:00") def test_date_time_in_edit_role(self): value = DateTime("2019-07-12T16:00") - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.EditRole) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.EditRole) self.assertEqual(formatted, join_value_and_type(*to_database(value))) def test_date_time_in_tool_tip_role(self): value = DateTime("2019-07-12T16:00") - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - self.assertIsNone(self.get_value(Qt.ItemDataRole.ToolTipRole)) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.ToolTipRole) + self.assertIsNone(formatted) def test_duration_in_display_role(self): value = Duration("3Y") - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.DisplayRole) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.DisplayRole) self.assertEqual(formatted, "3Y") def test_duration_in_edit_role(self): value = Duration("2M") - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.EditRole) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.EditRole) self.assertEqual(formatted, join_value_and_type(*to_database(value))) def test_duration_in_tool_tip_role(self): value = Duration("13D") - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - self.assertIsNone(self.get_value(Qt.ItemDataRole.ToolTipRole)) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.ToolTipRole) + self.assertIsNone(formatted) def test_time_pattern_in_display_role(self): value = TimePattern(["M1-12"], [5.0]) - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.DisplayRole) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.DisplayRole) self.assertEqual(formatted, "Time pattern") def test_time_pattern_in_edit_role(self): value = TimePattern(["M1-12"], [5.0]) - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.EditRole) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.EditRole) self.assertEqual(formatted, join_value_and_type(*to_database(value))) def test_time_pattern_in_tool_tip_role(self): value = TimePattern(["M1-12"], [5.0]) - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - self.assertIsNone(self.get_value(Qt.ItemDataRole.ToolTipRole)) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.ToolTipRole) + self.assertIsNone(formatted) def test_time_series_in_display_role(self): + self._db_map.add_alternative_item(name="fixed_resolution") + self._db_map.add_alternative_item(name="variable_resolution") value = TimeSeriesFixedResolution("2019-07-12T08:00", "7 hours", [1.1, 2.2, 3.3], False, False) - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.DisplayRole) + item = self._add_value(value, "fixed_resolution") + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.DisplayRole) self.assertEqual(formatted, "Time series") value = TimeSeriesVariableResolution(["2019-07-12T08:00", "2019-07-12T16:00"], [0.0, 100.0], False, False) - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.DisplayRole) + item = self._add_value(value, "variable_resolution") + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.DisplayRole) self.assertEqual(formatted, "Time series") def test_time_series_in_edit_role(self): + self._db_map.add_alternative_item(name="fixed_resolution") + self._db_map.add_alternative_item(name="variable_resolution") value = TimeSeriesFixedResolution("2019-07-12T08:00", "7 hours", [1.1, 2.2, 3.3], False, False) - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.EditRole) + item = self._add_value(value, "fixed_resolution") + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.EditRole) self.assertEqual(formatted, join_value_and_type(*to_database(value))) value = TimeSeriesVariableResolution(["2019-07-12T08:00", "2019-07-12T16:00"], [0.0, 100.0], False, False) - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.EditRole) + item = self._add_value(value, "variable_resolution") + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.EditRole) self.assertEqual(formatted, join_value_and_type(*to_database(value))) def test_time_series_in_tool_tip_role(self): + self._db_map.add_alternative_item(name="fixed_resolution") + self._db_map.add_alternative_item(name="variable_resolution") value = TimeSeriesFixedResolution("2019-07-12T08:00", ["7 hours", "12 hours"], [1.1, 2.2, 3.3], False, False) - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.ToolTipRole) + item = self._add_value(value, "fixed_resolution") + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.ToolTipRole) self.assertEqual(formatted, "Start: 2019-07-12 08:00:00
resolution: [7h, 12h]
length: 3
") value = TimeSeriesVariableResolution(["2019-07-12T08:00", "2019-07-12T16:00"], [0.0, 100.0], False, False) - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.ToolTipRole) + item = self._add_value(value, "variable_resolution") + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.ToolTipRole) self.assertEqual(formatted, "Start: 2019-07-12T08:00:00
resolution: variable
length: 2
") def test_broken_value_in_display_role(self): value = b"dubbidubbidu" - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(value, None) - formatted = self.get_value(Qt.ItemDataRole.DisplayRole) + item, error = self._db_map.add_parameter_value_item( + entity_class_name="Object", + entity_byname=("thing",), + parameter_definition_name="x", + alternative_name="Base", + value=value, + type=None, + ) + self.assertIsNone(error) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.DisplayRole) self.assertEqual(formatted, "Error") def test_broken_value_in_edit_role(self): value = b"diibadaaba" - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(value, None) - formatted = self.get_value(Qt.ItemDataRole.EditRole) + item, error = self._db_map.add_parameter_value_item( + entity_class_name="Object", + entity_byname=("thing",), + parameter_definition_name="x", + alternative_name="Base", + value=value, + type=None, + ) + self.assertIsNone(error) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.EditRole) self.assertEqual(formatted, join_value_and_type(b"diibadaaba", None)) def test_broken_value_in_tool_tip_role(self): value = b"diibadaaba" - self.db_mngr.get_item.side_effect = self._make_get_item_side_effect(value, None) - formatted = self.get_value(Qt.ItemDataRole.ToolTipRole) + item, error = self._db_map.add_parameter_value_item( + entity_class_name="Object", + entity_byname=("thing",), + parameter_definition_name="x", + alternative_name="Base", + value=value, + type=None, + ) + self.assertIsNone(error) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.ToolTipRole) self.assertTrue(formatted.startswith("Could not decode the value"))