From d5671582e2e625f86ce2bddd4965e5bc7eee7937 Mon Sep 17 00:00:00 2001 From: Antti Soininen Date: Thu, 18 Jul 2024 13:39:41 +0300 Subject: [PATCH 1/2] Implement parameter_type in Database editor Parameter definition table now has a 'valid types' column that contains parameter type information. Re #2791 --- CHANGELOG.md | 1 + spinetoolbox/spine_db_commands.py | 10 +- .../mvcmodels/compound_models.py | 7 +- .../mvcmodels/single_models.py | 13 +- .../ui/parameter_type_editor.py | 174 ++++++++++++++++ .../ui/parameter_type_editor.ui | 190 ++++++++++++++++++ .../widgets/custom_delegates.py | 32 ++- .../spine_db_editor/widgets/custom_editors.py | 100 ++++++++- .../widgets/custom_qtableview.py | 6 +- spinetoolbox/spine_db_manager.py | 5 +- spinetoolbox/widgets/select_database_items.py | 4 +- .../mvcmodels/test_compound_models.py | 9 +- .../mvcmodels/test_emptyParameterModels.py | 20 +- .../mvcmodels/test_single_parameter_models.py | 1 + .../widgets/test_SpineDBEditor.py | 8 +- .../widgets/test_custom_qtableview.py | 2 +- 16 files changed, 554 insertions(+), 28 deletions(-) create mode 100644 spinetoolbox/spine_db_editor/ui/parameter_type_editor.py create mode 100644 spinetoolbox/spine_db_editor/ui/parameter_type_editor.ui diff --git a/CHANGELOG.md b/CHANGELOG.md index 155f718f3..5c757d07d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) - Entity group column in *Add entities* dialog. If filled, the created entity will be added to the specified group. If the group doesn't yet exist, it will be created. - Native kernel (i.e. python3 for Python) can now be used in the Detached Console or in Tool execution. +- It is now possible to specify valid value types for parameters in the Parameter definition table in Database editor. - [Bundled App] **Embedded Python** now includes pip. - Graph view in database editor now also supports filtering by alternative and scenario tree selections. - Option to disable auto-build in entity graph. diff --git a/spinetoolbox/spine_db_commands.py b/spinetoolbox/spine_db_commands.py index 014002645..a7467f851 100644 --- a/spinetoolbox/spine_db_commands.py +++ b/spinetoolbox/spine_db_commands.py @@ -84,7 +84,7 @@ def __init__(self, db_mngr, db_map, **kwargs): """ Args: db_mngr (SpineDBManager): SpineDBManager instance - db_map (DiffDatabaseMapping): DiffDatabaseMapping instance + db_map (DatabaseMapping): DatabaseMapping instance """ super().__init__(**kwargs) self.db_mngr = db_mngr @@ -96,7 +96,7 @@ def __init__(self, db_mngr, db_map, item_type, data, check=True, **kwargs): """ Args: db_mngr (SpineDBManager): SpineDBManager instance - db_map (DiffDatabaseMapping): DiffDatabaseMapping instance + db_map (DatabaseMapping): DatabaseMapping instance data (list): list of dict-items to add item_type (str): the item type """ @@ -130,7 +130,7 @@ def __init__(self, db_mngr, db_map, item_type, data, check=True, **kwargs): """ Args: db_mngr (SpineDBManager): SpineDBManager instance - db_map (DiffDatabaseMapping): DiffDatabaseMapping instance + db_map (DatabaseMapping): DatabaseMapping instance item_type (str): the item type data (list): list of dict-items to update """ @@ -166,7 +166,7 @@ def __init__(self, db_mngr, db_map, item_type, data, check=True, **kwargs): """ Args: db_mngr (SpineDBManager): SpineDBManager instance - db_map (DiffDatabaseMapping): DiffDatabaseMapping instance + db_map (DatabaseMapping): DatabaseMapping instance item_type (str): the item type data (list): list of dict-items to add-update """ @@ -215,7 +215,7 @@ def __init__(self, db_mngr, db_map, item_type, ids, check=True, **kwargs): """ Args: db_mngr (SpineDBManager): SpineDBManager instance - db_map (DiffDatabaseMapping): DiffDatabaseMapping instance + db_map (DatabaseMapping): DatabaseMapping instance item_type (str): the item type ids (set): set of ids to remove """ diff --git a/spinetoolbox/spine_db_editor/mvcmodels/compound_models.py b/spinetoolbox/spine_db_editor/mvcmodels/compound_models.py index de5458baf..fe2546a60 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/compound_models.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/compound_models.py @@ -552,6 +552,7 @@ def _make_header(self): return [ "entity_class_name", "parameter_name", + "valid types", "value_list_name", "default_value", "description", @@ -560,7 +561,11 @@ def _make_header(self): @property def field_map(self): - return {"parameter_name": "name", "value_list_name": "parameter_value_list_name"} + return { + "parameter_name": "name", + "valid types": "parameter_type_list", + "value_list_name": "parameter_value_list_name", + } @property def _single_model_type(self): diff --git a/spinetoolbox/spine_db_editor/mvcmodels/single_models.py b/spinetoolbox/spine_db_editor/mvcmodels/single_models.py index a1df522cf..d92a2a4b5 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/single_models.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/single_models.py @@ -11,6 +11,7 @@ ###################################################################################################################### """Single models for parameter definitions and values (as 'for a single entity').""" +from typing import Iterable from PySide6.QtCore import Qt from spinetoolbox.helpers import DB_ITEM_SEPARATOR, order_key, plain_to_rich from ...mvcmodels.minimal_table_model import MinimalTableModel @@ -44,6 +45,8 @@ def _sort_key(self, element): class SingleModelBase(HalfSortedTableModel): """Base class for all single models that go in a CompoundModelBase subclass.""" + group_fields: Iterable[str] = () + def __init__(self, parent, db_map, entity_class_id, committed, lazy=False): """ Args: @@ -112,10 +115,6 @@ def dimension_id_list(self): def fixed_fields(self): return ["entity_class_name", "database"] - @property - def group_fields(self): - return ["entity_byname"] - @property def can_be_filtered(self): return True @@ -231,7 +230,7 @@ def batch_set_data(self, indexes, data): def split_value(value, column): if self.header[column] in self.group_fields: - return tuple(value.split(DB_ITEM_SEPARATOR)) + return tuple(value.split(DB_ITEM_SEPARATOR)) if value else () return value if not indexes or not data: @@ -342,6 +341,8 @@ def data(self, index, role=Qt.ItemDataRole.DisplayRole): class EntityMixin: + group_fields = ("entity_byname",) + def update_items_in_db(self, items): """Overriden to create entities on the fly first.""" for item in items: @@ -367,6 +368,8 @@ def _do_update_items_in_db(self, db_map_data): class SingleParameterDefinitionModel(SplitValueAndTypeMixin, ParameterMixin, SingleModelBase): """A parameter_definition model for a single entity_class.""" + group_fields = ("valid types",) + @property def item_type(self): return "parameter_definition" diff --git a/spinetoolbox/spine_db_editor/ui/parameter_type_editor.py b/spinetoolbox/spine_db_editor/ui/parameter_type_editor.py new file mode 100644 index 000000000..bbe5ad146 --- /dev/null +++ b/spinetoolbox/spine_db_editor/ui/parameter_type_editor.py @@ -0,0 +1,174 @@ +# -*- 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 'parameter_type_editor.ui' +## +## Created by: Qt User Interface Compiler version 6.6.3 +## +## 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, QCheckBox, QGridLayout, QHBoxLayout, + QLabel, QLineEdit, QPushButton, QSizePolicy, + QSpacerItem, QVBoxLayout, QWidget) + +class Ui_Form(object): + def setupUi(self, Form): + if not Form.objectName(): + Form.setObjectName(u"Form") + Form.resize(278, 178) + self.verticalLayout = QVBoxLayout(Form) + self.verticalLayout.setObjectName(u"verticalLayout") + self.horizontalLayout = QHBoxLayout() + self.horizontalLayout.setObjectName(u"horizontalLayout") + self.select_all_button = QPushButton(Form) + self.select_all_button.setObjectName(u"select_all_button") + + self.horizontalLayout.addWidget(self.select_all_button) + + self.clear_all_button = QPushButton(Form) + self.clear_all_button.setObjectName(u"clear_all_button") + + self.horizontalLayout.addWidget(self.clear_all_button) + + self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + + self.horizontalLayout.addItem(self.horizontalSpacer) + + + self.verticalLayout.addLayout(self.horizontalLayout) + + self.label_2 = QLabel(Form) + self.label_2.setObjectName(u"label_2") + + self.verticalLayout.addWidget(self.label_2) + + self.gridLayout = QGridLayout() + self.gridLayout.setObjectName(u"gridLayout") + self.duration_check_box = QCheckBox(Form) + self.duration_check_box.setObjectName(u"duration_check_box") + + self.gridLayout.addWidget(self.duration_check_box, 1, 1, 1, 1) + + self.bool_check_box = QCheckBox(Form) + self.bool_check_box.setObjectName(u"bool_check_box") + + self.gridLayout.addWidget(self.bool_check_box, 0, 2, 1, 1) + + self.float_check_box = QCheckBox(Form) + self.float_check_box.setObjectName(u"float_check_box") + + self.gridLayout.addWidget(self.float_check_box, 0, 0, 1, 1) + + self.str_check_box = QCheckBox(Form) + self.str_check_box.setObjectName(u"str_check_box") + + self.gridLayout.addWidget(self.str_check_box, 0, 1, 1, 1) + + self.date_time_check_box = QCheckBox(Form) + self.date_time_check_box.setObjectName(u"date_time_check_box") + + self.gridLayout.addWidget(self.date_time_check_box, 1, 0, 1, 1) + + self.array_check_box = QCheckBox(Form) + self.array_check_box.setObjectName(u"array_check_box") + + self.gridLayout.addWidget(self.array_check_box, 2, 0, 1, 1) + + self.time_pattern_check_box = QCheckBox(Form) + self.time_pattern_check_box.setObjectName(u"time_pattern_check_box") + + self.gridLayout.addWidget(self.time_pattern_check_box, 2, 1, 1, 1) + + self.time_series_check_box = QCheckBox(Form) + self.time_series_check_box.setObjectName(u"time_series_check_box") + + self.gridLayout.addWidget(self.time_series_check_box, 2, 2, 1, 1) + + + self.verticalLayout.addLayout(self.gridLayout) + + self.horizontalLayout_3 = QHBoxLayout() + self.horizontalLayout_3.setSpacing(0) + self.horizontalLayout_3.setObjectName(u"horizontalLayout_3") + self.map_check_box = QCheckBox(Form) + self.map_check_box.setObjectName(u"map_check_box") + + self.horizontalLayout_3.addWidget(self.map_check_box) + + self.horizontalLayout_2 = QHBoxLayout() + self.horizontalLayout_2.setSpacing(0) + self.horizontalLayout_2.setObjectName(u"horizontalLayout_2") + self.horizontalLayout_2.setContentsMargins(6, -1, 0, -1) + self.label = QLabel(Form) + self.label.setObjectName(u"label") + + self.horizontalLayout_2.addWidget(self.label) + + self.map_rank_line_edit = QLineEdit(Form) + self.map_rank_line_edit.setObjectName(u"map_rank_line_edit") + + self.horizontalLayout_2.addWidget(self.map_rank_line_edit) + + + self.horizontalLayout_3.addLayout(self.horizontalLayout_2) + + + self.verticalLayout.addLayout(self.horizontalLayout_3) + + QWidget.setTabOrder(self.select_all_button, self.clear_all_button) + QWidget.setTabOrder(self.clear_all_button, self.float_check_box) + QWidget.setTabOrder(self.float_check_box, self.str_check_box) + QWidget.setTabOrder(self.str_check_box, self.bool_check_box) + QWidget.setTabOrder(self.bool_check_box, self.date_time_check_box) + QWidget.setTabOrder(self.date_time_check_box, self.duration_check_box) + QWidget.setTabOrder(self.duration_check_box, self.array_check_box) + QWidget.setTabOrder(self.array_check_box, self.time_pattern_check_box) + QWidget.setTabOrder(self.time_pattern_check_box, self.time_series_check_box) + QWidget.setTabOrder(self.time_series_check_box, self.map_check_box) + QWidget.setTabOrder(self.map_check_box, self.map_rank_line_edit) + + self.retranslateUi(Form) + + QMetaObject.connectSlotsByName(Form) + # setupUi + + def retranslateUi(self, Form): + Form.setWindowTitle(QCoreApplication.translate("Form", u"Form", None)) + self.select_all_button.setText(QCoreApplication.translate("Form", u"Select &all", None)) + self.clear_all_button.setText(QCoreApplication.translate("Form", u"&Clear all", None)) + self.label_2.setText(QCoreApplication.translate("Form", u"No selection means any type is valid.", None)) + self.duration_check_box.setText(QCoreApplication.translate("Form", u"d&uration", None)) + self.bool_check_box.setText(QCoreApplication.translate("Form", u"&bool", None)) + self.float_check_box.setText(QCoreApplication.translate("Form", u"&float", None)) + self.str_check_box.setText(QCoreApplication.translate("Form", u"&str", None)) + self.date_time_check_box.setText(QCoreApplication.translate("Form", u"&date_time", None)) + self.array_check_box.setText(QCoreApplication.translate("Form", u"a&rray", None)) + self.time_pattern_check_box.setText(QCoreApplication.translate("Form", u"time_&pattern", None)) + self.time_series_check_box.setText(QCoreApplication.translate("Form", u"&time_series", None)) + self.map_check_box.setText(QCoreApplication.translate("Form", u"&map", None)) + self.label.setText(QCoreApplication.translate("Form", u"Ranks:", None)) +#if QT_CONFIG(tooltip) + self.map_rank_line_edit.setToolTip(QCoreApplication.translate("Form", u"A comma separated list of valid ranks.", None)) +#endif // QT_CONFIG(tooltip) + self.map_rank_line_edit.setText(QCoreApplication.translate("Form", u"1", None)) + # retranslateUi + diff --git a/spinetoolbox/spine_db_editor/ui/parameter_type_editor.ui b/spinetoolbox/spine_db_editor/ui/parameter_type_editor.ui new file mode 100644 index 000000000..42d8ed47c --- /dev/null +++ b/spinetoolbox/spine_db_editor/ui/parameter_type_editor.ui @@ -0,0 +1,190 @@ + + + + Form + + + + 0 + 0 + 278 + 178 + + + + Form + + + + + + + + Select &all + + + + + + + &Clear all + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + No selection means any type is valid. + + + + + + + + + d&uration + + + + + + + &bool + + + + + + + &float + + + + + + + &str + + + + + + + &date_time + + + + + + + a&rray + + + + + + + time_&pattern + + + + + + + &time_series + + + + + + + + + 0 + + + + + &map + + + + + + + 0 + + + 6 + + + 0 + + + + + Ranks: + + + + + + + A comma separated list of valid ranks. + + + 1 + + + + + + + + + + + select_all_button + clear_all_button + float_check_box + str_check_box + bool_check_box + date_time_check_box + duration_check_box + array_check_box + time_pattern_check_box + time_series_check_box + map_check_box + map_rank_line_edit + + + + diff --git a/spinetoolbox/spine_db_editor/widgets/custom_delegates.py b/spinetoolbox/spine_db_editor/widgets/custom_delegates.py index bec43ab7b..afb427e29 100644 --- a/spinetoolbox/spine_db_editor/widgets/custom_delegates.py +++ b/spinetoolbox/spine_db_editor/widgets/custom_delegates.py @@ -22,6 +22,7 @@ CheckListEditor, CustomComboBoxEditor, CustomLineEditor, + ParameterTypeEditor, ParameterValueLineEditor, PivotHeaderTableLineEditor, SearchBarEditor, @@ -972,8 +973,37 @@ def handle_button_click(self, index): def sizeHint(self, option, index): size = super().sizeHint(option, index) if self.is_entity_class(index): - # Add the width of the button so that the database -column does not overlap with the button + # Add the width of the button so that the database column does not overlap with the button text_width, icon_size = self.get_text_width_and_icon_size(option, index) button_width = self.get_button_rect(option, icon_size.width(), text_width).width() * 2 return QSize(size.width() + button_width, size.height()) return size + + +class ParameterTypeListDelegate(QStyledItemDelegate): + data_committed = Signal(QModelIndex, object) + + def __init__(self, db_editor, db_mngr): + """ + Args: + db_editor (SpineDBEditor): database editor widget + db_mngr (SpineDBManager): database manager + """ + super().__init__(db_editor) + + def setModelData(self, editor, model, index): + self.data_committed.emit(index, editor.data()) + + def setEditorData(self, editor, index): + editor.set_data(index.data()) + + def createEditor(self, parent, option, index): + return ParameterTypeEditor(parent) + + def updateEditorGeometry(self, editor, option, index): + top_left = option.rect.topLeft() + popup_position = editor.parent().mapToGlobal(top_left) + size_hint = editor.sizeHint() + editor.setGeometry( + popup_position.x(), popup_position.y(), max(option.rect.width(), size_hint.width()), size_hint.height() + ) diff --git a/spinetoolbox/spine_db_editor/widgets/custom_editors.py b/spinetoolbox/spine_db_editor/widgets/custom_editors.py index 2c62e9476..b78f31776 100644 --- a/spinetoolbox/spine_db_editor/widgets/custom_editors.py +++ b/spinetoolbox/spine_db_editor/widgets/custom_editors.py @@ -11,6 +11,7 @@ ###################################################################################################################### """Custom editors for model/view programming.""" +from contextlib import suppress from PySide6.QtCore import ( QCoreApplication, QEvent, @@ -39,7 +40,15 @@ QVBoxLayout, QWidget, ) -from spinetoolbox.helpers import IconListManager, interpret_icon_id, make_icon_id, order_key, try_number_from_string +from spinedb_api.parameter_value import Map, fancy_type_to_type_and_rank, type_and_rank_to_fancy_type +from spinetoolbox.helpers import ( + DB_ITEM_SEPARATOR, + IconListManager, + interpret_icon_id, + make_icon_id, + order_key, + try_number_from_string, +) from spinetoolbox.spine_db_editor.helpers import FALSE_STRING, TRUE_STRING @@ -568,3 +577,92 @@ def data(self): icon_code = self.icon_list.currentIndex().data(Qt.ItemDataRole.UserRole) color_code = self.color_dialog.currentColor().rgb() return make_icon_id(icon_code, color_code) + + +class ParameterTypeEditor(QWidget): + def __init__(self, parent): + """ + Args: + parent (QWidget, optional): parent widget + """ + super().__init__(parent) + self.setWindowFlags(Qt.WindowType.Popup) + from ..ui.parameter_type_editor import Ui_Form + + self._ui = Ui_Form() + self._ui.setupUi(self) + self._ui.select_all_button.clicked.connect(self._select_all) + self._ui.clear_all_button.clicked.connect(self._clear_all) + self._ui.map_rank_line_edit.textEdited.connect(self._ensure_map_selected) + + def data(self): + """Returns editor's data. + + Return: + str: parameter type list separated by DB_ITEM_SEPARATOR + """ + check_boxes = list(self._check_box_iter()) + first_checked = check_boxes[0].isChecked() + if all(box.isChecked() == first_checked for box in check_boxes[1:]): + return "" + types = [] + for check_box in check_boxes: + if not check_box.isChecked(): + continue + type_ = check_box.objectName()[: -len("_check_box")] + if type_ == Map.type_(): + rank_texts = self._ui.map_rank_line_edit.text().split(",") + ranks = [] + for token in rank_texts: + with suppress(ValueError): + ranks.append(int(token)) + if not ranks: + continue + types += [type_and_rank_to_fancy_type(type_, rank) for rank in ranks] + else: + types.append(type_) + return DB_ITEM_SEPARATOR.join(types) + + def set_data(self, type_list): + """Sets editor's data. + + Args: + type_list (str): parameter type list separated by DB_ITEM_SEPARATOR + """ + if not type_list: + self._select_all() + else: + self._clear_all() + map_ranks = [] + for fancy_type in type_list.split(DB_ITEM_SEPARATOR): + type_, rank = fancy_type_to_type_and_rank(fancy_type) + if type_ == Map.type_(): + map_ranks.append(str(rank)) + check_box = getattr(self._ui, f"{type_}_check_box") + check_box.setChecked(True) + self._ui.map_rank_line_edit.setText(", ".join(map_ranks)) + + def _check_box_iter(self): + for attribute in dir(self._ui): + if attribute.endswith("_check_box"): + yield getattr(self._ui, attribute) + + @Slot(bool) + def _select_all(self, _=True): + for check_box in self._check_box_iter(): + check_box.setChecked(True) + if not self._ui.map_rank_line_edit.text().strip(): + self._ui.map_rank_line_edit.setText("1") + + @Slot(bool) + def _clear_all(self, _=True): + for check_box in self._check_box_iter(): + check_box.setChecked(False) + + @Slot(str) + def _ensure_map_selected(self, rank_text): + if rank_text: + if not self._ui.map_check_box.isChecked(): + self._ui.map_check_box.setChecked(True) + elif self._ui.map_check_box.isChecked(): + self._ui.map_check_box.setChecked(False) diff --git a/spinetoolbox/spine_db_editor/widgets/custom_qtableview.py b/spinetoolbox/spine_db_editor/widgets/custom_qtableview.py index 0d919dd5d..67c109acb 100644 --- a/spinetoolbox/spine_db_editor/widgets/custom_qtableview.py +++ b/spinetoolbox/spine_db_editor/widgets/custom_qtableview.py @@ -45,6 +45,7 @@ ParameterDefaultValueDelegate, ParameterDefinitionNameAndDescriptionDelegate, ParameterNameDelegate, + ParameterTypeListDelegate, ParameterValueDelegate, ValueListDelegate, ) @@ -59,7 +60,7 @@ @Slot(QModelIndex, object) def _set_data(index, new_value): - """Updates (object or relationship) parameter_definition or value with newly edited data.""" + """Updates model value with newly edited data.""" index.model().setData(index, new_value) @@ -319,11 +320,12 @@ def plot_in_window(self, action): class ParameterDefinitionTableView(ParameterTableView): value_column_header = "default_value" - _EXPECTED_COLUMN_COUNT = 6 + _EXPECTED_COLUMN_COUNT = 7 _COLUMN_SIZE_HINTS = {"entity_class_name": 200, "parameter_name": 125, "list_value_name": 125, "description": 250} def create_delegates(self): super().create_delegates() + self._make_delegate("valid types", ParameterTypeListDelegate) self._make_delegate("value_list_name", ValueListDelegate) self._make_delegate("parameter_name", ParameterDefinitionNameAndDescriptionDelegate) self._make_delegate("description", ParameterDefinitionNameAndDescriptionDelegate) diff --git a/spinetoolbox/spine_db_manager.py b/spinetoolbox/spine_db_manager.py index 2ed2c8db8..89b4599d3 100644 --- a/spinetoolbox/spine_db_manager.py +++ b/spinetoolbox/spine_db_manager.py @@ -977,12 +977,15 @@ def import_data(self, db_map_data, command_text="Import data"): """ db_map_error_log = {} for db_map, data in db_map_data.items(): + errors = [] try: - data_for_import = get_data_for_import(db_map, **data) + data_for_import = get_data_for_import(db_map, errors, **data) except (TypeError, ValueError) as err: + errors.append(str(err)) msg = f"Failed to import data: {err}. Please check that your data source has the right format." db_map_error_log.setdefault(db_map, []).append(msg) continue + db_map_error_log.setdefault(db_map, []).extend(errors) identifier = self.get_command_identifier() for item_type, items in data_for_import: if isinstance(items, tuple): diff --git a/spinetoolbox/widgets/select_database_items.py b/spinetoolbox/widgets/select_database_items.py index 50fa64d37..8897dc560 100644 --- a/spinetoolbox/widgets/select_database_items.py +++ b/spinetoolbox/widgets/select_database_items.py @@ -76,7 +76,9 @@ def __init__(self, checked_states=None, parent=None): self._ui.setupUi(self) self._ui.select_data_items_button.clicked.connect(self._select_data_items) self._ui.select_scenario_items_button.clicked.connect(self._select_scenario_items) - checkable_item_types = tuple(type_ for type_ in DatabaseMapping.item_types() if type_ != "commit") + checkable_item_types = tuple( + type_ for type_ in DatabaseMapping.item_types() if type_ not in ("commit", "parameter_type") + ) checked_states = ( checked_states if checked_states is not None else {item: False for item in checkable_item_types} ) diff --git a/tests/spine_db_editor/mvcmodels/test_compound_models.py b/tests/spine_db_editor/mvcmodels/test_compound_models.py index 68505f422..9e06845c5 100644 --- a/tests/spine_db_editor/mvcmodels/test_compound_models.py +++ b/tests/spine_db_editor/mvcmodels/test_compound_models.py @@ -28,6 +28,7 @@ def test_horizontal_header(self): expected_header = [ "entity_class_name", "parameter_name", + "valid types", "value_list_name", "default_value", "description", @@ -43,9 +44,9 @@ def test_data_for_single_parameter_definition(self): self._db_mngr.add_entity_classes({self._db_map: [{"name": "oc", "id": 1}]}) self._db_mngr.add_parameter_definitions({self._db_map: [{"name": "p", "entity_class_id": 1, "id": 1}]}) self.assertEqual(model.rowCount(), 2) - self.assertEqual(model.columnCount(), 6) + self.assertEqual(model.columnCount(), 7) row = [model.index(0, column).data() for column in range(model.columnCount())] - expected = ["oc", "p", None, "None", None, self.db_codename] + expected = ["oc", "p", (), None, "None", None, self.db_codename] self.assertEqual(row, expected) def test_data_for_single_parameter_definition_in_multidimensional_entity_class(self): @@ -57,9 +58,9 @@ def test_data_for_single_parameter_definition_in_multidimensional_entity_class(s self._db_mngr.add_parameter_definitions({self._db_map: [{"name": "p", "entity_class_id": 2, "id": 1}]}) self._db_map.fetch_all() self.assertEqual(model.rowCount(), 2) - self.assertEqual(model.columnCount(), 6) + self.assertEqual(model.columnCount(), 7) row = [model.index(0, column).data() for column in range(model.columnCount())] - expected = ["rc", "p", None, "None", None, self.db_codename] + expected = ["rc", "p", (), None, "None", None, self.db_codename] self.assertEqual(row, expected) def test_model_updates_when_entity_class_is_removed(self): diff --git a/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py b/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py index 6db8f6cb0..db4c6479c 100644 --- a/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py +++ b/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py @@ -154,7 +154,7 @@ def test_add_object_parameter_definitions_to_db(self): """Test that object parameter definitions are added to the db when editing the table.""" model = TestEmptyParameterDefinitionModel(self._db_mngr) fetch_model(model) - self.assertTrue(model.batch_set_data(_empty_indexes(model), ["dog", "color", None, None, None, "mock_db"])) + self.assertTrue(model.batch_set_data(_empty_indexes(model), ["dog", "color", (), None, None, None, "mock_db"])) definitions = [ x for x in self._db_mngr.get_items(self._db_map, "parameter_definition") if not x["dimension_id_list"] ] @@ -162,6 +162,22 @@ def test_add_object_parameter_definitions_to_db(self): names = {d["name"] for d in definitions} self.assertEqual(names, {"breed", "color"}) + def test_add_parameter_definitions_with_types_to_db(self): + """Test that object parameter definitions are added to the db when editing the table.""" + model = TestEmptyParameterDefinitionModel(self._db_mngr) + fetch_model(model) + self.assertTrue( + model.batch_set_data( + _empty_indexes(model), ["dog", "color", ("string", "array"), None, None, None, "mock_db"] + ) + ) + definitions = [ + x for x in self._db_mngr.get_items(self._db_map, "parameter_definition") if not x["dimension_id_list"] + ] + self.assertEqual(len(definitions), 2) + type_lists = {d["parameter_type_list"] for d in definitions} + self.assertEqual(type_lists, {(), ("array", "string")}) + def test_do_not_add_invalid_object_parameter_definitions(self): """Test that object parameter definitions aren't added to the db if data is incomplete.""" model = TestEmptyParameterDefinitionModel(self._db_mngr) @@ -178,7 +194,7 @@ def test_add_relationship_parameter_definitions_to_db(self): model = TestEmptyParameterDefinitionModel(self._db_mngr) fetch_model(model) self.assertTrue( - model.batch_set_data(_empty_indexes(model), ["dog__fish", "combined_mojo", None, None, None, "mock_db"]) + model.batch_set_data(_empty_indexes(model), ["dog__fish", "combined_mojo", (), None, None, None, "mock_db"]) ) definitions = [ x for x in self._db_mngr.get_items(self._db_map, "parameter_definition") if x["dimension_id_list"] diff --git a/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py b/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py index adbb337fa..dce94f45f 100644 --- a/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py +++ b/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py @@ -50,6 +50,7 @@ class TestEmptySingleParameterDefinitionModel(unittest.TestCase): HEADER = [ "entity_class_name", "parameter_name", + "valid types", "list_value_name", "default_value", "description", diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditor.py b/tests/spine_db_editor/widgets/test_SpineDBEditor.py index a33f506ab..8b30d0d26 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditor.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditor.py @@ -139,7 +139,7 @@ def test_stacked_table_empty_row_defaults_when_entity_class_is_selected(self): ] expected_row_counts = [1, 1, 1] expected_empty_rows = [ - ["dog", None, None, None, None, self.db_codename], + ["dog", None, None, None, None, None, self.db_codename], ["dog", None, None, None, None, self.db_codename], ["dog", None, None, None, self.db_codename], ] @@ -169,7 +169,7 @@ def test_stacked_table_empty_row_defaults_are_updated_when_entity_class_is_renam ] expected_row_counts = [1, 1, 1] expected_empty_rows = [ - ["wolf", None, None, None, None, self.db_codename], + ["wolf", None, None, None, None, None, self.db_codename], ["wolf", None, None, None, None, self.db_codename], ["wolf", None, None, None, self.db_codename], ] @@ -202,7 +202,7 @@ def test_stacked_table_empty_row_defaults_when_entity_is_selected(self): ] expected_row_counts = [1, 1, 1] expected_empty_rows = [ - ["fish", None, None, None, None, self.db_codename], + ["fish", None, None, None, None, None, self.db_codename], ["fish", "nemo", None, None, None, self.db_codename], ["fish", "nemo", None, None, self.db_codename], ] @@ -238,7 +238,7 @@ def test_stacked_table_empty_row_defaults_are_updated_on_entity_rename(self): ] expected_row_counts = [1, 1, 1] expected_empty_rows = [ - ["fish", None, None, None, None, self.db_codename], + ["fish", None, None, None, None, None, self.db_codename], ["fish", "emon", None, None, None, self.db_codename], ["fish", "emon", None, None, self.db_codename], ] diff --git a/tests/spine_db_editor/widgets/test_custom_qtableview.py b/tests/spine_db_editor/widgets/test_custom_qtableview.py index c643cf80d..d00ae08fe 100644 --- a/tests/spine_db_editor/widgets/test_custom_qtableview.py +++ b/tests/spine_db_editor/widgets/test_custom_qtableview.py @@ -36,7 +36,7 @@ def test_plotting(self): table_view = self._db_editor.ui.tableView_parameter_definition model = table_view.model() fetch_model(model) - index = model.index(0, 3) + index = model.index(0, 4) plot_widget = table_view._plot_selection([index]) try: self.assertEqual(plot_widget.canvas.axes.get_title(), "TestParameterDefinitionTableView_db | Object | q") From edad04083c672f88b6ed014b663db9b874c97d7a Mon Sep 17 00:00:00 2001 From: Antti Soininen Date: Mon, 29 Jul 2024 15:37:24 +0300 Subject: [PATCH 2/2] Implement parameter type validation in DB editor Parameter (default) values are now validated in a parallel process in Database editor. Re #2791 --- docs/source/spine_db_editor/adding_data.rst | 12 +- spinetoolbox/mvcmodels/shared.py | 5 + spinetoolbox/parameter_type_validation.py | 144 ++++++++++ .../mvcmodels/compound_models.py | 66 +++-- .../spine_db_editor/mvcmodels/empty_models.py | 8 +- .../mvcmodels/single_models.py | 108 ++++++-- .../widgets/custom_delegates.py | 41 ++- .../spine_db_editor/widgets/custom_editors.py | 31 ++- spinetoolbox/spine_db_manager.py | 84 +++++- .../mvcmodels/test_emptyParameterModels.py | 34 ++- .../mvcmodels/test_single_parameter_models.py | 29 +- tests/spine_db_editor/test_graphics_items.py | 1 - .../widgets/spine_db_editor_test_base.py | 252 +++++++++--------- .../widgets/test_SpineDBEditor.py | 2 + .../widgets/test_SpineDBEditorAdd.py | 26 +- .../widgets/test_SpineDBEditorFilter.py | 4 + .../widgets/test_SpineDBEditorUpdate.py | 47 ++-- .../widgets/test_custom_editors.py | 45 +++- .../widgets/test_custom_qtableview.py | 6 +- tests/test_SpineDBManager.py | 6 +- tests/test_parameter_type_validation.py | 111 ++++++++ 21 files changed, 808 insertions(+), 254 deletions(-) create mode 100644 spinetoolbox/parameter_type_validation.py create mode 100644 tests/test_parameter_type_validation.py diff --git a/docs/source/spine_db_editor/adding_data.rst b/docs/source/spine_db_editor/adding_data.rst index 4a31e45c2..6a53816b9 100644 --- a/docs/source/spine_db_editor/adding_data.rst +++ b/docs/source/spine_db_editor/adding_data.rst @@ -171,7 +171,13 @@ Only two of the fields are required when creating a new parameter definition: *e *parameter_name*. Enter the name of the class under *entity_class_name*. To display a list of available entity classes, start typing in the empty cell or double click it. For the name of the parameter choose something that isn't already defined for the specified entity class. Optionally, you can also -specify a parameter value list, a default value and a description. +specify valid value types, a parameter value list, a default value and a description. + +The *valid types* column defines value types that are valid for the parameter. +An empty field means that all types are valid. +All values are validated against this column and non-valid types are marked invalid +in the *default_value* and *value* (in Parameter value table) columns. +Valid types are not enforced, however, so it is still possible to commit values of invalid type to the database. In the column *value_list_name* a name for a parameter value list can be selected. Leaving this field empty means that later on when creating parameter values with this definition, the values are arbitrary. Meaning that @@ -182,7 +188,9 @@ see :ref:`parameter_value_list`. In the *default_value* field, the default value can be set. The default value can be used in cases where the value is not specified. The usage of *default_value* is really tool dependent, meaning that the Spine Database Editor doesn't use the information of the default value anywhere, but it is instead left to the tool creators on how to -utilize the default value. A short description for the parameter can be written in the *description* column. +utilize the default value. + +A short description for the parameter can be written in the *description* column. The parameter is added when the background of the cells under *entity_class_name* and *database* become gray. diff --git a/spinetoolbox/mvcmodels/shared.py b/spinetoolbox/mvcmodels/shared.py index 487754ab3..5daed1541 100644 --- a/spinetoolbox/mvcmodels/shared.py +++ b/spinetoolbox/mvcmodels/shared.py @@ -15,3 +15,8 @@ PARSED_ROLE = Qt.ItemDataRole.UserRole DB_MAP_ROLE = Qt.ItemDataRole.UserRole + 1 +PARAMETER_TYPE_VALIDATION_ROLE = Qt.ItemDataRole.UserRole + 2 + +INVALID_TYPE = 0 +TYPE_NOT_VALIDATED = 1 +VALID_TYPE = 2 diff --git a/spinetoolbox/parameter_type_validation.py b/spinetoolbox/parameter_type_validation.py new file mode 100644 index 000000000..f09bbc57d --- /dev/null +++ b/spinetoolbox/parameter_type_validation.py @@ -0,0 +1,144 @@ +###################################################################################################################### +# 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 . +###################################################################################################################### +"""Contains utilities for validating parameter types.""" +from dataclasses import dataclass +from multiprocessing import Pipe, Process +from typing import Any, Iterable, Optional, Tuple +from PySide6.QtCore import QObject, QTimer, Signal, Slot +from spinedb_api.db_mapping_helpers import is_parameter_type_valid, type_check_args + +CHUNK_SIZE = 20 + + +@dataclass(frozen=True) +class ValidationKey: + item_type: str + db_map_id: int + item_private_id: int + + +@dataclass(frozen=True) +class ValidatableValue: + key: ValidationKey + args: Tuple[Iterable[str], Optional[bytes], Optional[Any], Optional[str]] + + +class ParameterTypeValidator(QObject): + """Handles parameter type validation in a concurrent process.""" + + validated = Signal(ValidationKey, bool) + + def __init__(self, parent=None): + """ + Args: + parent (QObject, optional): parent object + """ + super().__init__(parent) + self._connection, scheduler_connection = Pipe() + self._process = Process(target=schedule, name="Type validation worker", args=(scheduler_connection,)) + self._timer = QTimer(self) + self._timer.setInterval(100) + self._timer.timeout.connect(self._communicate) + self._task_queue = [] + self._sent_task_count = 0 + + def set_interval(self, interval): + """Sets the interval between communication attempts with the validation process. + + Args: + interval (int): interval in milliseconds + """ + self._timer.setInterval(interval) + + def start_validating(self, db_mngr, db_map, value_item_ids): + """Initiates validation of given parameter definition/value items. + + Args: + db_mngr (SpineDBManager): database manager + db_map (DatabaseMapping): database mapping + value_item_ids (Iterable of TempId): item ids to validate + """ + if not self._process.is_alive(): + self._process.start() + for item_id in value_item_ids: + item = db_mngr.get_item(db_map, item_id.item_type, item_id) + args = type_check_args(item) + self._task_queue.append( + ValidatableValue(ValidationKey(item_id.item_type, id(db_map), item_id.private_id), args) + ) + self._sent_task_count += 1 + if not self._timer.isActive(): + chunk = self._task_queue[:CHUNK_SIZE] + self._task_queue = self._task_queue[CHUNK_SIZE:] + self._connection.send(chunk) + self._timer.start() + + @Slot() + def _communicate(self): + """Communicates with the validation process.""" + self._timer.stop() + if self._connection.poll(): + results = self._connection.recv() + for key, result in results.items(): + self.validated.emit(key, result) + self._sent_task_count -= len(results) + if self._task_queue and self._sent_task_count < 3 * CHUNK_SIZE: + chunk = self._task_queue[:CHUNK_SIZE] + self._task_queue = self._task_queue[CHUNK_SIZE:] + self._connection.send(chunk) + if not self._task_queue and self._sent_task_count == 0: + return + self._timer.start() + + def tear_down(self): + """Cleans up the validation process.""" + self._timer.stop() + if self._process.is_alive(): + self._connection.send("quit") + self._process.join() + + +def validate_chunk(validatable_values): + """Validates given parameter definitions/values. + + Args: + validatable_values (Iterable of ValidatableValue): values to validate + + Returns: + dict: mapping from ValidationKey to boolean + """ + results = {} + for validatable_value in validatable_values: + results[validatable_value.key] = is_parameter_type_valid(*validatable_value.args) + return results + + +def schedule(connection): + """Loops over incoming messages and sends responses back. + + Args: + connection (Connection): A duplex Pipe end + """ + validatable_values = [] + while True: + if connection.poll() or not validatable_values: + while True: + task = connection.recv() + if task == "quit": + return + validatable_values += task + if not connection.poll(): + break + chunk = validatable_values[:CHUNK_SIZE] + validatable_values = validatable_values[CHUNK_SIZE:] + results = validate_chunk(chunk) + connection.send(results) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/compound_models.py b/spinetoolbox/spine_db_editor/mvcmodels/compound_models.py index fe2546a60..735944a1a 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/compound_models.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/compound_models.py @@ -11,6 +11,7 @@ ###################################################################################################################### """ Compound models. These models concatenate several 'single' models and one 'empty' model. """ +from typing import ClassVar from PySide6.QtCore import QModelIndex, Qt, QTimer, Slot from PySide6.QtGui import QFont from spinedb_api.parameter_value import join_value_and_type @@ -25,6 +26,8 @@ class CompoundModelBase(CompoundWithEmptyTableModel): """A base model for all models that show data in stacked format.""" + item_type: ClassVar[str] = NotImplemented + def __init__(self, parent, db_mngr, *db_maps): """ Args: @@ -65,15 +68,6 @@ def column_filters(self): def field_map(self): return {} - @property - def item_type(self): - """Returns the DB item type, e.g., 'parameter_value'. - - Returns: - str - """ - raise NotImplementedError() - @property def _single_model_type(self): """ @@ -318,7 +312,7 @@ def _items_per_class(items): def handle_items_added(self, db_map_data): """Runs when either parameter definitions or values are added to the dbs. Adds necessary sub-models and initializes them with data. - Also notifies the empty model so it can remove rows that are already in. + Also notifies the empty model, so it can remove rows that are already in. Args: db_map_data (dict): list of added dict-items keyed by DatabaseMapping @@ -493,6 +487,19 @@ def _create_single_model(self, db_map, entity_class_id, committed): class EditParameterValueMixin: """Provides the interface to edit values via ParameterValueEditor.""" + def handle_items_updated(self, db_map_data): + super().handle_items_updated(db_map_data) + for db_map, items in db_map_data.items(): + if db_map not in self.db_maps: + continue + items_by_class = self._items_per_class(items) + for entity_class_id, class_items in items_by_class.items(): + single_model = next( + (m for m in self.single_models if (m.db_map, m.entity_class_id) == (db_map, entity_class_id)), None + ) + if single_model is not None: + single_model.revalidate_item_types(class_items) + def index_name(self, index): """Generates a name for data at given index. @@ -544,9 +551,7 @@ def get_set_data_delayed(self, index): class CompoundParameterDefinitionModel(EditParameterValueMixin, CompoundModelBase): """A model that concatenates several single parameter_definition models and one empty parameter_definition model.""" - @property - def item_type(self): - return "parameter_definition" + item_type = "parameter_definition" def _make_header(self): return [ @@ -579,9 +584,16 @@ def _empty_model_type(self): class CompoundParameterValueModel(FilterEntityAlternativeMixin, EditParameterValueMixin, CompoundModelBase): """A model that concatenates several single parameter_value models and one empty parameter_value model.""" - @property - def item_type(self): - return "parameter_value" + item_type = "parameter_value" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._definition_fetch_parent = FlexibleFetchParent( + "parameter_definition", + shows_item=lambda item, db_map: True, + handle_items_updated=self._handle_parameter_definitions_updated, + owner=self, + ) def _make_header(self): return [ @@ -605,11 +617,27 @@ def _single_model_type(self): def _empty_model_type(self): return EmptyParameterValueModel + def reset_db_map(self, db_maps): + super().reset_db_maps(db_maps) + self._definition_fetch_parent.set_obsolete(False) + self._definition_fetch_parent.reset() + + def _handle_parameter_definitions_updated(self, db_map_data): + for db_map, items in db_map_data.items(): + if db_map not in self.db_maps: + continue + items_by_class = self._items_per_class(items) + for entity_class_id, class_items in items_by_class.items(): + single_model = next( + (m for m in self.single_models if (m.db_map, m.entity_class_id) == (db_map, entity_class_id)), None + ) + if single_model is not None: + single_model.revalidate_item_typs(class_items) + class CompoundEntityAlternativeModel(FilterEntityAlternativeMixin, CompoundModelBase): - @property - def item_type(self): - return "entity_alternative" + + item_type = "entity_alternative" def _make_header(self): return [ diff --git a/spinetoolbox/spine_db_editor/mvcmodels/empty_models.py b/spinetoolbox/spine_db_editor/mvcmodels/empty_models.py index 02504dbc3..e4663f4fd 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/empty_models.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/empty_models.py @@ -45,7 +45,7 @@ def add_items_to_db(self, db_map_data): """Add items to db. Args: - db_map_data (dict): mapping DiffDatabaseMapping instance to list of items + db_map_data (dict): mapping DatabaseMapping instance to list of items """ db_map_items = {} db_map_error_log = {} @@ -161,7 +161,7 @@ def _make_db_map_data(self, rows): rows (set): group data from these rows Returns: - dict: mapping DiffDatabaseMapping instance to list of items + dict: mapping DatabaseMapping instance to list of items """ items = [self._make_item(row) for row in rows] db_map_data = {} @@ -187,12 +187,12 @@ def value_field(self): return {"parameter_value": "value", "parameter_definition": "default_value"}[self.item_type] def data(self, index, role=Qt.ItemDataRole.DisplayRole): - if self.header[index.column()] == self.value_field and role in ( + if self.header[index.column()] == self.value_field and role in { Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole, Qt.ItemDataRole.TextAlignmentRole, PARSED_ROLE, - ): + }: data = super().data(index, role=Qt.ItemDataRole.EditRole) return self.db_mngr.get_value_from_data(data, role) return super().data(index, role) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/single_models.py b/spinetoolbox/spine_db_editor/mvcmodels/single_models.py index d92a2a4b5..885fb1871 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/single_models.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/single_models.py @@ -11,11 +11,12 @@ ###################################################################################################################### """Single models for parameter definitions and values (as 'for a single entity').""" -from typing import Iterable -from PySide6.QtCore import Qt +from typing import ClassVar, Iterable +from PySide6.QtCore import Qt, Slot +from PySide6.QtGui import QColor from spinetoolbox.helpers import DB_ITEM_SEPARATOR, order_key, plain_to_rich from ...mvcmodels.minimal_table_model import MinimalTableModel -from ...mvcmodels.shared import DB_MAP_ROLE, PARSED_ROLE +from ...mvcmodels.shared import DB_MAP_ROLE, PARAMETER_TYPE_VALIDATION_ROLE, PARSED_ROLE from ..mvcmodels.single_and_empty_model_mixins import MakeEntityOnTheFlyMixin, SplitValueAndTypeMixin from .colors import FIXED_FIELD_COLOR @@ -45,7 +46,8 @@ def _sort_key(self, element): class SingleModelBase(HalfSortedTableModel): """Base class for all single models that go in a CompoundModelBase subclass.""" - group_fields: Iterable[str] = () + item_type: ClassVar[str] = NotImplemented + group_fields: ClassVar[Iterable[str]] = () def __init__(self, parent, db_map, entity_class_id, committed, lazy=False): """ @@ -54,6 +56,7 @@ def __init__(self, parent, db_map, entity_class_id, committed, lazy=False): db_map (DatabaseMapping) entity_class_id (int) committed (bool) + lazy (bool) """ super().__init__(parent=parent, header=parent.header, lazy=lazy) self.db_mngr = parent.db_mngr @@ -75,11 +78,6 @@ def __lt__(self, other): ) return keys["left"] < keys["right"] - @property - def item_type(self): - """The DB item type, required by the data method.""" - raise NotImplementedError() - @property def field_map(self): return self._parent.field_map @@ -298,13 +296,13 @@ def _alternative_filter_accepts_item(self, item): class ParameterMixin: """Provides the data method for parameter values and definitions.""" - @property - def value_field(self): - return {"parameter_definition": "default_value", "parameter_value": "value"}[self.item_type] + value_field: ClassVar[str] = NotImplemented + parameter_definition_id_key: ClassVar[str] = NotImplemented - @property - def parameter_definition_id_key(self): - return {"parameter_definition": "id", "parameter_value": "parameter_id"}[self.item_type] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._ids_pending_type_validation = set() + self.destroyed.connect(self._stop_waiting_validation) @property def _references(self): @@ -320,6 +318,15 @@ def _references(self): "alternative_name": ("alternative_id", "alternative"), } + def reset_model(self, main_data=None): + """Resets the model.""" + super().reset_model(main_data) + if self._ids_pending_type_validation: + self.db_mngr.parameter_type_validator.validated.disconnect(self._parameter_type_validated) + self._ids_pending_type_validation.clear() + if main_data: + self._start_validating_types(main_data) + def data(self, index, role=Qt.ItemDataRole.DisplayRole): """Gets the id and database for the row, and reads data from the db manager using the item_type property. @@ -327,18 +334,66 @@ def data(self, index, role=Qt.ItemDataRole.DisplayRole): Also paint background of fixed indexes gray and apply custom format to JSON fields.""" field = self.header[index.column()] # Display, edit, tool tip, alignment role of 'value fields' - if field == self.value_field and role in ( + if field == self.value_field and role in { Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole, Qt.ItemDataRole.ToolTipRole, - Qt.TextAlignmentRole, + Qt.ItemDataRole.TextAlignmentRole, PARSED_ROLE, - ): + PARAMETER_TYPE_VALIDATION_ROLE, + }: id_ = self._main_data[index.row()] 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) + def add_rows(self, ids): + super().add_rows(ids) + self._start_validating_types(ids) + + def revalidate_item_types(self, items): + ids = tuple(item["id"] for item in items) + self._start_validating_types(ids) + + def _start_validating_types(self, ids): + """""" + private_ids = set(temp_id.private_id for temp_id in ids) + new_ids = private_ids - self._ids_pending_type_validation + if not new_ids: + return + self._ids_pending_type_validation |= new_ids + self.db_mngr.parameter_type_validator.validated.connect( + self._parameter_type_validated, Qt.ConnectionType.UniqueConnection + ) + self.db_mngr.parameter_type_validator.start_validating( + self.db_mngr, self.db_map, (id_ for id_ in ids if id_.private_id in new_ids) + ) + + def _parameter_type_validated(self, key, is_valid): + """Notifies the model that values have been validated. + + Args: + key (ValidationKey): validation key + is_valid (bool): True if value type is valid, False otherwise + """ + if key.item_type != self.item_type or key.db_map_id != id(self.db_map): + return + self._ids_pending_type_validation.discard(key.item_private_id) + if not self._ids_pending_type_validation: + self.db_mngr.parameter_type_validator.validated.disconnect(self._parameter_type_validated) + value_column = self.header.index(self.value_field) + for row, id_ in enumerate(self._main_data): + if id_.private_id == key.item_private_id: + self.dataChanged.emit(self.index(row, value_column), [PARAMETER_TYPE_VALIDATION_ROLE]) + break + + @Slot(object) + def _stop_waiting_validation(self): + """Stops the model from waiting for type validation notifications.""" + if self._ids_pending_type_validation: + self.db_mngr.parameter_type_validator.validated.disconnect(self._parameter_type_validated) + self._ids_pending_type_validation.clear() + class EntityMixin: group_fields = ("entity_byname",) @@ -368,12 +423,11 @@ def _do_update_items_in_db(self, db_map_data): class SingleParameterDefinitionModel(SplitValueAndTypeMixin, ParameterMixin, SingleModelBase): """A parameter_definition model for a single entity_class.""" + item_type = "parameter_definition" + value_field = "default_value" + parameter_definition_id_key = "id" group_fields = ("valid types",) - @property - def item_type(self): - return "parameter_definition" - def _sort_key(self, element): item = self.db_item_from_id(element) return order_key(item.get("name", "")) @@ -392,9 +446,9 @@ class SingleParameterValueModel( ): """A parameter_value model for a single entity_class.""" - @property - def item_type(self): - return "parameter_value" + item_type = "parameter_value" + value_field = "value" + parameter_definition_id_key = "parameter_id" def _sort_key(self, element): item = self.db_item_from_id(element) @@ -410,9 +464,7 @@ def _do_update_items_in_db(self, db_map_data): class SingleEntityAlternativeModel(MakeEntityOnTheFlyMixin, EntityMixin, FilterEntityAlternativeMixin, SingleModelBase): """An entity_alternative model for a single entity_class.""" - @property - def item_type(self): - return "entity_alternative" + item_type = "entity_alternative" def _sort_key(self, element): item = self.db_item_from_id(element) diff --git a/spinetoolbox/spine_db_editor/widgets/custom_delegates.py b/spinetoolbox/spine_db_editor/widgets/custom_delegates.py index afb427e29..98ec8ffb7 100644 --- a/spinetoolbox/spine_db_editor/widgets/custom_delegates.py +++ b/spinetoolbox/spine_db_editor/widgets/custom_delegates.py @@ -13,7 +13,7 @@ """Custom item delegates.""" from numbers import Number from PySide6.QtCore import QEvent, QModelIndex, QRect, QSize, Qt, Signal -from PySide6.QtGui import QFontMetrics, QIcon +from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon from PySide6.QtWidgets import QStyledItemDelegate from spinedb_api import to_database from spinedb_api.parameter_value import join_value_and_type @@ -29,7 +29,7 @@ SearchBarEditorWithCreation, ) from ...helpers import object_icon -from ...mvcmodels.shared import DB_MAP_ROLE, PARSED_ROLE +from ...mvcmodels.shared import DB_MAP_ROLE, INVALID_TYPE, PARAMETER_TYPE_VALIDATION_ROLE, PARSED_ROLE from ...widgets.custom_delegates import CheckBoxDelegate, RankDelegate from ..mvcmodels.metadata_table_model_base import Column as MetadataColumn @@ -280,20 +280,51 @@ def createEditor(self, parent, option, index): return editor +def _make_exclamation_font(): + """Creates font for invalid parameter type notification. + + Returns: + QFont: font + """ + font = QFont("Font Awesome 5 Free Solid") + font.setPixelSize(12) + return font + + class ParameterValueOrDefaultValueDelegate(TableDelegate): """A delegate for either the value or the default value.""" parameter_value_editor_requested = Signal(QModelIndex) + EXCLAMATION_FONT = _make_exclamation_font() + EXCLAMATION_COLOR = QColor("red") + INDICATOR_WIDTH = 18 def __init__(self, parent, db_mngr): """ Args: parent (QWidget): parent widget - db_mngr (SpineDatabaseManager): database manager + db_mngr (SpineDBManager): database manager """ super().__init__(parent, db_mngr) self._db_value_list_lookup = {} + def paint(self, painter, option, index): + validation_state = index.data(PARAMETER_TYPE_VALIDATION_ROLE) + if validation_state == INVALID_TYPE: + left = option.rect.x() + width = option.rect.width() + height = option.rect.height() + indicator_left = left + width - self.INDICATOR_WIDTH + indicator_rect = QRect(indicator_left, option.rect.y(), self.INDICATOR_WIDTH, height) + option.rect.setRight(indicator_left) + text_position = indicator_rect.center() + text_position.setY(text_position.y() + 5) + text_position.setX(text_position.x() - 5) + painter.setFont(self.EXCLAMATION_FONT) + painter.setPen(self.EXCLAMATION_COLOR) + painter.drawText(text_position, "\uf06a") + super().paint(painter, option, index) + def setModelData(self, editor, model, index): """Send signal.""" display_value = editor.data() @@ -325,7 +356,7 @@ def _get_value_list_id(self, index, db_map): Args: index (QModelIndex): value list's index - db_map (DiffDatabaseMapping): database mapping + db_map (DatabaseMapping): database mapping Returns: int: value list id @@ -981,6 +1012,8 @@ def sizeHint(self, option, index): class ParameterTypeListDelegate(QStyledItemDelegate): + """Delegate for the 'valid types' column in Parameter definition table.""" + data_committed = Signal(QModelIndex, object) def __init__(self, db_editor, db_mngr): diff --git a/spinetoolbox/spine_db_editor/widgets/custom_editors.py b/spinetoolbox/spine_db_editor/widgets/custom_editors.py index b78f31776..39300221c 100644 --- a/spinetoolbox/spine_db_editor/widgets/custom_editors.py +++ b/spinetoolbox/spine_db_editor/widgets/custom_editors.py @@ -580,6 +580,8 @@ def data(self): class ParameterTypeEditor(QWidget): + """Editor to select valid parameter types.""" + def __init__(self, parent): """ Args: @@ -594,6 +596,7 @@ def __init__(self, parent): self._ui.select_all_button.clicked.connect(self._select_all) self._ui.clear_all_button.clicked.connect(self._clear_all) self._ui.map_rank_line_edit.textEdited.connect(self._ensure_map_selected) + self._ui.map_check_box.clicked.connect(self._edit_rank) def data(self): """Returns editor's data. @@ -630,7 +633,7 @@ def set_data(self, type_list): type_list (str): parameter type list separated by DB_ITEM_SEPARATOR """ if not type_list: - self._select_all() + self._clear_all() else: self._clear_all() map_ranks = [] @@ -643,12 +646,18 @@ def set_data(self, type_list): self._ui.map_rank_line_edit.setText(", ".join(map_ranks)) def _check_box_iter(self): + """Yields type check boxes. + + Yields: + QCheckBox: type check box + """ for attribute in dir(self._ui): if attribute.endswith("_check_box"): yield getattr(self._ui, attribute) @Slot(bool) def _select_all(self, _=True): + """Selects all check boxes.""" for check_box in self._check_box_iter(): check_box.setChecked(True) if not self._ui.map_rank_line_edit.text().strip(): @@ -656,13 +665,33 @@ def _select_all(self, _=True): @Slot(bool) def _clear_all(self, _=True): + """Clears all check boxes.""" for check_box in self._check_box_iter(): check_box.setChecked(False) @Slot(str) def _ensure_map_selected(self, rank_text): + """Makes sure the map check box is checked. + + Args: + rank_text (str): text in the rank line edit + """ if rank_text: if not self._ui.map_check_box.isChecked(): self._ui.map_check_box.setChecked(True) elif self._ui.map_check_box.isChecked(): self._ui.map_check_box.setChecked(False) + + @Slot(bool) + def _edit_rank(self, map_checked): + """Focuses on the rank line edit and select all its contents if map has been checked. + + Args: + map_checked (bool): map checkbox state + """ + if not map_checked: + return + if not self._ui.map_rank_line_edit.text(): + self._ui.map_rank_line_edit.setText("1") + self._ui.map_rank_line_edit.selectAll() + self._ui.map_rank_line_edit.setFocus(Qt.FocusReason.OtherFocusReason) diff --git a/spinetoolbox/spine_db_manager.py b/spinetoolbox/spine_db_manager.py index 89b4599d3..07f209269 100644 --- a/spinetoolbox/spine_db_manager.py +++ b/spinetoolbox/spine_db_manager.py @@ -11,6 +11,7 @@ ###################################################################################################################### """The SpineDBManager class.""" +from contextlib import suppress import json import os from PySide6.QtCore import QObject, Qt, Signal, Slot @@ -48,7 +49,8 @@ ) from spinedb_api.spine_io.exporters.excel import export_spine_database_to_xlsx from .helpers import busy_effect, plain_to_tool_tip -from .mvcmodels.shared import PARSED_ROLE +from .mvcmodels.shared import INVALID_TYPE, PARAMETER_TYPE_VALIDATION_ROLE, PARSED_ROLE, TYPE_NOT_VALIDATED, VALID_TYPE +from .parameter_type_validation import ParameterTypeValidator, ValidationKey from .spine_db_commands import ( AddItemsCommand, AddUpdateItemsCommand, @@ -115,11 +117,18 @@ def __init__(self, settings, parent, synchronous=False): self._cmd_id = 0 self._synchronous = synchronous self.data_stores = {} + self._validated_values = {"parameter_definition": {}, "parameter_value": {}} + self._parameter_type_validator = ParameterTypeValidator(self) + self._parameter_type_validator.validated.connect(self._parameter_value_validated) def _connect_signals(self): self.error_msg.connect(self.receive_error_msg) qApp.aboutToQuit.connect(self.clean_up) # pylint: disable=undefined-variable + @property + def parameter_type_validator(self) -> ParameterTypeValidator: + return self._parameter_type_validator + @Slot(object) def receive_error_msg(self, db_map_error_log): for db_map, error_log in db_map_error_log.items(): @@ -314,6 +323,8 @@ def close_session(self, url): if worker is not None: worker.close_db_map() # NOTE: This calls ThreadPoolExecutor.shutdown() which waits for Futures to finish worker.clean_up() + del self._validated_values["parameter_definition"][id(db_map)] + del self._validated_values["parameter_value"][id(db_map)] del self.undo_stack[db_map] del self.undo_action[db_map] del self.redo_action[db_map] @@ -389,6 +400,8 @@ def _do_get_db_map(self, url, **kwargs): raise error self._workers[db_map] = worker self._db_maps[url] = db_map + self._validated_values["parameter_definition"][id(db_map)] = {} + self._validated_values["parameter_value"][id(db_map)] = {} stack = self.undo_stack[db_map] = AgedUndoStack(self) self.undo_action[db_map] = stack.createUndoAction(self) self.redo_action[db_map] = stack.createRedoAction(self) @@ -563,6 +576,7 @@ def clean_up(self): while self._workers: _, worker = self._workers.popitem() worker.clean_up() + self._parameter_type_validator.tear_down() self.deleteLater() def refresh_session(self, *db_maps): @@ -677,10 +691,13 @@ def _do_rollback_session(self, db_map): """ try: db_map.rollback_session() - self.undo_stack[db_map].clear() - self.receive_session_rolled_back({db_map}) except SpineDBAPIError as err: self.error_msg.emit({db_map: [err.msg]}) + return + self._validated_values["parameter_definition"][id(db_map)].clear() + self._validated_values["parameter_value"][id(db_map)].clear() + self.undo_stack[db_map].clear() + self.receive_session_rolled_back({db_map}) def entity_class_renderer(self, db_map, entity_class_id, for_group=False, color=None): """Returns an icon renderer for a given entity class. @@ -816,6 +833,24 @@ def tool_tip_data_from_parsed(parsed_data): tool_tip_data = None return plain_to_tool_tip(tool_tip_data) + def _tool_tip_for_invalid_parameter_type(self, item): + """Returns tool tip for parameter (default) values that have an invalid type. + + Args: + item (PublicItem): + + Returns: + str: tool tip + """ + if item.item_type == "parameter_value": + definition = self.get_item(item.db_map, "parameter_definition", item["parameter_definition_id"]) + else: + definition = item + type_list = definition["parameter_type_list"] + if len(type_list) == 1: + return plain_to_tool_tip(f"Expected value's type to be {type_list[0]}.") + return plain_to_tool_tip(f"Expected value's type to be one of {', '.join(type_list)}.") + def _format_list_value(self, db_map, item_type, value, list_value_id): list_value = self.get_item(db_map, "list_value", list_value_id) if not list_value: @@ -838,18 +873,32 @@ def get_value(self, db_map, item, role=Qt.ItemDataRole.DisplayRole): role (Qt.ItemDataRole): data role Returns: - any + Any: """ if not item: return None + if role == PARAMETER_TYPE_VALIDATION_ROLE: + try: + is_valid = self._validated_values[item.item_type][id(db_map)][item["id"].private_id] + except KeyError: + return TYPE_NOT_VALIDATED + return VALID_TYPE if is_valid else INVALID_TYPE + if role == Qt.ItemDataRole.ToolTipRole: + try: + is_valid = self._validated_values[item.item_type][id(db_map)][item["id"].private_id] + except KeyError: + pass + else: + if not is_valid: + return self._tool_tip_for_invalid_parameter_type(item) value_field, type_field = { "parameter_value": ("value", "type"), "list_value": ("value", "type"), "parameter_definition": ("default_value", "default_type"), }[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: + list_value_id = item["id"] if item.item_type == "list_value" else item["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]) @@ -1362,6 +1411,8 @@ def update_items(self, item_type, db_map_data, identifier=None, **kwargs): """Pushes commands to update items to undo stack.""" if identifier is None: identifier = self.get_command_identifier() + if item_type in ("parameter_definition", "parameter_value"): + self._clear_validated_value_ids(item_type, db_map_data) for db_map, data in db_map_data.items(): self.undo_stack[db_map].push( UpdateItemsCommand(self, db_map, item_type, data, identifier=identifier, **kwargs) @@ -1371,6 +1422,8 @@ def add_update_items(self, item_type, db_map_data, identifier=None, **kwargs): """Pushes commands to add_update items to undo stack.""" if identifier is None: identifier = self.get_command_identifier() + if item_type in ("parameter_definition", "parameter_value"): + self._clear_validated_value_ids(item_type, db_map_data) for db_map, data in db_map_data.items(): self.undo_stack[db_map].push( AddUpdateItemsCommand(self, db_map, item_type, data, identifier=identifier, **kwargs) @@ -1382,6 +1435,14 @@ def remove_items(self, db_map_typed_ids, identifier=None, **kwargs): identifier = self.get_command_identifier() for db_map, ids_per_type in db_map_typed_ids.items(): for item_type, ids in ids_per_type.items(): + if item_type in ("parameter_definition", "parameter_value"): + if Asterisk in ids: + self._validated_values[item_type][id(db_map)].clear() + else: + validated_values = self._validated_values[item_type][id(db_map)] + for id_ in ids: + with suppress(KeyError): + del validated_values[id_.private_id] self.undo_stack[db_map].push( RemoveItemsCommand(self, db_map, item_type, ids, identifier=identifier, **kwargs) ) @@ -1728,3 +1789,16 @@ def open_db_editor(self, db_url_codenames, reuse_existing_editor): if multi_db_editor.isMinimized(): multi_db_editor.showNormal() multi_db_editor.activateWindow() + + @Slot(ValidationKey, bool) + def _parameter_value_validated(self, key, is_valid): + with suppress(KeyError): + self._validated_values[key.item_type][key.db_map_id][key.item_private_id] = is_valid + + def _clear_validated_value_ids(self, item_type, db_map_data): + db_map_validated_values = self._validated_values[item_type] + for db_map, data in db_map_data.items(): + validated_values = db_map_validated_values[id(db_map)] + for item in data: + with suppress(KeyError): + del validated_values[item["id"].private_id] diff --git a/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py b/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py index db4c6479c..26c461796 100644 --- a/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py +++ b/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py @@ -22,7 +22,7 @@ import_relationship_parameters, import_relationships, ) -from spinedb_api.parameter_value import join_value_and_type +from spinedb_api.parameter_value import join_value_and_type, to_database from spinetoolbox.helpers import DB_ITEM_SEPARATOR from spinetoolbox.spine_db_editor.mvcmodels.compound_models import ( CompoundParameterDefinitionModel, @@ -75,10 +75,11 @@ def test_add_object_parameter_values_to_db(self): """Test that object parameter values are added to the db when editing the table.""" model = TestEmptyParameterValueModel(self._db_mngr) fetch_model(model) + value, value_type = to_database("bloodhound") self.assertTrue( model.batch_set_data( _empty_indexes(model), - ["dog", "pluto", "breed", "Base", join_value_and_type(b'"bloodhound"', None), "mock_db"], + ["dog", "pluto", "breed", "Base", join_value_and_type(value, value_type), "mock_db"], ) ) values = self._db_mngr.get_items(self._db_map, "parameter_value") @@ -86,7 +87,7 @@ def test_add_object_parameter_values_to_db(self): self.assertEqual(values[0]["entity_class_name"], "dog") self.assertEqual(values[0]["entity_name"], "pluto") self.assertEqual(values[0]["parameter_name"], "breed") - self.assertEqual(values[0]["value"], b'"bloodhound"') + self.assertEqual(values[0]["value"], value) def test_do_not_add_invalid_object_parameter_values(self): """Test that object parameter values aren't added to the db if data is incomplete.""" @@ -103,9 +104,10 @@ def test_infer_class_from_object_and_parameter(self): model = TestEmptyParameterValueModel(self._db_mngr) fetch_model(model) indexes = _empty_indexes(model) + value, value_type = to_database("bloodhound") self.assertTrue( model.batch_set_data( - indexes, ["cat", "pluto", "breed", "Base", join_value_and_type(b'"bloodhound"', None), "mock_db"] + indexes, ["cat", "pluto", "breed", "Base", join_value_and_type(value, value_type), "mock_db"] ) ) self.assertEqual(indexes[0].data(), "dog") @@ -114,12 +116,13 @@ def test_infer_class_from_object_and_parameter(self): self.assertEqual(values[0]["entity_class_name"], "dog") self.assertEqual(values[0]["entity_name"], "pluto") self.assertEqual(values[0]["parameter_name"], "breed") - self.assertEqual(values[0]["value"], b'"bloodhound"') + self.assertEqual(values[0]["value"], value) def test_add_relationship_parameter_values_to_db(self): """Test that relationship parameter values are added to the db when editing the table.""" model = TestEmptyParameterValueModel(self._db_mngr) fetch_model(model) + value, value_type = to_database(-1) self.assertTrue( model.batch_set_data( _empty_indexes(model), @@ -128,7 +131,7 @@ def test_add_relationship_parameter_values_to_db(self): DB_ITEM_SEPARATOR.join(["pluto", "nemo"]), "relative_speed", "Base", - join_value_and_type(b"-1", None), + join_value_and_type(value, value_type), "mock_db", ], ) @@ -138,7 +141,7 @@ def test_add_relationship_parameter_values_to_db(self): self.assertEqual(values[0]["entity_class_name"], "dog__fish") self.assertEqual(values[0]["element_name_list"], ("pluto", "nemo")) self.assertEqual(values[0]["parameter_name"], "relative_speed") - self.assertEqual(values[0]["value"], b"-1") + self.assertEqual(values[0]["value"], value) def test_do_not_add_invalid_relationship_parameter_values(self): """Test that relationship parameter values aren't added to the db if data is incomplete.""" @@ -222,19 +225,24 @@ def test_add_entity_parameter_values_adds_entity(self): """Test that adding parameter a value for a nonexistent entity creates the entity.""" model = TestEmptyParameterValueModel(self._db_mngr) fetch_model(model) - self.assertTrue( - model.batch_set_data( - _empty_indexes(model), - ["dog", "plato", "breed", "Base", join_value_and_type(b'"dog-human"', None), "mock_db"], + value, value_type = to_database("dog-human") + with mock.patch("spinetoolbox.spine_db_editor.mvcmodels.empty_models.AddedEntitiesPopup") as add_entities_popup: + show_method = mock.MagicMock() + add_entities_popup.return_value = show_method + self.assertTrue( + model.batch_set_data( + _empty_indexes(model), + ["dog", "plato", "breed", "Base", join_value_and_type(value, value_type), "mock_db"], + ) ) - ) + show_method.show.assert_called_once() parameter_values = self._db_mngr.get_items(self._db_map, "parameter_value") entities = self._db_mngr.get_items(self._db_map, "entity") self.assertEqual(len(parameter_values), 1) self.assertEqual(parameter_values[0]["entity_class_name"], "dog") self.assertEqual(parameter_values[0]["entity_name"], "plato") self.assertEqual(parameter_values[0]["parameter_name"], "breed") - self.assertEqual(parameter_values[0]["value"], b'"dog-human"') + self.assertEqual(parameter_values[0]["value"], value) self.assertEqual(len(entities), 4) self.assertEqual(entities[0]["name"], "pluto") self.assertEqual(entities[1]["name"], "nemo") diff --git a/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py b/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py index dce94f45f..ff1c7f3c5 100644 --- a/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py +++ b/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py @@ -97,30 +97,39 @@ def tearDown(self): self._db_mngr.deleteLater() def test_data_db_map_role(self): - self._db_mngr.add_entity_classes({self._db_map: [{"name": "my_class", "id": 1}]}) + self._db_mngr.add_entity_classes({self._db_map: [{"name": "my_class"}]}) + entity_class = self._db_map.get_entity_class_item(name="my_class") self._db_mngr.add_parameter_definitions( - {self._db_map: [{"entity_class_id": 1, "name": "my_parameter", "id": 1}]} + {self._db_map: [{"entity_class_id": entity_class["id"], "name": "my_parameter"}]} ) - self._db_mngr.add_entities({self._db_map: [{"class_id": 1, "name": "my_object", "id": 1}]}) + definition = self._db_map.get_parameter_definition_item(entity_class_id=entity_class["id"], name="my_parameter") + self._db_mngr.add_entities({self._db_map: [{"class_id": entity_class["id"], "name": "my_object"}]}) + entity = self._db_map.get_entity_item(class_id=entity_class["id"], name="my_object") + alternative = self._db_map.get_alternative_item(name="Base") value, type_ = to_database(2.3) self._db_mngr.add_parameter_values( { self._db_map: [ { - "entity_class_id": 1, - "entity_id": 1, - "parameter_definition_id": 1, + "entity_class_id": entity_class["id"], + "entity_id": entity["id"], + "parameter_definition_id": definition["id"], "value": value, "type": type_, - "alternative_id": 1, - "id": 1, + "alternative_id": alternative["id"], } ] } ) - with q_object(TestSingleParameterValueModel(self._db_mngr, self._db_map, 1, True)) as model: + parameter_value = self._db_map.get_parameter_value_item( + entity_class_id=entity_class["id"], + entity_id=entity["id"], + parameter_definition_id=definition["id"], + alternative_id=alternative["id"], + ) + with q_object(TestSingleParameterValueModel(self._db_mngr, self._db_map, parameter_value["id"], True)) as model: fetch_model(model) - model.add_rows([1]) + model.add_rows([parameter_value["id"]]) self.assertEqual(model.index(0, 0).data(DB_MAP_ROLE), self._db_map) diff --git a/tests/spine_db_editor/test_graphics_items.py b/tests/spine_db_editor/test_graphics_items.py index 35ddf1779..ac8b1c087 100644 --- a/tests/spine_db_editor/test_graphics_items.py +++ b/tests/spine_db_editor/test_graphics_items.py @@ -25,7 +25,6 @@ class TestEntityItem(unittest.TestCase): @classmethod def setUpClass(cls): - # SpineDBEditor takes long to construct hence we make only one of them for the entire suite. if not QApplication.instance(): QApplication() diff --git a/tests/spine_db_editor/widgets/spine_db_editor_test_base.py b/tests/spine_db_editor/widgets/spine_db_editor_test_base.py index b7cb1cd6f..4ade6c89e 100644 --- a/tests/spine_db_editor/widgets/spine_db_editor_test_base.py +++ b/tests/spine_db_editor/widgets/spine_db_editor_test_base.py @@ -14,116 +14,16 @@ import unittest from unittest import mock from PySide6.QtWidgets import QApplication +from spinedb_api import to_database from spinetoolbox.spine_db_editor.widgets.spine_db_editor import SpineDBEditor from tests.mock_helpers import TestSpineDBManager class DBEditorTestBase(unittest.TestCase): - @staticmethod - def _entity_class(*args): - return dict(zip(["id", "name", "dimension_id_list"], args)) - - @staticmethod - def _entity(*args): - return dict(zip(["id", "class_id", "name", "element_id_list"], args)) - - @staticmethod - def _parameter_definition(*args): - d = dict(zip(["id", "entity_class_id", "name"], args)) - d.update({"default_value": None, "default_type": None}) - return d - - @staticmethod - def _parameter_value(*args): - return dict( - zip( - ["id", "entity_class_id", "entity_id", "parameter_definition_id", "alternative_id", "value", "type"], - args, - ) - ) - @classmethod def setUpClass(cls): if not QApplication.instance(): QApplication() - cls.create_mock_dataset() - - @classmethod - def create_mock_dataset(cls): - cls.fish_class = cls._entity_class(1, "fish") - cls.dog_class = cls._entity_class(2, "dog") - cls.fish_dog_class = cls._entity_class(3, "fish__dog", [cls.fish_class["id"], cls.dog_class["id"]]) - cls.dog_fish_class = cls._entity_class(4, "dog__fish", [cls.dog_class["id"], cls.fish_class["id"]]) - cls.nemo_object = cls._entity(1, cls.fish_class["id"], "nemo") - cls.pluto_object = cls._entity(2, cls.dog_class["id"], "pluto") - cls.scooby_object = cls._entity(3, cls.dog_class["id"], "scooby") - cls.pluto_nemo_rel = cls._entity( - 4, cls.dog_fish_class["id"], "dog__fish_pluto__nemo", [cls.pluto_object["id"], cls.nemo_object["id"]] - ) - cls.nemo_pluto_rel = cls._entity( - 5, cls.fish_dog_class["id"], "fish__dog_nemo__pluto", [cls.nemo_object["id"], cls.pluto_object["id"]] - ) - cls.nemo_scooby_rel = cls._entity( - 6, cls.fish_dog_class["id"], "fish__dog_nemo__scooby", [cls.nemo_object["id"], cls.scooby_object["id"]] - ) - cls.water_parameter = cls._parameter_definition(1, cls.fish_class["id"], "water") - cls.breed_parameter = cls._parameter_definition(2, cls.dog_class["id"], "breed") - cls.relative_speed_parameter = cls._parameter_definition(3, cls.fish_dog_class["id"], "relative_speed") - cls.combined_mojo_parameter = cls._parameter_definition(4, cls.dog_fish_class["id"], "combined_mojo") - cls.nemo_water = cls._parameter_value( - 1, - cls.water_parameter["entity_class_id"], - cls.nemo_object["id"], - cls.water_parameter["id"], - 1, - b'"salt"', - None, - ) - cls.pluto_breed = cls._parameter_value( - 2, - cls.breed_parameter["entity_class_id"], - cls.pluto_object["id"], - cls.breed_parameter["id"], - 1, - b'"bloodhound"', - None, - ) - cls.scooby_breed = cls._parameter_value( - 3, - cls.breed_parameter["entity_class_id"], - cls.scooby_object["id"], - cls.breed_parameter["id"], - 1, - b'"great dane"', - None, - ) - cls.nemo_pluto_relative_speed = cls._parameter_value( - 4, - cls.relative_speed_parameter["entity_class_id"], - cls.nemo_pluto_rel["id"], - cls.relative_speed_parameter["id"], - 1, - b"-1", - None, - ) - cls.nemo_scooby_relative_speed = cls._parameter_value( - 5, - cls.relative_speed_parameter["entity_class_id"], - cls.nemo_scooby_rel["id"], - cls.relative_speed_parameter["id"], - 1, - b"5", - None, - ) - cls.pluto_nemo_combined_mojo = cls._parameter_value( - 6, - cls.combined_mojo_parameter["entity_class_id"], - cls.pluto_nemo_rel["id"], - cls.combined_mojo_parameter["id"], - 1, - b"100", - None, - ) db_codename = "database" @@ -154,53 +54,155 @@ def tearDown(self): self.spine_db_editor.deleteLater() self.spine_db_editor = None + def _assert_success(self, result): + item, error = result + self.assertIsNone(error) + return item + def put_mock_object_classes_in_db_mngr(self): """Puts fish and dog object classes in the db mngr.""" - object_classes = [self.fish_class, self.dog_class] - self.db_mngr.add_entity_classes({self.mock_db_map: object_classes}) - self.fetch_entity_tree_model() + self.fish_class = self._assert_success(self.mock_db_map.add_entity_class_item(name="fish")) + self.dog_class = self._assert_success(self.mock_db_map.add_entity_class_item(name="dog")) def put_mock_objects_in_db_mngr(self): """Puts nemo, pluto and scooby objects in the db mngr.""" - objects = [self.nemo_object, self.pluto_object, self.scooby_object] - self.db_mngr.add_entities({self.mock_db_map: objects}) - self.fetch_entity_tree_model() + self.nemo_object = self._assert_success( + self.mock_db_map.add_entity_item(entity_class_name=self.fish_class["name"], name="nemo") + ) + self.pluto_object = self._assert_success( + self.mock_db_map.add_entity_item(entity_class_name=self.dog_class["name"], name="pluto") + ) + self.scooby_object = self._assert_success( + self.mock_db_map.add_entity_item(entity_class_name=self.dog_class["name"], name="scooby") + ) def put_mock_relationship_classes_in_db_mngr(self): """Puts dog__fish and fish__dog relationship classes in the db mngr.""" - relationship_classes = [self.fish_dog_class, self.dog_fish_class] - self.db_mngr.add_entity_classes({self.mock_db_map: relationship_classes}) - self.fetch_entity_tree_model() + self.fish_dog_class = self._assert_success( + self.mock_db_map.add_entity_class_item( + dimension_name_list=(self.fish_class["name"], self.dog_class["name"]) + ) + ) + self.dog_fish_class = self._assert_success( + self.mock_db_map.add_entity_class_item( + dimension_name_list=(self.dog_class["name"], self.fish_class["name"]) + ) + ) def put_mock_relationships_in_db_mngr(self): """Puts pluto_nemo, nemo_pluto and nemo_scooby relationships in the db mngr.""" - relationships = [self.pluto_nemo_rel, self.nemo_pluto_rel, self.nemo_scooby_rel] - self.db_mngr.add_entities({self.mock_db_map: relationships}) - self.fetch_entity_tree_model() + self.pluto_nemo_rel = self._assert_success( + self.mock_db_map.add_entity_item( + entity_class_name=self.dog_fish_class["name"], + entity_byname=(self.pluto_object["name"], self.nemo_object["name"]), + ) + ) + self.nemo_pluto_rel = self._assert_success( + self.mock_db_map.add_entity_item( + entity_class_name=self.fish_dog_class["name"], + entity_byname=(self.nemo_object["name"], self.pluto_object["name"]), + ) + ) + self.nemo_scooby_rel = self._assert_success( + self.mock_db_map.add_entity_item( + entity_class_name=self.fish_dog_class["name"], + entity_byname=(self.nemo_object["name"], self.scooby_object["name"]), + ) + ) def put_mock_object_parameter_definitions_in_db_mngr(self): """Puts water and breed object parameter definitions in the db mngr.""" - parameter_definitions = [self.water_parameter, self.breed_parameter] - self.db_mngr.add_parameter_definitions({self.mock_db_map: parameter_definitions}) + self.water_parameter = self._assert_success( + self.mock_db_map.add_parameter_definition_item(entity_class_name=self.fish_class["name"], name="water") + ) + self.breed_parameter = self._assert_success( + self.mock_db_map.add_parameter_definition_item(entity_class_name=self.dog_class["name"], name="breed") + ) def put_mock_relationship_parameter_definitions_in_db_mngr(self): """Puts relative speed and combined mojo relationship parameter definitions in the db mngr.""" - parameter_definitions = [self.relative_speed_parameter, self.combined_mojo_parameter] - self.db_mngr.add_parameter_definitions({self.mock_db_map: parameter_definitions}) + self.relative_speed_parameter = self._assert_success( + self.mock_db_map.add_parameter_definition_item( + entity_class_name=self.fish_dog_class["name"], name="relative_speed" + ) + ) + self.combined_mojo_parameter = self._assert_success( + self.mock_db_map.add_parameter_definition_item( + entity_class_name=self.dog_fish_class["name"], name="combined_mojo" + ) + ) def put_mock_object_parameter_values_in_db_mngr(self): """Puts some object parameter values in the db mngr.""" - parameter_values = [self.nemo_water, self.pluto_breed, self.scooby_breed] - self.db_mngr.add_parameter_values({self.mock_db_map: parameter_values}) + value, type_ = to_database("salt") + self.nemo_water = self._assert_success( + self.mock_db_map.add_parameter_value_item( + entity_class_name=self.fish_class["name"], + entity_byname=(self.nemo_object["name"],), + parameter_definition_name=self.water_parameter["name"], + alternative_name="Base", + value=value, + type=type_, + ) + ) + value, type_ = to_database("bloodhound") + self.pluto_breed = self._assert_success( + self.mock_db_map.add_parameter_value_item( + entity_class_name=self.dog_class["name"], + entity_byname=(self.pluto_object["name"],), + parameter_definition_name=self.breed_parameter["name"], + alternative_name="Base", + value=value, + type=type_, + ) + ) + value, type_ = to_database("great dane") + self.scooby_breed = self._assert_success( + self.mock_db_map.add_parameter_value_item( + entity_class_name=self.dog_class["name"], + entity_byname=(self.scooby_object["name"],), + parameter_definition_name=self.breed_parameter["name"], + alternative_name="Base", + value=value, + type=type_, + ) + ) def put_mock_relationship_parameter_values_in_db_mngr(self): """Puts some relationship parameter values in the db mngr.""" - parameter_values = [ - self.nemo_pluto_relative_speed, - self.nemo_scooby_relative_speed, - self.pluto_nemo_combined_mojo, - ] - self.db_mngr.add_parameter_values({self.mock_db_map: parameter_values}) + value, type_ = to_database(-1) + self.nemo_pluto_relative_speed = self._assert_success( + self.mock_db_map.add_parameter_value_item( + entity_class_name=self.fish_dog_class["name"], + entity_byname=(self.nemo_object["name"], self.pluto_object["name"]), + parameter_definition_name=self.relative_speed_parameter["name"], + alternative_name="Base", + value=value, + type=type_, + ) + ) + value, type_ = to_database(5) + self.nemo_scooby_relative_speed = self._assert_success( + self.mock_db_map.add_parameter_value_item( + entity_class_name=self.fish_dog_class["name"], + entity_byname=(self.nemo_object["name"], self.scooby_object["name"]), + parameter_definition_name=self.relative_speed_parameter["name"], + alternative_name="Base", + value=value, + type=type_, + ) + ) + value, type_ = to_database(100) + self.pluto_nemo_combined_mojo = self._assert_success( + self.mock_db_map.add_parameter_value_item( + entity_class_name=self.dog_fish_class["name"], + entity_byname=(self.pluto_object["name"], self.nemo_object["name"]), + parameter_definition_name=self.combined_mojo_parameter["name"], + alternative_name="Base", + value=value, + type=type_, + ) + ) def put_mock_dataset_in_db_mngr(self): """Puts mock dataset in the db mngr.""" diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditor.py b/tests/spine_db_editor/widgets/test_SpineDBEditor.py index 8b30d0d26..eebdc8be8 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditor.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditor.py @@ -70,6 +70,8 @@ def test_open_element_name_list_editor(self): QApplication.processEvents() model = self.spine_db_editor.parameter_value_model index = model.index(0, 1) + self.assertEqual(index.data(), "nemo ǀ pluto") + self.assertEqual(model.index(1, 1).data(), "nemo ǀ scooby") with mock.patch( "spinetoolbox.spine_db_editor.widgets.stacked_view_mixin.ElementNameListEditor" ) as editor_constructor: diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditorAdd.py b/tests/spine_db_editor/widgets/test_SpineDBEditorAdd.py index 8258122cd..d642af351 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditorAdd.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditorAdd.py @@ -54,10 +54,10 @@ def test_add_entities_to_object_tree_model(self): def test_add_relationship_classes_to_object_tree_model(self): """Test that entity classes are added to the object tree model.""" self.spine_db_editor.init_models() - self.fetch_entity_tree_model() self.put_mock_object_classes_in_db_mngr() self.put_mock_objects_in_db_mngr() self.put_mock_relationship_classes_in_db_mngr() + self.fetch_entity_tree_model() root_item = self.spine_db_editor.entity_tree_model.root_item dog_fish_item = next(x for x in root_item.children if x.display_data == "dog__fish") fish_dog_item = next(x for x in root_item.children if x.display_data == "fish__dog") @@ -111,14 +111,15 @@ def test_add_object_parameter_definitions_to_model(self): with mock.patch.object(SingleParameterDefinitionModel, "__lt__") as lt_mocked: lt_mocked.return_value = False self.put_mock_object_parameter_definitions_in_db_mngr() + self.fetch_entity_tree_model() h = model.header.index parameters = [] for row in range(model.rowCount()): parameters.append( (model.index(row, h("entity_class_name")).data(), model.index(row, h("parameter_name")).data()) ) - self.assertTrue(("fish", "water") in parameters) - self.assertTrue(("dog", "breed") in parameters) + self.assertIn(("fish", "water"), parameters) + self.assertIn(("dog", "breed"), parameters) def test_add_relationship_parameter_definitions_to_model(self): """Test that entity parameter definitions are added to the model.""" @@ -130,14 +131,15 @@ def test_add_relationship_parameter_definitions_to_model(self): with mock.patch.object(SingleParameterDefinitionModel, "__lt__") as lt_mocked: lt_mocked.return_value = False self.put_mock_relationship_parameter_definitions_in_db_mngr() + self.fetch_entity_tree_model() h = model.header.index parameters = [] for row in range(model.rowCount()): parameters.append( (model.index(row, h("entity_class_name")).data(), model.index(row, h("parameter_name")).data()) ) - self.assertTrue(("fish__dog", "relative_speed") in parameters) - self.assertTrue(("dog__fish", "combined_mojo") in parameters) + self.assertIn(("fish__dog", "relative_speed"), parameters) + self.assertIn(("dog__fish", "combined_mojo"), parameters) def test_add_object_parameter_values_to_model(self): """Test that object parameter values are added to the model.""" @@ -150,6 +152,7 @@ def test_add_object_parameter_values_to_model(self): with mock.patch.object(SingleParameterDefinitionModel, "__lt__") as lt_mocked: lt_mocked.return_value = False self.put_mock_object_parameter_values_in_db_mngr() + self.fetch_entity_tree_model() h = model.header.index parameters = [] for row in range(model.rowCount()): @@ -160,9 +163,9 @@ def test_add_object_parameter_values_to_model(self): model.index(row, h("value")).data(), ) ) - self.assertTrue(("nemo", "water", "salt") in parameters) - self.assertTrue(("pluto", "breed", "bloodhound") in parameters) - self.assertTrue(("scooby", "breed", "great dane") in parameters) + self.assertIn(("nemo", "water", "salt"), parameters) + self.assertIn(("pluto", "breed", "bloodhound"), parameters) + self.assertIn(("scooby", "breed", "great dane"), parameters) def test_add_relationship_parameter_values_to_model(self): """Test that object parameter values are added to the model.""" @@ -178,6 +181,7 @@ def test_add_relationship_parameter_values_to_model(self): with mock.patch.object(SingleParameterDefinitionModel, "__lt__") as lt_mocked: lt_mocked.return_value = False self.put_mock_relationship_parameter_values_in_db_mngr() + self.fetch_entity_tree_model() h = model.header.index parameters = [] for row in range(model.rowCount()): @@ -188,6 +192,6 @@ def test_add_relationship_parameter_values_to_model(self): model.index(row, h("value")).data(), ) ) - self.assertTrue((("nemo", "pluto"), "relative_speed", "-1.0") in parameters) - self.assertTrue((("nemo", "scooby"), "relative_speed", "5.0") in parameters) - self.assertTrue((("pluto", "nemo"), "combined_mojo", "100.0") in parameters) + self.assertIn((("nemo", "pluto"), "relative_speed", "-1.0"), parameters) + self.assertIn((("nemo", "scooby"), "relative_speed", "5.0"), parameters) + self.assertIn((("pluto", "nemo"), "combined_mojo", "100.0"), parameters) diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditorFilter.py b/tests/spine_db_editor/widgets/test_SpineDBEditorFilter.py index 27226cf64..680cae6de 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditorFilter.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditorFilter.py @@ -61,6 +61,7 @@ def test_filter_parameter_tables_per_zero_dimensional_entity_class(self): if model.canFetchMore(None): model.fetchMore(None) self.put_mock_dataset_in_db_mngr() + self.fetch_entity_tree_model() root_item = self.spine_db_editor.entity_tree_model.root_item fish_item = next(x for x in root_item.children if x.display_data == "fish") fish_index = self.spine_db_editor.entity_tree_model.index_from_item(fish_item) @@ -79,6 +80,7 @@ def test_filter_parameter_tables_per_nonzero_dimensional_entity_class(self): if model.canFetchMore(None): model.fetchMore(None) self.put_mock_dataset_in_db_mngr() + self.fetch_entity_tree_model() root_item = self.spine_db_editor.entity_tree_model.root_item fish_dog_item = next(x for x in root_item.children if x.display_data == "fish__dog") fish_dog_index = self.spine_db_editor.entity_tree_model.index_from_item(fish_dog_item) @@ -101,6 +103,7 @@ def test_filter_parameter_tables_per_entity_class_and_entity_cross_selection(sel if model.canFetchMore(None): model.fetchMore(None) self.put_mock_dataset_in_db_mngr() + self.fetch_entity_tree_model() root_item = self.spine_db_editor.entity_tree_model.root_item fish_item = next(x for x in root_item.children if x.display_data == "fish") fish_index = self.spine_db_editor.entity_tree_model.index_from_item(fish_item) @@ -128,6 +131,7 @@ def test_filter_parameter_tables_per_entity(self): if model.canFetchMore(None): model.fetchMore(None) self.put_mock_dataset_in_db_mngr() + self.fetch_entity_tree_model() root_item = self.spine_db_editor.entity_tree_model.root_item dog_item = next(x for x in root_item.children if x.display_data == "dog") pluto_item = next(x for x in dog_item.children if x.display_data == "pluto") diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditorUpdate.py b/tests/spine_db_editor/widgets/test_SpineDBEditorUpdate.py index bca80d872..5a0a7f735 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditorUpdate.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditorUpdate.py @@ -11,6 +11,8 @@ ###################################################################################################################### """Unit tests for database item update functionality in Database editor.""" +from PySide6.QtWidgets import QApplication +from spinedb_api import to_database from spinetoolbox.helpers import DB_ITEM_SEPARATOR from .spine_db_editor_test_base import DBEditorTestBase @@ -21,8 +23,8 @@ def test_update_object_classes_in_object_tree_model(self): self.spine_db_editor.init_models() self.put_mock_object_classes_in_db_mngr() self.fetch_entity_tree_model() - self.fish_class = self._entity_class(1, "octopus") - self.db_mngr.update_entity_classes({self.mock_db_map: [self.fish_class]}) + fish_update = {"id": self.fish_class["id"], "name": "octopus"} + self.db_mngr.update_entity_classes({self.mock_db_map: [fish_update]}) root_item = self.spine_db_editor.entity_tree_model.root_item fish_item = root_item.child(1) self.assertEqual(fish_item.item_type, "entity_class") @@ -34,8 +36,8 @@ def test_update_objects_in_object_tree_model(self): self.put_mock_object_classes_in_db_mngr() self.put_mock_objects_in_db_mngr() self.fetch_entity_tree_model() - self.nemo_object = self._entity(1, self.fish_class["id"], "dory") - self.db_mngr.update_entities({self.mock_db_map: [self.nemo_object]}) + nemo_update = {"id": self.nemo_object["id"], "name": "dory"} + self.db_mngr.update_entities({self.mock_db_map: [nemo_update]}) root_item = self.spine_db_editor.entity_tree_model.root_item fish_item = root_item.child(1) nemo_item = fish_item.child(0) @@ -49,8 +51,8 @@ def test_update_relationship_classes_in_object_tree_model(self): self.put_mock_objects_in_db_mngr() self.put_mock_relationship_classes_in_db_mngr() self.fetch_entity_tree_model() - self.fish_dog_class = {"id": 3, "name": "octopus__dog"} - self.db_mngr.update_entity_classes({self.mock_db_map: [self.fish_dog_class]}) + fish_dog_update = {"id": self.fish_dog_class["id"], "name": "octopus__dog"} + self.db_mngr.update_entity_classes({self.mock_db_map: [fish_dog_update]}) root_item = self.spine_db_editor.entity_tree_model.root_item fish_dog_item = root_item.child(3) self.assertEqual(fish_dog_item.item_type, "entity_class") @@ -65,8 +67,8 @@ def test_update_object_parameter_definitions_in_model(self): self.put_mock_object_classes_in_db_mngr() self.put_mock_object_parameter_definitions_in_db_mngr() self.fetch_entity_tree_model() - self.water_parameter = self._parameter_definition(1, self.fish_class["id"], "fire") - self.db_mngr.update_parameter_definitions({self.mock_db_map: [self.water_parameter]}) + water_update = {"id": self.water_parameter["id"], "name": "fire"} + self.db_mngr.update_parameter_definitions({self.mock_db_map: [water_update]}) h = model.header.index parameters = [] for row in range(model.rowCount()): @@ -86,8 +88,8 @@ def test_update_relationship_parameter_definitions_in_model(self): self.put_mock_object_parameter_definitions_in_db_mngr() self.put_mock_relationship_parameter_definitions_in_db_mngr() self.fetch_entity_tree_model() - self.relative_speed_parameter = self._parameter_definition(3, self.fish_dog_class["id"], "each_others_opinion") - self.db_mngr.update_parameter_definitions({self.mock_db_map: [self.relative_speed_parameter]}) + relative_speed_update = {"id": self.relative_speed_parameter["id"], "name": "each_others_opinion"} + self.db_mngr.update_parameter_definitions({self.mock_db_map: [relative_speed_update]}) h = model.header.index parameters = [] for row in range(model.rowCount()): @@ -107,10 +109,9 @@ def test_update_object_parameter_values_in_model(self): self.put_mock_object_parameter_definitions_in_db_mngr() self.put_mock_object_parameter_values_in_db_mngr() self.fetch_entity_tree_model() - self.nemo_water = self._parameter_value( - 1, self.fish_class["id"], self.nemo_object["id"], self.water_parameter["id"], 1, b'"pepper"', None - ) - self.db_mngr.update_parameter_values({self.mock_db_map: [self.nemo_water]}) + value, type_ = to_database("pepper") + nemo_water_update = {"id": self.nemo_water["id"], "value": value, "type": type_} + self.db_mngr.update_parameter_values({self.mock_db_map: [nemo_water_update]}) h = model.header.index parameters = [] for row in range(model.rowCount()): @@ -130,16 +131,14 @@ def test_update_relationship_parameter_values_in_model(self): if model.canFetchMore(None): model.fetchMore(None) self.put_mock_dataset_in_db_mngr() - self.nemo_pluto_relative_speed = self._parameter_value( - 4, - self.fish_dog_class["id"], - self.nemo_pluto_rel["id"], - self.relative_speed_parameter["id"], - 1, - b"100", - None, - ) - self.db_mngr.update_parameter_values({self.mock_db_map: [self.nemo_pluto_relative_speed]}) + self.fetch_entity_tree_model() + value, type_ = to_database(100) + nemo_pluto_relative_speed_update = { + "id": self.nemo_pluto_relative_speed["id"], + "value": value, + "type": type_, + } + self.db_mngr.update_parameter_values({self.mock_db_map: [nemo_pluto_relative_speed_update]}) h = model.header.index parameters = [] for row in range(model.rowCount()): diff --git a/tests/spine_db_editor/widgets/test_custom_editors.py b/tests/spine_db_editor/widgets/test_custom_editors.py index 621087dd0..ef6c44b9f 100644 --- a/tests/spine_db_editor/widgets/test_custom_editors.py +++ b/tests/spine_db_editor/widgets/test_custom_editors.py @@ -15,7 +15,7 @@ from PySide6.QtCore import QEvent, QPoint, Qt from PySide6.QtGui import QFocusEvent, QKeyEvent, QStandardItem, QStandardItemModel from PySide6.QtWidgets import QApplication, QStyleOptionViewItem, QWidget -from spinetoolbox.helpers import make_icon_id +from spinetoolbox.helpers import DB_ITEM_SEPARATOR, make_icon_id from spinetoolbox.resources_icons_rc import qInitResources from spinetoolbox.spine_db_editor.widgets.custom_editors import ( BooleanSearchBarEditor, @@ -23,6 +23,7 @@ CustomComboBoxEditor, CustomLineEditor, IconColorEditor, + ParameterTypeEditor, ParameterValueLineEditor, PivotHeaderTableLineEditor, SearchBarEditor, @@ -140,5 +141,47 @@ def test_boolean_searchbar_editor(self): self.assertEqual(True, retval) +class TestParameterTypeEditor(unittest.TestCase): + @classmethod + def setUpClass(cls): + qInitResources() + if not QApplication.instance(): + QApplication() + + def setUp(self): + self._editor = ParameterTypeEditor(None) + + def tearDown(self): + self._editor.deleteLater() + + def test_select_all(self): + self._editor.set_data("") + self._editor._ui.select_all_button.click() + for check_box in self._editor._check_box_iter(): + with self.subTest(check_box_text=check_box.text()): + self.assertTrue(check_box.isChecked()) + self.assertEqual(self._editor._ui.map_rank_line_edit.text(), "1") + self.assertEqual(self._editor.data(), "") + + def test_select_single_type(self): + expected_data = { + "a&rray": "array", + "&bool": "bool", + "&date_time": "date_time", + "d&uration": "duration", + "&float": "float", + "&map": DB_ITEM_SEPARATOR.join(("2d_map", "3d_map")), + "&str": "str", + "time_&pattern": "time_pattern", + "&time_series": "time_series", + } + for check_box in self._editor._check_box_iter(): + self._editor._clear_all() + check_box.setChecked(True) + self._editor._ui.map_rank_line_edit.setText("2,3") + with self.subTest(check_box_text=check_box.text()): + self.assertEqual(self._editor.data(), expected_data[check_box.text()]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/widgets/test_custom_qtableview.py b/tests/spine_db_editor/widgets/test_custom_qtableview.py index d00ae08fe..c4fc90503 100644 --- a/tests/spine_db_editor/widgets/test_custom_qtableview.py +++ b/tests/spine_db_editor/widgets/test_custom_qtableview.py @@ -382,12 +382,12 @@ def test_purging_value_data_leaves_empty_rows_intact(self): for row, column in itertools.product(range(model.rowCount()), range(model.columnCount())): self.assertEqual(model.index(row, column).data(), expected[row][column]) - def test_removing_fetched_rows_allows_still_fetching_more(self): + def test_remove_fetched_rows(self): table_view = self._db_editor.ui.tableView_parameter_value model = table_view.model() self.assertEqual(model.rowCount(), self._CHUNK_SIZE + 1) - n_values = self._whole_model_rowcount() - 1 - self._db_mngr.remove_items({self._db_map: {"parameter_value": set(range(1, n_values, 2))}}) + ids = [model.item_at_row(row) for row in range(0, model.rowCount() - 1, 2)] + self._db_mngr.remove_items({self._db_map: {"parameter_value": set(ids)}}) self.assertEqual(model.rowCount(), self._CHUNK_SIZE / 2 + 1) def test_undoing_purge(self): diff --git a/tests/test_SpineDBManager.py b/tests/test_SpineDBManager.py index 1935922fd..dcbafce5b 100644 --- a/tests/test_SpineDBManager.py +++ b/tests/test_SpineDBManager.py @@ -199,7 +199,7 @@ def test_broken_value_in_display_role(self): parameter_definition_name="x", alternative_name="Base", value=value, - type=None, + type="float", ) self.assertIsNone(error) formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.DisplayRole) @@ -213,7 +213,7 @@ def test_broken_value_in_edit_role(self): parameter_definition_name="x", alternative_name="Base", value=value, - type=None, + type="str", ) self.assertIsNone(error) formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.EditRole) @@ -227,7 +227,7 @@ def test_broken_value_in_tool_tip_role(self): parameter_definition_name="x", alternative_name="Base", value=value, - type=None, + type="duration", ) self.assertIsNone(error) formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.ToolTipRole) diff --git a/tests/test_parameter_type_validation.py b/tests/test_parameter_type_validation.py new file mode 100644 index 000000000..104cb20b3 --- /dev/null +++ b/tests/test_parameter_type_validation.py @@ -0,0 +1,111 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Database API contributors +# This file is part of Spine Database API. +# Spine Database API 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 . +###################################################################################################################### +import unittest +from unittest import mock +from PySide6.QtWidgets import QApplication +from spinedb_api import to_database +from spinetoolbox.helpers import signal_waiter +from spinetoolbox.parameter_type_validation import ValidationKey +from tests.mock_helpers import TestSpineDBManager + + +class TestTypeValidator(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.db_codename = cls.__name__ + "_db" + if not QApplication.instance(): + QApplication() + + def setUp(self): + mock_settings = mock.MagicMock() + mock_settings.value.side_effect = lambda *args, **kwargs: 0 + self._db_mngr = TestSpineDBManager(mock_settings, None) + logger = mock.MagicMock() + self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename=self.db_codename, create=True) + self._db_mngr.parameter_type_validator.set_interval(0) + + def tearDown(self): + self._db_mngr.close_all_sessions() + while not self._db_map.closed: + QApplication.processEvents() + self._db_mngr.clean_up() + + def _assert_success(self, result): + item, error = result + self.assertIsNone(error) + return item + + def test_valid_parameter_default_value(self): + self._assert_success(self._db_map.add_entity_class_item(name="Recipe")) + value, value_type = to_database(23.0) + price = self._assert_success( + self._db_map.add_parameter_definition_item( + name="price", entity_class_name="Recipe", default_value=value, default_type=value_type + ) + ) + self._db_map.commit_session("Add test data.") + with signal_waiter(self._db_mngr.parameter_type_validator.validated, timeout=2) as waiter: + self._db_mngr.parameter_type_validator.start_validating(self._db_mngr, self._db_map, [price["id"]]) + waiter.wait() + self.assertEqual( + waiter.args, + (ValidationKey("parameter_definition", id(self._db_map), price["id"].private_id), True), + ) + + def test_invalid_parameter_default_value(self): + self._assert_success(self._db_map.add_entity_class_item(name="Recipe")) + value, value_type = to_database(23.0) + price = self._assert_success( + self._db_map.add_parameter_definition_item( + name="price", + entity_class_name="Recipe", + parameter_type_list=("str",), + default_value=value, + default_type=value_type, + ) + ) + with signal_waiter(self._db_mngr.parameter_type_validator.validated, timeout=5.0) as waiter: + self._db_mngr.parameter_type_validator.start_validating(self._db_mngr, self._db_map, [price["id"]]) + waiter.wait() + self.assertEqual( + waiter.args, + (ValidationKey("parameter_definition", id(self._db_map), price["id"].private_id), False), + ) + + def test_valid_parameter_value(self): + self._assert_success(self._db_map.add_entity_class_item(name="Recipe")) + self._assert_success(self._db_map.add_entity_item(name="fish_n_chips", entity_class_name="Recipe")) + self._assert_success(self._db_map.add_parameter_definition_item(name="price", entity_class_name="Recipe")) + value, value_type = to_database(23.0) + fish_n_chips_price = self._assert_success( + self._db_map.add_parameter_value_item( + entity_class_name="Recipe", + parameter_definition_name="price", + entity_byname=("fish_n_chips",), + alternative_name="Base", + value=value, + type=value_type, + ) + ) + with signal_waiter(self._db_mngr.parameter_type_validator.validated, timeout=2) as waiter: + self._db_mngr.parameter_type_validator.start_validating( + self._db_mngr, self._db_map, [fish_n_chips_price["id"]] + ) + waiter.wait() + self.assertEqual( + waiter.args, + (ValidationKey("parameter_value", id(self._db_map), fish_n_chips_price["id"].private_id), True), + ) + + +if __name__ == "__main__": + unittest.main()