From 8dbef97d8565698cf1fdb9aff773d28f75b08c62 Mon Sep 17 00:00:00 2001 From: den25 Date: Mon, 1 May 2023 18:11:15 +0300 Subject: [PATCH] docs/tests: Add documentation and tests for migration interfaces and implementations This commit adds documentation and tests for the migration interfaces and their implementations. The documentation explains the purpose and usage of each interface, and the tests verify that the implementations behave as expected. This will help developers understand the code and ensure that changes to the code do not inadvertently introduce bugs. --- mongorunway/kernel/application/ui.py | 8 +- mongorunway/kernel/domain/migration.py | 137 +++++++++++++++++- .../kernel/domain/migration_command.py | 4 +- .../kernel/infrastructure/migrations.py | 114 ++++++++++++++- tests/integration/test_migrations.py | 58 ++++++++ tests/unit/test_migrations.py | 62 ++++++++ 6 files changed, 368 insertions(+), 15 deletions(-) create mode 100644 tests/integration/test_migrations.py create mode 100644 tests/unit/test_migrations.py diff --git a/mongorunway/kernel/application/ui.py b/mongorunway/kernel/application/ui.py index eef2a20..43f607e 100644 --- a/mongorunway/kernel/application/ui.py +++ b/mongorunway/kernel/application/ui.py @@ -309,7 +309,7 @@ def remove_pending_migration(self, migration_version: int, /) -> None: @requires_pending_migration def upgrade_once(self) -> int: upgrading_version = get_upgrading_version(self) - nowait_migration = self.pending.pop_nowait_migration() + nowait_migration = self.pending.pop_waiting_migration() _LOGGER.info( "%s: upgrading nowait migration (#%s -> #%s)...", @@ -331,7 +331,7 @@ def upgrade_once(self) -> int: @requires_applied_migration def downgrade_once(self) -> int: downgrading_version = get_downgrading_version(self) - nowait_migration = self.applied.pop_nowait_migration() + nowait_migration = self.applied.pop_waiting_migration() _LOGGER.info( "%s: downgrading nowait migration (#%s -> #%s)...", @@ -356,7 +356,7 @@ def upgrade_while(self, predicate: typing.Callable[[Migration], bool], /) -> int while self.pending.has_migrations(): upgrading_version = get_upgrading_version(self) - migration = self.pending.pop_nowait_migration() + migration = self.pending.pop_waiting_migration() if not predicate(migration): break @@ -386,7 +386,7 @@ def downgrade_while(self, predicate: typing.Callable[[Migration], bool], /) -> i while self.applied.has_migrations(): downgrading_version = get_downgrading_version(self) - migration = self.applied.pop_nowait_migration() + migration = self.applied.pop_waiting_migration() _LOGGER.info( "%s: downgrading nowait migration (#%s -> #%s)...", diff --git a/mongorunway/kernel/domain/migration.py b/mongorunway/kernel/domain/migration.py index e4da026..7fa1415 100644 --- a/mongorunway/kernel/domain/migration.py +++ b/mongorunway/kernel/domain/migration.py @@ -16,65 +16,190 @@ class Migration(abc.ABC): + """Interface for defining database migrations. + + Notes + ----- + This interface defines the methods and properties required to define a database + migration. A migration is a set of instructions for modifying a database schema + to support new features or fix issues. + + Each migration consists of a version number, a name, and one or more commands + that describe how to upgrade or downgrade the schema. + """ + __slots__: typing.Sequence[str] = () @property @abc.abstractmethod - def name(self): + def name(self) -> str: + """Returns the name of the migration. + + Returns + ------- + str + The name of the migration. + """ ... @property @abc.abstractmethod - def version(self): + def version(self) -> int: + """Get the version of the migration. + + Returns + ------- + int + The version of the migration. + """ ... @property @abc.abstractmethod - def checksum(self): + def checksum(self) -> str: + """Get the checksum of the migration. + + Returns + ------- + str + The checksum of the migration. + """ ... @property @abc.abstractmethod - def description(self): + def description(self) -> str: + """Get the description of the migration. + + Returns + ------- + str + The description of the migration. + """ ... @property @abc.abstractmethod def upgrade_commands(self) -> typing.Sequence[MigrationCommand]: + """Get the upgrade commands for the migration. + + Returns + ------- + Sequence[MigrationCommand] + A sequence of MigrationCommand objects representing the upgrade commands + for the migration. + """ ... @property @abc.abstractmethod def downgrade_commands(self) -> typing.Sequence[MigrationCommand]: + """Get the downgrade commands for the object. + + Returns + ------- + Sequence[MigrationCommand] + A sequence of MigrationCommand objects representing the downgrade commands + for the migration. + """ ... @abc.abstractmethod def downgrade(self, client: pymongo.MongoClient[typing.Dict[str, typing.Any]], /) -> None: + """Downgrade the object to a previous version. + + Parameters + ---------- + client : pymongo.MongoClient[Dict[str, Any]] + The MongoDB client object representing the connection to the database. + """ ... @abc.abstractmethod def upgrade(self, client: pymongo.MongoClient[typing.Dict[str, typing.Any]], /) -> None: + """Upgrade the migration to a previous version. + + Parameters + ---------- + client : pymongo.MongoClient[Dict[str, Any]] + The MongoDB client object representing the connection to the database. + """ ... @abc.abstractmethod def to_mongo_dict(self) -> typing.Dict[str, typing.Any]: + """Convert the object to a dictionary representation for MongoDB. + + Returns + ------- + Dict[str, Any] + A dictionary representation of the object suitable for storing in a + MongoDB collection. + """ ... @dataclasses.dataclass class MigrationReadModel: + """Represents a read model of a migration that provides information about the migration. + + Attributes + ---------- + name : str + The name of the migration. + version : int + The version of the migration. + checksum : str + The checksum of the migration. + description : str + The description of the migration. + """ + name: str = dataclasses.field() + """The name of the migration.""" + version: int = dataclasses.field() + """The version of the migration.""" + checksum: str = dataclasses.field() + """The checksum of the migration.""" + description: str = dataclasses.field() + """The description of the migration.""" @classmethod - def from_dict(cls, mapping): + def from_dict(cls, mapping: typing.MutableMapping[str, typing.Any], /) -> MigrationReadModel: + """Create a MigrationReadModel instance from a dictionary. + + Parameters + ---------- + mapping : typing.MutableMapping[str, typing.Any] + A dictionary containing the attributes of the migration. + + Returns + ------- + MigrationReadModel + An instance of MigrationReadModel initialized with the attributes + from the dictionary. + """ mapping.pop("_id", None) # For mongo records return cls(**mapping) @classmethod - def from_migration(cls, migration): + def from_migration(cls, migration: Migration, /) -> MigrationReadModel: + """Create a MigrationReadModel instance from a Migration instance. + + Parameters + ---------- + migration : Migration + A Migration instance to create the MigrationReadModel instance from. + + Returns + ------- + MigrationReadModel + An instance of MigrationReadModel initialized with the attributes + from the Migration instance. + """ return cls( name=migration.name, version=migration.version, diff --git a/mongorunway/kernel/domain/migration_command.py b/mongorunway/kernel/domain/migration_command.py index bf01ed3..8b0363d 100644 --- a/mongorunway/kernel/domain/migration_command.py +++ b/mongorunway/kernel/domain/migration_command.py @@ -5,10 +5,12 @@ import abc import typing +import pymongo + class MigrationCommand(abc.ABC): __slots__: typing.Sequence[str] = () @abc.abstractmethod - def execute(self, conn, **kwargs) -> None: + def execute(self, conn: pymongo.MongoClient[typing.Dict[str, typing.Any]], **kwargs: typing.Any) -> None: ... diff --git a/mongorunway/kernel/infrastructure/migrations.py b/mongorunway/kernel/infrastructure/migrations.py index 6958395..0218d7f 100644 --- a/mongorunway/kernel/infrastructure/migrations.py +++ b/mongorunway/kernel/infrastructure/migrations.py @@ -1,3 +1,24 @@ +# Copyright (c) 2023 Animatea +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +"""This module provides implementations of the `Migration` interface.""" from __future__ import annotations __all__: typing.Sequence[str] = ("BaseMigration",) @@ -13,6 +34,25 @@ class BaseMigration(Migration): + """This class provides methods for upgrading and downgrading a database schema using + a sequence of MigrationCommand instances. + + Parameters + ---------- + name : str + The name of the migration. + version : int + The version number of the migration. + checksum : str + A hash checksum of the migration contents. + description : str + A description of the migration. + upgrade_commands : sequence of MigrationCommand + A sequence of commands to upgrade the database schema. + downgrade_commands : sequence of MigrationCommand + A sequence of commands to downgrade the database schema. + """ + __slots__: typing.Sequence[str] = ( "_name", "_version", @@ -40,38 +80,104 @@ def __init__( self._downgrade_commands = downgrade_commands @property - def name(self): + def name(self) -> str: + """Returns the name of the migration. + + Returns + ------- + str + The name of the migration. + """ return self._name @property - def version(self): + def version(self) -> int: + """Get the version of the migration. + + Returns + ------- + int + The version of the migration. + """ return self._version @property - def checksum(self): + def checksum(self) -> str: + """Get the checksum of the migration. + + Returns + ------- + str + The checksum of the migration. + """ return self._checksum @property - def description(self): + def description(self) -> str: + """Get the description of the migration. + + Returns + ------- + str + The description of the migration. + """ return self._description @property def upgrade_commands(self) -> typing.Sequence[MigrationCommand]: + """Get the upgrade commands for the migration. + + Returns + ------- + Sequence[MigrationCommand] + A sequence of MigrationCommand objects representing the upgrade commands + for the migration. + """ return self._upgrade_commands @property def downgrade_commands(self) -> typing.Sequence[MigrationCommand]: + """Get the downgrade commands for the object. + + Returns + ------- + Sequence[MigrationCommand] + A sequence of MigrationCommand objects representing the downgrade commands + for the migration. + """ return self._downgrade_commands def downgrade(self, client: pymongo.MongoClient[typing.Dict[str, typing.Any]], /) -> None: + """Downgrade the object to a previous version. + + Parameters + ---------- + client : pymongo.MongoClient[Dict[str, Any]] + The MongoDB client object representing the connection to the database. + """ for command in self._downgrade_commands: command.execute(client) def upgrade(self, client: pymongo.MongoClient[typing.Dict[str, typing.Any]], /) -> None: + """Upgrade the migration to a previous version. + + Parameters + ---------- + client : pymongo.MongoClient[Dict[str, Any]] + The MongoDB client object representing the connection to the database. + """ for command in self._upgrade_commands: command.execute(client) def to_mongo_dict(self) -> typing.Dict[str, typing.Any]: + """Convert the object to a dictionary representation for MongoDB. + + Returns + ------- + Dict[str, Any] + A dictionary representation of the object suitable for storing in a + MongoDB collection. + """ return { "_id": self.version, "name": self.name, diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py new file mode 100644 index 0000000..8e15401 --- /dev/null +++ b/tests/integration/test_migrations.py @@ -0,0 +1,58 @@ +# Copyright (c) 2023 Animatea +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +from __future__ import annotations + +import typing +from unittest.mock import Mock + +import pymongo + +from mongorunway.kernel.domain.migration_command import MigrationCommand +from mongorunway.kernel.infrastructure.migrations import BaseMigration + + +class FakeCommand(MigrationCommand): + def execute(self, conn: pymongo.MongoClient[typing.Dict[str, typing.Any]], **kwargs: typing.Any) -> None: + pass + + +def test_base_migration() -> None: + migration = BaseMigration( + version=1, + name="abc", + checksum="abc", + description="abc", + upgrade_commands=[Mock(), Mock()], + downgrade_commands=[Mock(), Mock()], + ) + + assert len(migration.downgrade_commands) == 2 + assert len(migration.upgrade_commands) == 2 + + mock_client = Mock(spec=pymongo.MongoClient) + + migration.upgrade(mock_client) + for command in migration.upgrade_commands: + command.execute.assert_called_once_with(mock_client) + + migration.downgrade(mock_client) + for command in migration.downgrade_commands: + command.execute.assert_called_once_with(mock_client) diff --git a/tests/unit/test_migrations.py b/tests/unit/test_migrations.py new file mode 100644 index 0000000..284ff36 --- /dev/null +++ b/tests/unit/test_migrations.py @@ -0,0 +1,62 @@ +# Copyright (c) 2023 Animatea +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +from __future__ import annotations + +from mongorunway.kernel.domain.migration import Migration, MigrationReadModel +from mongorunway.kernel.infrastructure.migrations import BaseMigration + + +def test_migration() -> None: + migration = BaseMigration( + version=1, + name="abc", + checksum="abc", + description="abc", + upgrade_commands=[], + downgrade_commands=[], + ) + + assert migration.version == 1 + assert migration.name == "abc" + assert migration.checksum == "abc" + assert migration.description == "abc" + assert not migration.upgrade_commands + assert not migration.downgrade_commands + + assert migration.to_mongo_dict() == { + "_id": 1, + "version": 1, + "name": "abc", + "checksum": "abc", + "description": "abc", + } + + +def test_migration_read_model(migration: Migration) -> None: + migration_read_model = MigrationReadModel.from_migration(migration) + + assert migration_read_model.name == migration.name + assert migration_read_model.version == migration.version + assert migration_read_model.checksum == migration.checksum + assert migration_read_model.description == migration.description + + assert not hasattr(migration_read_model, "upgrade_commands") + assert not hasattr(migration_read_model, "downgrade_commands")