From 1e3fb734cd00489a941b48edef92ec3943c97a9e Mon Sep 17 00:00:00 2001 From: Rob B <73846016+robons@users.noreply.github.com> Date: Fri, 6 Aug 2021 15:05:14 +0100 Subject: [PATCH] Issue #82 - Making validation errors more type-specific & adding some unit tests (#122) --- csvqb/csvqb/models/cube/__init__.py | 1 + csvqb/csvqb/models/cube/csvqb/__init__.py | 1 + .../models/cube/csvqb/validationerrors.py | 163 ++++++++ csvqb/csvqb/models/cube/cube.py | 25 +- csvqb/csvqb/models/cube/validationerrors.py | 45 +++ csvqb/csvqb/models/rdf/__init__.py | 3 + csvqb/csvqb/models/validationerror.py | 16 +- .../cube/qb/test_cubeqb_errorvalidation.py | 353 +++++++++++++++++- .../unit/cube/test_cube_errorvalidation.py | 6 + csvqb/csvqb/tests/unit/test_baseunit.py | 4 +- csvqb/csvqb/utils/qb/cube.py | 66 ++-- 11 files changed, 617 insertions(+), 66 deletions(-) create mode 100644 csvqb/csvqb/models/cube/csvqb/validationerrors.py create mode 100644 csvqb/csvqb/models/cube/validationerrors.py diff --git a/csvqb/csvqb/models/cube/__init__.py b/csvqb/csvqb/models/cube/__init__.py index eb7c63228..5049fd562 100644 --- a/csvqb/csvqb/models/cube/__init__.py +++ b/csvqb/csvqb/models/cube/__init__.py @@ -2,3 +2,4 @@ from .cube import Cube from .catalog import CatalogMetadataBase from .csvqb import * +from .validationerrors import * diff --git a/csvqb/csvqb/models/cube/csvqb/__init__.py b/csvqb/csvqb/models/cube/csvqb/__init__.py index c29c3e8c3..4b506dec3 100644 --- a/csvqb/csvqb/models/cube/csvqb/__init__.py +++ b/csvqb/csvqb/models/cube/csvqb/__init__.py @@ -2,5 +2,6 @@ from .components import * from .catalog import CatalogMetadata from ..cube import Cube +from .validationerrors import * QbCube = Cube[CatalogMetadata] diff --git a/csvqb/csvqb/models/cube/csvqb/validationerrors.py b/csvqb/csvqb/models/cube/csvqb/validationerrors.py new file mode 100644 index 000000000..1980cd438 --- /dev/null +++ b/csvqb/csvqb/models/cube/csvqb/validationerrors.py @@ -0,0 +1,163 @@ +""" +Qb-Cube Validation Errors +------------------------- +""" + +from dataclasses import dataclass +from typing import Optional, Type, Union + +from csvqb.models.cube.csvqb.components import ( + QbObservationValue, + QbMultiUnits, + QbDataStructureDefinition, +) +from csvqb.models.validationerror import SpecificValidationError + +ComponentTypeDescription = Union[str, Type[QbDataStructureDefinition]] + + +def _get_description_for_component(t: ComponentTypeDescription) -> str: + if isinstance(t, str): + return t + + return t.__name__ + + +@dataclass +class OutputUriTemplateMissingError(SpecificValidationError): + """ + Represents an error where the user has defined a component which cannot infer its own output_uri_template without + manually specifying an output_uri_template. + """ + + csv_column_name: str + component_type: ComponentTypeDescription + + def __post_init__(self): + self.message = ( + f"'{self.csv_column_name}' - a {_get_description_for_component(self.component_type)} must have an " + + "output_uri_template defined." + ) + + +@dataclass +class MaxNumComponentsExceededError(SpecificValidationError): + """ + Represents an error where the user can define a maximum number of components of a given type, but has defined + too many. + """ + + component_type: ComponentTypeDescription + maximum_number: int + actual_number: int + additional_explanation: Optional[str] = None + + def __post_init__(self): + self.message = ( + f"Found {self.actual_number} of {_get_description_for_component(self.component_type)}s. " + + f"Expected a maximum of {self.maximum_number}." + ) + if self.additional_explanation is not None: + self.message += " " + self.additional_explanation + + +@dataclass +class MinNumComponentsNotSatisfiedError(SpecificValidationError): + """ + Represents an error where the user must define a minimum number of components of a given type, but has not done so. + """ + + component_type: ComponentTypeDescription + minimum_number: int + actual_number: int + additional_explanation: Optional[str] = None + + def __post_init__(self): + self.message = ( + f"At least {self.minimum_number} {_get_description_for_component(self.component_type)}s must be defined." + + f" Found {self.actual_number}." + ) + if self.additional_explanation is not None: + self.message += " " + self.additional_explanation + + +@dataclass +class WrongNumberComponentsError(SpecificValidationError): + """ + Represents an error where the user must include a specific number of components, but has not done so. + """ + + component_type: ComponentTypeDescription + expected_number: int + actual_number: int + additional_explanation: Optional[str] = None + + def __post_init__(self): + self.message = ( + f"Found {self.actual_number} {_get_description_for_component(self.component_type)}s." + + f" Expected exactly {self.expected_number}." + ) + if self.additional_explanation is not None: + self.message += " " + self.additional_explanation + + +@dataclass +class NeitherDefinedError(SpecificValidationError): + """ + An error for when the user must define one of two different kinds of component, but has defined neither. + """ + + component_one: ComponentTypeDescription + component_two: ComponentTypeDescription + additional_explanation: Optional[str] = None + + def __post_init__(self): + self.message = ( + f"Found neither {_get_description_for_component(self.component_one)} " + + f"nor {_get_description_for_component(self.component_two)} defined. " + + "One of these must be defined." + ) + if self.additional_explanation is not None: + self.message += " " + self.additional_explanation + + +@dataclass +class UnitsNotDefinedError(NeitherDefinedError): + """ + An error for when the user has not defined any units for the dataset. + """ + + component_one: ComponentTypeDescription = f"{QbObservationValue.__name__}.unit" + component_two: ComponentTypeDescription = QbMultiUnits + additional_explanation: Optional[str] = None + + +@dataclass +class IncompatibleComponentsError(SpecificValidationError): + """ + An error for when the user has defined components which are incompatible with each-other. + """ + + component_one: ComponentTypeDescription + component_two: ComponentTypeDescription + additional_explanation: Optional[str] = None + + def __post_init__(self): + self.message = ( + f"Both {_get_description_for_component(self.component_one)} " + + f"and {_get_description_for_component(self.component_two)} have been defined. " + + f"These components cannot be used together." + ) + if self.additional_explanation is not None: + self.message += " " + self.additional_explanation + + +@dataclass +class BothUnitTypesDefinedError(IncompatibleComponentsError): + """ + An error for when the user has both a units column as well as setting `QbObservationValue.unit`. + """ + + component_one: ComponentTypeDescription = f"{QbObservationValue.__name__}.unit" + component_two: ComponentTypeDescription = QbMultiUnits + additional_explanation: Optional[str] = None diff --git a/csvqb/csvqb/models/cube/cube.py b/csvqb/csvqb/models/cube/cube.py index f03a610be..f6f6f89ef 100644 --- a/csvqb/csvqb/models/cube/cube.py +++ b/csvqb/csvqb/models/cube/cube.py @@ -7,9 +7,13 @@ import pandas as pd from csvqb.models.validationerror import ValidationError +from .validationerrors import ( + DuplicateColumnTitleError, + ColumnNotFoundInDataError, + MissingColumnDefinitionError, +) from .columns import CsvColumn -from csvqb.models.cube.catalog import CatalogMetadataBase -from csvqb.inputs import pandas_input_to_columnar +from .catalog import CatalogMetadataBase from ..pydanticmodel import PydanticModel TMetadata = TypeVar("TMetadata", bound=CatalogMetadataBase, covariant=True) @@ -35,9 +39,7 @@ def _validate_columns(self) -> List[ValidationError]: existing_col_titles: Set[str] = set() for col in self.columns: if col.csv_column_title in existing_col_titles: - errors.append( - ValidationError(f"Duplicate column title '{col.csv_column_title}'") - ) + errors.append(DuplicateColumnTitleError(col.csv_column_title)) else: existing_col_titles.add(col.csv_column_title) @@ -46,22 +48,15 @@ def _validate_columns(self) -> List[ValidationError]: if col.csv_column_title in self.data.columns: maybe_column_data = self.data[col.csv_column_title] else: - errors.append( - ValidationError( - f"Column '{col.csv_column_title}' not found in data provided." - ) - ) + errors.append(ColumnNotFoundInDataError(col.csv_column_title)) errors += col.validate_data(maybe_column_data) if self.data is not None: defined_column_titles = [c.csv_column_title for c in self.columns] for column in list(self.data.columns): + column = str(column) if column not in defined_column_titles: - errors.append( - ValidationError( - f"Column '{column}' does not have a mapping defined." - ) - ) + errors.append(MissingColumnDefinitionError(column)) return errors diff --git a/csvqb/csvqb/models/cube/validationerrors.py b/csvqb/csvqb/models/cube/validationerrors.py new file mode 100644 index 000000000..6bb2452a5 --- /dev/null +++ b/csvqb/csvqb/models/cube/validationerrors.py @@ -0,0 +1,45 @@ +""" +Cube Validation Errors +---------------------- +""" +from dataclasses import dataclass + +from csvqb.models.validationerror import SpecificValidationError + + +@dataclass +class DuplicateColumnTitleError(SpecificValidationError): + """ + An error to inform the user that they have defined two instances of the same column. + """ + + csv_column_title: str + + def __post_init__(self): + self.message = f"Duplicate column title '{self.csv_column_title}'" + + +@dataclass +class ColumnNotFoundInDataError(SpecificValidationError): + """ + An error to inform the user that they have defined a column which cannot be found in the provided data. + """ + + csv_column_title: str + + def __post_init__(self): + self.message = f"Column '{self.csv_column_title}' not found in data provided." + + +@dataclass +class MissingColumnDefinitionError(SpecificValidationError): + """ + An error to inform the user that there is a column in their data that does not have a mapping specified. + """ + + csv_column_title: str + + def __post_init__(self): + self.message = ( + f"Column '{self.csv_column_title}' does not have a mapping defined." + ) diff --git a/csvqb/csvqb/models/rdf/__init__.py b/csvqb/csvqb/models/rdf/__init__.py index e69de29bb..07b2b138a 100644 --- a/csvqb/csvqb/models/rdf/__init__.py +++ b/csvqb/csvqb/models/rdf/__init__.py @@ -0,0 +1,3 @@ +""" +Contains Models for mapping data to RDF which are unique to the `csvqb` package. +""" diff --git a/csvqb/csvqb/models/validationerror.py b/csvqb/csvqb/models/validationerror.py index dea606176..d2b5aedc5 100644 --- a/csvqb/csvqb/models/validationerror.py +++ b/csvqb/csvqb/models/validationerror.py @@ -2,11 +2,19 @@ ValidationError --------------- """ +from dataclasses import dataclass, field +from abc import ABC +@dataclass class ValidationError: - def __init__(self, message: str): - self.message: str = message + """Class representing an error validating a model.""" - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.message})" + message: str + + +@dataclass +class SpecificValidationError(ValidationError, ABC): + """Abstract base class to represent ValidationErrors which are more specific and so can be interpreted by code.""" + + message: str = field(init=False) diff --git a/csvqb/csvqb/tests/unit/cube/qb/test_cubeqb_errorvalidation.py b/csvqb/csvqb/tests/unit/cube/qb/test_cubeqb_errorvalidation.py index 13304ab3e..a058b31c8 100644 --- a/csvqb/csvqb/tests/unit/cube/qb/test_cubeqb_errorvalidation.py +++ b/csvqb/csvqb/tests/unit/cube/qb/test_cubeqb_errorvalidation.py @@ -5,6 +5,15 @@ import pandas as pd from csvqb.models.cube import * +from csvqb.models.cube.csvqb.validationerrors import ( + OutputUriTemplateMissingError, + MinNumComponentsNotSatisfiedError, + UnitsNotDefinedError, + BothUnitTypesDefinedError, + MaxNumComponentsExceededError, + WrongNumberComponentsError, + IncompatibleComponentsError, +) from csvqb.tests.unit.test_baseunit import * from csvqb.utils.qb.cube import validate_qb_component_constraints @@ -111,10 +120,329 @@ def test_existing_dimension_output_uri_template(): errors += validate_qb_component_constraints(cube) assert_num_validation_errors(errors, 1) - validation_errors = errors[0] + validation_error = errors[0] + assert isinstance(validation_error, OutputUriTemplateMissingError) + assert validation_error.csv_column_name == "Existing Dimension" + + +def test_no_dimensions_validation_error(): + """ + Ensure that we get an error message specifying that at least one dimension must be defined in a cube. + """ + + data = pd.DataFrame({"Value": [1, 2, 3]}) + cube = Cube( + CatalogMetadata("Some Qube"), + data, + [ + QbColumn( + "Value", + QbSingleMeasureObservationValue( + NewQbMeasure("Some Measure"), NewQbUnit("Some Unit") + ), + ) + ], + ) + + errors = validate_qb_component_constraints(cube) + + assert_num_validation_errors(errors, 1) + error = errors[0] + assert isinstance(error, MinNumComponentsNotSatisfiedError) + assert error.component_type == QbDimension + assert error.minimum_number == 1 + assert error.actual_number == 0 + + +def test_multiple_incompatible_unit_definitions(): + """ + Ensure that when we define a units column *and* an Observation value with a unit, that we get an error. + """ + data = pd.DataFrame( + { + "Some Dimension": ["a", "b", "c"], + "Some Unit": ["u1", "u2", "u1"], + "Value": [1, 2, 3], + } + ) + + cube = Cube( + CatalogMetadata("Some Qube"), + data, + [ + QbColumn( + "Some Dimension", + NewQbDimension.from_data("Some Dimension", data["Some Dimension"]), + ), + QbColumn("Some Unit", QbMultiUnits.new_units_from_data(data["Some Unit"])), + QbColumn( + "Value", + QbSingleMeasureObservationValue( + NewQbMeasure("Some New Measure"), NewQbUnit("Some New Unit") + ), + ), + ], + ) + errors = validate_qb_component_constraints(cube) + + assert_num_validation_errors(errors, 1) + error = errors[0] + assert isinstance(error, BothUnitTypesDefinedError) + + +def test_no_unit_defined(): + """ + Ensure that when we don't define a unit, we get an error message. + """ + data = pd.DataFrame( + { + "Some Dimension": ["a", "b", "c"], + "Value": [1, 2, 3], + } + ) + + cube = Cube( + CatalogMetadata("Some Qube"), + data, + [ + QbColumn( + "Some Dimension", + NewQbDimension.from_data("Some Dimension", data["Some Dimension"]), + ), + QbColumn( + "Value", + QbSingleMeasureObservationValue(NewQbMeasure("Some New Measure")), + ), + ], + ) + errors = validate_qb_component_constraints(cube) + + assert_num_validation_errors(errors, 1) + error = errors[0] + assert isinstance(error, UnitsNotDefinedError) + + +def test_multiple_units_columns(): + """ + Ensure that when a user defines multiple units columns, we get an error. + """ + data = pd.DataFrame( + { + "Some Dimension": ["a", "b", "c"], + "Some Unit": ["u1", "u2", "u1"], + "Some Other Unit": ["U1", "U2", "U3"], + "Value": [1, 2, 3], + } + ) + + cube = Cube( + CatalogMetadata("Some Qube"), + data, + [ + QbColumn( + "Some Dimension", + NewQbDimension.from_data("Some Dimension", data["Some Dimension"]), + ), + QbColumn("Some Unit", QbMultiUnits.new_units_from_data(data["Some Unit"])), + QbColumn( + "Some Other Unit", + QbMultiUnits.new_units_from_data(data["Some Other Unit"]), + ), + QbColumn( + "Value", + QbSingleMeasureObservationValue(NewQbMeasure("Some New Measure")), + ), + ], + ) + errors = validate_qb_component_constraints(cube) + + assert_num_validation_errors(errors, 1) + error = errors[0] + assert isinstance(error, MaxNumComponentsExceededError) + assert error.component_type == QbMultiUnits + assert error.maximum_number == 1 + assert error.actual_number == 2 + + +def test_multiple_obs_val_columns(): + """ + Ensure that when a user defines multiple observation value columns, we get an error. + + We only currently accept the `MeasureDimension` style of multi-measure datasets. + """ + data = pd.DataFrame( + { + "Some Dimension": ["a", "b", "c"], + "Value1": [3, 2, 1], + "Value2": [1, 2, 3], + } + ) + + cube = Cube( + CatalogMetadata("Some Qube"), + data, + [ + QbColumn( + "Some Dimension", + NewQbDimension.from_data("Some Dimension", data["Some Dimension"]), + ), + QbColumn( + "Value1", + QbSingleMeasureObservationValue( + NewQbMeasure("Some New Measure 1"), NewQbUnit("Some New Unit 1") + ), + ), + QbColumn( + "Value2", + QbSingleMeasureObservationValue( + NewQbMeasure("Some New Measure 2"), NewQbUnit("Some New Unit 2") + ), + ), + ], + ) + errors = validate_qb_component_constraints(cube) + + assert_num_validation_errors(errors, 1) + error = errors[0] + assert isinstance(error, WrongNumberComponentsError) + assert error.component_type == QbObservationValue + assert error.expected_number == 1 + assert error.actual_number == 2 + + +def test_multi_measure_obs_val_without_measure_dimension(): + """ + Ensure that when a user defines a multi-measure observation valuer, we get a warning if they haven't defined a + measure dimension. + """ + data = pd.DataFrame( + { + "Some Dimension": ["a", "b", "c"], + "Value": [1, 2, 3], + } + ) + + cube = Cube( + CatalogMetadata("Some Qube"), + data, + [ + QbColumn( + "Some Dimension", + NewQbDimension.from_data("Some Dimension", data["Some Dimension"]), + ), + QbColumn( + "Value", + QbMultiMeasureObservationValue(unit=NewQbUnit("Some New Unit 1")), + ), + ], + ) + errors = validate_qb_component_constraints(cube) + + assert_num_validation_errors(errors, 1) + error = errors[0] + assert isinstance(error, WrongNumberComponentsError) + assert error.component_type == QbMultiMeasureDimension + assert error.expected_number == 1 + assert error.actual_number == 0 assert ( - "'Existing Dimension' - an ExistingQbDimension must have an output_uri_template defined." - in validation_errors.message + error.additional_explanation + == "A multi-measure cube must have a measure dimension." + ) + + +def test_multi_measure_obs_val_with_multiple_measure_dimensions(): + """ + Ensure that a user gets an error if they try to define more than one measure dimension. + """ + data = pd.DataFrame( + { + "Some Dimension": ["a", "b", "c"], + "Measure Dimension 1": ["A1 Measure", "B1 Measure", "C1 Measure"], + "Measure Dimension 2": ["A2 Measure", "B2 Measure", "C2 Measure"], + "Value": [1, 2, 3], + } + ) + + cube = Cube( + CatalogMetadata("Some Qube"), + data, + [ + QbColumn( + "Some Dimension", + NewQbDimension.from_data("Some Dimension", data["Some Dimension"]), + ), + QbColumn( + "Measure Dimension 1", + QbMultiMeasureDimension.new_measures_from_data( + data["Measure Dimension 1"] + ), + ), + QbColumn( + "Measure Dimension 2", + QbMultiMeasureDimension.new_measures_from_data( + data["Measure Dimension 2"] + ), + ), + QbColumn( + "Value", + QbMultiMeasureObservationValue(unit=NewQbUnit("Some New Unit 1")), + ), + ], + ) + errors = validate_qb_component_constraints(cube) + + assert_num_validation_errors(errors, 1) + error = errors[0] + assert isinstance(error, MaxNumComponentsExceededError) + assert error.component_type == QbMultiMeasureDimension + assert error.maximum_number == 1 + assert error.actual_number == 2 + + +def test_measure_dimension_with_single_measure_obs_val(): + """ + Ensure that when a user defines a measure dimension with a single-measure observation value, they get an error. + """ + data = pd.DataFrame( + { + "Some Dimension": ["a", "b", "c"], + "Measure Dimension": ["A Measure", "B Measure", "C Measure"], + "Value": [1, 2, 3], + } + ) + + cube = Cube( + CatalogMetadata("Some Qube"), + data, + [ + QbColumn( + "Some Dimension", + NewQbDimension.from_data("Some Dimension", data["Some Dimension"]), + ), + QbColumn( + "Measure Dimension", + QbMultiMeasureDimension.new_measures_from_data( + data["Measure Dimension"] + ), + ), + QbColumn( + "Value", + QbSingleMeasureObservationValue( + NewQbMeasure("Some New Measure"), NewQbUnit("Some New Unit 1") + ), + ), + ], + ) + errors = validate_qb_component_constraints(cube) + + assert_num_validation_errors(errors, 1) + error = errors[0] + assert isinstance(error, IncompatibleComponentsError) + assert error.component_one == QbSingleMeasureObservationValue + assert error.component_two == QbMultiMeasureDimension + assert ( + error.additional_explanation + == "A single-measure cube cannot have a measure dimension." ) @@ -168,12 +496,10 @@ def test_existing_attribute_output_uri_template_required(): errors += validate_qb_component_constraints(cube) assert_num_validation_errors(errors, 1) - validation_errors = errors[0] - assert ( - "'Existing Attribute 1' - a QbAttribute using existing attribute values " - + "must have an output_uri_template defined." - in validation_errors.message - ) + error = errors[0] + assert isinstance(error, OutputUriTemplateMissingError) + assert error.csv_column_name == "Existing Attribute 1" + assert error.component_type == "ExistingQbAttribute using existing attribute values" def test_new_attribute_output_uri_template_required(): @@ -228,11 +554,10 @@ def test_new_attribute_output_uri_template_required(): errors += validate_qb_component_constraints(cube) assert_num_validation_errors(errors, 1) - validation_errors = errors[0] - assert ( - "'New Attribute 1' - a QbAttribute using existing attribute values must have an output_uri_template defined." - in validation_errors.message - ) + error = errors[0] + assert isinstance(error, OutputUriTemplateMissingError) + assert error.csv_column_name == "New Attribute 1" + assert error.component_type == "NewQbAttribute using existing attribute values" def test_new_qb_attribute_generation(): diff --git a/csvqb/csvqb/tests/unit/cube/test_cube_errorvalidation.py b/csvqb/csvqb/tests/unit/cube/test_cube_errorvalidation.py index 3bc85e3b2..200e3f1fc 100644 --- a/csvqb/csvqb/tests/unit/cube/test_cube_errorvalidation.py +++ b/csvqb/csvqb/tests/unit/cube/test_cube_errorvalidation.py @@ -20,6 +20,8 @@ def test_column_not_configured_error(): assert len(validation_errors) == 1 error = validation_errors[0] + assert isinstance(error, MissingColumnDefinitionError) + assert error.csv_column_title == "Some Dimension" assert "Column 'Some Dimension'" in error.message @@ -37,6 +39,8 @@ def test_column_title_wrong_error(): assert len(validation_errors) == 1 error = validation_errors[0] + assert isinstance(error, ColumnNotFoundInDataError) + assert error.csv_column_title == "Some Column Title" assert "Column 'Some Column Title'" in error.message @@ -59,6 +63,8 @@ def test_two_column_same_title(): assert len(validation_errors) == 1 error = validation_errors[0] + assert isinstance(error, DuplicateColumnTitleError) + assert error.csv_column_title == "Some Dimension" assert "Duplicate column title 'Some Dimension'" == error.message diff --git a/csvqb/csvqb/tests/unit/test_baseunit.py b/csvqb/csvqb/tests/unit/test_baseunit.py index ee713afc2..6652fc510 100644 --- a/csvqb/csvqb/tests/unit/test_baseunit.py +++ b/csvqb/csvqb/tests/unit/test_baseunit.py @@ -7,7 +7,7 @@ def get_test_base_dir() -> Path: path_parts = Path(".").absolute().parts test_index = path_parts.index("tests") - test_root_path = Path(*path_parts[0: test_index + 1]) + test_root_path = Path(*path_parts[0 : test_index + 1]) return test_root_path @@ -18,4 +18,4 @@ def get_test_cases_dir() -> Path: def assert_num_validation_errors( errors: List[ValidationError], num_errors_expected: int ): - assert num_errors_expected == len(errors), ", ".join([e.message for e in errors]) + assert len(errors) == num_errors_expected, ", ".join([e.message for e in errors]) diff --git a/csvqb/csvqb/utils/qb/cube.py b/csvqb/csvqb/utils/qb/cube.py index b85f2b93f..77e99d163 100644 --- a/csvqb/csvqb/utils/qb/cube.py +++ b/csvqb/csvqb/utils/qb/cube.py @@ -4,7 +4,15 @@ """ from typing import List, TypeVar, Type - +from csvqb.models.cube.csvqb.validationerrors import ( + OutputUriTemplateMissingError, + MinNumComponentsNotSatisfiedError, + MaxNumComponentsExceededError, + WrongNumberComponentsError, + UnitsNotDefinedError, + BothUnitTypesDefinedError, + IncompatibleComponentsError, +) from csvqb.models.validationerror import ValidationError from csvqb.models.cube.cube import Cube from csvqb.models.cube.csvqb.columns import QbColumn @@ -60,14 +68,13 @@ def _validate_dimensions(cube: Cube) -> List[ValidationError]: if isinstance(c, QbColumn) and isinstance(c.component, ExistingQbDimension): if c.output_uri_template is None: errors.append( - ValidationError( - f"'{c.csv_column_title}' - an ExistingQbDimension must have an output_uri_template " - "defined." + OutputUriTemplateMissingError( + c.csv_column_title, ExistingQbDimension ) ) if len(dimension_columns) == 0: - errors.append(ValidationError("At least one dimension must be defined.")) + errors.append(MinNumComponentsNotSatisfiedError(QbDimension, 1, 0)) return errors @@ -81,9 +88,9 @@ def _validate_attributes(cube: Cube) -> List[ValidationError]: and len(c.component.new_attribute_values) == 0 # type: ignore ): errors.append( - ValidationError( - f"'{c.csv_column_title}' - a QbAttribute using existing attribute values must have an " - f"output_uri_template defined." + OutputUriTemplateMissingError( + c.csv_column_title, + f"{c.component.__class__.__name__} using existing attribute values", ) ) @@ -97,15 +104,13 @@ def _validate_observation_value_constraints(cube: Cube) -> List[ValidationError] if len(multi_units_columns) > 1: errors.append( - ValidationError( - f"Found {len(multi_units_columns)} units columns. Expected maximum 1." - ) + MaxNumComponentsExceededError(QbMultiUnits, 1, len(multi_units_columns)) ) if len(observed_value_columns) != 1: errors.append( - ValidationError( - f"Found {len(observed_value_columns)} observation value columns. Expected 1." + WrongNumberComponentsError( + QbObservationValue, 1, len(observed_value_columns) ) ) else: @@ -120,9 +125,9 @@ def _validate_observation_value_constraints(cube: Cube) -> List[ValidationError] errors += _validate_observation_value(obs_val_column, multi_units_columns) errors += _validate_multi_measure_cube(cube, obs_val_column) elif len(single_measure_obs_val_columns) == 1: - errors += _validate_observation_value( - single_measure_obs_val_columns[0], multi_units_columns - ) + obs_val_column = single_measure_obs_val_columns[0] + errors += _validate_observation_value(obs_val_column, multi_units_columns) + errors += _validate_single_measure_cube(cube, obs_val_column) return errors @@ -135,12 +140,17 @@ def _validate_multi_measure_cube( multi_measure_columns = get_columns_of_dsd_type(cube, QbMultiMeasureDimension) if len(multi_measure_columns) == 0: errors.append( - ValidationError("No multi-measure column found in multi-measure cube.") + WrongNumberComponentsError( + QbMultiMeasureDimension, + expected_number=1, + actual_number=0, + additional_explanation="A multi-measure cube must have a measure dimension.", + ) ) elif len(multi_measure_columns) > 1: errors.append( - ValidationError( - f"Found {len(multi_measure_columns)} measure dimension columns defined. Expected 1." + MaxNumComponentsExceededError( + QbMultiMeasureDimension, 1, len(multi_measure_columns) ) ) @@ -155,8 +165,10 @@ def _validate_single_measure_cube( multi_measure_columns = get_columns_of_dsd_type(cube, QbMultiMeasureDimension) if len(multi_measure_columns) > 0: errors.append( - ValidationError( - f"Found {len(multi_measure_columns)} measure dimension columns in single measure cube." + IncompatibleComponentsError( + QbSingleMeasureObservationValue, + QbMultiMeasureDimension, + additional_explanation="A single-measure cube cannot have a measure dimension.", ) ) @@ -170,17 +182,9 @@ def _validate_observation_value( errors: List[ValidationError] = [] if observation_value.component.unit is None: if len(multi_unit_columns) == 0: - errors.append( - ValidationError( - f"{observation_value.component} must have either a defined unit, or a units column must be defined." - ) - ) + errors.append(UnitsNotDefinedError()) else: if len(multi_unit_columns) > 0: - errors.append( - ValidationError( - f"{observation_value.component} has a unit and a units column is also defined." - ) - ) + errors.append(BothUnitTypesDefinedError()) return errors