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"))