Skip to content

Commit

Permalink
Implement parameter type validation in DB editor
Browse files Browse the repository at this point in the history
Parameter (default) values are now validated in a parallel process
in Database editor.

Re #2791
  • Loading branch information
soininen committed Aug 7, 2024
1 parent d567158 commit 079035e
Show file tree
Hide file tree
Showing 21 changed files with 808 additions and 254 deletions.
12 changes: 10 additions & 2 deletions docs/source/spine_db_editor/adding_data.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions spinetoolbox/mvcmodels/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
144 changes: 144 additions & 0 deletions spinetoolbox/parameter_type_validation.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
######################################################################################################################
"""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)
66 changes: 47 additions & 19 deletions spinetoolbox/spine_db_editor/mvcmodels/compound_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 [
Expand Down Expand Up @@ -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 [
Expand All @@ -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 [
Expand Down
8 changes: 4 additions & 4 deletions spinetoolbox/spine_db_editor/mvcmodels/empty_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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 = {}
Expand All @@ -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)
Expand Down
Loading

0 comments on commit 079035e

Please sign in to comment.