From 8fff14e623130f487fdb1ad22b34a1905a6996cd Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Wed, 24 Sep 2025 14:40:52 +0300 Subject: [PATCH 01/24] structure: moved some files to _old_ directory --- .../experiments/graph_norm_experiment.py | 2 +- .../experiments/weibull_experiment.py | 0 {generators => _old_/generators}/gen.py | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename graph_norm_experiment.py => _old_/experiments/graph_norm_experiment.py (97%) rename weibull_experiment.py => _old_/experiments/weibull_experiment.py (100%) rename {generators => _old_/generators}/gen.py (100%) diff --git a/graph_norm_experiment.py b/_old_/experiments/graph_norm_experiment.py similarity index 97% rename from graph_norm_experiment.py rename to _old_/experiments/graph_norm_experiment.py index 6747722..83678f4 100644 --- a/graph_norm_experiment.py +++ b/_old_/experiments/graph_norm_experiment.py @@ -2,7 +2,7 @@ from numpy import random as rd -from pysatl_criterion.statistics.normal import ( +from pysatl_criterion.pysatl_criterion.statistics.normal import ( GraphEdgesNumberNormalityGofStatistic, GraphMaxDegreeNormalityGofStatistic, KolmogorovSmirnovNormalityGofStatistic, diff --git a/weibull_experiment.py b/_old_/experiments/weibull_experiment.py similarity index 100% rename from weibull_experiment.py rename to _old_/experiments/weibull_experiment.py diff --git a/generators/gen.py b/_old_/generators/gen.py similarity index 100% rename from generators/gen.py rename to _old_/generators/gen.py From 1ac1ce187c76d00b22b830455971e5a7fd312ac2 Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Wed, 24 Sep 2025 14:46:01 +0300 Subject: [PATCH 02/24] fix: small fix --- _old_/experiments/weibull_experiment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_old_/experiments/weibull_experiment.py b/_old_/experiments/weibull_experiment.py index 5645122..91b78fb 100644 --- a/_old_/experiments/weibull_experiment.py +++ b/_old_/experiments/weibull_experiment.py @@ -1,6 +1,6 @@ import multiprocessing -from pysatl_criterion.statistics.weibull import ( +from pysatl_criterion.pysatl_criterion.statistics.weibull import ( AndersonDarlingWeibullGofStatistic, Chi2PearsonWeibullGofStatistic, CrammerVonMisesWeibullGofStatistic, From 59f66066282a15d5c4517f1a4bf151d60de56778 Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Wed, 24 Sep 2025 16:11:45 +0300 Subject: [PATCH 03/24] fix: updated pysatl_criterion --- pysatl_criterion | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pysatl_criterion b/pysatl_criterion index b5288c3..b7e883c 160000 --- a/pysatl_criterion +++ b/pysatl_criterion @@ -1 +1 @@ -Subproject commit b5288c3f2d109b772f7d7b4926bd3337356525e4 +Subproject commit b7e883cb58407784ac4cb6ea45125e711258ba0b From 5fb1f96fe0ed5e2279352d01b60b5add9a4de4e9 Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Wed, 24 Sep 2025 16:26:05 +0300 Subject: [PATCH 04/24] fix: updated dependencies --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1b2a551..e8b9d45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ authors = [ ] license = {text = "MIT"} readme = "README.md" -requires-python = ">=3.10,<3.13" +requires-python = ">=3.11,<3.13" dependencies = [ "numpy>=1.25.1", "scipy>=1.11.2", @@ -51,6 +51,7 @@ ruff = "0.11.12" pytest-mock = "3.14.1" pre-commit = "4.2.0" mypy = "^1.15.0" +psycopg2 = "2.9.10" [tool.isort] line_length = 120 From 07eba89982a2a6461dc456d94cb29750e98884e4 Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Thu, 25 Sep 2025 03:23:05 +0300 Subject: [PATCH 05/24] fix: updated ci.yaml --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6aacfcc..5b4f56d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.11", "3.12", "3.13"] steps: - name: Checkout repository From 4a04d885f7e62edd7a5c763573e0339d348c32ef Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Thu, 25 Sep 2025 17:33:55 +0300 Subject: [PATCH 06/24] fix: updated dependencies --- _old_/experiments/graph_norm_experiment.py | 2 +- _old_/experiments/weibull_experiment.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/_old_/experiments/graph_norm_experiment.py b/_old_/experiments/graph_norm_experiment.py index 83678f4..6747722 100644 --- a/_old_/experiments/graph_norm_experiment.py +++ b/_old_/experiments/graph_norm_experiment.py @@ -2,7 +2,7 @@ from numpy import random as rd -from pysatl_criterion.pysatl_criterion.statistics.normal import ( +from pysatl_criterion.statistics.normal import ( GraphEdgesNumberNormalityGofStatistic, GraphMaxDegreeNormalityGofStatistic, KolmogorovSmirnovNormalityGofStatistic, diff --git a/_old_/experiments/weibull_experiment.py b/_old_/experiments/weibull_experiment.py index 91b78fb..5645122 100644 --- a/_old_/experiments/weibull_experiment.py +++ b/_old_/experiments/weibull_experiment.py @@ -1,6 +1,6 @@ import multiprocessing -from pysatl_criterion.pysatl_criterion.statistics.weibull import ( +from pysatl_criterion.statistics.weibull import ( AndersonDarlingWeibullGofStatistic, Chi2PearsonWeibullGofStatistic, CrammerVonMisesWeibullGofStatistic, From ae42fc60799c991a24afad70836b18a73842ca51 Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Thu, 25 Sep 2025 18:12:09 +0300 Subject: [PATCH 07/24] feat: implemented experiments' PostgreSQL data base + some tests for it --- .../experiment/postgresql/postgresql.py | 379 ++++++++++++++++++ .../persistence/experiment/sqlite/sqlite.py | 6 +- tests/persistence/postgresql/__init__.py | 0 .../postgresql/test_experiment_storage.py | 293 ++++++++++++++ 4 files changed, 676 insertions(+), 2 deletions(-) create mode 100644 pysatl_experiment/persistence/experiment/postgresql/postgresql.py create mode 100644 tests/persistence/postgresql/__init__.py create mode 100644 tests/persistence/postgresql/test_experiment_storage.py diff --git a/pysatl_experiment/persistence/experiment/postgresql/postgresql.py b/pysatl_experiment/persistence/experiment/postgresql/postgresql.py new file mode 100644 index 0000000..2559129 --- /dev/null +++ b/pysatl_experiment/persistence/experiment/postgresql/postgresql.py @@ -0,0 +1,379 @@ +import json +import logging + +import psycopg2 +from psycopg2.extras import RealDictCursor + +from pysatl_experiment.persistence.model.experiment.experiment import ( + ExperimentModel, + ExperimentQuery, + IExperimentStorage, +) + + +TABLE_NAME = "experiments" +logger = logging.getLogger(__name__) # TODO: ???? + + +class PostgreSQLExperimentStorage(IExperimentStorage): + """ + PostgreSQL implementation for experiment configuration storage. + """ + + def __init__(self, connection_string: str): + self.connection_string = connection_string + self.table_name = TABLE_NAME + self._initialized = False + + def _get_connection(self): + """Get database connection.""" + return psycopg2.connect(self.connection_string) + + def init(self) -> None: + """ + Initialize the database and create tables. + """ + if self._initialized: + return + + create_table_query = f""" + CREATE TABLE IF NOT EXISTS {self.table_name} ( + id SERIAL PRIMARY KEY, + experiment_type VARCHAR(255) NOT NULL, + storage_connection TEXT NOT NULL, + run_mode VARCHAR(100) NOT NULL, + report_mode VARCHAR(100) NOT NULL, + hypothesis TEXT NOT NULL, + generator_type VARCHAR(255) NOT NULL, + executor_type VARCHAR(255) NOT NULL, + report_builder_type VARCHAR(255) NOT NULL, + sample_sizes JSONB NOT NULL, + monte_carlo_count INTEGER NOT NULL, + criteria JSONB NOT NULL, + alternatives JSONB NOT NULL, + significance_levels JSONB NOT NULL, + is_generation_done BOOLEAN DEFAULT FALSE, + is_execution_done BOOLEAN DEFAULT FALSE, + is_report_building_done BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(experiment_type, storage_connection, run_mode, hypothesis, + generator_type, executor_type, report_builder_type, + sample_sizes, monte_carlo_count, criteria, alternatives, + significance_levels, report_mode), + + CONSTRAINT valid_monte_carlo_count CHECK (monte_carlo_count > 0), + CONSTRAINT valid_sample_sizes CHECK (jsonb_array_length(sample_sizes) > 0), + CONSTRAINT valid_significance_levels CHECK (jsonb_array_length(significance_levels) > 0) + ); + + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_type + ON {self.table_name} (experiment_type); + + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_status + ON {self.table_name} (is_generation_done, is_execution_done, is_report_building_done); + + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_composite + ON {self.table_name} (experiment_type, generator_type, executor_type); + + CREATE OR REPLACE FUNCTION update_updated_at_column() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; + END; + $$ language 'plpgsql'; + + DROP TRIGGER IF EXISTS update_{self.table_name}_updated_at ON {self.table_name}; + CREATE TRIGGER update_{self.table_name}_updated_at + BEFORE UPDATE ON {self.table_name} + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + """ + + try: + with self._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(create_table_query) + conn.commit() + self._initialized = True + logger.info(f"Experiment storage table '{self.table_name}' initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize experiment storage: {e}") + raise + + def insert_data(self, model: ExperimentModel) -> None: + """ + Insert or update experiment configuration. + """ + if not self._initialized: + self.init() + + insert_query = f""" + INSERT INTO {self.table_name} + (experiment_type, storage_connection, run_mode, report_mode, hypothesis, + generator_type, executor_type, report_builder_type, sample_sizes, + monte_carlo_count, criteria, alternatives, significance_levels, + is_generation_done, is_execution_done, is_report_building_done) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (experiment_type, storage_connection, run_mode, hypothesis, + generator_type, executor_type, report_builder_type, + sample_sizes, monte_carlo_count, criteria, alternatives, + significance_levels, report_mode) + DO UPDATE SET + report_mode = EXCLUDED.report_mode, + is_generation_done = EXCLUDED.is_generation_done, + is_execution_done = EXCLUDED.is_execution_done, + is_report_building_done = EXCLUDED.is_report_building_done, + updated_at = CURRENT_TIMESTAMP; + """ + + try: + with self._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute( + insert_query, + ( + model.experiment_type, + model.storage_connection, + model.run_mode, + model.report_mode, + model.hypothesis, + model.generator_type, + model.executor_type, + model.report_builder_type, + json.dumps(model.sample_sizes), + model.monte_carlo_count, + json.dumps(model.criteria), + json.dumps(model.alternatives), + json.dumps(model.significance_levels), + model.is_generation_done, + model.is_execution_done, + model.is_report_building_done, + ), + ) + conn.commit() + logger.debug(f"Experiment configuration inserted: {model.experiment_type}") + except Exception as e: + logger.error(f"Failed to insert experiment configuration: {e}") + raise + + def get_data(self, query: ExperimentQuery) -> ExperimentModel | None: + """ + Get experiment configuration by query. + """ + if not self._initialized: + self.init() # TODO: exception??? + + select_query = f""" + SELECT id, experiment_type, storage_connection, run_mode, report_mode, hypothesis, + generator_type, executor_type, report_builder_type, sample_sizes, + monte_carlo_count, criteria, alternatives, significance_levels, + is_generation_done, is_execution_done, is_report_building_done + FROM {self.table_name} + WHERE experiment_type = %s + AND storage_connection = %s + AND run_mode = %s + AND hypothesis = %s + AND generator_type = %s + AND executor_type = %s + AND report_builder_type = %s + AND sample_sizes = %s + AND monte_carlo_count = %s + AND criteria = %s + AND alternatives = %s + AND significance_levels = %s + AND report_mode = %s; + """ + + try: + with self._get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + cursor.execute( + select_query, + ( + query.experiment_type, + query.storage_connection, + query.run_mode, + query.hypothesis, + query.generator_type, + query.executor_type, + query.report_builder_type, + json.dumps(query.sample_sizes), + query.monte_carlo_count, + json.dumps(query.criteria), + json.dumps(query.alternatives), + json.dumps(query.significance_levels), + query.report_mode, + ), + ) + result = cursor.fetchone() + + if result: + return ExperimentModel( + experiment_type=result["experiment_type"], + storage_connection=result["storage_connection"], + run_mode=result["run_mode"], + report_mode=result["report_mode"], + hypothesis=result["hypothesis"], + generator_type=result["generator_type"], + executor_type=result["executor_type"], + report_builder_type=result["report_builder_type"], + sample_sizes=json.loads(result["sample_sizes"]), + monte_carlo_count=result["monte_carlo_count"], + criteria=json.loads(result["criteria"]), + alternatives=json.loads(result["alternatives"]), + significance_levels=json.loads(result["significance_levels"]), + is_generation_done=result["is_generation_done"], + is_execution_done=result["is_execution_done"], + is_report_building_done=result["is_report_building_done"], + ) + return None + except Exception as e: + logger.error(f"Failed to get experiment configuration: {e}") + raise + + def delete_data(self, query: ExperimentQuery) -> None: + """ + Delete experiment configuration by query. + """ + if not self._initialized: # TODO: exception?? + self.init() + + delete_query = f""" + DELETE FROM {self.table_name} + WHERE experiment_type = %s + AND storage_connection = %s + AND run_mode = %s + AND hypothesis = %s + AND generator_type = %s + AND executor_type = %s + AND report_builder_type = %s + AND sample_sizes = %s + AND monte_carlo_count = %s + AND criteria = %s + AND alternatives = %s + AND significance_levels = %s + AND report_mode = %s; + """ + + try: + with self._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute( + delete_query, + ( + query.experiment_type, + query.storage_connection, + query.run_mode, + query.hypothesis, + query.generator_type, + query.executor_type, + query.report_builder_type, + json.dumps(query.sample_sizes), + query.monte_carlo_count, + json.dumps(query.criteria), + json.dumps(query.alternatives), + json.dumps(query.significance_levels), + query.report_mode, + ), + ) + conn.commit() + logger.debug(f"Experiment configuration deleted: {query.experiment_type}") + except Exception as e: + logger.error(f"Failed to delete experiment configuration: {e}") + raise + + def get_experiment_id(self, query: ExperimentQuery) -> int | None: + """ + Get experiment ID by query. + """ + if not self._initialized: + self.init() + + select_query = f""" + SELECT id + FROM {self.table_name} + WHERE experiment_type = %s + AND storage_connection = %s + AND run_mode = %s + AND hypothesis = %s + AND generator_type = %s + AND executor_type = %s + AND report_builder_type = %s + AND sample_sizes = %s + AND monte_carlo_count = %s + AND criteria = %s + AND alternatives = %s + AND significance_levels = %s + AND report_mode = %s; + """ + + try: + with self._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute( + select_query, + ( + query.experiment_type, + query.storage_connection, + query.run_mode, + query.hypothesis, + query.generator_type, + query.executor_type, + query.report_builder_type, + json.dumps(query.sample_sizes), + query.monte_carlo_count, + json.dumps(query.criteria), + json.dumps(query.alternatives), + json.dumps(query.significance_levels), + query.report_mode, + ), + ) + result = cursor.fetchone() + return result[0] if result else None + except Exception as e: + logger.error(f"Failed to get experiment ID: {e}") + raise + + def set_generation_done(self, experiment_id: int) -> None: + """ + Mark generation step as completed. + """ + self._update_experiment_status(experiment_id, "is_generation_done", True) + + def set_execution_done(self, experiment_id: int) -> None: + """ + Mark execution step as completed. + """ + self._update_experiment_status(experiment_id, "is_execution_done", True) + + def set_report_building_done(self, experiment_id: int) -> None: + """ + Mark report building step as completed. + """ + self._update_experiment_status(experiment_id, "is_report_building_done", True) + + def _update_experiment_status(self, experiment_id: int, field: str, value: bool) -> None: + """ + Update a status field for an experiment. + """ + if not self._initialized: + self.init() + + update_query = f""" + UPDATE {self.table_name} + SET {field} = %s + WHERE id = %s; + """ + + try: + with self._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(update_query, (value, experiment_id)) + conn.commit() + logger.debug(f"Updated {field} to {value} for experiment {experiment_id}") + except Exception as e: + logger.error(f"Failed to update experiment status: {e}") + raise diff --git a/pysatl_experiment/persistence/experiment/sqlite/sqlite.py b/pysatl_experiment/persistence/experiment/sqlite/sqlite.py index ae42e32..01b1e35 100644 --- a/pysatl_experiment/persistence/experiment/sqlite/sqlite.py +++ b/pysatl_experiment/persistence/experiment/sqlite/sqlite.py @@ -65,7 +65,8 @@ def _get_connection(self): raise RuntimeError("Storage not initialized. Call init() first.") return self.conn - def _model_to_row(self, model: ExperimentModel) -> dict: + @staticmethod + def _model_to_row(model: ExperimentModel) -> dict: """Convert ExperimentModel to database row format.""" return { "experiment_type": model.experiment_type, @@ -86,7 +87,8 @@ def _model_to_row(self, model: ExperimentModel) -> dict: "is_report_building_done": int(model.is_report_building_done), } - def _row_to_model(self, row: dict) -> ExperimentModel: + @staticmethod + def _row_to_model(row: dict) -> ExperimentModel: """Convert database row to ExperimentModel.""" return ExperimentModel( experiment_type=row["experiment_type"], diff --git a/tests/persistence/postgresql/__init__.py b/tests/persistence/postgresql/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/persistence/postgresql/test_experiment_storage.py b/tests/persistence/postgresql/test_experiment_storage.py new file mode 100644 index 0000000..085ab67 --- /dev/null +++ b/tests/persistence/postgresql/test_experiment_storage.py @@ -0,0 +1,293 @@ +import json +from unittest.mock import MagicMock, patch + +import psycopg2 +import pytest + +from pysatl_experiment.persistence.experiment.postgresql.postgresql import PostgreSQLExperimentStorage +from pysatl_experiment.persistence.model.experiment.experiment import ExperimentModel, ExperimentQuery + + +class TestPostgreSQLExperimentStorage: + @pytest.fixture + def storage(self): + return PostgreSQLExperimentStorage(connection_string="postgresql://test:test@localhost/test") + + @pytest.fixture + def sample_experiment_model(self): # TODO: check for format + return ExperimentModel( + experiment_type="statistical_test", + storage_connection="postgresql://storage", + run_mode="sequential", + report_mode="detailed", + hypothesis="Test hypothesis", + generator_type="monte_carlo", + executor_type="multiprocessing", + report_builder_type="html", + sample_sizes=[100, 200, 300], + monte_carlo_count=1000, + criteria=["t_test", "mann_whitney"], + alternatives=["two_sided", "greater"], + significance_levels=[0.01, 0.05, 0.1], + is_generation_done=False, + is_execution_done=False, + is_report_building_done=False, + ) + + @pytest.fixture + def sample_experiment_query(self): # TODO: check for format + return ExperimentQuery( + experiment_type="statistical_test", + storage_connection="postgresql://storage", + run_mode="sequential", + hypothesis="Test hypothesis", + generator_type="monte_carlo", + executor_type="multiprocessing", + report_builder_type="html", + sample_sizes=[100, 200, 300], + monte_carlo_count=1000, + criteria=["t_test", "mann_whitney"], + alternatives=["two_sided", "greater"], + significance_levels=[0.01, 0.05, 0.1], + report_mode="detailed", + ) + + def test_initialization(self, storage): + assert storage.connection_string == "postgresql://test:test@localhost/test" + assert storage.table_name == "experiments" + assert not storage._initialized + + @patch("psycopg2.connect") + def test_init_success(self, mock_connect, storage): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + storage.init() + + assert storage._initialized + mock_cursor.execute.assert_called_once() + mock_conn.commit.assert_called_once() + + @patch("psycopg2.connect") + def test_init_already_initialized(self, mock_connect, storage): + storage._initialized = True + mock_conn = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + + storage.init() + + mock_conn.cursor.assert_not_called() + + @patch("psycopg2.connect") + def test_init_failure(self, mock_connect, storage): + mock_connect.side_effect = Exception("Connection failed") + + with pytest.raises(Exception, match="Connection failed"): + storage.init() + + @patch("psycopg2.connect") + def test_insert_success(self, mock_connect, storage, sample_experiment_model): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + storage._initialized = True + storage.insert_data(sample_experiment_model) + + mock_cursor.execute.assert_called_once() + mock_conn.commit.assert_called_once() + + @patch("psycopg2.connect") + def test_insert_auto_init(self, mock_connect, storage, sample_experiment_model): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + storage.insert_data(sample_experiment_model) + + # Must be 2 counts + assert mock_cursor.execute.call_count == 2 + assert storage._initialized + + @patch("psycopg2.connect") + def test_get_success(self, mock_connect, storage, sample_experiment_query): # TODO: check for actual ones + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + # Mock query result + mock_result = { + "experiment_type": "statistical_test", + "storage_connection": "postgresql://storage", + "run_mode": "sequential", + "report_mode": "detailed", + "hypothesis": "Test hypothesis", + "generator_type": "monte_carlo", + "executor_type": "multiprocessing", + "report_builder_type": "html", + "sample_sizes": json.dumps([100, 200, 300]), + "monte_carlo_count": 1000, + "criteria": json.dumps(["t_test", "mann_whitney"]), + "alternatives": json.dumps(["two_sided", "greater"]), + "significance_levels": json.dumps([0.01, 0.05, 0.1]), + "is_generation_done": False, + "is_execution_done": False, + "is_report_building_done": False, + } + mock_cursor.fetchone.return_value = mock_result + + storage._initialized = True + result = storage.get_data(sample_experiment_query) + + assert result is not None + assert result.experiment_type == sample_experiment_query.experiment_type + mock_cursor.execute.assert_called_once() + + @patch("psycopg2.connect") + def test_get_not_found(self, mock_connect, storage, sample_experiment_query): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + mock_cursor.fetchone.return_value = None + + storage._initialized = True + result = storage.get_data(sample_experiment_query) + + assert result is None + + @patch("psycopg2.connect") + def test_delete_success(self, mock_connect, storage, sample_experiment_query): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + storage._initialized = True + storage.delete_data(sample_experiment_query) + + mock_cursor.execute.assert_called_once() + mock_conn.commit.assert_called_once() + + # TODO: do some proper test environment? + + @patch("psycopg2.connect") + def test_get_experiment_id_success(self, mock_connect, storage, sample_experiment_query): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + mock_cursor.fetchone.return_value = (123,) + + storage._initialized = True + result = storage.get_experiment_id(sample_experiment_query) + + assert result == 123 + + @patch("psycopg2.connect") + def test_set_generation_done(self, mock_connect, storage): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + storage._initialized = True + storage.set_generation_done(123) + + mock_cursor.execute.assert_called_once() + assert "is_generation_done" in mock_cursor.execute.call_args[0][0] + mock_conn.commit.assert_called_once() + + @patch("psycopg2.connect") + def test_set_execution_done(self, mock_connect, storage): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + storage._initialized = True + storage.set_execution_done(123) + + mock_cursor.execute.assert_called_once() + assert "is_execution_done" in mock_cursor.execute.call_args[0][0] + + @patch("psycopg2.connect") + def test_set_report_building_done(self, mock_connect, storage): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + storage._initialized = True + storage.set_report_building_done(123) + + mock_cursor.execute.assert_called_once() + assert "is_report_building_done" in mock_cursor.execute.call_args[0][0] + + @patch("psycopg2.connect") + def test_error_handling(self, mock_connect, storage, sample_experiment_model): + mock_connect.side_effect = psycopg2.Error("Database error") + + storage._initialized = True + + with pytest.raises(psycopg2.Error, match="Database error"): + storage.insert_data(sample_experiment_model) + + # TODO: test below are optional, could be removed + + +# Integration tests +@pytest.mark.integration +class TestPostgreSQLExperimentStorageIntegration: + @pytest.fixture + def integration_storage(self): + # Should use real DB + storage = PostgreSQLExperimentStorage(connection_string="postgresql://test:test@localhost/test_db") + + # Clearing before test + with storage._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(f"DROP TABLE IF EXISTS {storage.table_name}") + conn.commit() + + yield storage + + # Clearing after test + with storage._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(f"DROP TABLE IF EXISTS {storage.table_name}") + conn.commit() + + def test_integration_crud_operations(self, integration_storage, sample_experiment_model, sample_experiment_query): + # Create + integration_storage.insert_data(sample_experiment_model) + + # Read + result = integration_storage.get_data(sample_experiment_query) + assert result is not None + assert result.experiment_type == sample_experiment_model.experiment_type + + # Get ID + experiment_id = integration_storage.get_experiment_id(sample_experiment_query) + assert experiment_id is not None + + # Update status + integration_storage.set_generation_done(experiment_id) + + # Verify update + experiment_data = integration_storage.get_data(sample_experiment_query) + assert experiment_data.is_generation_done is True + + # Delete + integration_storage.delete_data(sample_experiment_query) + + # Verify deletion + result_after_delete = integration_storage.get_data(sample_experiment_query) + assert result_after_delete is None From 3b2456fc2a9762cf947ab029c028152b7263457e Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Thu, 25 Sep 2025 18:19:58 +0300 Subject: [PATCH 08/24] fix: added missing interfaces --- .../persistence/model/power/power.py | 20 ++++++++++++++++++- .../model/time_complexity/time_complexity.py | 20 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/pysatl_experiment/persistence/model/power/power.py b/pysatl_experiment/persistence/model/power/power.py index aa02675..028c3a2 100644 --- a/pysatl_experiment/persistence/model/power/power.py +++ b/pysatl_experiment/persistence/model/power/power.py @@ -33,4 +33,22 @@ class IPowerStorage(IDataStorage[PowerModel, PowerQuery], Protocol): Power storage interface. """ - pass + def init(self) -> None: + """ + Initialize SQLite power storage and create tables. + """ + + def insert_data(self, data: PowerModel) -> None: + """ + Insert or replace a power entry. + """ + + def get_data(self, query: PowerQuery) -> PowerModel | None: + """ + Retrieve power data matching the query. + """ + + def delete_data(self, query: PowerQuery) -> None: + """ + Delete power data matching the query. + """ diff --git a/pysatl_experiment/persistence/model/time_complexity/time_complexity.py b/pysatl_experiment/persistence/model/time_complexity/time_complexity.py index 60acc81..efcf4c9 100644 --- a/pysatl_experiment/persistence/model/time_complexity/time_complexity.py +++ b/pysatl_experiment/persistence/model/time_complexity/time_complexity.py @@ -27,4 +27,22 @@ class ITimeComplexityStorage(IDataStorage[TimeComplexityModel, TimeComplexityQue Time complexity storage interface. """ - pass + def init(self) -> None: + """ + Initialize SQLite time complexity storage and create tables. + """ + + def insert_data(self, data: TimeComplexityModel) -> None: + """ + Insert or replace time complexity data. + """ + + def get_data(self, query: TimeComplexityQuery) -> TimeComplexityModel | None: + """ + Get time complexity data matching the query. + """ + + def delete_data(self, query: TimeComplexityQuery) -> None: + """ + Delete time complexity data matching the query. + """ From c3071f5efee891186e5951c994c1aeda0d504c3c Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Mon, 29 Sep 2025 11:29:51 +0300 Subject: [PATCH 09/24] docs: added myself to authors --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index e8b9d45..4345219 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" description = "package for PySATL math statistics experiments" authors = [ {name = "Ivan Pokhabov", email = "vanek3372@gmail.com"}, + {name = "Dmitrii Kuznetsov", email = "dmitrvlkuznetsov@gmail.com"} ] license = {text = "MIT"} readme = "README.md" From a43c0242c3501f52a8353f32a3ccf6a021685d70 Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Mon, 29 Sep 2025 11:38:28 +0300 Subject: [PATCH 10/24] deps: added psycopg2 to dependencies --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 4345219..9e8dee9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "rich==13.9.4", "click>=8.2.1", "dacite==1.9.2", + "psycopg2==2.9.10", "pysatl-criterion @ ./pysatl_criterion" ] From bd250f3661d96be19c755fc698a20a3002a1062e Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Sun, 5 Oct 2025 21:44:28 +0300 Subject: [PATCH 11/24] fix: grammar fix --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f0b9670..63119e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,7 +103,7 @@ Exceptions: - Minor corrections and fixes to pull requests submitted by others. - While making a formal release, the release manager can make necessary, appropriate changes. -- Small documentation changes that reinforce existing subject matter. Most commonly being, but not limited to spelling and grammar corrections. +- Small documentation changes that reinforce existing subject. Most commonly being, but not limited to spelling and grammar corrections. ### Responsibilities From da99faded215e20460b66203b49e2383c64f3215 Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Sun, 5 Oct 2025 21:50:50 +0300 Subject: [PATCH 12/24] feat: stub for internal storage commands --- pysatl_experiment/cli/cli/cli.py | 2 ++ pysatl_experiment/cli/commands/common/common.py | 3 ++- .../configure/storage_connection/storage_connection.py | 2 ++ .../configuration/experiment_config/experiment_config.py | 2 ++ .../abstract_experiment_factory.py | 6 +++--- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/pysatl_experiment/cli/cli/cli.py b/pysatl_experiment/cli/cli/cli.py index e7e9a11..dedc4cb 100644 --- a/pysatl_experiment/cli/cli/cli.py +++ b/pysatl_experiment/cli/cli/cli.py @@ -18,6 +18,7 @@ from pysatl_experiment.cli.commands.configure.show.show import show from pysatl_experiment.cli.commands.configure.significance_levels.significance_levels import significance_levels from pysatl_experiment.cli.commands.configure.storage_connection.storage_connection import storage_connection +from pysatl_experiment.cli.commands.configure.storage_type.storage_type import storage_type from pysatl_experiment.cli.commands.create.create import create @@ -50,6 +51,7 @@ def _create_experiments_dir() -> None: cli.add_command(generator_type) cli.add_command(executor_type) cli.add_command(report_builder_type) +cli.add_command(storage_type) cli.add_command(sample_sizes) cli.add_command(monte_carlo_count) cli.add_command(significance_levels) diff --git a/pysatl_experiment/cli/commands/common/common.py b/pysatl_experiment/cli/commands/common/common.py index a3ddaa2..a1683a8 100644 --- a/pysatl_experiment/cli/commands/common/common.py +++ b/pysatl_experiment/cli/commands/common/common.py @@ -53,9 +53,10 @@ def create_storage_path(storage_name: str) -> str: :return: path to the storage. """ + # TODO: add support for link! # pysatl-experiment/.storage storage_dir = Path(__file__).resolve().parents[4] / ".storage" - storage_file_name = f"{storage_name}.sqlite" + storage_file_name = f"{storage_name}.sqlite" # TODO: different storages storage_path = storage_dir / storage_file_name diff --git a/pysatl_experiment/cli/commands/configure/storage_connection/storage_connection.py b/pysatl_experiment/cli/commands/configure/storage_connection/storage_connection.py index 590142d..e6b0f09 100644 --- a/pysatl_experiment/cli/commands/configure/storage_connection/storage_connection.py +++ b/pysatl_experiment/cli/commands/configure/storage_connection/storage_connection.py @@ -19,6 +19,8 @@ def storage_connection(ctx: Context, connection: str) -> None: :param connection: storage connection. """ + # TODO: add support for different remote storages + experiment_name, experiment_config = get_experiment_name_and_config(ctx) storage_path = create_storage_path(connection) diff --git a/pysatl_experiment/configuration/experiment_config/experiment_config.py b/pysatl_experiment/configuration/experiment_config/experiment_config.py index 31881a3..965026c 100644 --- a/pysatl_experiment/configuration/experiment_config/experiment_config.py +++ b/pysatl_experiment/configuration/experiment_config/experiment_config.py @@ -6,6 +6,7 @@ from pysatl_experiment.configuration.model.report_mode.report_mode import ReportMode from pysatl_experiment.configuration.model.run_mode.run_mode import RunMode from pysatl_experiment.configuration.model.step_type.step_type import StepType +from pysatl_experiment.configuration.model.storage_type.storage_type import StorageType @dataclass @@ -16,6 +17,7 @@ class ExperimentConfig: experiment_type: ExperimentType storage_connection: str + storage_type: StorageType run_mode: RunMode hypothesis: Hypothesis generator_type: StepType diff --git a/pysatl_experiment/factory/model/abstract_experiment_factory/abstract_experiment_factory.py b/pysatl_experiment/factory/model/abstract_experiment_factory/abstract_experiment_factory.py index 0c88a33..cbc60b5 100644 --- a/pysatl_experiment/factory/model/abstract_experiment_factory/abstract_experiment_factory.py +++ b/pysatl_experiment/factory/model/abstract_experiment_factory/abstract_experiment_factory.py @@ -434,7 +434,7 @@ def _init_data_storage(self) -> IRandomValuesStorage: """ storage_connection = self.experiment_data.config.storage_connection - data_storage = SQLiteRandomValuesStorage(storage_connection) + data_storage = SQLiteRandomValuesStorage(storage_connection) # TODO: improve smh data_storage.init() return data_storage @@ -447,7 +447,7 @@ def _init_experiment_storage(self) -> IExperimentStorage: """ storage_connection = self.experiment_data.config.storage_connection - data_storage = SQLiteExperimentStorage(storage_connection) + data_storage = SQLiteExperimentStorage(storage_connection) # TODO: improve smh data_storage.init() return data_storage @@ -459,7 +459,7 @@ def _init_result_storage(self) -> RS: :return: result storage. """ - experiment_type = self.experiment_data.config.experiment_type + experiment_type = self.experiment_data.config.experiment_type # TODO: improve smh storage_connection = self.experiment_data.config.storage_connection if experiment_type == ExperimentType.CRITICAL_VALUE: limit_distribution_storage = SQLiteLimitDistributionStorage(storage_connection) From a724c0598cfa016d9a52f9608bafbcd71506f347 Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Sun, 5 Oct 2025 21:52:40 +0300 Subject: [PATCH 13/24] feat: stub for internal storage commands --- .../configure/storage_type/storage_type.py | 29 +++++++++++++++++++ .../model/storage_type/storage_type.py | 10 +++++++ 2 files changed, 39 insertions(+) create mode 100644 pysatl_experiment/cli/commands/configure/storage_type/storage_type.py create mode 100644 pysatl_experiment/configuration/model/storage_type/storage_type.py diff --git a/pysatl_experiment/cli/commands/configure/storage_type/storage_type.py b/pysatl_experiment/cli/commands/configure/storage_type/storage_type.py new file mode 100644 index 0000000..c578503 --- /dev/null +++ b/pysatl_experiment/cli/commands/configure/storage_type/storage_type.py @@ -0,0 +1,29 @@ +from click import Context, argument, echo, pass_context + +from pysatl_experiment.cli.commands.common.common import get_experiment_name_and_config, save_experiment_config +from pysatl_experiment.cli.commands.configure.configure import configure + + +@configure.command() +@argument("storage_type") +@pass_context +def storage_type(ctx: Context, store_type: str) -> None: + """ + Configure storage type. + + :param ctx: context. + :param store_type: storage type. + """ + + # TODO: add support for remote storage + + experiment_name, experiment_config = get_experiment_name_and_config(ctx) + + # TODO + storage_type_lower = store_type.lower() + experiment_config["storage_type"] = storage_type_lower + # TODO + + save_experiment_config(ctx, experiment_name, experiment_config) + + echo(f"Report builder type of the experiment '{experiment_name}' is set to '{storage_type_lower}'.") diff --git a/pysatl_experiment/configuration/model/storage_type/storage_type.py b/pysatl_experiment/configuration/model/storage_type/storage_type.py new file mode 100644 index 0000000..592c0da --- /dev/null +++ b/pysatl_experiment/configuration/model/storage_type/storage_type.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class StorageType(Enum): + """ + Internal storage. + """ + + SQLITE = "sqlite" + POSTGRESQL = "postgresql" From a324ba408ab0b2974cdafb7973f6d3a622c3541a Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Sun, 5 Oct 2025 22:04:45 +0300 Subject: [PATCH 14/24] structure: changed storage structure --- .../abstract_experiment_factory.py | 8 +- .../experiment/postgresql/postgresql.py | 0 .../{ => storage}/experiment/sqlite/sqlite.py | 0 .../{ => storage}/power/sqlite/sqlite.py | 0 .../random_values/postgresql/__init__.py | 0 .../random_values/postgresql/postgresql.py | 263 ++++++++++++++++++ .../storage/random_values/sqlite/__init__.py | 0 .../random_values/sqlite/sqlite.py | 0 .../time_complexity/postgresql/__init__.py | 0 .../time_complexity/sqlite/__init__.py | 0 .../time_complexity/sqlite/sqlite.py | 0 .../commands/build_and_run/build_and_run.py | 2 +- 12 files changed, 268 insertions(+), 5 deletions(-) rename pysatl_experiment/persistence/{ => storage}/experiment/postgresql/postgresql.py (100%) rename pysatl_experiment/persistence/{ => storage}/experiment/sqlite/sqlite.py (100%) rename pysatl_experiment/persistence/{ => storage}/power/sqlite/sqlite.py (100%) create mode 100644 pysatl_experiment/persistence/storage/random_values/postgresql/__init__.py create mode 100644 pysatl_experiment/persistence/storage/random_values/postgresql/postgresql.py create mode 100644 pysatl_experiment/persistence/storage/random_values/sqlite/__init__.py rename pysatl_experiment/persistence/{ => storage}/random_values/sqlite/sqlite.py (100%) create mode 100644 pysatl_experiment/persistence/storage/time_complexity/postgresql/__init__.py create mode 100644 pysatl_experiment/persistence/storage/time_complexity/sqlite/__init__.py rename pysatl_experiment/persistence/{ => storage}/time_complexity/sqlite/sqlite.py (100%) diff --git a/pysatl_experiment/factory/model/abstract_experiment_factory/abstract_experiment_factory.py b/pysatl_experiment/factory/model/abstract_experiment_factory/abstract_experiment_factory.py index cbc60b5..a50a4bf 100644 --- a/pysatl_experiment/factory/model/abstract_experiment_factory/abstract_experiment_factory.py +++ b/pysatl_experiment/factory/model/abstract_experiment_factory/abstract_experiment_factory.py @@ -18,14 +18,14 @@ from pysatl_experiment.experiment.generator.generators import ExponentialGenerator, NormalGenerator, WeibullGenerator from pysatl_experiment.experiment_new.experiment_steps.experiment_steps import ExperimentSteps from pysatl_experiment.experiment_new.model.experiment_step.experiment_step import IExperimentStep -from pysatl_experiment.persistence.experiment.sqlite.sqlite import SQLiteExperimentStorage from pysatl_experiment.persistence.model.experiment.experiment import ExperimentQuery, IExperimentStorage from pysatl_experiment.persistence.model.power.power import PowerQuery from pysatl_experiment.persistence.model.random_values.random_values import IRandomValuesStorage, RandomValuesAllQuery from pysatl_experiment.persistence.model.time_complexity.time_complexity import TimeComplexityQuery -from pysatl_experiment.persistence.power.sqlite.sqlite import SQLitePowerStorage -from pysatl_experiment.persistence.random_values.sqlite.sqlite import SQLiteRandomValuesStorage -from pysatl_experiment.persistence.time_complexity.sqlite.sqlite import SQLiteTimeComplexityStorage +from pysatl_experiment.persistence.storage.experiment.sqlite.sqlite import SQLiteExperimentStorage +from pysatl_experiment.persistence.storage.power.sqlite.sqlite import SQLitePowerStorage +from pysatl_experiment.persistence.storage.random_values.sqlite.sqlite import SQLiteRandomValuesStorage +from pysatl_experiment.persistence.storage.time_complexity.sqlite.sqlite import SQLiteTimeComplexityStorage D = TypeVar("D", contravariant=True, bound=ExperimentData) diff --git a/pysatl_experiment/persistence/experiment/postgresql/postgresql.py b/pysatl_experiment/persistence/storage/experiment/postgresql/postgresql.py similarity index 100% rename from pysatl_experiment/persistence/experiment/postgresql/postgresql.py rename to pysatl_experiment/persistence/storage/experiment/postgresql/postgresql.py diff --git a/pysatl_experiment/persistence/experiment/sqlite/sqlite.py b/pysatl_experiment/persistence/storage/experiment/sqlite/sqlite.py similarity index 100% rename from pysatl_experiment/persistence/experiment/sqlite/sqlite.py rename to pysatl_experiment/persistence/storage/experiment/sqlite/sqlite.py diff --git a/pysatl_experiment/persistence/power/sqlite/sqlite.py b/pysatl_experiment/persistence/storage/power/sqlite/sqlite.py similarity index 100% rename from pysatl_experiment/persistence/power/sqlite/sqlite.py rename to pysatl_experiment/persistence/storage/power/sqlite/sqlite.py diff --git a/pysatl_experiment/persistence/storage/random_values/postgresql/__init__.py b/pysatl_experiment/persistence/storage/random_values/postgresql/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pysatl_experiment/persistence/storage/random_values/postgresql/postgresql.py b/pysatl_experiment/persistence/storage/random_values/postgresql/postgresql.py new file mode 100644 index 0000000..abb81f2 --- /dev/null +++ b/pysatl_experiment/persistence/storage/random_values/postgresql/postgresql.py @@ -0,0 +1,263 @@ +import json + +import psycopg2 +from psycopg2.extras import RealDictCursor + +from pysatl_experiment.persistence.model.random_values.random_values import ( + IRandomValuesStorage, + RandomValuesAllModel, + RandomValuesAllQuery, + RandomValuesCountQuery, + RandomValuesModel, + RandomValuesQuery, +) + + +class PostgreSQLRandomValuesStorage(IRandomValuesStorage): + """ + PostgreSQL implementation of random values storage. + """ + + def __init__(self, connection_string: str, table_name: str = "random_values"): + self.connection_string = connection_string + self.table_name = table_name + self._init_database() + + def _get_connection(self): + """Get database connection, ensuring it's initialized.""" + return psycopg2.connect(self.connection_string) + + # TODO created at?? + def _init_database(self): + """Initialize database table if it doesn't exist.""" + create_table_query = f""" + CREATE TABLE IF NOT EXISTS {self.table_name} ( + id SERIAL PRIMARY KEY, + generator_name VARCHAR(255) NOT NULL, + generator_parameters JSONB NOT NULL, + sample_size INTEGER NOT NULL, + sample_num INTEGER NOT NULL, + data TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (generator_name, generator_parameters, sample_size, sample_num) + ); + + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_generator + ON {self.table_name} (generator_name, generator_parameters, sample_size); + """ + + with self._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(create_table_query) + conn.commit() + + def insert(self, model: RandomValuesModel) -> None: + """Insert a single RandomValuesModel.""" + insert_query = f""" + INSERT INTO {self.table_name} + (generator_name, generator_parameters, sample_size, sample_num, data) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (generator_name, generator_parameters, sample_size, sample_num) + DO UPDATE SET data = EXCLUDED.data; + """ + + with self._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute( + insert_query, + ( + model.generator_name, + json.dumps(model.generator_parameters), + model.sample_size, + model.sample_num, + json.dumps(model.data), + ), + ) + conn.commit() + + def get_data(self, query: RandomValuesQuery) -> RandomValuesModel | None: + """Get a single RandomValuesModel by query.""" + select_query = f""" + SELECT data FROM {self.table_name} + WHERE generator_name = %s + AND generator_parameters = %s + AND sample_size = %s + AND sample_num = %s; + """ + + with self._get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + cursor.execute( + select_query, + (query.generator_name, json.dumps(query.generator_parameters), query.sample_size, query.sample_num), + ) + result = cursor.fetchone() + + if result: + return RandomValuesModel( + generator_name=result["generator_name"], + generator_parameters=json.loads(result["generator_parameters"]), + sample_size=result["sample_size"], + sample_num=result["sample_num"], + data=json.loads(result["data"]), + ) + return None + + def update(self, model: RandomValuesModel) -> None: + """Update a RandomValuesModel.""" + # Используем insert с ON CONFLICT для upsert операции + self.insert(model) + + def delete(self, query: RandomValuesQuery) -> None: + """Delete a RandomValuesModel by query.""" + delete_query = f""" + DELETE FROM {self.table_name} + WHERE generator_name = %s + AND generator_parameters = %s + AND sample_size = %s + AND sample_num = %s; + """ + + with self._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute( + delete_query, + (query.generator_name, json.dumps(query.generator_parameters), query.sample_size, query.sample_num), + ) + conn.commit() + + def get_rvs_count(self, query: RandomValuesAllQuery) -> int: + """Get count of samples.""" + count_query = f""" + SELECT COUNT(*) as count + FROM {self.table_name} + WHERE generator_name = %s + AND generator_parameters = %s + AND sample_size = %s; + """ + + with self._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute( + count_query, (query.generator_name, json.dumps(query.generator_parameters), query.sample_size) + ) + result = cursor.fetchone() + return result[0] if result else 0 + + def insert_all_data(self, model: RandomValuesAllModel) -> None: + """Insert all data based on hypothesis and sample size.""" + # Delete old TODO?? + delete_query = f""" + DELETE FROM {self.table_name} + WHERE generator_name = %s + AND generator_parameters = %s + AND sample_size = %s; + """ + + insert_query = f""" + INSERT INTO {self.table_name} + (generator_name, generator_parameters, sample_size, sample_num, data) + VALUES (%s, %s, %s, %s, %s); + """ + + with self._get_connection() as conn: + with conn.cursor() as cursor: + # Delete + cursor.execute( + delete_query, (model.generator_name, json.dumps(model.generator_parameters), model.sample_size) + ) + + # Insert + for sample_num, data in enumerate(model.data, 1): + cursor.execute( + insert_query, + ( + model.generator_name, + json.dumps(model.generator_parameters), + model.sample_size, + sample_num, + json.dumps(data), + ), + ) + + conn.commit() + + def get_all_data(self, query: RandomValuesAllQuery) -> list[RandomValuesModel] | None: + """Get all data based on hypothesis and sample size.""" + select_query = f""" + SELECT generator_name, generator_parameters, sample_size, sample_num, data + FROM {self.table_name} + WHERE generator_name = %s + AND generator_parameters = %s + AND sample_size = %s + ORDER BY sample_num; + """ + + with self._get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + cursor.execute( + select_query, (query.generator_name, json.dumps(query.generator_parameters), query.sample_size) + ) + results = cursor.fetchall() + + if results: + return [ + RandomValuesModel( + generator_name=row["generator_name"], + generator_parameters=json.loads(row["generator_parameters"]), + sample_size=row["sample_size"], + sample_num=row["sample_num"], + data=json.loads(row["data"]), + ) + for row in results + ] + return None + + def delete_all_data(self, query: RandomValuesAllQuery) -> None: + """Delete all data based on hypothesis and sample size.""" + delete_query = f""" + DELETE FROM {self.table_name} + WHERE generator_name = %s + AND generator_parameters = %s + AND sample_size = %s; + """ + + with self._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute( + delete_query, (query.generator_name, json.dumps(query.generator_parameters), query.sample_size) + ) + conn.commit() + + def get_count_data(self, query: RandomValuesCountQuery) -> list[RandomValuesModel] | None: + """Get count data based on hypothesis and sample size.""" + select_query = f""" + SELECT generator_name, generator_parameters, sample_size, sample_num, data + FROM {self.table_name} + WHERE generator_name = %s + AND generator_parameters = %s + AND sample_size = %s + ORDER BY sample_num + LIMIT %s; + """ + + with self._get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + cursor.execute( + select_query, + (query.generator_name, json.dumps(query.generator_parameters), query.sample_size, query.count), + ) + results = cursor.fetchall() + + if results: + return [ + RandomValuesModel( + generator_name=row["generator_name"], + generator_parameters=json.loads(row["generator_parameters"]), + sample_size=row["sample_size"], + sample_num=row["sample_num"], + data=json.loads(row["data"]), + ) + for row in results + ] + return None diff --git a/pysatl_experiment/persistence/storage/random_values/sqlite/__init__.py b/pysatl_experiment/persistence/storage/random_values/sqlite/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pysatl_experiment/persistence/random_values/sqlite/sqlite.py b/pysatl_experiment/persistence/storage/random_values/sqlite/sqlite.py similarity index 100% rename from pysatl_experiment/persistence/random_values/sqlite/sqlite.py rename to pysatl_experiment/persistence/storage/random_values/sqlite/sqlite.py diff --git a/pysatl_experiment/persistence/storage/time_complexity/postgresql/__init__.py b/pysatl_experiment/persistence/storage/time_complexity/postgresql/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pysatl_experiment/persistence/storage/time_complexity/sqlite/__init__.py b/pysatl_experiment/persistence/storage/time_complexity/sqlite/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pysatl_experiment/persistence/time_complexity/sqlite/sqlite.py b/pysatl_experiment/persistence/storage/time_complexity/sqlite/sqlite.py similarity index 100% rename from pysatl_experiment/persistence/time_complexity/sqlite/sqlite.py rename to pysatl_experiment/persistence/storage/time_complexity/sqlite/sqlite.py diff --git a/pysatl_experiment/validation/cli/commands/build_and_run/build_and_run.py b/pysatl_experiment/validation/cli/commands/build_and_run/build_and_run.py index 5dbab04..6907791 100644 --- a/pysatl_experiment/validation/cli/commands/build_and_run/build_and_run.py +++ b/pysatl_experiment/validation/cli/commands/build_and_run/build_and_run.py @@ -19,12 +19,12 @@ from pysatl_experiment.configuration.model.hypothesis.hypothesis import Hypothesis from pysatl_experiment.configuration.model.run_mode.run_mode import RunMode from pysatl_experiment.configuration.model.step_type.step_type import StepType -from pysatl_experiment.persistence.experiment.sqlite.sqlite import SQLiteExperimentStorage from pysatl_experiment.persistence.model.experiment.experiment import ( ExperimentModel, ExperimentQuery, IExperimentStorage, ) +from pysatl_experiment.persistence.storage.experiment.sqlite.sqlite import SQLiteExperimentStorage def validate_build_and_run(experiment_data_dict: dict) -> ExperimentData: From d33af143f50a8b23fa11fe4b87904a080e892215 Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Sun, 5 Oct 2025 22:07:12 +0300 Subject: [PATCH 15/24] fix: experiment test updated --- .../postgresql/test_experiment_storage.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/persistence/postgresql/test_experiment_storage.py b/tests/persistence/postgresql/test_experiment_storage.py index 085ab67..08ea0ef 100644 --- a/tests/persistence/postgresql/test_experiment_storage.py +++ b/tests/persistence/postgresql/test_experiment_storage.py @@ -4,8 +4,11 @@ import psycopg2 import pytest -from pysatl_experiment.persistence.experiment.postgresql.postgresql import PostgreSQLExperimentStorage from pysatl_experiment.persistence.model.experiment.experiment import ExperimentModel, ExperimentQuery +from pysatl_experiment.persistence.storage.experiment.postgresql.postgresql import PostgreSQLExperimentStorage + + +TABLE_NAME = "experiments" class TestPostgreSQLExperimentStorage: @@ -54,7 +57,7 @@ def sample_experiment_query(self): # TODO: check for format def test_initialization(self, storage): assert storage.connection_string == "postgresql://test:test@localhost/test" - assert storage.table_name == "experiments" + assert storage.table_name == TABLE_NAME assert not storage._initialized @patch("psycopg2.connect") @@ -240,7 +243,7 @@ def test_error_handling(self, mock_connect, storage, sample_experiment_model): with pytest.raises(psycopg2.Error, match="Database error"): storage.insert_data(sample_experiment_model) - # TODO: test below are optional, could be removed + # TODO: logger tests # Integration tests @@ -291,3 +294,6 @@ def test_integration_crud_operations(self, integration_storage, sample_experimen # Verify deletion result_after_delete = integration_storage.get_data(sample_experiment_query) assert result_after_delete is None + + +# TODO: big integration tests with CLI in different file From fd90442f4dec24eba571e8987f0719cdf310c381 Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Sun, 5 Oct 2025 22:23:04 +0300 Subject: [PATCH 16/24] feat: added storage error --- pysatl_experiment/exceptions.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pysatl_experiment/exceptions.py b/pysatl_experiment/exceptions.py index a427d09..518f635 100644 --- a/pysatl_experiment/exceptions.py +++ b/pysatl_experiment/exceptions.py @@ -16,3 +16,9 @@ class ConfigurationError(OperationalException): """ Configuration error. Usually caused by invalid configuration. """ + + +class StorageError(OperationalException): # TODO: description!!! + """ + Storage error. Used in storage creation. + """ From 19a668e03ca9cc594af99af1c72f701606a2a723 Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Sun, 5 Oct 2025 22:23:20 +0300 Subject: [PATCH 17/24] feat: power storage + stub for tests --- .../storage/power/postgresql/postgresql.py | 395 ++++++++++++ .../postgresql/test_power_storage.py | 580 ++++++++++++++++++ 2 files changed, 975 insertions(+) create mode 100644 pysatl_experiment/persistence/storage/power/postgresql/postgresql.py create mode 100644 tests/persistence/postgresql/test_power_storage.py diff --git a/pysatl_experiment/persistence/storage/power/postgresql/postgresql.py b/pysatl_experiment/persistence/storage/power/postgresql/postgresql.py new file mode 100644 index 0000000..b824751 --- /dev/null +++ b/pysatl_experiment/persistence/storage/power/postgresql/postgresql.py @@ -0,0 +1,395 @@ +import json +import logging + +import psycopg2 +from psycopg2.extras import RealDictCursor + +from pysatl_experiment.exceptions import StorageError +from pysatl_experiment.persistence.model.power.power import IPowerStorage, PowerModel, PowerQuery + + +logger = logging.getLogger(__name__) # TODO ??? + + +class PostgreSQLPowerStorage(IPowerStorage): + """ + PostgreSQL implementation of PowerStorage interface. + """ + + def __init__(self, connection_string: str, table_name: str = "power_analysis"): + """ + Initialize PostgreSQL power storage. + + Args: + connection_string: PostgreSQL connection string + table_name: Name of the table to store power analysis data + """ + self.connection_string = connection_string + self.table_name = table_name + self._initialized = False + + def _get_connection(self): + """Get database connection.""" + return psycopg2.connect(self.connection_string) + + def init(self) -> None: + """ + Initialize SQLite power storage and create tables. + """ + if self._initialized: + return + + create_table_query = f""" + CREATE TABLE IF NOT EXISTS {self.table_name} ( + id SERIAL PRIMARY KEY, + experiment_id INTEGER NOT NULL, + criterion_code VARCHAR(255) NOT NULL, + criterion_parameters JSONB NOT NULL, + sample_size INTEGER NOT NULL, + alternative_code VARCHAR(255) NOT NULL, + alternative_parameters JSONB NOT NULL, + monte_carlo_count INTEGER NOT NULL, + significance_level FLOAT NOT NULL, + results_criteria JSONB NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(experiment_id, criterion_code, sample_size, alternative_code, + monte_carlo_count, significance_level), + + CONSTRAINT valid_significance_level CHECK (significance_level > 0 AND significance_level < 1), + CONSTRAINT valid_sample_size CHECK (sample_size > 0), + CONSTRAINT valid_monte_carlo_count CHECK (monte_carlo_count > 0) + ); + + -- Индексы для ускорения поиска + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_experiment + ON {self.table_name} (experiment_id); + + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_criterion + ON {self.table_name} (criterion_code, criterion_parameters); + + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_alternative + ON {self.table_name} (alternative_code, alternative_parameters); + + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_sample_size + ON {self.table_name} (sample_size); + + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_composite + ON {self.table_name} (criterion_code, alternative_code, sample_size, significance_level); + """ + + try: + with self._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(create_table_query) + conn.commit() + self._initialized = True + logger.info(f"Power storage table '{self.table_name}' initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize power storage: {e}") + raise StorageError + + def insert(self, model: PowerModel) -> None: + """ + Insert a PowerModel into storage. + + Args: + model: PowerModel to insert + """ + self.insert_data(model) + + def insert_data(self, data: PowerModel) -> None: + """ + Insert or replace a power entry. + """ + if not self._initialized: + self.init() + + insert_query = f""" + INSERT INTO {self.table_name} + (experiment_id, criterion_code, criterion_parameters, sample_size, + alternative_code, alternative_parameters, monte_carlo_count, + significance_level, results_criteria) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (experiment_id, criterion_code, sample_size, alternative_code, + monte_carlo_count, significance_level) + DO UPDATE SET + criterion_parameters = EXCLUDED.criterion_parameters, + alternative_parameters = EXCLUDED.alternative_parameters, + results_criteria = EXCLUDED.results_criteria, + created_at = CURRENT_TIMESTAMP; + """ + + try: + with self._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute( + insert_query, + ( + data.experiment_id, + data.criterion_code, + json.dumps(data.criterion_parameters), + data.sample_size, + data.alternative_code, + json.dumps(data.alternative_parameters), + data.monte_carlo_count, + data.significance_level, + json.dumps(data.results_criteria), + ), + ) + conn.commit() + logger.debug(f"Power data inserted for experiment {data.experiment_id}") + except Exception as e: + logger.error(f"Failed to insert power data: {e}") + raise StorageError + + def get(self, query: PowerQuery) -> PowerModel | None: + """ + Get a PowerModel by query. + + Args: + query: PowerQuery to search for + + Returns: + PowerModel if found, None otherwise + """ + return self.get_data(query) + + def get_data(self, query: PowerQuery) -> PowerModel | None: + """ + Retrieve power data matching the query. + """ + if not self._initialized: + self.init() + + select_query = f""" + SELECT experiment_id, criterion_code, criterion_parameters, sample_size, + alternative_code, alternative_parameters, monte_carlo_count, + significance_level, results_criteria + FROM {self.table_name} + WHERE criterion_code = %s + AND criterion_parameters = %s + AND sample_size = %s + AND alternative_code = %s + AND alternative_parameters = %s + AND monte_carlo_count = %s + AND significance_level = %s; + """ + + try: + with self._get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + cursor.execute( + select_query, + ( + query.criterion_code, + json.dumps(query.criterion_parameters), + query.sample_size, + query.alternative_code, + json.dumps(query.alternative_parameters), + query.monte_carlo_count, + query.significance_level, + ), + ) + result = cursor.fetchone() + + if result: + return PowerModel( + experiment_id=result["experiment_id"], + criterion_code=result["criterion_code"], + criterion_parameters=json.loads(result["criterion_parameters"]), + sample_size=result["sample_size"], + alternative_code=result["alternative_code"], + alternative_parameters=json.loads(result["alternative_parameters"]), + monte_carlo_count=result["monte_carlo_count"], + significance_level=result["significance_level"], + results_criteria=json.loads(result["results_criteria"]), + ) + return None + except Exception as e: + logger.error(f"Failed to get power data: {e}") + raise StorageError + + def update(self, model: PowerModel) -> None: + """ + Update a PowerModel (uses insert with upsert). + + Args: + model: PowerModel to update + """ + self.insert_data(model) + + def delete(self, query: PowerQuery) -> None: + """ + Delete a PowerModel by query. + + Args: + query: PowerQuery to identify record to delete + """ + self.delete_data(query) + + def delete_data(self, query: PowerQuery) -> None: + """ + Delete power data matching the query. + """ + if not self._initialized: + self.init() + + delete_query = f""" + DELETE FROM {self.table_name} + WHERE criterion_code = %s + AND criterion_parameters = %s + AND sample_size = %s + AND alternative_code = %s + AND alternative_parameters = %s + AND monte_carlo_count = %s + AND significance_level = %s; + """ + + try: + with self._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute( + delete_query, + ( + query.criterion_code, + json.dumps(query.criterion_parameters), + query.sample_size, + query.alternative_code, + json.dumps(query.alternative_parameters), + query.monte_carlo_count, + query.significance_level, + ), + ) + conn.commit() + logger.debug(f"Power data deleted for query: {query}") + except Exception as e: + logger.error(f"Failed to delete power data: {e}") + raise StorageError + + # TODO: remove if unnecessary + def get_by_experiment_id(self, experiment_id: int) -> list[PowerModel]: + """ + Get all power data for a specific experiment ID. + + Args: + experiment_id: Experiment ID to search for + + Returns: + List of PowerModel objects + """ + if not self._initialized: + self.init() + + select_query = f""" + SELECT experiment_id, criterion_code, criterion_parameters, sample_size, + alternative_code, alternative_parameters, monte_carlo_count, + significance_level, results_criteria + FROM {self.table_name} + WHERE experiment_id = %s + ORDER BY criterion_code, alternative_code; + """ + + try: + with self._get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + cursor.execute(select_query, (experiment_id,)) + results = cursor.fetchall() + + return [ + PowerModel( + experiment_id=row["experiment_id"], + criterion_code=row["criterion_code"], + criterion_parameters=json.loads(row["criterion_parameters"]), + sample_size=row["sample_size"], + alternative_code=row["alternative_code"], + alternative_parameters=json.loads(row["alternative_parameters"]), + monte_carlo_count=row["monte_carlo_count"], + significance_level=row["significance_level"], + results_criteria=json.loads(row["results_criteria"]), + ) + for row in results + ] + except Exception as e: + logger.error(f"Failed to get power data by experiment ID: {e}") + raise StorageError + + def calculate_power(self, query: PowerQuery) -> float | None: + """ + Calculate power from results_criteria. + + Args: + query: PowerQuery to identify the data + + Returns: + Power value (proportion of True in results_criteria) or None if not found + """ + data = self.get_data(query) + if data and data.results_criteria: + true_count = sum(1 for result in data.results_criteria if result) + return true_count / len(data.results_criteria) + return None + + def batch_insert(self, models: list[PowerModel]) -> int: + """ + Insert multiple PowerModel objects in batch. + + Args: + models: List of PowerModel objects to insert + + Returns: + Number of successfully inserted records + """ + if not self._initialized: + self.init() + + insert_query = f""" + INSERT INTO {self.table_name} + (experiment_id, criterion_code, criterion_parameters, sample_size, + alternative_code, alternative_parameters, monte_carlo_count, + significance_level, results_criteria) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (experiment_id, criterion_code, sample_size, alternative_code, + monte_carlo_count, significance_level) + DO NOTHING; + """ + + try: + with self._get_connection() as conn: + with conn.cursor() as cursor: + data_tuples = [] + for model in models: + data_tuples.append( + ( + model.experiment_id, + model.criterion_code, + json.dumps(model.criterion_parameters), + model.sample_size, + model.alternative_code, + json.dumps(model.alternative_parameters), + model.monte_carlo_count, + model.significance_level, + json.dumps(model.results_criteria), + ) + ) + + cursor.executemany(insert_query, data_tuples) + conn.commit() + inserted_count = cursor.rowcount + logger.debug(f"Batch inserted {inserted_count} power records") + return inserted_count + except Exception as e: + logger.error(f"Failed to batch insert power data: {e}") + raise StorageError + + def exists(self, query: PowerQuery) -> bool: + """ + Check if power data exists for the given query. + + Args: + query: PowerQuery to check + + Returns: + True if data exists, False otherwise + """ + return self.get_data(query) is not None diff --git a/tests/persistence/postgresql/test_power_storage.py b/tests/persistence/postgresql/test_power_storage.py new file mode 100644 index 0000000..d35e06f --- /dev/null +++ b/tests/persistence/postgresql/test_power_storage.py @@ -0,0 +1,580 @@ +from unittest.mock import patch + +import psycopg2 +import pytest + +from pysatl_experiment.exceptions import StorageError +from pysatl_experiment.persistence.model.power.power import PowerModel, PowerQuery +from pysatl_experiment.persistence.storage.power.postgresql.postgresql import PostgreSQLPowerStorage + + +# TODO: clearance + + +# Temporary PostgreSQL database fixture +@pytest.fixture(scope="function") +def temp_postgres_db(): + test_db_name = "test_power_db" + original_connection_string = "postgresql://user:password@localhost:5432/postgres" + + # Creating test BD + conn = psycopg2.connect(original_connection_string) + conn.autocommit = True + cursor = conn.cursor() + + # Ending all old connections + cursor.execute(f""" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{test_db_name}' + AND pid <> pg_backend_pid(); + """) + + cursor.execute(f"DROP DATABASE IF EXISTS {test_db_name};") + cursor.execute(f"CREATE DATABASE {test_db_name};") + cursor.close() + conn.close() + + # Return connection string for test BD + test_connection_string = f"postgresql://user:password@localhost:5432/{test_db_name}" + + yield test_connection_string + + # Clearing after test + conn = psycopg2.connect(original_connection_string) + conn.autocommit = True + cursor = conn.cursor() + cursor.execute(f"DROP DATABASE {test_db_name};") + cursor.close() + conn.close() + + +@pytest.fixture +def storage(temp_postgres_db): + storage = PostgreSQLPowerStorage(temp_postgres_db, "test_power_analysis") + storage.init() + return storage + + +@pytest.fixture +def sample_power_model(): + return PowerModel( + experiment_id=1, + criterion_code="ks_test", + criterion_parameters=[0.5, 1.0], + sample_size=100, + alternative_code="normal_shift", + alternative_parameters=[0.0, 1.0, 0.5], + monte_carlo_count=1000, + significance_level=0.05, + results_criteria=[True] * 700 + [False] * 300, # 70% power + ) + + +@pytest.fixture +def sample_power_query(): + return PowerQuery( + criterion_code="ks_test", + criterion_parameters=[0.5, 1.0], + sample_size=100, + alternative_code="normal_shift", + alternative_parameters=[0.0, 1.0, 0.5], + monte_carlo_count=1000, + significance_level=0.05, + ) + + +@pytest.fixture +def multiple_power_models(): + base_params = { + "criterion_parameters": [0.5, 1.0], + "alternative_parameters": [0.0, 1.0], + "monte_carlo_count": 1000, + "significance_level": 0.05, + "results_criteria": [True] * 600 + [False] * 400, # 60% power + } + + models = [] + for i in range(3): + model = PowerModel( + experiment_id=i + 1, + criterion_code=f"test_{i}", + sample_size=50 * (i + 1), + alternative_code=f"alt_{i}", + **base_params, + ) + models.append(model) + + return models + + +class TestPostgreSQLPowerStorage: + def test_initialization(self, storage): + assert storage is not None + assert storage.table_name == "test_power_analysis" + assert storage._initialized is True + + def test_table_creation(self, storage): + # Check for database created + with storage._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = %s + ); + """, + (storage.table_name,), + ) + result = cursor.fetchone() + assert result[0] is True + + def test_insert_and_get_data(self, storage, sample_power_model, sample_power_query): + # Insert + storage.insert_data(sample_power_model) + + # Get + result = storage.get_data(sample_power_query) + + # Check + assert result is not None + assert result.experiment_id == sample_power_model.experiment_id + assert result.criterion_code == sample_power_model.criterion_code + assert result.criterion_parameters == sample_power_model.criterion_parameters + assert result.sample_size == sample_power_model.sample_size + assert result.alternative_code == sample_power_model.alternative_code + assert result.alternative_parameters == sample_power_model.alternative_parameters + assert result.monte_carlo_count == sample_power_model.monte_carlo_count + assert result.significance_level == sample_power_model.significance_level + assert result.results_criteria == sample_power_model.results_criteria + + def test_insert_and_get_interface_methods(self, storage, sample_power_model, sample_power_query): + # Insert + storage.insert(sample_power_model) + + # Get + result = storage.get(sample_power_query) + assert result is not None + assert result.experiment_id == sample_power_model.experiment_id + + def test_get_nonexistent_data(self, storage, sample_power_query): + result = storage.get_data(sample_power_query) + assert result is None + + def test_insert_duplicate(self, storage, sample_power_model): + # First insert + storage.insert_data(sample_power_model) + + # Insert after modify + modified_model = PowerModel( + experiment_id=sample_power_model.experiment_id, + criterion_code=sample_power_model.criterion_code, + criterion_parameters=sample_power_model.criterion_parameters, + sample_size=sample_power_model.sample_size, + alternative_code=sample_power_model.alternative_code, + alternative_parameters=sample_power_model.alternative_parameters, + monte_carlo_count=sample_power_model.monte_carlo_count, + significance_level=sample_power_model.significance_level, + results_criteria=[True] * 800 + [False] * 200, # Updated to 80% power + ) + + storage.insert_data(modified_model) + + # Check if data updated + query = PowerQuery( + criterion_code=sample_power_model.criterion_code, + criterion_parameters=sample_power_model.criterion_parameters, + sample_size=sample_power_model.sample_size, + alternative_code=sample_power_model.alternative_code, + alternative_parameters=sample_power_model.alternative_parameters, + monte_carlo_count=sample_power_model.monte_carlo_count, + significance_level=sample_power_model.significance_level, + ) + + result = storage.get_data(query) + assert result.results_criteria == modified_model.results_criteria + + def test_update_method(self, storage, sample_power_model): + # Insert + storage.insert(sample_power_model) + + # Update + updated_model = PowerModel( + experiment_id=sample_power_model.experiment_id, + criterion_code=sample_power_model.criterion_code, + criterion_parameters=sample_power_model.criterion_parameters, + sample_size=sample_power_model.sample_size, + alternative_code=sample_power_model.alternative_code, + alternative_parameters=sample_power_model.alternative_parameters, + monte_carlo_count=sample_power_model.monte_carlo_count, + significance_level=sample_power_model.significance_level, + results_criteria=[True] * 900 + [False] * 100, # Updated + ) + + storage.update(updated_model) + + # Check + query = PowerQuery( + criterion_code=sample_power_model.criterion_code, + criterion_parameters=sample_power_model.criterion_parameters, + sample_size=sample_power_model.sample_size, + alternative_code=sample_power_model.alternative_code, + alternative_parameters=sample_power_model.alternative_parameters, + monte_carlo_count=sample_power_model.monte_carlo_count, + significance_level=sample_power_model.significance_level, + ) + + result = storage.get_data(query) + assert result.results_criteria == updated_model.results_criteria + + def test_delete_data(self, storage, sample_power_model, sample_power_query): + # Insert + storage.insert_data(sample_power_model) + assert storage.get_data(sample_power_query) is not None + + # Delete + storage.delete_data(sample_power_query) + assert storage.get_data(sample_power_query) is None + + def test_delete_interface_method(self, storage, sample_power_model, sample_power_query): + storage.insert(sample_power_model) + assert storage.get(sample_power_query) is not None + + storage.delete(sample_power_query) + assert storage.get(sample_power_query) is None + + def test_get_by_experiment_id(self, storage, multiple_power_models): + for model in multiple_power_models: + storage.insert_data(model) + + results = storage.get_by_experiment_id(1) + assert len(results) == 1 + assert results[0].experiment_id == 1 + assert results[0].criterion_code == "test_0" + + results = storage.get_by_experiment_id(999) + assert len(results) == 0 + + def test_calculate_power(self, storage, sample_power_model, sample_power_query): + storage.insert_data(sample_power_model) # Know 70% power + + power = storage.calculate_power(sample_power_query) + + assert power is not None + assert abs(power - 0.7) < 0.001 + + def test_calculate_power_nonexistent(self, storage, sample_power_query): + power = storage.calculate_power(sample_power_query) + assert power is None + + def test_calculate_power_empty_results(self, storage): + model = PowerModel( + experiment_id=1, + criterion_code="empty_test", + criterion_parameters=[], + sample_size=100, + alternative_code="empty_alt", + alternative_parameters=[], + monte_carlo_count=1000, + significance_level=0.05, + results_criteria=[], + ) + + storage.insert_data(model) + + query = PowerQuery( + criterion_code="empty_test", + criterion_parameters=[], + sample_size=100, + alternative_code="empty_alt", + alternative_parameters=[], + monte_carlo_count=1000, + significance_level=0.05, + ) + + power = storage.calculate_power(query) + assert power is None # TODO: check for actual one + + def test_batch_insert(self, storage, multiple_power_models): + inserted_count = storage.batch_insert(multiple_power_models) + + assert inserted_count == len(multiple_power_models) + + # Insert + for model in multiple_power_models: + query = PowerQuery( + criterion_code=model.criterion_code, + criterion_parameters=model.criterion_parameters, + sample_size=model.sample_size, + alternative_code=model.alternative_code, + alternative_parameters=model.alternative_parameters, + monte_carlo_count=model.monte_carlo_count, + significance_level=model.significance_level, + ) + result = storage.get_data(query) + assert result is not None + + def test_batch_insert_duplicates(self, storage, multiple_power_models): + storage.batch_insert(multiple_power_models) + + inserted_count = storage.batch_insert(multiple_power_models) + assert inserted_count == 0 + + def test_exists_method(self, storage, sample_power_model, sample_power_query): + assert storage.exists(sample_power_query) is False + + storage.insert_data(sample_power_model) + assert storage.exists(sample_power_query) is True + + def test_complex_parameters(self, storage): + complex_criterion_params = [0.1, 0.2, 0.3, 0.4, 0.5] + complex_alternative_params = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] + + model = PowerModel( + experiment_id=1, + criterion_code="complex_test", + criterion_parameters=complex_criterion_params, + sample_size=200, + alternative_code="complex_alt", + alternative_parameters=complex_alternative_params, + monte_carlo_count=5000, + significance_level=0.01, + results_criteria=[True] * 2500 + [False] * 2500, + ) + + storage.insert_data(model) + + query = PowerQuery( + criterion_code="complex_test", + criterion_parameters=complex_criterion_params, + sample_size=200, + alternative_code="complex_alt", + alternative_parameters=complex_alternative_params, + monte_carlo_count=5000, + significance_level=0.01, + ) + + result = storage.get_data(query) + assert result is not None + assert result.criterion_parameters == complex_criterion_params + assert result.alternative_parameters == complex_alternative_params + + +class TestEdgeCases: + def test_empty_parameters(self, storage): + model = PowerModel( + experiment_id=1, + criterion_code="empty_params", + criterion_parameters=[], + sample_size=100, + alternative_code="empty_alt", + alternative_parameters=[], + monte_carlo_count=1000, + significance_level=0.05, + results_criteria=[True, False, True], + ) + + storage.insert_data(model) + + query = PowerQuery( + criterion_code="empty_params", + criterion_parameters=[], + sample_size=100, + alternative_code="empty_alt", + alternative_parameters=[], + monte_carlo_count=1000, + significance_level=0.05, + ) + + result = storage.get_data(query) + assert result is not None + assert result.criterion_parameters == [] + assert result.alternative_parameters == [] + + def test_single_element_parameters(self, storage): + model = PowerModel( + experiment_id=1, + criterion_code="single_param", + criterion_parameters=[42.0], + sample_size=100, + alternative_code="single_alt", + alternative_parameters=[3.14], + monte_carlo_count=1000, + significance_level=0.05, + results_criteria=[True] * 1000, + ) + + storage.insert_data(model) + + query = PowerQuery( + criterion_code="single_param", + criterion_parameters=[42.0], + sample_size=100, + alternative_code="single_alt", + alternative_parameters=[3.14], + monte_carlo_count=1000, + significance_level=0.05, + ) + + result = storage.get_data(query) + assert result is not None + assert result.criterion_parameters == [42.0] + assert result.alternative_parameters == [3.14] + + def test_large_sample_size(self, storage): + model = PowerModel( + experiment_id=1, + criterion_code="large_sample", + criterion_parameters=[0.5, 1.0], + sample_size=100000, + alternative_code="large_alt", + alternative_parameters=[0.0, 1.0], + monte_carlo_count=1000, + significance_level=0.05, + results_criteria=[True] * 1000, + ) + + storage.insert_data(model) + + query = PowerQuery( + criterion_code="large_sample", + criterion_parameters=[0.5, 1.0], + sample_size=100000, + alternative_code="large_alt", + alternative_parameters=[0.0, 1.0], + monte_carlo_count=1000, + significance_level=0.05, + ) + + result = storage.get_data(query) + assert result is not None + assert result.sample_size == 100000 + + def test_special_characters_in_codes(self, storage): + special_criterion = "criterion-with-dashes_and_underscores" + special_alternative = "alternative.with.dots" + + model = PowerModel( + experiment_id=1, + criterion_code=special_criterion, + criterion_parameters=[0.5, 1.0], + sample_size=100, + alternative_code=special_alternative, + alternative_parameters=[0.0, 1.0], + monte_carlo_count=1000, + significance_level=0.05, + results_criteria=[True] * 500 + [False] * 500, + ) + + storage.insert_data(model) + + query = PowerQuery( + criterion_code=special_criterion, + criterion_parameters=[0.5, 1.0], + sample_size=100, + alternative_code=special_alternative, + alternative_parameters=[0.0, 1.0], + monte_carlo_count=1000, + significance_level=0.05, + ) + + result = storage.get_data(query) + assert result is not None + assert result.criterion_code == special_criterion + assert result.alternative_code == special_alternative + + +class TestErrorHandling: + @patch("psycopg2.connect") + def test_connection_error_on_init(self, mock_connect): + mock_connect.side_effect = StorageError("Connection failed") + + storage = PostgreSQLPowerStorage("invalid_connection_string") + with pytest.raises(StorageError): + storage.init() + + def test_invalid_significance_level(self, storage): + model = PowerModel( + experiment_id=1, + criterion_code="invalid_alpha", + criterion_parameters=[0.5], + sample_size=100, + alternative_code="invalid_alt", + alternative_parameters=[0.0, 1.0], + monte_carlo_count=1000, + significance_level=1.5, + results_criteria=[True] * 1000, + ) + + with pytest.raises(StorageError): + storage.insert_data(model) + + def test_invalid_sample_size(self, storage): + model = PowerModel( + experiment_id=1, + criterion_code="invalid_size", + criterion_parameters=[0.5], + sample_size=0, + alternative_code="invalid_alt", + alternative_parameters=[0.0, 1.0], + monte_carlo_count=1000, + significance_level=0.05, + results_criteria=[True] * 1000, + ) + + with pytest.raises(StorageError): + storage.insert_data(model) + + +class TestIntegration: + def test_complete_workflow(self, storage): + models = [] + for i in range(5): + model = PowerModel( + experiment_id=i + 1, + criterion_code=f"criterion_{i}", + criterion_parameters=[float(i), float(i + 1)], + sample_size=50 * (i + 1), + alternative_code=f"alternative_{i}", + alternative_parameters=[float(i * 0.1), float(i * 0.2)], + monte_carlo_count=1000 + i * 100, + significance_level=0.01 * (i + 1), + results_criteria=[True] * (600 + i * 100) + [False] * (400 - i * 100), + ) + models.append(model) + + inserted_count = storage.batch_insert(models) + assert inserted_count == 5 + + for i, model in enumerate(models): + query = PowerQuery( + criterion_code=model.criterion_code, + criterion_parameters=model.criterion_parameters, + sample_size=model.sample_size, + alternative_code=model.alternative_code, + alternative_parameters=model.alternative_parameters, + monte_carlo_count=model.monte_carlo_count, + significance_level=model.significance_level, + ) + assert storage.exists(query) is True + + power = storage.calculate_power(query) + expected_power = (600 + i * 100) / 1000.0 + assert abs(power - expected_power) < 0.01 + + for i, model in enumerate(models): + query = PowerQuery( + criterion_code=model.criterion_code, + criterion_parameters=model.criterion_parameters, + sample_size=model.sample_size, + alternative_code=model.alternative_code, + alternative_parameters=model.alternative_parameters, + monte_carlo_count=model.monte_carlo_count, + significance_level=model.significance_level, + ) + storage.delete_data(query) + assert storage.exists(query) is False + + +# TODO: proper description From f2f62b396f45ca506ed5bfbc3b3aa8d02604af80 Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Sun, 5 Oct 2025 22:29:08 +0300 Subject: [PATCH 18/24] feat: stub for random values storage tests --- .../postgresql/test_random_values_storage.py | 456 ++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 tests/persistence/postgresql/test_random_values_storage.py diff --git a/tests/persistence/postgresql/test_random_values_storage.py b/tests/persistence/postgresql/test_random_values_storage.py new file mode 100644 index 0000000..d502bad --- /dev/null +++ b/tests/persistence/postgresql/test_random_values_storage.py @@ -0,0 +1,456 @@ +import psycopg2 +import pytest + +from pysatl_experiment.persistence.model.random_values.random_values import ( + RandomValuesAllModel, + RandomValuesAllQuery, + RandomValuesCountQuery, + RandomValuesModel, + RandomValuesQuery, +) +from pysatl_experiment.persistence.storage.random_values.postgresql.postgresql import PostgreSQLRandomValuesStorage + + +# TODO: clearance + + +@pytest.fixture(scope="function") +def temp_postgres_db(): + test_db_name = "test_random_values_db" + original_connection_string = "postgresql://user:password@localhost:5432/postgres" + + conn = psycopg2.connect(original_connection_string) + conn.autocommit = True + cursor = conn.cursor() + + cursor.execute(f""" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{test_db_name}' + AND pid <> pg_backend_pid(); + """) + + cursor.execute(f"DROP DATABASE IF EXISTS {test_db_name};") + cursor.execute(f"CREATE DATABASE {test_db_name};") + cursor.close() + conn.close() + + test_connection_string = f"postgresql://user:password@localhost:5432/{test_db_name}" + + yield test_connection_string + + try: + conn = psycopg2.connect(original_connection_string) + conn.autocommit = True + cursor = conn.cursor() + cursor.execute(f"DROP DATABASE {test_db_name};") + cursor.close() + conn.close() + except Exception: + pass + + +@pytest.fixture +def storage(temp_postgres_db): + return PostgreSQLRandomValuesStorage(temp_postgres_db, "test_random_values") + + +@pytest.fixture +def sample_model(): + return RandomValuesModel( + generator_name="normal", + generator_parameters=[0.0, 1.0], + sample_size=100, + sample_num=1, + data=[0.1, 0.2, 0.3, 0.4, 0.5], + ) + + +@pytest.fixture +def sample_query(): + return RandomValuesQuery(generator_name="normal", generator_parameters=[0.0, 1.0], sample_size=100, sample_num=1) + + +@pytest.fixture +def sample_all_query(): + return RandomValuesAllQuery(generator_name="normal", generator_parameters=[0.0, 1.0], sample_size=100) + + +@pytest.fixture +def sample_count_query(): + return RandomValuesCountQuery(generator_name="normal", generator_parameters=[0.0, 1.0], sample_size=100, count=2) + + +class TestPostgreSQLRandomValuesStorage: + def test_initialization(self, storage): + assert storage is not None + assert storage.table_name == "test_random_values" + + def test_table_creation(self, storage): + with storage._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = %s + ); + """, + (storage.table_name,), + ) + result = cursor.fetchone() + assert result[0] is True + + def test_insert_and_get(self, storage, sample_model, sample_query): + storage.insert(sample_model) + + result = storage.get_data(sample_query) + + assert result is not None + assert result.generator_name == sample_model.generator_name + assert result.generator_parameters == sample_model.generator_parameters + assert result.sample_size == sample_model.sample_size + assert result.sample_num == sample_model.sample_num + assert result.data == sample_model.data + + def test_get_nonexistent(self, storage, sample_query): + result = storage.get_data(sample_query) + assert result is None + + def test_insert_duplicate(self, storage, sample_model): + storage.insert(sample_model) + + modified_model = RandomValuesModel( + generator_name=sample_model.generator_name, + generator_parameters=sample_model.generator_parameters, + sample_size=sample_model.sample_size, + sample_num=sample_model.sample_num, + data=[1.0, 2.0, 3.0], + ) + + storage.insert(modified_model) + + query = RandomValuesQuery( + generator_name=sample_model.generator_name, + generator_parameters=sample_model.generator_parameters, + sample_size=sample_model.sample_size, + sample_num=sample_model.sample_num, + ) + + result = storage.get_data(query) + assert result.data == modified_model.data + + def test_update(self, storage, sample_model): + storage.insert(sample_model) + + updated_model = RandomValuesModel( + generator_name=sample_model.generator_name, + generator_parameters=sample_model.generator_parameters, + sample_size=sample_model.sample_size, + sample_num=sample_model.sample_num, + data=[9.0, 8.0, 7.0], + ) + + storage.update(updated_model) + + query = RandomValuesQuery( + generator_name=sample_model.generator_name, + generator_parameters=sample_model.generator_parameters, + sample_size=sample_model.sample_size, + sample_num=sample_model.sample_num, + ) + + result = storage.get_data(query) + assert result.data == updated_model.data + + def test_delete(self, storage, sample_model, sample_query): + storage.insert(sample_model) + assert storage.get_data(sample_query) is not None + + storage.delete(sample_query) + assert storage.get_data(sample_query) is None + + def test_get_rvs_count(self, storage, sample_all_query): + count = storage.get_rvs_count(sample_all_query) + assert count == 0 + + for i in range(3): + model = RandomValuesModel( + generator_name="normal", + generator_parameters=[0.0, 1.0], + sample_size=100, + sample_num=i + 1, + data=[float(i)] * 5, + ) + storage.insert(model) + + count = storage.get_rvs_count(sample_all_query) + assert count == 3 + + def test_insert_all_data(self, storage, sample_all_query): + all_data_model = RandomValuesAllModel( + generator_name="normal", + generator_parameters=[0.0, 1.0], + sample_size=100, + data=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], + ) + + storage.insert_all_data(all_data_model) + + results = storage.get_all_data(sample_all_query) + assert results is not None + assert len(results) == 3 + + for i, result in enumerate(results, 1): + assert result.sample_num == i + assert result.data == all_data_model.data[i - 1] + + def test_get_all_data(self, storage, sample_all_query): + for i in range(3): + model = RandomValuesModel( + generator_name="normal", + generator_parameters=[0.0, 1.0], + sample_size=100, + sample_num=i + 1, + data=[float(i + j) for j in range(3)], + ) + storage.insert(model) + + results = storage.get_all_data(sample_all_query) + + assert results is not None + assert len(results) == 3 + + sample_nums = [r.sample_num for r in results] + assert sample_nums == [1, 2, 3] + + def test_get_all_data_empty(self, storage, sample_all_query): + results = storage.get_all_data(sample_all_query) + assert results is None + + def test_delete_all_data(self, storage, sample_all_query): + for i in range(3): + model = RandomValuesModel( + generator_name="normal", + generator_parameters=[0.0, 1.0], + sample_size=100, + sample_num=i + 1, + data=[float(i)] * 5, + ) + storage.insert(model) + + assert storage.get_rvs_count(sample_all_query) == 3 + + storage.delete_all_data(sample_all_query) + + assert storage.get_rvs_count(sample_all_query) == 0 + assert storage.get_all_data(sample_all_query) is None + + def test_get_count_data(self, storage, sample_count_query): + for i in range(5): + model = RandomValuesModel( + generator_name="normal", + generator_parameters=[0.0, 1.0], + sample_size=100, + sample_num=i + 1, + data=[float(i)] * 3, + ) + storage.insert(model) + + results = storage.get_count_data(sample_count_query) + + assert results is not None + assert len(results) == 2 + + assert results[0].sample_num == 1 + assert results[1].sample_num == 2 + + def test_get_count_data_more_than_exists(self, storage, sample_count_query): + model = RandomValuesModel( + generator_name="normal", + generator_parameters=[0.0, 1.0], + sample_size=100, + sample_num=1, + data=[1.0, 2.0, 3.0], + ) + storage.insert(model) + + sample_count_query.count = 2 + results = storage.get_count_data(sample_count_query) + + assert results is not None + assert len(results) == 1 + + def test_different_generators(self, storage): + model1 = RandomValuesModel( + generator_name="normal", + generator_parameters=[0.0, 1.0], + sample_size=100, + sample_num=1, + data=[1.0, 2.0, 3.0], + ) + + model2 = RandomValuesModel( + generator_name="uniform", + generator_parameters=[0.0, 10.0], + sample_size=100, + sample_num=1, + data=[5.0, 6.0, 7.0], + ) + + storage.insert(model1) + storage.insert(model2) + + query1 = RandomValuesAllQuery(generator_name="normal", generator_parameters=[0.0, 1.0], sample_size=100) + + query2 = RandomValuesAllQuery(generator_name="uniform", generator_parameters=[0.0, 10.0], sample_size=100) + + results1 = storage.get_all_data(query1) + results2 = storage.get_all_data(query2) + + assert results1 is not None + assert results2 is not None + assert len(results1) == 1 + assert len(results2) == 1 + assert results1[0].generator_name == "normal" + assert results2[0].generator_name == "uniform" + + def test_complex_parameters(self, storage): + complex_parameters = [0.0, 1.0, 2.0, 3.14159] + + model = RandomValuesModel( + generator_name="complex", + generator_parameters=complex_parameters, + sample_size=100, + sample_num=1, + data=list(range(10)), + ) + + storage.insert(model) + + query = RandomValuesQuery( + generator_name="complex", generator_parameters=complex_parameters, sample_size=100, sample_num=1 + ) + + result = storage.get_data(query) + assert result is not None + assert result.generator_parameters == complex_parameters + + def test_large_data(self, storage): + large_data = list(range(10000)) + + model = RandomValuesModel( + generator_name="large", generator_parameters=[0.0, 1.0], sample_size=10000, sample_num=1, data=large_data + ) + + storage.insert(model) + + query = RandomValuesQuery( + generator_name="large", generator_parameters=[0.0, 1.0], sample_size=10000, sample_num=1 + ) + + result = storage.get_data(query) + assert result is not None + assert len(result.data) == 10000 + assert result.data == large_data + + +class TestEdgeCases: + def test_empty_parameters(self, storage): + model = RandomValuesModel( + generator_name="empty_params", generator_parameters=[], sample_size=100, sample_num=1, data=[1.0, 2.0, 3.0] + ) + + storage.insert(model) + + query = RandomValuesQuery(generator_name="empty_params", generator_parameters=[], sample_size=100, sample_num=1) + + result = storage.get_data(query) + assert result is not None + assert result.generator_parameters == [] + + def test_empty_data(self, storage): + model = RandomValuesModel( + generator_name="empty_data", generator_parameters=[1.0, 2.0], sample_size=100, sample_num=1, data=[] + ) + + storage.insert(model) + + query = RandomValuesQuery( + generator_name="empty_data", generator_parameters=[1.0, 2.0], sample_size=100, sample_num=1 + ) + + result = storage.get_data(query) + assert result is not None + assert result.data == [] + + def test_special_characters_in_name(self, storage): + special_name = "generator-with-dashes_and_underscores" + + model = RandomValuesModel( + generator_name=special_name, + generator_parameters=[0.0, 1.0], + sample_size=100, + sample_num=1, + data=[1.0, 2.0, 3.0], + ) + + storage.insert(model) + + query = RandomValuesQuery( + generator_name=special_name, generator_parameters=[0.0, 1.0], sample_size=100, sample_num=1 + ) + + result = storage.get_data(query) + assert result is not None + assert result.generator_name == special_name + + +class TestIntegration: + def test_complete_workflow(self, storage): + models = [] + for i in range(5): + model = RandomValuesModel( + generator_name="workflow_test", + generator_parameters=[i * 1.0, i * 2.0], + sample_size=100, + sample_num=i + 1, + data=[float(i + j) for j in range(10)], + ) + models.append(model) + storage.insert(model) + + all_query = RandomValuesAllQuery( + generator_name="workflow_test", + generator_parameters=[0.0, 0.0], + sample_size=100, + ) + + count = storage.get_rvs_count(all_query) + assert count == 1 + + for i, model in enumerate(models): + query = RandomValuesAllQuery( + generator_name="workflow_test", generator_parameters=[i * 1.0, i * 2.0], sample_size=100 + ) + + results = storage.get_all_data(query) + assert results is not None + assert len(results) == 1 + assert results[0].data == model.data + + for i, model in enumerate(models): + delete_query = RandomValuesQuery( + generator_name="workflow_test", + generator_parameters=[i * 1.0, i * 2.0], + sample_size=100, + sample_num=i + 1, + ) + storage.delete(delete_query) + + result = storage.get_data(delete_query) + assert result is None + + +# TODO: comments! +# TODO: think about connection error From e92852c043c1673fec323c6d56e2798f513a1a3e Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Sun, 5 Oct 2025 22:33:26 +0300 Subject: [PATCH 19/24] feat: time complexity PostgreSQL storage + stub for tests --- .../time_complexity/postgresql/postgresql.py | 186 +++++++++ .../test_time_complexity_storage.py | 362 ++++++++++++++++++ 2 files changed, 548 insertions(+) create mode 100644 pysatl_experiment/persistence/storage/time_complexity/postgresql/postgresql.py create mode 100644 tests/persistence/postgresql/test_time_complexity_storage.py diff --git a/pysatl_experiment/persistence/storage/time_complexity/postgresql/postgresql.py b/pysatl_experiment/persistence/storage/time_complexity/postgresql/postgresql.py new file mode 100644 index 0000000..e4e9dbe --- /dev/null +++ b/pysatl_experiment/persistence/storage/time_complexity/postgresql/postgresql.py @@ -0,0 +1,186 @@ +import json + +import psycopg2 +from psycopg2.extras import RealDictCursor + +from pysatl_experiment.persistence.model.time_complexity.time_complexity import ( + ITimeComplexityStorage, + TimeComplexityModel, + TimeComplexityQuery, +) + + +class PostgreSQLTimeComplexityStorage(ITimeComplexityStorage): + """ + PostgreSQL implementation of time complexity storage. + """ + + def __init__(self, connection_string: str, table_name: str = "time_complexity"): + """ + Initialize PostgreSQL storage. + + Args: + connection_string: PostgreSQL connection string + table_name: Name of the table to store time complexity data + """ + self.connection_string = connection_string + self.table_name = table_name + self._connection = None + + def _get_connection(self): + """Get or create database connection.""" + if self._connection is None or self._connection.closed: + self._connection = psycopg2.connect(self.connection_string) + return self._connection + + def init(self) -> None: + """ + Initialize PostgreSQL time complexity storage and create tables. + """ + create_table_query = f""" + CREATE TABLE IF NOT EXISTS {self.table_name} ( + id SERIAL PRIMARY KEY, + experiment_id INTEGER NOT NULL, + criterion_code VARCHAR(255) NOT NULL, + criterion_parameters JSONB NOT NULL, + sample_size INTEGER NOT NULL, + monte_carlo_count INTEGER NOT NULL, + results_times JSONB NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(experiment_id, criterion_code, sample_size, monte_carlo_count) + ); + + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_experiment_id ON {self.table_name}(experiment_id); + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_criterion_code ON {self.table_name}(criterion_code); + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_sample_size ON {self.table_name}(sample_size); + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_monte_carlo_count ON {self.table_name}(monte_carlo_count); + """ + + try: + conn = self._get_connection() + with conn.cursor() as cursor: + cursor.execute(create_table_query) + conn.commit() + except Exception as e: + raise Exception(f"Failed to initialize time complexity storage: {e}") + + def insert_data(self, data: TimeComplexityModel) -> None: + """ + Insert or replace time complexity data. + """ + insert_query = f""" + INSERT INTO {self.table_name} + (experiment_id, criterion_code, criterion_parameters, sample_size, monte_carlo_count, results_times) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT (experiment_id, criterion_code, sample_size, monte_carlo_count) + DO UPDATE SET + criterion_parameters = EXCLUDED.criterion_parameters, + results_times = EXCLUDED.results_times, + created_at = CURRENT_TIMESTAMP + """ + + try: + conn = self._get_connection() + with conn.cursor() as cursor: + cursor.execute( + insert_query, + ( + data.experiment_id, + data.criterion_code, + json.dumps(data.criterion_parameters), + data.sample_size, + data.monte_carlo_count, + json.dumps(data.results_times), + ), + ) + conn.commit() + except Exception as e: + raise Exception(f"Failed to insert time complexity data: {e}") + + def get_data(self, query: TimeComplexityQuery) -> TimeComplexityModel | None: + """ + Get time complexity data matching the query. + """ + select_query = f""" + SELECT experiment_id, criterion_code, criterion_parameters, sample_size, + monte_carlo_count, results_times + FROM {self.table_name} + WHERE criterion_code = %s + AND criterion_parameters = %s + AND sample_size = %s + AND monte_carlo_count = %s + ORDER BY created_at DESC + LIMIT 1 + """ + + try: + conn = self._get_connection() + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + cursor.execute( + select_query, + ( + query.criterion_code, + json.dumps(query.criterion_parameters), + query.sample_size, + query.monte_carlo_count, + ), + ) + + result = cursor.fetchone() + + if result: + return TimeComplexityModel( + experiment_id=result["experiment_id"], + criterion_code=result["criterion_code"], + criterion_parameters=json.loads(result["criterion_parameters"]), + sample_size=result["sample_size"], + monte_carlo_count=result["monte_carlo_count"], + results_times=json.loads(result["results_times"]), + ) + return None + + except Exception as e: + raise Exception(f"Failed to get time complexity data: {e}") + + def delete_data(self, query: TimeComplexityQuery) -> None: + """ + Delete time complexity data matching the query. + """ + delete_query = f""" + DELETE FROM {self.table_name} + WHERE criterion_code = %s + AND criterion_parameters = %s + AND sample_size = %s + AND monte_carlo_count = %s + """ + + try: + conn = self._get_connection() + with conn.cursor() as cursor: + cursor.execute( + delete_query, + ( + query.criterion_code, + json.dumps(query.criterion_parameters), + query.sample_size, + query.monte_carlo_count, + ), + ) + conn.commit() + except Exception as e: + raise Exception(f"Failed to delete time complexity data: {e}") + + def close(self) -> None: + """Close database connection.""" + if self._connection and not self._connection.closed: + self._connection.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + +# TODO: logger! +# TODO: indexes not needed? diff --git a/tests/persistence/postgresql/test_time_complexity_storage.py b/tests/persistence/postgresql/test_time_complexity_storage.py new file mode 100644 index 0000000..c016f0c --- /dev/null +++ b/tests/persistence/postgresql/test_time_complexity_storage.py @@ -0,0 +1,362 @@ +import json +import os +from unittest.mock import Mock, patch + +import pytest + +from pysatl_experiment.persistence.model.time_complexity.time_complexity import TimeComplexityModel, TimeComplexityQuery +from pysatl_experiment.persistence.storage.time_complexity.postgresql.postgresql import PostgreSQLTimeComplexityStorage + + +# TODO: clearance + + +class TestPostgreSQLTimeComplexityStorage: + """Test cases for PostgreSQLTimeComplexityStorage""" + + @pytest.fixture + def mock_connection(self): + """Mock PostgreSQL connection""" + with patch("psycopg2.connect") as mock_connect: + mock_conn = Mock() + mock_cursor = Mock() + mock_connect.return_value = mock_conn + mock_conn.cursor.return_value = mock_cursor + mock_conn.closed = False + yield mock_connect, mock_conn, mock_cursor + + @pytest.fixture + def storage(self): + """Create storage instance for testing""" + return PostgreSQLTimeComplexityStorage(connection_string="postgresql://test:test@localhost/test_db") + + @pytest.fixture + def sample_data(self): + """Sample data for testing""" + return TimeComplexityModel( + experiment_id=1, + criterion_code="ks_test", + criterion_parameters=[0.05, 0.1], + sample_size=100, + monte_carlo_count=1000, + results_times=[1.2, 1.3, 1.1, 1.4], + ) + + @pytest.fixture + def sample_query(self): + """Sample query for testing""" + return TimeComplexityQuery( + criterion_code="ks_test", criterion_parameters=[0.05, 0.1], sample_size=100, monte_carlo_count=1000 + ) + + def test_init(self, storage, mock_connection): + """Test storage initialization""" + mock_connect, mock_conn, mock_cursor = mock_connection + + storage.init() + + # Verify connection was created + mock_connect.assert_called_once_with("postgresql://test:test@localhost/test_db") + # Verify table creation query was executed + assert mock_cursor.execute.call_count == 1 + mock_conn.commit.assert_called_once() + + def test_init_with_custom_table_name(self, mock_connection): + """Test storage initialization with custom table name""" + storage = PostgreSQLTimeComplexityStorage( + connection_string="postgresql://test:test@localhost/test_db", table_name="custom_table" + ) + mock_connect, mock_conn, mock_cursor = mock_connection + + storage.init() + + # Verify table name is used in queries + call_args = mock_cursor.execute.call_args[0][0] + assert "custom_table" in call_args + assert "CREATE TABLE IF NOT EXISTS custom_table" in call_args + + def test_init_rollback_on_error(self, mock_connection): + """Test rollback on initialization error""" + mock_connect, mock_conn, mock_cursor = mock_connection + mock_cursor.execute.side_effect = Exception("Database error") + storage = PostgreSQLTimeComplexityStorage("test_connection_string") + + with pytest.raises(Exception, match="Failed to initialize time complexity storage:"): + storage.init() + + mock_conn.rollback.assert_called_once() + + def test_insert_data(self, storage, mock_connection, sample_data): + """Test inserting data""" + mock_connect, mock_conn, mock_cursor = mock_connection + + storage.insert_data(sample_data) + + # Verify insert query was executed with correct parameters + mock_cursor.execute.assert_called_once() + call_args = mock_cursor.execute.call_args[0] + + assert "INSERT INTO time_complexity" in call_args[0] + assert call_args[1] == ( + sample_data.experiment_id, + sample_data.criterion_code, + json.dumps(sample_data.criterion_parameters), + sample_data.sample_size, + sample_data.monte_carlo_count, + json.dumps(sample_data.results_times), + ) + mock_conn.commit.assert_called_once() + + def test_insert_data_rollback_on_error(self, storage, mock_connection, sample_data): + """Test rollback on insert error""" + mock_connect, mock_conn, mock_cursor = mock_connection + mock_cursor.execute.side_effect = Exception("Insert error") + + with pytest.raises(Exception, match="Failed to insert time complexity data:"): + storage.insert_data(sample_data) + + mock_conn.rollback.assert_called_once() + + def test_get_data_found(self, storage, mock_connection, sample_query): + """Test getting existing data""" + mock_connect, mock_conn, mock_cursor = mock_connection + + # Mock database result + mock_result = { + "experiment_id": 1, + "criterion_code": "ks_test", + "criterion_parameters": json.dumps([0.05, 0.1]), + "sample_size": 100, + "monte_carlo_count": 1000, + "results_times": json.dumps([1.2, 1.3, 1.1, 1.4]), + } + mock_cursor.fetchone.return_value = mock_result + + result = storage.get_data(sample_query) + + # Verify query was executed with correct parameters + mock_cursor.execute.assert_called_once() + call_args = mock_cursor.execute.call_args[0] + + assert "SELECT" in call_args[0] + assert call_args[1] == ( + sample_query.criterion_code, + json.dumps(sample_query.criterion_parameters), + sample_query.sample_size, + sample_query.monte_carlo_count, + ) + + # Verify result conversion + assert result is not None + assert result.experiment_id == 1 + assert result.criterion_code == "ks_test" + assert result.criterion_parameters == [0.05, 0.1] + assert result.sample_size == 100 + assert result.monte_carlo_count == 1000 + assert result.results_times == [1.2, 1.3, 1.1, 1.4] + + def test_get_data_not_found(self, storage, mock_connection, sample_query): + """Test getting non-existent data""" + mock_connect, mock_conn, mock_cursor = mock_connection + mock_cursor.fetchone.return_value = None + + result = storage.get_data(sample_query) + + assert result is None + mock_cursor.execute.assert_called_once() + + def test_get_data_error(self, storage, mock_connection, sample_query): + """Test error during data retrieval""" + mock_connect, mock_conn, mock_cursor = mock_connection + mock_cursor.execute.side_effect = Exception("Query error") + + with pytest.raises(Exception, match="Failed to get time complexity data:"): + storage.get_data(sample_query) + + def test_delete_data(self, storage, mock_connection, sample_query): + """Test deleting data""" + mock_connect, mock_conn, mock_cursor = mock_connection + + storage.delete_data(sample_query) + + # Verify delete query was executed with correct parameters + mock_cursor.execute.assert_called_once() + call_args = mock_cursor.execute.call_args[0] + + assert "DELETE FROM time_complexity" in call_args[0] + assert call_args[1] == ( + sample_query.criterion_code, + json.dumps(sample_query.criterion_parameters), + sample_query.sample_size, + sample_query.monte_carlo_count, + ) + mock_conn.commit.assert_called_once() + + def test_delete_data_rollback_on_error(self, storage, mock_connection, sample_query): + """Test rollback on delete error""" + mock_connect, mock_conn, mock_cursor = mock_connection + mock_cursor.execute.side_effect = Exception("Delete error") + + with pytest.raises(Exception, match="Failed to delete time complexity data:"): + storage.delete_data(sample_query) + + mock_conn.rollback.assert_called_once() + + def test_close_connection(self, storage, mock_connection): + """Test closing database connection""" + mock_connect, mock_conn, mock_cursor = mock_connection + + # First, establish a connection + storage.init() + storage.close() + + mock_conn.close.assert_called_once() + + def test_context_manager(self, mock_connection): + """Test using storage as context manager""" + mock_connect, mock_conn, mock_cursor = mock_connection + + with PostgreSQLTimeComplexityStorage("test_connection_string") as storage: + storage.init() + + mock_conn.close.assert_called_once() + + def test_connection_reuse(self, storage, mock_connection): + """Test that connection is reused when not closed""" + mock_connect, mock_conn, mock_cursor = mock_connection + + # First call + storage.init() + first_connection = storage._get_connection() + + # Second call should return same connection + second_connection = storage._get_connection() + + assert first_connection is second_connection + mock_connect.assert_called_once() + + def test_json_serialization(self, storage, sample_data): + """Test JSON serialization of complex parameters""" + # Test with various data types in parameters + complex_data = TimeComplexityModel( + experiment_id=2, + criterion_code="complex_test", + criterion_parameters=[0.05, 1.5e-10, -3.14], # Various float formats + sample_size=50, + monte_carlo_count=500, + results_times=[0.001, 0.002, 0.0015], # Very small times + ) + + # This should not raise serialization errors + json_params = json.dumps(complex_data.criterion_parameters) + json_times = json.dumps(complex_data.results_times) + + # Verify round-trip serialization + assert complex_data.criterion_parameters == json.loads(json_params) + assert complex_data.results_times == json.loads(json_times) + + +# Integration tests (require actual PostgreSQL database) +@pytest.mark.integration +class TestPostgreSQLTimeComplexityStorageIntegration: + """Integration tests with real PostgreSQL database""" + + @pytest.fixture + def integration_storage(self): + """Create storage instance for integration testing""" + # Use environment variable for connection string + connection_string = os.getenv("TEST_POSTGRESQL_CONNECTION_STRING", "postgresql://test:test@localhost/test_db") + + storage = PostgreSQLTimeComplexityStorage( + connection_string=connection_string, table_name="test_time_complexity" + ) + + # Clean up before test + storage.init() + with storage._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(f"DELETE FROM {storage.table_name}") + conn.commit() + yield storage + + # Clean up after test + with storage._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(f"DROP TABLE IF EXISTS {storage.table_name}") + conn.commit() + storage.close() + + def test_integration_crud_operations(self, integration_storage): + """Test full CRUD operations with real database""" + # Create test data + test_data = TimeComplexityModel( + experiment_id=999, + criterion_code="integration_test", + criterion_parameters=[0.01, 0.02], + sample_size=200, + monte_carlo_count=2000, + results_times=[2.1, 2.2, 2.3], + ) + + test_query = TimeComplexityQuery( + criterion_code="integration_test", + criterion_parameters=[0.01, 0.02], + sample_size=200, + monte_carlo_count=2000, + ) + + # Test insert + integration_storage.insert_data(test_data) + + # Test get (should find the data) + result = integration_storage.get_data(test_query) + assert result is not None + assert result.experiment_id == test_data.experiment_id + assert result.criterion_code == test_data.criterion_code + assert result.criterion_parameters == test_data.criterion_parameters + assert result.sample_size == test_data.sample_size + assert result.monte_carlo_count == test_data.monte_carlo_count + assert result.results_times == test_data.results_times + + # Test delete + integration_storage.delete_data(test_query) + + # Test get after delete (should not find the data) + result = integration_storage.get_data(test_query) + assert result is None + + def test_integration_unique_constraint(self, integration_storage): + """Test unique constraint enforcement""" + data1 = TimeComplexityModel( + experiment_id=1, + criterion_code="unique_test", + criterion_parameters=[1.0], + sample_size=100, + monte_carlo_count=1000, + results_times=[1.0], + ) + + # Same unique key, different results + data2 = TimeComplexityModel( + experiment_id=1, + criterion_code="unique_test", + criterion_parameters=[1.0], + sample_size=100, + monte_carlo_count=1000, + results_times=[2.0], # Different results + ) + + # Insert first record + integration_storage.insert_data(data1) + + # Insert second record with same unique key - should update existing + integration_storage.insert_data(data2) + + # Should get the updated record + query = TimeComplexityQuery( + criterion_code="unique_test", criterion_parameters=[1.0], sample_size=100, monte_carlo_count=1000 + ) + + result = integration_storage.get_data(query) + assert result is not None + assert result.results_times == [2.0] # Should have updated results From d282b45fa12c4de7e3c1673f955ef66395e12214 Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Sun, 5 Oct 2025 23:41:32 +0300 Subject: [PATCH 20/24] fix: toml fix --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 62fb8f4..6ced0f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ package-mode = true packages = [{include = "pysatl_experiment"}] [tool.poetry.dependencies] -python = ">=3.10,<3.13" +python = ">=3.11,<3.13" numpy = ">=1.25.1" scipy = ">=1.11.2" matplotlib = ">=3.8.0" From 8d953a9c063f517fba805d06e150194bfc5ca646 Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Sun, 5 Oct 2025 23:43:17 +0300 Subject: [PATCH 21/24] fix: toml fix --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6ced0f7..42521fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ package-mode = true packages = [{include = "pysatl_experiment"}] [tool.poetry.dependencies] -python = ">=3.11,<3.13" +python = ">=3.11,<3.14" numpy = ">=1.25.1" scipy = ">=1.11.2" matplotlib = ">=3.8.0" From 4ce4e79549454e7f5077d36d2d99db85c8c1c286 Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Mon, 6 Oct 2025 17:16:14 +0300 Subject: [PATCH 22/24] tests: added stubs for sqlite storages tests --- tests/persistence/postgresql/__init__.py | 0 .../sqlite/test_experiment_storage.py | 299 +++++++++ .../persistence/sqlite/test_power_storage.py | 580 ++++++++++++++++++ .../sqlite/test_random_values_storage.py | 456 ++++++++++++++ .../sqlite/test_time_complexity_storage.py | 360 +++++++++++ 5 files changed, 1695 insertions(+) delete mode 100644 tests/persistence/postgresql/__init__.py create mode 100644 tests/persistence/sqlite/test_experiment_storage.py create mode 100644 tests/persistence/sqlite/test_power_storage.py create mode 100644 tests/persistence/sqlite/test_random_values_storage.py create mode 100644 tests/persistence/sqlite/test_time_complexity_storage.py diff --git a/tests/persistence/postgresql/__init__.py b/tests/persistence/postgresql/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/persistence/sqlite/test_experiment_storage.py b/tests/persistence/sqlite/test_experiment_storage.py new file mode 100644 index 0000000..9e4c87c --- /dev/null +++ b/tests/persistence/sqlite/test_experiment_storage.py @@ -0,0 +1,299 @@ +import json +from unittest.mock import MagicMock, patch + +import psycopg2 +import pytest + +from pysatl_experiment.persistence.model.experiment.experiment import ExperimentModel, ExperimentQuery +from pysatl_experiment.persistence.storage.experiment.sqlite.sqlite import SQLiteExperimentStorage + + +TABLE_NAME = "experiments" + + +class TestSQLiteExperimentStorage: + @pytest.fixture + def storage(self): + return SQLiteExperimentStorage(connection_string="postgresql://test:test@localhost/test") + + @pytest.fixture + def sample_experiment_model(self): # TODO: check for format + return ExperimentModel( + experiment_type="statistical_test", + storage_connection="postgresql://storage", + run_mode="sequential", + report_mode="detailed", + hypothesis="Test hypothesis", + generator_type="monte_carlo", + executor_type="multiprocessing", + report_builder_type="html", + sample_sizes=[100, 200, 300], + monte_carlo_count=1000, + criteria=["t_test", "mann_whitney"], + alternatives=["two_sided", "greater"], + significance_levels=[0.01, 0.05, 0.1], + is_generation_done=False, + is_execution_done=False, + is_report_building_done=False, + ) + + @pytest.fixture + def sample_experiment_query(self): # TODO: check for format + return ExperimentQuery( + experiment_type="statistical_test", + storage_connection="postgresql://storage", + run_mode="sequential", + hypothesis="Test hypothesis", + generator_type="monte_carlo", + executor_type="multiprocessing", + report_builder_type="html", + sample_sizes=[100, 200, 300], + monte_carlo_count=1000, + criteria=["t_test", "mann_whitney"], + alternatives=["two_sided", "greater"], + significance_levels=[0.01, 0.05, 0.1], + report_mode="detailed", + ) + + def test_initialization(self, storage): + assert storage.connection_string == "postgresql://test:test@localhost/test" + assert storage.table_name == TABLE_NAME + assert not storage._initialized + + @patch("psycopg2.connect") + def test_init_success(self, mock_connect, storage): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + storage.init() + + assert storage._initialized + mock_cursor.execute.assert_called_once() + mock_conn.commit.assert_called_once() + + @patch("psycopg2.connect") + def test_init_already_initialized(self, mock_connect, storage): + storage._initialized = True + mock_conn = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + + storage.init() + + mock_conn.cursor.assert_not_called() + + @patch("psycopg2.connect") + def test_init_failure(self, mock_connect, storage): + mock_connect.side_effect = Exception("Connection failed") + + with pytest.raises(Exception, match="Connection failed"): + storage.init() + + @patch("psycopg2.connect") + def test_insert_success(self, mock_connect, storage, sample_experiment_model): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + storage._initialized = True + storage.insert_data(sample_experiment_model) + + mock_cursor.execute.assert_called_once() + mock_conn.commit.assert_called_once() + + @patch("psycopg2.connect") + def test_insert_auto_init(self, mock_connect, storage, sample_experiment_model): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + storage.insert_data(sample_experiment_model) + + # Must be 2 counts + assert mock_cursor.execute.call_count == 2 + assert storage._initialized + + @patch("psycopg2.connect") + def test_get_success(self, mock_connect, storage, sample_experiment_query): # TODO: check for actual ones + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + # Mock query result + mock_result = { + "experiment_type": "statistical_test", + "storage_connection": "postgresql://storage", + "run_mode": "sequential", + "report_mode": "detailed", + "hypothesis": "Test hypothesis", + "generator_type": "monte_carlo", + "executor_type": "multiprocessing", + "report_builder_type": "html", + "sample_sizes": json.dumps([100, 200, 300]), + "monte_carlo_count": 1000, + "criteria": json.dumps(["t_test", "mann_whitney"]), + "alternatives": json.dumps(["two_sided", "greater"]), + "significance_levels": json.dumps([0.01, 0.05, 0.1]), + "is_generation_done": False, + "is_execution_done": False, + "is_report_building_done": False, + } + mock_cursor.fetchone.return_value = mock_result + + storage._initialized = True + result = storage.get_data(sample_experiment_query) + + assert result is not None + assert result.experiment_type == sample_experiment_query.experiment_type + mock_cursor.execute.assert_called_once() + + @patch("psycopg2.connect") + def test_get_not_found(self, mock_connect, storage, sample_experiment_query): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + mock_cursor.fetchone.return_value = None + + storage._initialized = True + result = storage.get_data(sample_experiment_query) + + assert result is None + + @patch("psycopg2.connect") + def test_delete_success(self, mock_connect, storage, sample_experiment_query): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + storage._initialized = True + storage.delete_data(sample_experiment_query) + + mock_cursor.execute.assert_called_once() + mock_conn.commit.assert_called_once() + + # TODO: do some proper test environment? + + @patch("psycopg2.connect") + def test_get_experiment_id_success(self, mock_connect, storage, sample_experiment_query): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + mock_cursor.fetchone.return_value = (123,) + + storage._initialized = True + result = storage.get_experiment_id(sample_experiment_query) + + assert result == 123 + + @patch("psycopg2.connect") + def test_set_generation_done(self, mock_connect, storage): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + storage._initialized = True + storage.set_generation_done(123) + + mock_cursor.execute.assert_called_once() + assert "is_generation_done" in mock_cursor.execute.call_args[0][0] + mock_conn.commit.assert_called_once() + + @patch("psycopg2.connect") + def test_set_execution_done(self, mock_connect, storage): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + storage._initialized = True + storage.set_execution_done(123) + + mock_cursor.execute.assert_called_once() + assert "is_execution_done" in mock_cursor.execute.call_args[0][0] + + @patch("psycopg2.connect") + def test_set_report_building_done(self, mock_connect, storage): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + storage._initialized = True + storage.set_report_building_done(123) + + mock_cursor.execute.assert_called_once() + assert "is_report_building_done" in mock_cursor.execute.call_args[0][0] + + @patch("psycopg2.connect") + def test_error_handling(self, mock_connect, storage, sample_experiment_model): + mock_connect.side_effect = psycopg2.Error("Database error") + + storage._initialized = True + + with pytest.raises(psycopg2.Error, match="Database error"): + storage.insert_data(sample_experiment_model) + + # TODO: logger tests + + +# Integration tests +@pytest.mark.integration +class TestSQLiteExperimentStorageIntegration: + @pytest.fixture + def integration_storage(self): + # Should use real DB + storage = SQLiteExperimentStorage(connection_string="postgresql://test:test@localhost/test_db") + + # Clearing before test + with storage._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(f"DROP TABLE IF EXISTS {storage.table_name}") + conn.commit() + + yield storage + + # Clearing after test + with storage._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(f"DROP TABLE IF EXISTS {storage.table_name}") + conn.commit() + + def test_integration_crud_operations(self, integration_storage, sample_experiment_model, sample_experiment_query): + # Create + integration_storage.insert_data(sample_experiment_model) + + # Read + result = integration_storage.get_data(sample_experiment_query) + assert result is not None + assert result.experiment_type == sample_experiment_model.experiment_type + + # Get ID + experiment_id = integration_storage.get_experiment_id(sample_experiment_query) + assert experiment_id is not None + + # Update status + integration_storage.set_generation_done(experiment_id) + + # Verify update + experiment_data = integration_storage.get_data(sample_experiment_query) + assert experiment_data.is_generation_done is True + + # Delete + integration_storage.delete_data(sample_experiment_query) + + # Verify deletion + result_after_delete = integration_storage.get_data(sample_experiment_query) + assert result_after_delete is None + + +# TODO: big integration tests with CLI in different file diff --git a/tests/persistence/sqlite/test_power_storage.py b/tests/persistence/sqlite/test_power_storage.py new file mode 100644 index 0000000..92773f3 --- /dev/null +++ b/tests/persistence/sqlite/test_power_storage.py @@ -0,0 +1,580 @@ +from unittest.mock import patch + +import psycopg2 +import pytest + +from pysatl_experiment.exceptions import StorageError +from pysatl_experiment.persistence.model.power.power import PowerModel, PowerQuery +from pysatl_experiment.persistence.storage.power.sqlite.sqlite import SQLitePowerStorage + + +# TODO: clearance + + +# Temporary SQLite database fixture +@pytest.fixture(scope="function") +def temp_postgres_db(): + test_db_name = "test_power_db" + original_connection_string = "postgresql://user:password@localhost:5432/postgres" + + # Creating test BD + conn = psycopg2.connect(original_connection_string) + conn.autocommit = True + cursor = conn.cursor() + + # Ending all old connections + cursor.execute(f""" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{test_db_name}' + AND pid <> pg_backend_pid(); + """) + + cursor.execute(f"DROP DATABASE IF EXISTS {test_db_name};") + cursor.execute(f"CREATE DATABASE {test_db_name};") + cursor.close() + conn.close() + + # Return connection string for test BD + test_connection_string = f"postgresql://user:password@localhost:5432/{test_db_name}" + + yield test_connection_string + + # Clearing after test + conn = psycopg2.connect(original_connection_string) + conn.autocommit = True + cursor = conn.cursor() + cursor.execute(f"DROP DATABASE {test_db_name};") + cursor.close() + conn.close() + + +@pytest.fixture +def storage(temp_postgres_db): + storage = SQLitePowerStorage(temp_postgres_db, "test_power_analysis") + storage.init() + return storage + + +@pytest.fixture +def sample_power_model(): + return PowerModel( + experiment_id=1, + criterion_code="ks_test", + criterion_parameters=[0.5, 1.0], + sample_size=100, + alternative_code="normal_shift", + alternative_parameters=[0.0, 1.0, 0.5], + monte_carlo_count=1000, + significance_level=0.05, + results_criteria=[True] * 700 + [False] * 300, # 70% power + ) + + +@pytest.fixture +def sample_power_query(): + return PowerQuery( + criterion_code="ks_test", + criterion_parameters=[0.5, 1.0], + sample_size=100, + alternative_code="normal_shift", + alternative_parameters=[0.0, 1.0, 0.5], + monte_carlo_count=1000, + significance_level=0.05, + ) + + +@pytest.fixture +def multiple_power_models(): + base_params = { + "criterion_parameters": [0.5, 1.0], + "alternative_parameters": [0.0, 1.0], + "monte_carlo_count": 1000, + "significance_level": 0.05, + "results_criteria": [True] * 600 + [False] * 400, # 60% power + } + + models = [] + for i in range(3): + model = PowerModel( + experiment_id=i + 1, + criterion_code=f"test_{i}", + sample_size=50 * (i + 1), + alternative_code=f"alt_{i}", + **base_params, + ) + models.append(model) + + return models + + +class TestSQLitePowerStorage: + def test_initialization(self, storage): + assert storage is not None + assert storage.table_name == "test_power_analysis" + assert storage._initialized is True + + def test_table_creation(self, storage): + # Check for database created + with storage._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = %s + ); + """, + (storage.table_name,), + ) + result = cursor.fetchone() + assert result[0] is True + + def test_insert_and_get_data(self, storage, sample_power_model, sample_power_query): + # Insert + storage.insert_data(sample_power_model) + + # Get + result = storage.get_data(sample_power_query) + + # Check + assert result is not None + assert result.experiment_id == sample_power_model.experiment_id + assert result.criterion_code == sample_power_model.criterion_code + assert result.criterion_parameters == sample_power_model.criterion_parameters + assert result.sample_size == sample_power_model.sample_size + assert result.alternative_code == sample_power_model.alternative_code + assert result.alternative_parameters == sample_power_model.alternative_parameters + assert result.monte_carlo_count == sample_power_model.monte_carlo_count + assert result.significance_level == sample_power_model.significance_level + assert result.results_criteria == sample_power_model.results_criteria + + def test_insert_and_get_interface_methods(self, storage, sample_power_model, sample_power_query): + # Insert + storage.insert(sample_power_model) + + # Get + result = storage.get(sample_power_query) + assert result is not None + assert result.experiment_id == sample_power_model.experiment_id + + def test_get_nonexistent_data(self, storage, sample_power_query): + result = storage.get_data(sample_power_query) + assert result is None + + def test_insert_duplicate(self, storage, sample_power_model): + # First insert + storage.insert_data(sample_power_model) + + # Insert after modify + modified_model = PowerModel( + experiment_id=sample_power_model.experiment_id, + criterion_code=sample_power_model.criterion_code, + criterion_parameters=sample_power_model.criterion_parameters, + sample_size=sample_power_model.sample_size, + alternative_code=sample_power_model.alternative_code, + alternative_parameters=sample_power_model.alternative_parameters, + monte_carlo_count=sample_power_model.monte_carlo_count, + significance_level=sample_power_model.significance_level, + results_criteria=[True] * 800 + [False] * 200, # Updated to 80% power + ) + + storage.insert_data(modified_model) + + # Check if data updated + query = PowerQuery( + criterion_code=sample_power_model.criterion_code, + criterion_parameters=sample_power_model.criterion_parameters, + sample_size=sample_power_model.sample_size, + alternative_code=sample_power_model.alternative_code, + alternative_parameters=sample_power_model.alternative_parameters, + monte_carlo_count=sample_power_model.monte_carlo_count, + significance_level=sample_power_model.significance_level, + ) + + result = storage.get_data(query) + assert result.results_criteria == modified_model.results_criteria + + def test_update_method(self, storage, sample_power_model): + # Insert + storage.insert(sample_power_model) + + # Update + updated_model = PowerModel( + experiment_id=sample_power_model.experiment_id, + criterion_code=sample_power_model.criterion_code, + criterion_parameters=sample_power_model.criterion_parameters, + sample_size=sample_power_model.sample_size, + alternative_code=sample_power_model.alternative_code, + alternative_parameters=sample_power_model.alternative_parameters, + monte_carlo_count=sample_power_model.monte_carlo_count, + significance_level=sample_power_model.significance_level, + results_criteria=[True] * 900 + [False] * 100, # Updated + ) + + storage.update(updated_model) + + # Check + query = PowerQuery( + criterion_code=sample_power_model.criterion_code, + criterion_parameters=sample_power_model.criterion_parameters, + sample_size=sample_power_model.sample_size, + alternative_code=sample_power_model.alternative_code, + alternative_parameters=sample_power_model.alternative_parameters, + monte_carlo_count=sample_power_model.monte_carlo_count, + significance_level=sample_power_model.significance_level, + ) + + result = storage.get_data(query) + assert result.results_criteria == updated_model.results_criteria + + def test_delete_data(self, storage, sample_power_model, sample_power_query): + # Insert + storage.insert_data(sample_power_model) + assert storage.get_data(sample_power_query) is not None + + # Delete + storage.delete_data(sample_power_query) + assert storage.get_data(sample_power_query) is None + + def test_delete_interface_method(self, storage, sample_power_model, sample_power_query): + storage.insert(sample_power_model) + assert storage.get(sample_power_query) is not None + + storage.delete(sample_power_query) + assert storage.get(sample_power_query) is None + + def test_get_by_experiment_id(self, storage, multiple_power_models): + for model in multiple_power_models: + storage.insert_data(model) + + results = storage.get_by_experiment_id(1) + assert len(results) == 1 + assert results[0].experiment_id == 1 + assert results[0].criterion_code == "test_0" + + results = storage.get_by_experiment_id(999) + assert len(results) == 0 + + def test_calculate_power(self, storage, sample_power_model, sample_power_query): + storage.insert_data(sample_power_model) # Know 70% power + + power = storage.calculate_power(sample_power_query) + + assert power is not None + assert abs(power - 0.7) < 0.001 + + def test_calculate_power_nonexistent(self, storage, sample_power_query): + power = storage.calculate_power(sample_power_query) + assert power is None + + def test_calculate_power_empty_results(self, storage): + model = PowerModel( + experiment_id=1, + criterion_code="empty_test", + criterion_parameters=[], + sample_size=100, + alternative_code="empty_alt", + alternative_parameters=[], + monte_carlo_count=1000, + significance_level=0.05, + results_criteria=[], + ) + + storage.insert_data(model) + + query = PowerQuery( + criterion_code="empty_test", + criterion_parameters=[], + sample_size=100, + alternative_code="empty_alt", + alternative_parameters=[], + monte_carlo_count=1000, + significance_level=0.05, + ) + + power = storage.calculate_power(query) + assert power is None # TODO: check for actual one + + def test_batch_insert(self, storage, multiple_power_models): + inserted_count = storage.batch_insert(multiple_power_models) + + assert inserted_count == len(multiple_power_models) + + # Insert + for model in multiple_power_models: + query = PowerQuery( + criterion_code=model.criterion_code, + criterion_parameters=model.criterion_parameters, + sample_size=model.sample_size, + alternative_code=model.alternative_code, + alternative_parameters=model.alternative_parameters, + monte_carlo_count=model.monte_carlo_count, + significance_level=model.significance_level, + ) + result = storage.get_data(query) + assert result is not None + + def test_batch_insert_duplicates(self, storage, multiple_power_models): + storage.batch_insert(multiple_power_models) + + inserted_count = storage.batch_insert(multiple_power_models) + assert inserted_count == 0 + + def test_exists_method(self, storage, sample_power_model, sample_power_query): + assert storage.exists(sample_power_query) is False + + storage.insert_data(sample_power_model) + assert storage.exists(sample_power_query) is True + + def test_complex_parameters(self, storage): + complex_criterion_params = [0.1, 0.2, 0.3, 0.4, 0.5] + complex_alternative_params = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] + + model = PowerModel( + experiment_id=1, + criterion_code="complex_test", + criterion_parameters=complex_criterion_params, + sample_size=200, + alternative_code="complex_alt", + alternative_parameters=complex_alternative_params, + monte_carlo_count=5000, + significance_level=0.01, + results_criteria=[True] * 2500 + [False] * 2500, + ) + + storage.insert_data(model) + + query = PowerQuery( + criterion_code="complex_test", + criterion_parameters=complex_criterion_params, + sample_size=200, + alternative_code="complex_alt", + alternative_parameters=complex_alternative_params, + monte_carlo_count=5000, + significance_level=0.01, + ) + + result = storage.get_data(query) + assert result is not None + assert result.criterion_parameters == complex_criterion_params + assert result.alternative_parameters == complex_alternative_params + + +class TestEdgeCases: + def test_empty_parameters(self, storage): + model = PowerModel( + experiment_id=1, + criterion_code="empty_params", + criterion_parameters=[], + sample_size=100, + alternative_code="empty_alt", + alternative_parameters=[], + monte_carlo_count=1000, + significance_level=0.05, + results_criteria=[True, False, True], + ) + + storage.insert_data(model) + + query = PowerQuery( + criterion_code="empty_params", + criterion_parameters=[], + sample_size=100, + alternative_code="empty_alt", + alternative_parameters=[], + monte_carlo_count=1000, + significance_level=0.05, + ) + + result = storage.get_data(query) + assert result is not None + assert result.criterion_parameters == [] + assert result.alternative_parameters == [] + + def test_single_element_parameters(self, storage): + model = PowerModel( + experiment_id=1, + criterion_code="single_param", + criterion_parameters=[42.0], + sample_size=100, + alternative_code="single_alt", + alternative_parameters=[3.14], + monte_carlo_count=1000, + significance_level=0.05, + results_criteria=[True] * 1000, + ) + + storage.insert_data(model) + + query = PowerQuery( + criterion_code="single_param", + criterion_parameters=[42.0], + sample_size=100, + alternative_code="single_alt", + alternative_parameters=[3.14], + monte_carlo_count=1000, + significance_level=0.05, + ) + + result = storage.get_data(query) + assert result is not None + assert result.criterion_parameters == [42.0] + assert result.alternative_parameters == [3.14] + + def test_large_sample_size(self, storage): + model = PowerModel( + experiment_id=1, + criterion_code="large_sample", + criterion_parameters=[0.5, 1.0], + sample_size=100000, + alternative_code="large_alt", + alternative_parameters=[0.0, 1.0], + monte_carlo_count=1000, + significance_level=0.05, + results_criteria=[True] * 1000, + ) + + storage.insert_data(model) + + query = PowerQuery( + criterion_code="large_sample", + criterion_parameters=[0.5, 1.0], + sample_size=100000, + alternative_code="large_alt", + alternative_parameters=[0.0, 1.0], + monte_carlo_count=1000, + significance_level=0.05, + ) + + result = storage.get_data(query) + assert result is not None + assert result.sample_size == 100000 + + def test_special_characters_in_codes(self, storage): + special_criterion = "criterion-with-dashes_and_underscores" + special_alternative = "alternative.with.dots" + + model = PowerModel( + experiment_id=1, + criterion_code=special_criterion, + criterion_parameters=[0.5, 1.0], + sample_size=100, + alternative_code=special_alternative, + alternative_parameters=[0.0, 1.0], + monte_carlo_count=1000, + significance_level=0.05, + results_criteria=[True] * 500 + [False] * 500, + ) + + storage.insert_data(model) + + query = PowerQuery( + criterion_code=special_criterion, + criterion_parameters=[0.5, 1.0], + sample_size=100, + alternative_code=special_alternative, + alternative_parameters=[0.0, 1.0], + monte_carlo_count=1000, + significance_level=0.05, + ) + + result = storage.get_data(query) + assert result is not None + assert result.criterion_code == special_criterion + assert result.alternative_code == special_alternative + + +class TestErrorHandling: + @patch("psycopg2.connect") + def test_connection_error_on_init(self, mock_connect): + mock_connect.side_effect = StorageError("Connection failed") + + storage = SQLitePowerStorage("invalid_connection_string") + with pytest.raises(StorageError): + storage.init() + + def test_invalid_significance_level(self, storage): + model = PowerModel( + experiment_id=1, + criterion_code="invalid_alpha", + criterion_parameters=[0.5], + sample_size=100, + alternative_code="invalid_alt", + alternative_parameters=[0.0, 1.0], + monte_carlo_count=1000, + significance_level=1.5, + results_criteria=[True] * 1000, + ) + + with pytest.raises(StorageError): + storage.insert_data(model) + + def test_invalid_sample_size(self, storage): + model = PowerModel( + experiment_id=1, + criterion_code="invalid_size", + criterion_parameters=[0.5], + sample_size=0, + alternative_code="invalid_alt", + alternative_parameters=[0.0, 1.0], + monte_carlo_count=1000, + significance_level=0.05, + results_criteria=[True] * 1000, + ) + + with pytest.raises(StorageError): + storage.insert_data(model) + + +class TestIntegration: + def test_complete_workflow(self, storage): + models = [] + for i in range(5): + model = PowerModel( + experiment_id=i + 1, + criterion_code=f"criterion_{i}", + criterion_parameters=[float(i), float(i + 1)], + sample_size=50 * (i + 1), + alternative_code=f"alternative_{i}", + alternative_parameters=[float(i * 0.1), float(i * 0.2)], + monte_carlo_count=1000 + i * 100, + significance_level=0.01 * (i + 1), + results_criteria=[True] * (600 + i * 100) + [False] * (400 - i * 100), + ) + models.append(model) + + inserted_count = storage.batch_insert(models) + assert inserted_count == 5 + + for i, model in enumerate(models): + query = PowerQuery( + criterion_code=model.criterion_code, + criterion_parameters=model.criterion_parameters, + sample_size=model.sample_size, + alternative_code=model.alternative_code, + alternative_parameters=model.alternative_parameters, + monte_carlo_count=model.monte_carlo_count, + significance_level=model.significance_level, + ) + assert storage.exists(query) is True + + power = storage.calculate_power(query) + expected_power = (600 + i * 100) / 1000.0 + assert abs(power - expected_power) < 0.01 + + for i, model in enumerate(models): + query = PowerQuery( + criterion_code=model.criterion_code, + criterion_parameters=model.criterion_parameters, + sample_size=model.sample_size, + alternative_code=model.alternative_code, + alternative_parameters=model.alternative_parameters, + monte_carlo_count=model.monte_carlo_count, + significance_level=model.significance_level, + ) + storage.delete_data(query) + assert storage.exists(query) is False + + +# TODO: proper description diff --git a/tests/persistence/sqlite/test_random_values_storage.py b/tests/persistence/sqlite/test_random_values_storage.py new file mode 100644 index 0000000..2d6e5f0 --- /dev/null +++ b/tests/persistence/sqlite/test_random_values_storage.py @@ -0,0 +1,456 @@ +import psycopg2 +import pytest + +from pysatl_experiment.persistence.model.random_values.random_values import ( + RandomValuesAllModel, + RandomValuesAllQuery, + RandomValuesCountQuery, + RandomValuesModel, + RandomValuesQuery, +) +from pysatl_experiment.persistence.storage.random_values.sqlite.sqlite import SQLiteRandomValuesStorage + + +# TODO: clearance + + +@pytest.fixture(scope="function") +def temp_postgres_db(): + test_db_name = "test_random_values_db" + original_connection_string = "postgresql://user:password@localhost:5432/postgres" + + conn = psycopg2.connect(original_connection_string) + conn.autocommit = True + cursor = conn.cursor() + + cursor.execute(f""" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{test_db_name}' + AND pid <> pg_backend_pid(); + """) + + cursor.execute(f"DROP DATABASE IF EXISTS {test_db_name};") + cursor.execute(f"CREATE DATABASE {test_db_name};") + cursor.close() + conn.close() + + test_connection_string = f"postgresql://user:password@localhost:5432/{test_db_name}" + + yield test_connection_string + + try: + conn = psycopg2.connect(original_connection_string) + conn.autocommit = True + cursor = conn.cursor() + cursor.execute(f"DROP DATABASE {test_db_name};") + cursor.close() + conn.close() + except Exception: + pass + + +@pytest.fixture +def storage(temp_postgres_db): + return SQLiteRandomValuesStorage(temp_postgres_db, "test_random_values") + + +@pytest.fixture +def sample_model(): + return RandomValuesModel( + generator_name="normal", + generator_parameters=[0.0, 1.0], + sample_size=100, + sample_num=1, + data=[0.1, 0.2, 0.3, 0.4, 0.5], + ) + + +@pytest.fixture +def sample_query(): + return RandomValuesQuery(generator_name="normal", generator_parameters=[0.0, 1.0], sample_size=100, sample_num=1) + + +@pytest.fixture +def sample_all_query(): + return RandomValuesAllQuery(generator_name="normal", generator_parameters=[0.0, 1.0], sample_size=100) + + +@pytest.fixture +def sample_count_query(): + return RandomValuesCountQuery(generator_name="normal", generator_parameters=[0.0, 1.0], sample_size=100, count=2) + + +class TestSQLiteRandomValuesStorage: + def test_initialization(self, storage): + assert storage is not None + assert storage.table_name == "test_random_values" + + def test_table_creation(self, storage): + with storage._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = %s + ); + """, + (storage.table_name,), + ) + result = cursor.fetchone() + assert result[0] is True + + def test_insert_and_get(self, storage, sample_model, sample_query): + storage.insert(sample_model) + + result = storage.get_data(sample_query) + + assert result is not None + assert result.generator_name == sample_model.generator_name + assert result.generator_parameters == sample_model.generator_parameters + assert result.sample_size == sample_model.sample_size + assert result.sample_num == sample_model.sample_num + assert result.data == sample_model.data + + def test_get_nonexistent(self, storage, sample_query): + result = storage.get_data(sample_query) + assert result is None + + def test_insert_duplicate(self, storage, sample_model): + storage.insert(sample_model) + + modified_model = RandomValuesModel( + generator_name=sample_model.generator_name, + generator_parameters=sample_model.generator_parameters, + sample_size=sample_model.sample_size, + sample_num=sample_model.sample_num, + data=[1.0, 2.0, 3.0], + ) + + storage.insert(modified_model) + + query = RandomValuesQuery( + generator_name=sample_model.generator_name, + generator_parameters=sample_model.generator_parameters, + sample_size=sample_model.sample_size, + sample_num=sample_model.sample_num, + ) + + result = storage.get_data(query) + assert result.data == modified_model.data + + def test_update(self, storage, sample_model): + storage.insert(sample_model) + + updated_model = RandomValuesModel( + generator_name=sample_model.generator_name, + generator_parameters=sample_model.generator_parameters, + sample_size=sample_model.sample_size, + sample_num=sample_model.sample_num, + data=[9.0, 8.0, 7.0], + ) + + storage.update(updated_model) + + query = RandomValuesQuery( + generator_name=sample_model.generator_name, + generator_parameters=sample_model.generator_parameters, + sample_size=sample_model.sample_size, + sample_num=sample_model.sample_num, + ) + + result = storage.get_data(query) + assert result.data == updated_model.data + + def test_delete(self, storage, sample_model, sample_query): + storage.insert(sample_model) + assert storage.get_data(sample_query) is not None + + storage.delete(sample_query) + assert storage.get_data(sample_query) is None + + def test_get_rvs_count(self, storage, sample_all_query): + count = storage.get_rvs_count(sample_all_query) + assert count == 0 + + for i in range(3): + model = RandomValuesModel( + generator_name="normal", + generator_parameters=[0.0, 1.0], + sample_size=100, + sample_num=i + 1, + data=[float(i)] * 5, + ) + storage.insert(model) + + count = storage.get_rvs_count(sample_all_query) + assert count == 3 + + def test_insert_all_data(self, storage, sample_all_query): + all_data_model = RandomValuesAllModel( + generator_name="normal", + generator_parameters=[0.0, 1.0], + sample_size=100, + data=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], + ) + + storage.insert_all_data(all_data_model) + + results = storage.get_all_data(sample_all_query) + assert results is not None + assert len(results) == 3 + + for i, result in enumerate(results, 1): + assert result.sample_num == i + assert result.data == all_data_model.data[i - 1] + + def test_get_all_data(self, storage, sample_all_query): + for i in range(3): + model = RandomValuesModel( + generator_name="normal", + generator_parameters=[0.0, 1.0], + sample_size=100, + sample_num=i + 1, + data=[float(i + j) for j in range(3)], + ) + storage.insert(model) + + results = storage.get_all_data(sample_all_query) + + assert results is not None + assert len(results) == 3 + + sample_nums = [r.sample_num for r in results] + assert sample_nums == [1, 2, 3] + + def test_get_all_data_empty(self, storage, sample_all_query): + results = storage.get_all_data(sample_all_query) + assert results is None + + def test_delete_all_data(self, storage, sample_all_query): + for i in range(3): + model = RandomValuesModel( + generator_name="normal", + generator_parameters=[0.0, 1.0], + sample_size=100, + sample_num=i + 1, + data=[float(i)] * 5, + ) + storage.insert(model) + + assert storage.get_rvs_count(sample_all_query) == 3 + + storage.delete_all_data(sample_all_query) + + assert storage.get_rvs_count(sample_all_query) == 0 + assert storage.get_all_data(sample_all_query) is None + + def test_get_count_data(self, storage, sample_count_query): + for i in range(5): + model = RandomValuesModel( + generator_name="normal", + generator_parameters=[0.0, 1.0], + sample_size=100, + sample_num=i + 1, + data=[float(i)] * 3, + ) + storage.insert(model) + + results = storage.get_count_data(sample_count_query) + + assert results is not None + assert len(results) == 2 + + assert results[0].sample_num == 1 + assert results[1].sample_num == 2 + + def test_get_count_data_more_than_exists(self, storage, sample_count_query): + model = RandomValuesModel( + generator_name="normal", + generator_parameters=[0.0, 1.0], + sample_size=100, + sample_num=1, + data=[1.0, 2.0, 3.0], + ) + storage.insert(model) + + sample_count_query.count = 2 + results = storage.get_count_data(sample_count_query) + + assert results is not None + assert len(results) == 1 + + def test_different_generators(self, storage): + model1 = RandomValuesModel( + generator_name="normal", + generator_parameters=[0.0, 1.0], + sample_size=100, + sample_num=1, + data=[1.0, 2.0, 3.0], + ) + + model2 = RandomValuesModel( + generator_name="uniform", + generator_parameters=[0.0, 10.0], + sample_size=100, + sample_num=1, + data=[5.0, 6.0, 7.0], + ) + + storage.insert(model1) + storage.insert(model2) + + query1 = RandomValuesAllQuery(generator_name="normal", generator_parameters=[0.0, 1.0], sample_size=100) + + query2 = RandomValuesAllQuery(generator_name="uniform", generator_parameters=[0.0, 10.0], sample_size=100) + + results1 = storage.get_all_data(query1) + results2 = storage.get_all_data(query2) + + assert results1 is not None + assert results2 is not None + assert len(results1) == 1 + assert len(results2) == 1 + assert results1[0].generator_name == "normal" + assert results2[0].generator_name == "uniform" + + def test_complex_parameters(self, storage): + complex_parameters = [0.0, 1.0, 2.0, 3.14159] + + model = RandomValuesModel( + generator_name="complex", + generator_parameters=complex_parameters, + sample_size=100, + sample_num=1, + data=list(range(10)), + ) + + storage.insert(model) + + query = RandomValuesQuery( + generator_name="complex", generator_parameters=complex_parameters, sample_size=100, sample_num=1 + ) + + result = storage.get_data(query) + assert result is not None + assert result.generator_parameters == complex_parameters + + def test_large_data(self, storage): + large_data = list(range(10000)) + + model = RandomValuesModel( + generator_name="large", generator_parameters=[0.0, 1.0], sample_size=10000, sample_num=1, data=large_data + ) + + storage.insert(model) + + query = RandomValuesQuery( + generator_name="large", generator_parameters=[0.0, 1.0], sample_size=10000, sample_num=1 + ) + + result = storage.get_data(query) + assert result is not None + assert len(result.data) == 10000 + assert result.data == large_data + + +class TestEdgeCases: + def test_empty_parameters(self, storage): + model = RandomValuesModel( + generator_name="empty_params", generator_parameters=[], sample_size=100, sample_num=1, data=[1.0, 2.0, 3.0] + ) + + storage.insert(model) + + query = RandomValuesQuery(generator_name="empty_params", generator_parameters=[], sample_size=100, sample_num=1) + + result = storage.get_data(query) + assert result is not None + assert result.generator_parameters == [] + + def test_empty_data(self, storage): + model = RandomValuesModel( + generator_name="empty_data", generator_parameters=[1.0, 2.0], sample_size=100, sample_num=1, data=[] + ) + + storage.insert(model) + + query = RandomValuesQuery( + generator_name="empty_data", generator_parameters=[1.0, 2.0], sample_size=100, sample_num=1 + ) + + result = storage.get_data(query) + assert result is not None + assert result.data == [] + + def test_special_characters_in_name(self, storage): + special_name = "generator-with-dashes_and_underscores" + + model = RandomValuesModel( + generator_name=special_name, + generator_parameters=[0.0, 1.0], + sample_size=100, + sample_num=1, + data=[1.0, 2.0, 3.0], + ) + + storage.insert(model) + + query = RandomValuesQuery( + generator_name=special_name, generator_parameters=[0.0, 1.0], sample_size=100, sample_num=1 + ) + + result = storage.get_data(query) + assert result is not None + assert result.generator_name == special_name + + +class TestIntegration: + def test_complete_workflow(self, storage): + models = [] + for i in range(5): + model = RandomValuesModel( + generator_name="workflow_test", + generator_parameters=[i * 1.0, i * 2.0], + sample_size=100, + sample_num=i + 1, + data=[float(i + j) for j in range(10)], + ) + models.append(model) + storage.insert(model) + + all_query = RandomValuesAllQuery( + generator_name="workflow_test", + generator_parameters=[0.0, 0.0], + sample_size=100, + ) + + count = storage.get_rvs_count(all_query) + assert count == 1 + + for i, model in enumerate(models): + query = RandomValuesAllQuery( + generator_name="workflow_test", generator_parameters=[i * 1.0, i * 2.0], sample_size=100 + ) + + results = storage.get_all_data(query) + assert results is not None + assert len(results) == 1 + assert results[0].data == model.data + + for i, model in enumerate(models): + delete_query = RandomValuesQuery( + generator_name="workflow_test", + generator_parameters=[i * 1.0, i * 2.0], + sample_size=100, + sample_num=i + 1, + ) + storage.delete(delete_query) + + result = storage.get_data(delete_query) + assert result is None + + +# TODO: comments! +# TODO: think about connection error diff --git a/tests/persistence/sqlite/test_time_complexity_storage.py b/tests/persistence/sqlite/test_time_complexity_storage.py new file mode 100644 index 0000000..be2feea --- /dev/null +++ b/tests/persistence/sqlite/test_time_complexity_storage.py @@ -0,0 +1,360 @@ +import json +import os +from unittest.mock import Mock, patch + +import pytest + +from pysatl_experiment.persistence.model.time_complexity.time_complexity import TimeComplexityModel, TimeComplexityQuery +from pysatl_experiment.persistence.storage.time_complexity.sqlite.sqlite import SQLiteTimeComplexityStorage + + +# TODO: clearance + + +class TestSQLiteTimeComplexityStorage: + """Test cases for SQLite time complexity storage""" + + @pytest.fixture + def mock_connection(self): + """Mock SQLite connection""" + with patch("psycopg2.connect") as mock_connect: + mock_conn = Mock() + mock_cursor = Mock() + mock_connect.return_value = mock_conn + mock_conn.cursor.return_value = mock_cursor + mock_conn.closed = False + yield mock_connect, mock_conn, mock_cursor + + @pytest.fixture + def storage(self): + """Create storage instance for testing""" + return SQLiteTimeComplexityStorage(connection_string="postgresql://test:test@localhost/test_db") + + @pytest.fixture + def sample_data(self): + """Sample data for testing""" + return TimeComplexityModel( + experiment_id=1, + criterion_code="ks_test", + criterion_parameters=[0.05, 0.1], + sample_size=100, + monte_carlo_count=1000, + results_times=[1.2, 1.3, 1.1, 1.4], + ) + + @pytest.fixture + def sample_query(self): + """Sample query for testing""" + return TimeComplexityQuery( + criterion_code="ks_test", criterion_parameters=[0.05, 0.1], sample_size=100, monte_carlo_count=1000 + ) + + def test_init(self, storage, mock_connection): + """Test storage initialization""" + mock_connect, mock_conn, mock_cursor = mock_connection + + storage.init() + + # Verify connection was created + mock_connect.assert_called_once_with("postgresql://test:test@localhost/test_db") + # Verify table creation query was executed + assert mock_cursor.execute.call_count == 1 + mock_conn.commit.assert_called_once() + + def test_init_with_custom_table_name(self, mock_connection): + """Test storage initialization with custom table name""" + storage = SQLiteTimeComplexityStorage( + connection_string="postgresql://test:test@localhost/test_db", table_name="custom_table" + ) + mock_connect, mock_conn, mock_cursor = mock_connection + + storage.init() + + # Verify table name is used in queries + call_args = mock_cursor.execute.call_args[0][0] + assert "custom_table" in call_args + assert "CREATE TABLE IF NOT EXISTS custom_table" in call_args + + def test_init_rollback_on_error(self, mock_connection): + """Test rollback on initialization error""" + mock_connect, mock_conn, mock_cursor = mock_connection + mock_cursor.execute.side_effect = Exception("Database error") + storage = SQLiteTimeComplexityStorage("test_connection_string") + + with pytest.raises(Exception, match="Failed to initialize time complexity storage:"): + storage.init() + + mock_conn.rollback.assert_called_once() + + def test_insert_data(self, storage, mock_connection, sample_data): + """Test inserting data""" + mock_connect, mock_conn, mock_cursor = mock_connection + + storage.insert_data(sample_data) + + # Verify insert query was executed with correct parameters + mock_cursor.execute.assert_called_once() + call_args = mock_cursor.execute.call_args[0] + + assert "INSERT INTO time_complexity" in call_args[0] + assert call_args[1] == ( + sample_data.experiment_id, + sample_data.criterion_code, + json.dumps(sample_data.criterion_parameters), + sample_data.sample_size, + sample_data.monte_carlo_count, + json.dumps(sample_data.results_times), + ) + mock_conn.commit.assert_called_once() + + def test_insert_data_rollback_on_error(self, storage, mock_connection, sample_data): + """Test rollback on insert error""" + mock_connect, mock_conn, mock_cursor = mock_connection + mock_cursor.execute.side_effect = Exception("Insert error") + + with pytest.raises(Exception, match="Failed to insert time complexity data:"): + storage.insert_data(sample_data) + + mock_conn.rollback.assert_called_once() + + def test_get_data_found(self, storage, mock_connection, sample_query): + """Test getting existing data""" + mock_connect, mock_conn, mock_cursor = mock_connection + + # Mock database result + mock_result = { + "experiment_id": 1, + "criterion_code": "ks_test", + "criterion_parameters": json.dumps([0.05, 0.1]), + "sample_size": 100, + "monte_carlo_count": 1000, + "results_times": json.dumps([1.2, 1.3, 1.1, 1.4]), + } + mock_cursor.fetchone.return_value = mock_result + + result = storage.get_data(sample_query) + + # Verify query was executed with correct parameters + mock_cursor.execute.assert_called_once() + call_args = mock_cursor.execute.call_args[0] + + assert "SELECT" in call_args[0] + assert call_args[1] == ( + sample_query.criterion_code, + json.dumps(sample_query.criterion_parameters), + sample_query.sample_size, + sample_query.monte_carlo_count, + ) + + # Verify result conversion + assert result is not None + assert result.experiment_id == 1 + assert result.criterion_code == "ks_test" + assert result.criterion_parameters == [0.05, 0.1] + assert result.sample_size == 100 + assert result.monte_carlo_count == 1000 + assert result.results_times == [1.2, 1.3, 1.1, 1.4] + + def test_get_data_not_found(self, storage, mock_connection, sample_query): + """Test getting non-existent data""" + mock_connect, mock_conn, mock_cursor = mock_connection + mock_cursor.fetchone.return_value = None + + result = storage.get_data(sample_query) + + assert result is None + mock_cursor.execute.assert_called_once() + + def test_get_data_error(self, storage, mock_connection, sample_query): + """Test error during data retrieval""" + mock_connect, mock_conn, mock_cursor = mock_connection + mock_cursor.execute.side_effect = Exception("Query error") + + with pytest.raises(Exception, match="Failed to get time complexity data:"): + storage.get_data(sample_query) + + def test_delete_data(self, storage, mock_connection, sample_query): + """Test deleting data""" + mock_connect, mock_conn, mock_cursor = mock_connection + + storage.delete_data(sample_query) + + # Verify delete query was executed with correct parameters + mock_cursor.execute.assert_called_once() + call_args = mock_cursor.execute.call_args[0] + + assert "DELETE FROM time_complexity" in call_args[0] + assert call_args[1] == ( + sample_query.criterion_code, + json.dumps(sample_query.criterion_parameters), + sample_query.sample_size, + sample_query.monte_carlo_count, + ) + mock_conn.commit.assert_called_once() + + def test_delete_data_rollback_on_error(self, storage, mock_connection, sample_query): + """Test rollback on delete error""" + mock_connect, mock_conn, mock_cursor = mock_connection + mock_cursor.execute.side_effect = Exception("Delete error") + + with pytest.raises(Exception, match="Failed to delete time complexity data:"): + storage.delete_data(sample_query) + + mock_conn.rollback.assert_called_once() + + def test_close_connection(self, storage, mock_connection): + """Test closing database connection""" + mock_connect, mock_conn, mock_cursor = mock_connection + + # First, establish a connection + storage.init() + storage.close() + + mock_conn.close.assert_called_once() + + def test_context_manager(self, mock_connection): + """Test using storage as context manager""" + mock_connect, mock_conn, mock_cursor = mock_connection + + with SQLiteTimeComplexityStorage("test_connection_string") as storage: + storage.init() + + mock_conn.close.assert_called_once() + + def test_connection_reuse(self, storage, mock_connection): + """Test that connection is reused when not closed""" + mock_connect, mock_conn, mock_cursor = mock_connection + + # First call + storage.init() + first_connection = storage._get_connection() + + # Second call should return same connection + second_connection = storage._get_connection() + + assert first_connection is second_connection + mock_connect.assert_called_once() + + def test_json_serialization(self, storage, sample_data): + """Test JSON serialization of complex parameters""" + # Test with various data types in parameters + complex_data = TimeComplexityModel( + experiment_id=2, + criterion_code="complex_test", + criterion_parameters=[0.05, 1.5e-10, -3.14], # Various float formats + sample_size=50, + monte_carlo_count=500, + results_times=[0.001, 0.002, 0.0015], # Very small times + ) + + # This should not raise serialization errors + json_params = json.dumps(complex_data.criterion_parameters) + json_times = json.dumps(complex_data.results_times) + + # Verify round-trip serialization + assert complex_data.criterion_parameters == json.loads(json_params) + assert complex_data.results_times == json.loads(json_times) + + +# Integration tests (require actual SQLite database) +@pytest.mark.integration +class TestSQLiteTimeComplexityStorageIntegration: + """Integration tests with real SQLite database""" + + @pytest.fixture + def integration_storage(self): + """Create storage instance for integration testing""" + # Use environment variable for connection string + connection_string = os.getenv("TEST_POSTGRESQL_CONNECTION_STRING", "postgresql://test:test@localhost/test_db") + + storage = SQLiteTimeComplexityStorage(connection_string=connection_string, table_name="test_time_complexity") + + # Clean up before test + storage.init() + with storage._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(f"DELETE FROM {storage.table_name}") + conn.commit() + yield storage + + # Clean up after test + with storage._get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(f"DROP TABLE IF EXISTS {storage.table_name}") + conn.commit() + storage.close() + + def test_integration_crud_operations(self, integration_storage): + """Test full CRUD operations with real database""" + # Create test data + test_data = TimeComplexityModel( + experiment_id=999, + criterion_code="integration_test", + criterion_parameters=[0.01, 0.02], + sample_size=200, + monte_carlo_count=2000, + results_times=[2.1, 2.2, 2.3], + ) + + test_query = TimeComplexityQuery( + criterion_code="integration_test", + criterion_parameters=[0.01, 0.02], + sample_size=200, + monte_carlo_count=2000, + ) + + # Test insert + integration_storage.insert_data(test_data) + + # Test get (should find the data) + result = integration_storage.get_data(test_query) + assert result is not None + assert result.experiment_id == test_data.experiment_id + assert result.criterion_code == test_data.criterion_code + assert result.criterion_parameters == test_data.criterion_parameters + assert result.sample_size == test_data.sample_size + assert result.monte_carlo_count == test_data.monte_carlo_count + assert result.results_times == test_data.results_times + + # Test delete + integration_storage.delete_data(test_query) + + # Test get after delete (should not find the data) + result = integration_storage.get_data(test_query) + assert result is None + + def test_integration_unique_constraint(self, integration_storage): + """Test unique constraint enforcement""" + data1 = TimeComplexityModel( + experiment_id=1, + criterion_code="unique_test", + criterion_parameters=[1.0], + sample_size=100, + monte_carlo_count=1000, + results_times=[1.0], + ) + + # Same unique key, different results + data2 = TimeComplexityModel( + experiment_id=1, + criterion_code="unique_test", + criterion_parameters=[1.0], + sample_size=100, + monte_carlo_count=1000, + results_times=[2.0], # Different results + ) + + # Insert first record + integration_storage.insert_data(data1) + + # Insert second record with same unique key - should update existing + integration_storage.insert_data(data2) + + # Should get the updated record + query = TimeComplexityQuery( + criterion_code="unique_test", criterion_parameters=[1.0], sample_size=100, monte_carlo_count=1000 + ) + + result = integration_storage.get_data(query) + assert result is not None + assert result.results_times == [2.0] # Should have updated results From 11a21649f12579b087b2e737189e04818e6e5b47 Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Fri, 10 Oct 2025 11:33:32 +0300 Subject: [PATCH 23/24] fix: small changes --- .../cli/commands/configure/storage_type/storage_type.py | 2 +- tests/persistence/sqlite/test_experiment_storage.py | 6 +++--- tests/persistence/sqlite/test_power_storage.py | 2 +- tests/persistence/sqlite/test_random_values_storage.py | 2 +- tests/persistence/sqlite/test_time_complexity_storage.py | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pysatl_experiment/cli/commands/configure/storage_type/storage_type.py b/pysatl_experiment/cli/commands/configure/storage_type/storage_type.py index c578503..bdbd064 100644 --- a/pysatl_experiment/cli/commands/configure/storage_type/storage_type.py +++ b/pysatl_experiment/cli/commands/configure/storage_type/storage_type.py @@ -5,7 +5,7 @@ @configure.command() -@argument("storage_type") +@argument("store_type") @pass_context def storage_type(ctx: Context, store_type: str) -> None: """ diff --git a/tests/persistence/sqlite/test_experiment_storage.py b/tests/persistence/sqlite/test_experiment_storage.py index 9e4c87c..8ba368c 100644 --- a/tests/persistence/sqlite/test_experiment_storage.py +++ b/tests/persistence/sqlite/test_experiment_storage.py @@ -20,7 +20,7 @@ def storage(self): def sample_experiment_model(self): # TODO: check for format return ExperimentModel( experiment_type="statistical_test", - storage_connection="postgresql://storage", + storage_connection="test.db", run_mode="sequential", report_mode="detailed", hypothesis="Test hypothesis", @@ -41,7 +41,7 @@ def sample_experiment_model(self): # TODO: check for format def sample_experiment_query(self): # TODO: check for format return ExperimentQuery( experiment_type="statistical_test", - storage_connection="postgresql://storage", + storage_connection="test.db", run_mode="sequential", hypothesis="Test hypothesis", generator_type="monte_carlo", @@ -56,7 +56,7 @@ def sample_experiment_query(self): # TODO: check for format ) def test_initialization(self, storage): - assert storage.connection_string == "postgresql://test:test@localhost/test" + assert storage.connection_string == "test.db" assert storage.table_name == TABLE_NAME assert not storage._initialized diff --git a/tests/persistence/sqlite/test_power_storage.py b/tests/persistence/sqlite/test_power_storage.py index 92773f3..5b62b08 100644 --- a/tests/persistence/sqlite/test_power_storage.py +++ b/tests/persistence/sqlite/test_power_storage.py @@ -15,7 +15,7 @@ @pytest.fixture(scope="function") def temp_postgres_db(): test_db_name = "test_power_db" - original_connection_string = "postgresql://user:password@localhost:5432/postgres" + original_connection_string = "test.db" # Creating test BD conn = psycopg2.connect(original_connection_string) diff --git a/tests/persistence/sqlite/test_random_values_storage.py b/tests/persistence/sqlite/test_random_values_storage.py index 2d6e5f0..33634b9 100644 --- a/tests/persistence/sqlite/test_random_values_storage.py +++ b/tests/persistence/sqlite/test_random_values_storage.py @@ -17,7 +17,7 @@ @pytest.fixture(scope="function") def temp_postgres_db(): test_db_name = "test_random_values_db" - original_connection_string = "postgresql://user:password@localhost:5432/postgres" + original_connection_string = "test.db" conn = psycopg2.connect(original_connection_string) conn.autocommit = True diff --git a/tests/persistence/sqlite/test_time_complexity_storage.py b/tests/persistence/sqlite/test_time_complexity_storage.py index be2feea..231f8c4 100644 --- a/tests/persistence/sqlite/test_time_complexity_storage.py +++ b/tests/persistence/sqlite/test_time_complexity_storage.py @@ -28,7 +28,7 @@ def mock_connection(self): @pytest.fixture def storage(self): """Create storage instance for testing""" - return SQLiteTimeComplexityStorage(connection_string="postgresql://test:test@localhost/test_db") + return SQLiteTimeComplexityStorage(connection_string="test.db") @pytest.fixture def sample_data(self): From 642ab80505cc99ec29576a8c1931eef8258572ca Mon Sep 17 00:00:00 2001 From: Dmitri Kuznetsov Date: Fri, 10 Oct 2025 11:34:20 +0300 Subject: [PATCH 24/24] fix: changed pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 42521fb..6ced0f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ package-mode = true packages = [{include = "pysatl_experiment"}] [tool.poetry.dependencies] -python = ">=3.11,<3.14" +python = ">=3.11,<3.13" numpy = ">=1.25.1" scipy = ">=1.11.2" matplotlib = ">=3.8.0"